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;