diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md
index a36df2b77a..b73f33b68b 100644
--- a/etc/lime-elements.api.md
+++ b/etc/lime-elements.api.md
@@ -907,6 +907,7 @@ export namespace Components {
export interface LimelTooltip {
"elementId": string;
"helperLabel"?: string;
+ "hotkey"?: string;
"label": string;
"maxlength"?: number;
"openDirection": OpenDirection;
@@ -914,6 +915,7 @@ export namespace Components {
// @internal
export interface LimelTooltipContent {
"helperLabel"?: string;
+ "hotkey"?: string;
"label": string;
"maxlength"?: number;
}
@@ -3597,6 +3599,7 @@ export namespace JSX {
export interface LimelTooltip {
"elementId": string;
"helperLabel"?: string;
+ "hotkey"?: string;
"label": string;
"maxlength"?: number;
"openDirection"?: OpenDirection;
@@ -3609,6 +3612,8 @@ export namespace JSX {
// (undocumented)
"helperLabel": string;
// (undocumented)
+ "hotkey": string;
+ // (undocumented)
"label": string;
// (undocumented)
"maxlength": number;
@@ -3619,6 +3624,7 @@ export namespace JSX {
// @internal
export interface LimelTooltipContent {
"helperLabel"?: string;
+ "hotkey"?: string;
"label": string;
"maxlength"?: number;
}
@@ -3628,6 +3634,8 @@ export namespace JSX {
// (undocumented)
"helperLabel": string;
// (undocumented)
+ "hotkey": string;
+ // (undocumented)
"label": string;
// (undocumented)
"maxlength": number;
diff --git a/src/components/icon-button/icon-button.tsx b/src/components/icon-button/icon-button.tsx
index d5627f9aac..b84d847cae 100644
--- a/src/components/icon-button/icon-button.tsx
+++ b/src/components/icon-button/icon-button.tsx
@@ -95,6 +95,7 @@ export class IconButton {
return (
` next to a trigger, the
+ * component sets
+ * [`aria-describedby`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby)
+ * on the element matching that id, pointing to the tooltip's content (which
+ * carries `role="tooltip"`). This follows the [WAI-ARIA Authoring Practices
+ * for the tooltip pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/).
+ *
+ * `aria-describedby` provides a *description*, not a *name*. If the trigger
+ * has no accessible name of its own, a screen reader will announce the
+ * description but there is nothing to describe — the control is unnamed.
+ * This is an accessibility bug in the trigger, not something the tooltip can
+ * fix.
+ *
+ * #### Where the name comes from, for each kind of trigger
+ *
+ * The three triggers below show how different lime-elements components get
+ * their accessible name:
+ *
+ * 1. **`limel-icon`** — the host is what the user focuses, so `aria-label`
+ * on the host is the name.
+ * 2. **`limel-icon-button`** — the required `label` prop is rendered as
+ * `aria-label` on the inner ``, which makes it the accessible
+ * name. The same prop also drives the component's built-in tooltip on
+ * hover and focus, so this trigger does not need an external
+ * `` attached to it — it already has one. `icon.title` is
+ * not a name source: it describes what the icon visually depicts (e.g.
+ * icon={{ name: 'search', title: 'Magnifying glass' }})
+ * and is announced as supplementary content. For
+ * decorative icons inside a labelled button, leave it unset and the icon
+ * will be marked `aria-hidden`.
+ * 3. **`limel-button`** — the visible `label` prop is rendered as text
+ * inside the inner ``, so the name is computed automatically from
+ * that text content.
+ *
+ * #### Rules of thumb
+ *
+ * 1. Icon-only triggers must carry their own name, even if it duplicates the
+ * tooltip's `label`. The tooltip is a visual hint for sighted hover/focus
+ * users; the name is what screen reader users hear as the control's
+ * identity.
+ * 2. Triggers with visible text already have a name. Keep the tooltip
+ * focused on supplementary info like a keyboard shortcut via `hotkey`, or
+ * extra context via `helperLabel`.
+ * 3. Don't use a tooltip where a label belongs. If the information is
+ * essential to operating the control, put it in the UI directly. Don't
+ * hide it behind hover.
+ *
+ * _Inspect the triggers below in the browser's DevTools Accessibility panel
+ * to see the computed name and description._
+ */
+@Component({
+ tag: 'limel-example-tooltip-accessibility',
+ shadow: true,
+ styleUrl: 'tooltip-max-character.scss',
+})
+export class TooltipAccessibilityExample {
+ public render() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/components/tooltip/examples/tooltip-basic.tsx b/src/components/tooltip/examples/tooltip-basic.tsx
index 4dc8d8835a..731711f865 100644
--- a/src/components/tooltip/examples/tooltip-basic.tsx
+++ b/src/components/tooltip/examples/tooltip-basic.tsx
@@ -2,6 +2,18 @@ import { Component, h } from '@stencil/core';
/**
* Basic example
+ * In order to display the tooltip, the tooltip element and its trigger element
+ * must be within the same document or document fragment (the same `shadowRoot`).
+ * Often, it's easiest to just place them next to each other like in the example
+ * below, but if you need to, you can place them differently.
+ *
+ * Since `limel-tooltip` is absolutely positioned, it will not occupy any
+ * space in the layout.
+ *
+ * ```html
+ *
+ *
+ * ```
*/
@Component({
tag: 'limel-example-tooltip-basic',
@@ -13,7 +25,7 @@ export class TooltipBasicExample {
,
,
];
diff --git a/src/components/tooltip/examples/tooltip-hotkey.tsx b/src/components/tooltip/examples/tooltip-hotkey.tsx
new file mode 100644
index 0000000000..0216ec7038
--- /dev/null
+++ b/src/components/tooltip/examples/tooltip-hotkey.tsx
@@ -0,0 +1,62 @@
+import { Component, h, Host } from '@stencil/core';
+
+/**
+ * Visualizing a keyboard shortcut
+ *
+ * Use the `hotkey` property to render a keyboard shortcut inside the tooltip,
+ * next to the `label` (and the optional `helperLabel`). The keyboard shortcut
+ * that you will define will automatically have platform-aware glyphs.
+ * For example, `meta+c` renders as ⌘ C on macOS and
+ * as ⊞ Win + C on Windows.
+ *
+ * :::important
+ * The `hotkey` property is for visualization purposes only.
+ * The tooltip does **not** listen for, or handle any keyboard events
+ * on its own. Catching the key combination and running
+ * the associated action is the responsibility of the consumer of the tooltip.
+ * :::
+ *
+ * :::note
+ * 1. `meta` means the Meta key.
+ * It is rendered as ⌘ on Apple devices, and ⊞ Win on
+ * Windows/Linux. (`cmd`, `command`, `win`, `windows` are aliases for `meta`.)
+ *
+ * If you want "primary modifier" hotkeys (e.g. ⌘ on macOS and
+ * Ctrl on Windows/Linux), detect OS in your application and
+ * provide different `hotkey` values.
+ *
+ * `ctrl` means the Control key on all platforms.
+ *
+ * 2. All browsers and operating systems have some built-in hotkeys
+ * that may conflict with your defined hotkeys.
+ * For example, `cmd+p` is often used to print the current page.
+ * Make sure to choose hotkeys that do not conflict with common browser
+ * hotkeys, and user's expected behavior.
+ * :::
+ */
+@Component({
+ tag: 'limel-example-tooltip-hotkey',
+ shadow: true,
+ styleUrl: 'tooltip-max-character.scss',
+})
+export class TooltipHotkeyExample {
+ public render() {
+ return (
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/components/tooltip/examples/tooltip-max-character.scss b/src/components/tooltip/examples/tooltip-max-character.scss
index 276e2060c5..419db8fe9b 100644
--- a/src/components/tooltip/examples/tooltip-max-character.scss
+++ b/src/components/tooltip/examples/tooltip-max-character.scss
@@ -1,5 +1,6 @@
-:host(limel-example-tooltip-max-character) {
+:host {
display: flex;
gap: 1rem;
- justify-content: space-between;
+ align-items: center;
+ justify-content: space-around;
}
diff --git a/src/components/tooltip/tooltip-content.scss b/src/components/tooltip/tooltip-content.scss
index 7e455c9624..aed6b0adc3 100644
--- a/src/components/tooltip/tooltip-content.scss
+++ b/src/components/tooltip/tooltip-content.scss
@@ -1,39 +1,46 @@
:host(limel-tooltip-content) {
+ box-sizing: border-box;
display: flex;
+ align-items: center;
+ gap: 0.5rem 1rem;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
+
+ font-size: var(--limel-theme-default-font-size);
+ line-height: normal;
+
background-color: rgb(var(--contrast-1300));
- box-shadow: var(--shadow-depth-16);
+ box-shadow: var(--shadow-depth-16), var(--shadow-brighten-edges-inside);
}
-text {
- font-size: var(--limel-theme-default-font-size); // 14px
- line-height: 1.25;
- display: flex;
- column-gap: 1rem;
-
- &.has-column-layout {
- display: table-cell;
- width: fit-content;
- max-width: min(var(--tooltip-max-width-of-text), 80vw);
- .label {
- padding-bottom: 0.5rem;
- }
- .helper-label {
- padding-bottom: 0.25rem;
- }
+:host(.has-column-layout) {
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 0.5rem;
+
+ max-width: min(var(--tooltip-max-width-of-text), 80vw);
+
+ .label,
+ .helper-label {
+ flex-grow: 1;
}
}
+.label,
+.helper-label {
+ min-width: 0;
+ min-height: 0;
+}
+
.label {
color: rgb(var(--contrast-200));
}
.helper-label {
color: rgb(var(--contrast-800));
+}
- &:empty {
- display: none;
- }
+limel-hotkey {
+ margin-right: -0.25rem;
}
diff --git a/src/components/tooltip/tooltip-content.tsx b/src/components/tooltip/tooltip-content.tsx
index 772a28099d..028cb7c349 100644
--- a/src/components/tooltip/tooltip-content.tsx
+++ b/src/components/tooltip/tooltip-content.tsx
@@ -1,4 +1,5 @@
-import { Component, h, Prop } from '@stencil/core';
+import { Component, h, Host, Prop } from '@stencil/core';
+import { normalizeHotkeyString } from '../../util/hotkeys';
/**
* This component is used internally by `limel-tooltip`.
@@ -29,6 +30,12 @@ export class TooltipContent {
@Prop({ reflect: true })
maxlength?: number;
+ /**
+ * Read more in tooltip.tsx
+ */
+ @Prop({ reflect: true })
+ hotkey?: string;
+
public render() {
let isLabelsTextLong = false;
if (this.helperLabel && this.maxlength) {
@@ -36,18 +43,40 @@ export class TooltipContent {
this.label.length + this.helperLabel.length > this.maxlength;
}
- const props: any = {};
- if (this.maxlength) {
- props.style = {
- '--tooltip-max-width-of-text': `${this.maxlength}` + 'ch',
- };
- }
+ const style = this.maxlength
+ ? { '--tooltip-max-width-of-text': `${this.maxlength}ch` }
+ : undefined;
- return [
-
+ return (
+
{this.label}
- {this.helperLabel}
- ,
- ];
+ {this.renderHelperLabel()}
+ {this.renderHotkey()}
+
+ );
+ }
+
+ private renderHelperLabel() {
+ if (!this.helperLabel) {
+ return;
+ }
+
+ return
{this.helperLabel}
;
+ }
+
+ private renderHotkey() {
+ if (!this.hotkey) {
+ return;
+ }
+
+ const normalized = normalizeHotkeyString(this.hotkey);
+ if (!normalized) {
+ return;
+ }
+
+ return ;
}
}
diff --git a/src/components/tooltip/tooltip.spec.tsx b/src/components/tooltip/tooltip.spec.tsx
index 74c321e1d9..f7e0910634 100644
--- a/src/components/tooltip/tooltip.spec.tsx
+++ b/src/components/tooltip/tooltip.spec.tsx
@@ -14,13 +14,16 @@ vi.mock('./tooltip-timer', () => {
import { render, h } from '@stencil/vitest';
describe('limel-tooltip', () => {
- async function setup() {
+ async function setup(
+ props: { hotkey?: string; helperLabel?: string } = {}
+ ) {
const { root, waitForChanges } = await render(
);
@@ -34,8 +37,17 @@ describe('limel-tooltip', () => {
'limel-tooltip-content'
);
const portal = tooltip?.shadowRoot?.querySelector('limel-portal');
+ const hotkey = content?.shadowRoot?.querySelector('limel-hotkey');
- return { root, anchor, tooltip, content, portal, waitForChanges };
+ return {
+ root,
+ anchor,
+ tooltip,
+ content,
+ portal,
+ hotkey,
+ waitForChanges,
+ };
}
test('the component renders', async () => {
@@ -67,4 +79,28 @@ describe('limel-tooltip', () => {
expect(content).not.toBeNull();
expect(content!.getAttribute('aria-hidden')).not.toBe('true');
});
+
+ describe('hotkey prop', () => {
+ test('does not render limel-hotkey when hotkey is not set', async () => {
+ const { hotkey } = await setup();
+ expect(hotkey).toBeFalsy();
+ });
+
+ test('renders limel-hotkey when hotkey is set', async () => {
+ const { hotkey } = await setup({ hotkey: 'ctrl+f' });
+ expect(hotkey).toBeTruthy();
+ expect(hotkey!.getAttribute('value')).toBe('ctrl+f');
+ });
+
+ test('normalizes the hotkey string before passing it on', async () => {
+ const { hotkey } = await setup({ hotkey: 'CMD+Enter' });
+ expect(hotkey).toBeTruthy();
+ expect(hotkey!.getAttribute('value')).toBe('meta+enter');
+ });
+
+ test('does not render limel-hotkey for an invalid hotkey', async () => {
+ const { hotkey } = await setup({ hotkey: 'ctrl+' });
+ expect(hotkey).toBeFalsy();
+ });
+ });
});
diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx
index f898a19663..95aee3a721 100644
--- a/src/components/tooltip/tooltip.tsx
+++ b/src/components/tooltip/tooltip.tsx
@@ -21,18 +21,6 @@ const DEFAULT_MAX_LENGTH = 50;
* itself is interactive, it will remain interactible even with a tooltip bound
* to it.
*
- * :::note
- * In order to display the tooltip, the tooltip element and its trigger element
- * must be within the same document or document fragment (the same shadowRoot).
- * Often, it's easiest to just place them next to each other like in the example
- * below, but if you need to, you can place them differently.
- *
- * ```html
- *
- *
- * ```
- * :::
- *
* ## Usage
* - Keep in mind that tooltips can be distracting, and can be perceived as an interruption.
* Use them only when they add significant value.
@@ -49,6 +37,8 @@ const DEFAULT_MAX_LENGTH = 50;
*
* @exampleComponent limel-example-tooltip-basic
* @exampleComponent limel-example-tooltip-max-character
+ * @exampleComponent limel-example-tooltip-hotkey
+ * @exampleComponent limel-example-tooltip-accessibility
* @exampleComponent limel-example-tooltip-composite
*/
@Component({
@@ -73,12 +63,18 @@ export class Tooltip {
/**
* Additional helper text for the element.
- * Example usage can be a keyboard shortcut to activate the function of the
- * owner element.
*/
@Prop({ reflect: true })
public helperLabel?: string;
+ /**
+ * Keyboard shortcut to visualize inside the tooltip, e.g. `"ctrl+f"`.
+ * Display-only: the tooltip does not listen for the keystroke.
+ * Catching the hotkey is the consumer's responsibility.
+ */
+ @Prop({ reflect: true })
+ public hotkey?: string;
+
/**
* The maximum amount of characters before rendering 'label' and
* 'helperLabel' in two rows.
@@ -114,7 +110,7 @@ export class Tooltip {
public connectedCallback() {
this.ownerElement = getOwnerElement(this.elementId, this.host);
- this.setOwnerAriaLabel();
+ this.setOwnerAriaDescribedby();
this.addListeners();
}
@@ -142,6 +138,7 @@ export class Tooltip {