diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index c93077e77..74a769dc8 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -1282,6 +1282,53 @@ export namespace CommandRegistry { mods.push(key); return mods.join(' '); } + + /** + * Format keystroke aria labels. + * + * If a list of keystrokes includes puntuation, it will create an + * aria label and substitue symbol values to text. + * + * @param keystroke The keystrokes to format + * @param keyToText - Optional object for converting punctuation. + * @returns The keystrokes representation + */ + export function formatKeystrokeAriaLabel( + keystroke: readonly string[], + keyToText?: { [key: string]: string } + ): string { + const internalKeyToText = keyToText ?? { + ']': 'Closing bracket', + '[': 'Opening bracket', + ',': 'Comma', + '.': 'Full stop', + "'": 'Single quote', + '-': 'Hyphen-minus' + }; + + let result = CommandRegistry.formatKeystroke(keystroke); + + let punctuationRegex = /\p{P}/u; + let punctuations = result?.match(punctuationRegex); + if (!punctuations) { + return result; + } + for (const punctuation of punctuations) { + if ( + result != null && + Object.keys(internalKeyToText).includes(punctuation) + ) { + const individualKeys = result.split('+'); + let index = individualKeys.indexOf(punctuation); + if (index != -1) { + individualKeys[index] = internalKeyToText[punctuation]; + } + const textShortcut = individualKeys.join('+'); + return textShortcut; + } + } + return result; + } } /** diff --git a/packages/widgets/src/commandpalette.ts b/packages/widgets/src/commandpalette.ts index 4e2d7ef73..fbcc7e80f 100644 --- a/packages/widgets/src/commandpalette.ts +++ b/packages/widgets/src/commandpalette.ts @@ -39,6 +39,7 @@ export class CommandPalette extends Widget { super({ node: Private.createNode() }); this.addClass('lm-CommandPalette'); this.setFlag(Widget.Flag.DisallowLayout); + this.keyToText = options.renderer?.keyToText; this.commands = options.commands; this.renderer = options.renderer || CommandPalette.defaultRenderer; this.commands.commandChanged.connect(this._onGenericChange, this); @@ -63,6 +64,10 @@ export class CommandPalette extends Widget { * The renderer used by the command palette. */ readonly renderer: CommandPalette.IRenderer; + /** + * The optional object used for translation of aria label punctuation. + */ + readonly keyToText: CommandPalette.IRenderer['keyToText']; /** * The command palette search node. @@ -750,12 +755,21 @@ export namespace CommandPalette { * @returns A virtual element representing the message. */ renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement; + + /** + * The optional object used for translation of aria label punctuation. + */ + keyToText?: { [key: string]: string }; } /** * The default implementation of `IRenderer`. */ export class Renderer implements IRenderer { + /** + * The optional object used for translation of aria label punctuation. + */ + keyToText?: { [key: string]: string }; /** * Render the virtual element for a command palette header. * @@ -877,7 +891,14 @@ export namespace CommandPalette { */ renderItemShortcut(data: IItemRenderData): VirtualElement { let content = this.formatItemShortcut(data); - return h.div({ className: 'lm-CommandPalette-itemShortcut' }, content); + let ariaContent = this.formatItemAria(data); + return h.div( + { + className: 'lm-CommandPalette-itemShortcut', + 'aria-label': `${ariaContent}` + }, + content + ); } /** @@ -973,6 +994,18 @@ export namespace CommandPalette { return kb ? CommandRegistry.formatKeystroke(kb.keys) : null; } + /** + * @param data - The data to use for the aria label content. + * + * @returns The aria label content to add to the shortcut node. + */ + formatItemAria(data: IItemRenderData): h.Child { + let kbText = data.item.keyBinding; + return kbText + ? CommandRegistry.formatKeystrokeAriaLabel(kbText.keys, this.keyToText) + : null; + } + /** * Create the render content for the item label node. * diff --git a/packages/widgets/src/menu.ts b/packages/widgets/src/menu.ts index 054162675..8927c69ab 100644 --- a/packages/widgets/src/menu.ts +++ b/packages/widgets/src/menu.ts @@ -52,6 +52,7 @@ export class Menu extends Widget { super({ node: Private.createNode() }); this.addClass('lm-Menu'); this.setFlag(Widget.Flag.DisallowLayout); + this.keyToText = options.renderer?.keyToText; this.commands = options.commands; this.renderer = options.renderer || Menu.defaultRenderer; } @@ -64,6 +65,10 @@ export class Menu extends Widget { this._items.length = 0; super.dispose(); } + /** + * The optional object used for translation of aria label punctuation. + */ + readonly keyToText: Menu.IRenderer['keyToText']; /** * A signal emitted just before the menu is closed. @@ -1151,6 +1156,10 @@ export namespace Menu { * @returns A virtual element representing the item. */ renderItem(data: IRenderData): VirtualElement; + /** + * The optional object used for translation of aria label punctuation. + */ + keyToText?: { [key: string]: string }; } /** @@ -1160,6 +1169,10 @@ export namespace Menu { * Subclasses are free to reimplement rendering methods as needed. */ export class Renderer implements IRenderer { + /** + * The optional object used for translation of aria label punctuation. + */ + keyToText?: { [key: string]: string }; /** * Render the virtual element for a menu item. * @@ -1221,7 +1234,14 @@ export namespace Menu { */ renderShortcut(data: IRenderData): VirtualElement { let content = this.formatShortcut(data); - return h.div({ className: 'lm-Menu-itemShortcut' }, content); + let ariaContent = this.formatShortcutText(data); + return h.div( + { + className: 'lm-Menu-itemShortcut', + 'aria-label': `${ariaContent}` + }, + content + ); } /** @@ -1371,6 +1391,19 @@ export namespace Menu { let kb = data.item.keyBinding; return kb ? CommandRegistry.formatKeystroke(kb.keys) : null; } + + /** + * @param data - The data to use for the aria label content. + * + * @returns The aria label content to add to the shortcut node. + */ + formatShortcutText(data: IRenderData): h.Child { + let kbText = data.item.keyBinding; + + return kbText + ? CommandRegistry.formatKeystrokeAriaLabel(kbText.keys, this.keyToText) + : null; + } } /** diff --git a/packages/widgets/tests/src/commandpalette.spec.ts b/packages/widgets/tests/src/commandpalette.spec.ts index c57f8205e..97d4a2adb 100644 --- a/packages/widgets/tests/src/commandpalette.spec.ts +++ b/packages/widgets/tests/src/commandpalette.spec.ts @@ -552,6 +552,20 @@ describe('@lumino/widgets', () => { keys: ['Ctrl A'], selector: 'body' }); + commands.addCommand('test-aria', { + label: 'Test Aria', + caption: 'A simple aria-label test', + className: 'testAriaClass', + isEnabled: () => enabledFlag, + isToggled: () => toggledFlag, + execute: () => {} + }); + commands.addKeyBinding({ + command: 'test-aria', + keys: ['Ctrl ,'], + selector: 'body' + }); + item = palette.addItem({ command: 'test', category: 'Test Category' @@ -827,6 +841,39 @@ describe('@lumino/widgets', () => { expect(child).to.equal('A simple test command'); }); }); + + describe('#formatItemShortcut()', () => { + it('should format the item shortcut text', () => { + let child = renderer.formatItemShortcut({ + item, + indices: null, + active: false + }); + if (Platform.IS_MAC) { + expect(child).to.equal('\u2303 A'); + } else { + expect(child).to.equal('Ctrl+A'); + } + }); + }); + describe('#formatItemAria', () => { + it('should format the item aria-label', () => { + let item = palette.addItem({ + command: 'test-aria', + category: 'Test Category' + }); + let child = renderer.formatItemAria({ + item, + indices: null, + active: false + }); + if (Platform.IS_MAC) { + expect(child).to.equal('\u2303 ,'); + } else { + expect(child).to.equal('Ctrl+Comma'); + } + }); + }); }); }); }); diff --git a/packages/widgets/tests/src/menu.spec.ts b/packages/widgets/tests/src/menu.spec.ts index a4f1c1983..dfaff03af 100644 --- a/packages/widgets/tests/src/menu.spec.ts +++ b/packages/widgets/tests/src/menu.spec.ts @@ -85,6 +85,17 @@ describe('@lumino/widgets', () => { className: 'testClass', mnemonic: 0 }); + commands.addCommand('test-aria', { + execute: (args: JSONObject) => { + executed = 'test-aria'; + }, + label: 'Test Aria Label', + icon: iconRenderer, + iconClass, + caption: 'Test Caption', + className: 'testClass', + mnemonic: 0 + }); commands.addCommand('test-toggled', { execute: (args: JSONObject) => { executed = 'test-toggled'; @@ -127,6 +138,11 @@ describe('@lumino/widgets', () => { selector: 'body', command: 'test' }); + commands.addKeyBinding({ + keys: ['Ctrl ,'], + selector: 'body', + command: 'test-aria' + }); }); beforeEach(() => { @@ -1591,6 +1607,21 @@ describe('@lumino/widgets', () => { expect(child).to.equal('Ctrl+T'); } }); + describe('#formatShortcutText()', () => { + it('should format the item aria-label', () => { + let item = menu.addItem({ command: 'test-aria' }); + let child = renderer.formatShortcutText({ + item, + active: false, + collapsed: false + }); + if (Platform.IS_MAC) { + expect(child).to.equal('\u2303 ,'); + } else { + expect(child).to.equal('Ctrl+Comma'); + } + }); + }); }); }); }); diff --git a/review/api/commands.api.md b/review/api/commands.api.md index 81ed057ee..6f10048b2 100644 --- a/review/api/commands.api.md +++ b/review/api/commands.api.md @@ -49,6 +49,9 @@ export namespace CommandRegistry { args: ReadonlyJSONObject | null; }; export function formatKeystroke(keystroke: string | readonly string[]): string; + export function formatKeystrokeAriaLabel(keystroke: readonly string[], keyToText?: { + [key: string]: string; + }): string; export interface ICommandChangedArgs { readonly id: string | undefined; readonly type: 'added' | 'removed' | 'changed' | 'many-changed'; diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index 7462b947b..d0e1af01d 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -180,6 +180,7 @@ export class CommandPalette extends Widget { handleEvent(event: Event): void; get inputNode(): HTMLInputElement; get items(): ReadonlyArray; + readonly keyToText: CommandPalette.IRenderer['keyToText']; protected onActivateRequest(msg: Message): void; protected onAfterDetach(msg: Message): void; protected onAfterShow(msg: Message): void; @@ -235,6 +236,9 @@ export namespace CommandPalette { renderer?: IRenderer; } export interface IRenderer { + keyToText?: { + [key: string]: string; + }; renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement; renderHeader(data: IHeaderRenderData): VirtualElement; renderItem(data: IItemRenderData): VirtualElement; @@ -245,9 +249,14 @@ export namespace CommandPalette { createItemDataset(data: IItemRenderData): ElementDataset; formatEmptyMessage(data: IEmptyMessageRenderData): h.Child; formatHeader(data: IHeaderRenderData): h.Child; + // (undocumented) + formatItemAria(data: IItemRenderData): h.Child; formatItemCaption(data: IItemRenderData): h.Child; formatItemLabel(data: IItemRenderData): h.Child; formatItemShortcut(data: IItemRenderData): h.Child; + keyToText?: { + [key: string]: string; + }; renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement; renderHeader(data: IHeaderRenderData): VirtualElement; renderItem(data: IItemRenderData): VirtualElement; @@ -695,6 +704,7 @@ export class Menu extends Widget { handleEvent(event: Event): void; insertItem(index: number, options: Menu.IItemOptions): Menu.IItem; get items(): ReadonlyArray; + readonly keyToText: Menu.IRenderer['keyToText']; get leafMenu(): Menu; get menuRequested(): ISignal; protected onActivateRequest(msg: Message): void; @@ -753,6 +763,9 @@ export namespace Menu { readonly onfocus?: () => void; } export interface IRenderer { + keyToText?: { + [key: string]: string; + }; renderItem(data: IRenderData): VirtualElement; } export type ItemType = 'command' | 'submenu' | 'separator'; @@ -763,6 +776,11 @@ export namespace Menu { createItemDataset(data: IRenderData): ElementDataset; formatLabel(data: IRenderData): h.Child; formatShortcut(data: IRenderData): h.Child; + // (undocumented) + formatShortcutText(data: IRenderData): h.Child; + keyToText?: { + [key: string]: string; + }; renderIcon(data: IRenderData): VirtualElement; renderItem(data: IRenderData): VirtualElement; renderLabel(data: IRenderData): VirtualElement;