dockview-core
Version:
Zero dependency layout manager supporting tabs, groups, grids and splitviews for vanilla TypeScript
160 lines (159 loc) • 6.67 kB
JavaScript
import { CompositeDisposable } from '../lifecycle';
import { resolveMessages } from './accessibilityMessages';
import { defineModule } from './modules';
/** Bulk transactions whose per-panel events should not each be announced. */
const isBulk = (kind) => kind === 'load' || kind === 'clear';
function createLiveRegion(politeness) {
const el = document.createElement('div');
el.className =
politeness === 'assertive'
? 'dv-live-region-assertive'
: 'dv-live-region';
// assertive interrupts the SR (errors / cancellations); polite waits for a
// pause (routine status). `alert` implies assertive, `status` implies polite.
el.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status');
el.setAttribute('aria-live', politeness);
el.setAttribute('aria-atomic', 'true');
// Visually hidden but kept in the accessibility tree (never display:none /
// visibility:hidden, which would drop it from AT). Standard clip pattern.
Object.assign(el.style, {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
border: '0',
});
return el;
}
/**
* Narrates layout state changes to screen readers via visually-hidden
* `aria-live` regions. Free / core (WCAG 4.1.3). Announces panel open/close +
* the shared `announce()` sink (the accessibility module narrates docking here too).
* Two regions: a **polite** one for routine status and an **assertive** one
* for errors/cancellations. The bulk load/clear burst is suppressed via the
* mutation-transaction events, and an app can take over delivery entirely with
* the `announcer` option.
*/
export class LiveRegionService extends CompositeDisposable {
constructor(host) {
super();
this._suppressDepth = 0;
this._locationSubs = new Map();
this._host = host;
this._polite = createLiveRegion('polite');
this._assertive = createLiveRegion('assertive');
host.element.appendChild(this._polite);
host.element.appendChild(this._assertive);
this.addDisposables({ dispose: () => this._polite.remove() }, { dispose: () => this._assertive.remove() }, host.onDidAddPanel((panel) => this._announce(panel, 'open')), host.onDidRemovePanel((panel) => this._announce(panel, 'close')),
// Bracket bulk transactions so a fromJSON / clear doesn't announce
// every nested add/remove.
host.onWillMutateLayout((e) => {
if (isBulk(e.kind)) {
this._suppressDepth++;
}
}), host.onDidMutateLayout((e) => {
if (isBulk(e.kind)) {
this._suppressDepth = Math.max(0, this._suppressDepth - 1);
}
}), host.onDidMaximizedGroupChange((e) => {
const panel = e.group.activePanel;
if (panel) {
this._announce(panel, e.isMaximized ? 'maximize' : 'restore');
}
}),
// Narrate a group floating / docking back / popping out. A group is
// born in the grid then transitions, so track each group's previous
// location and ignore the no-op initial `-> grid`.
host.onDidAddGroup((group) => this._trackLocation(group)), host.onDidRemoveGroup((group) => {
var _a;
(_a = this._locationSubs.get(group.id)) === null || _a === void 0 ? void 0 : _a.dispose();
this._locationSubs.delete(group.id);
}), {
dispose: () => {
this._locationSubs.forEach((sub) => sub.dispose());
this._locationSubs.clear();
},
});
}
_trackLocation(group) {
let prev = group.api.location.type;
const sub = group.api.onDidLocationChange((e) => {
const next = e.location.type;
if (next === prev) {
return;
}
prev = next;
const panel = group.activePanel;
if (!panel) {
return;
}
const kind = next === 'floating'
? 'float'
: next === 'popout'
? 'popout'
: 'dock';
this._announce(panel, kind);
});
this._locationSubs.set(group.id, sub);
}
announce(message, politeness = 'polite') {
// Opt-out (read live so `updateOptions({ announcements })` applies).
if (this._host.options.announcements === false ||
this._suppressDepth > 0 ||
!message) {
return;
}
// Apps can route announcements into their own SR system instead of the
// built-in regions (e.g. a shared app-wide live region).
const announcer = this._host.options.announcer;
if (announcer) {
announcer({ message, politeness });
return;
}
// Clearing first forces SRs to re-announce an identical message.
const region = politeness === 'assertive' ? this._assertive : this._polite;
region.textContent = '';
region.textContent = message;
}
_announce(panel, kind) {
var _a, _b;
// The app may localise/override the message, suppress it (null / ''),
// or fall through to the default (undefined).
const custom = (_b = (_a = this._host.options).getAnnouncement) === null || _b === void 0 ? void 0 : _b.call(_a, { kind, panel });
if (custom === null || custom === '') {
return;
}
this.announce(custom !== null && custom !== void 0 ? custom : this._defaultMessage(panel, kind));
}
_defaultMessage(panel, kind) {
var _a;
const m = resolveMessages(this._host.options.messages);
const name = (_a = panel.title) !== null && _a !== void 0 ? _a : panel.id;
switch (kind) {
case 'open':
return m.panelOpened(name);
case 'close':
return m.panelClosed(name);
case 'maximize':
return m.groupMaximized(name);
case 'restore':
return m.groupRestored(name);
case 'float':
return m.groupFloated(name);
case 'dock':
return m.groupDocked(name);
case 'popout':
return m.groupPoppedOut(name);
}
}
}
export const LiveRegionModule = defineModule({
name: 'LiveRegion',
serviceKey: 'liveRegionService',
create: (host) => new LiveRegionService(host),
});