@limetech/lime-elements 40.10.0 → 40.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cjs/lime-elements.cjs.js +1 -1
  3. package/dist/cjs/limel-hotkey_4.cjs.entry.js +229 -13
  4. package/dist/cjs/limel-side-panel.cjs.entry.js +67 -22
  5. package/dist/cjs/loader.cjs.js +1 -1
  6. package/dist/collection/components/hotkey/get-hotkey-aria-label.js +14 -0
  7. package/dist/collection/components/hotkey/hotkey.js +3 -5
  8. package/dist/collection/components/side-panel/side-panel.css +176 -1
  9. package/dist/collection/components/side-panel/side-panel.js +197 -22
  10. package/dist/collection/components/side-panel/side-panel.types.js +1 -0
  11. package/dist/collection/components/tooltip/description-registry.js +57 -0
  12. package/dist/collection/components/tooltip/get-description.js +42 -0
  13. package/dist/collection/components/tooltip/tooltip.js +53 -11
  14. package/dist/collection/interface.js +1 -0
  15. package/dist/collection/util/get-focus-target.js +69 -0
  16. package/dist/esm/lime-elements.js +1 -1
  17. package/dist/esm/limel-hotkey_4.entry.js +229 -13
  18. package/dist/esm/limel-side-panel.entry.js +67 -22
  19. package/dist/esm/loader.js +1 -1
  20. package/dist/lime-elements/lime-elements.esm.js +1 -1
  21. package/dist/lime-elements/p-1a5478e3.entry.js +1 -0
  22. package/dist/lime-elements/p-82c65f70.entry.js +1 -0
  23. package/dist/types/components/hotkey/get-hotkey-aria-label.d.ts +8 -0
  24. package/dist/types/components/side-panel/side-panel.d.ts +89 -8
  25. package/dist/types/components/side-panel/side-panel.types.d.ts +83 -0
  26. package/dist/types/components/tooltip/description-registry.d.ts +21 -0
  27. package/dist/types/components/tooltip/get-description.d.ts +13 -0
  28. package/dist/types/components/tooltip/tooltip.d.ts +11 -3
  29. package/dist/types/components.d.ts +221 -10
  30. package/dist/types/interface.d.ts +1 -0
  31. package/dist/types/util/get-focus-target.d.ts +14 -0
  32. package/package.json +3 -3
  33. package/dist/lime-elements/p-566750e5.entry.js +0 -1
  34. package/dist/lime-elements/p-a373013f.entry.js +0 -1
@@ -7,12 +7,22 @@ import { createRandomString } from "../../util/random-string";
7
7
  * Escape.
8
8
  */
9
9
  const openPanels = [];
10
+ // The same icons as the navigation buttons in the Lime CRM web client.
11
+ const ACTION_ICONS = {
12
+ newTab: 'up_right_arrow',
13
+ expand: 'expand',
14
+ };
15
+ function getLinkTarget(action) {
16
+ if (action.href && action.type === 'newTab') {
17
+ return '_blank';
18
+ }
19
+ }
10
20
  function stopEvent(event) {
11
21
  event.stopPropagation();
12
22
  }
13
23
  /**
14
24
  * A resizable panel that is displayed on top of the right side of the page.
15
- * The content is placed in the default slot, below a toolbar with a close button.
25
+ * The content is placed in the default slot, below a toolbar and a header.
16
26
  *
17
27
  * The panel is positioned relative to its nearest positioned ancestor. Set
18
28
  * `position: relative` on that element. Use `--side-panel-top-inset`,
@@ -33,9 +43,68 @@ function stopEvent(event) {
33
43
  *
34
44
  * See the examples for how closing and resizing work in detail.
35
45
  *
46
+ * ## Header
47
+ *
48
+ * The header shows the heading, subheading and icon of the last entry in
49
+ * `stack`. When the entry has a `color`, the header gets that background color,
50
+ * and the headings are white with a text shadow. When `stack` is empty, the
51
+ * panel has no header.
52
+ *
53
+ * Put controls for the header, for example a follow button or a menu, in the
54
+ * `header-actions` slot. They are shown at the right end of the header. When
55
+ * `stack` is empty, there is no header, and the slot content is not shown.
56
+ *
57
+ * The slot is for small controls. It is at most half as wide as the header,
58
+ * and at most as tall as the heading and subheading together. Larger content
59
+ * is cut off, so that the header keeps its size.
60
+ *
61
+ * The slot content is not tied to an entry in `stack`. When the last entry
62
+ * changes, the consumer must also change the slot content.
63
+ *
64
+ * The slot content keeps its own colors. Use `--side-panel-header-text-color`
65
+ * to match the text color of the header. It is white when the header has a
66
+ * `color`. It can also be used as the `color` of an `Icon`.
67
+ *
68
+ * ## Actions
69
+ *
70
+ * The toolbar has a close button on the left. Other buttons are set with
71
+ * `actions`, and are shown on the right. The `type` of an action decides its
72
+ * icon. When the user clicks an action, the panel emits `selectAction` with the
73
+ * `id` of the action and the click event. The consumer decides what the action
74
+ * does.
75
+ *
76
+ * An action with an `href` is a link, so the user can open it in a new tab.
77
+ * To handle the navigation in another way, for example with the router of the
78
+ * application, call `preventDefault` on the click event in `selectAction`. Do
79
+ * this only for a plain click with the primary button. Let the browser handle
80
+ * all other clicks, so that Ctrl-click and Cmd-click still open the link in a
81
+ * new tab:
82
+ *
83
+ * ```ts
84
+ * function handleSelectAction(event: CustomEvent<SidePanelActionSelect>) {
85
+ * const click = event.detail.event;
86
+ * const isPlainClick =
87
+ * click.button === 0 &&
88
+ * !click.metaKey &&
89
+ * !click.ctrlKey &&
90
+ * !click.shiftKey &&
91
+ * !click.altKey;
92
+ * if (!isPlainClick) {
93
+ * return;
94
+ * }
95
+ *
96
+ * click.preventDefault();
97
+ * router.navigate(event.detail.id);
98
+ * }
99
+ * ```
100
+ *
36
101
  * @exampleComponent limel-example-side-panel-basic
37
102
  * @exampleComponent limel-example-side-panel-insets
38
103
  * @exampleComponent limel-example-side-panel-escape
104
+ * @exampleComponent limel-example-side-panel-actions
105
+ * @exampleComponent limel-example-side-panel-header-actions
106
+ * @slot - Content of the panel
107
+ * @slot header-actions - Small controls at the right end of the header. Content that is larger than half the header width or the height of the headings is cut off.
39
108
  * @private
40
109
  */
41
110
  export class SidePanel {
@@ -45,6 +114,16 @@ export class SidePanel {
45
114
  * name.
46
115
  */
47
116
  this.closeLabel = 'Close';
117
+ /**
118
+ * The panels in the stack, outermost first. The last entry is the panel
119
+ * that is displayed. Its heading, subheading and icon are shown in the
120
+ * header.
121
+ */
122
+ this.stack = [];
123
+ /**
124
+ * Buttons in the toolbar, to the right of the close button.
125
+ */
126
+ this.actions = [];
48
127
  /**
49
128
  * Label of the resize handle. Used as the accessible name.
50
129
  */
@@ -56,9 +135,28 @@ export class SidePanel {
56
135
  */
57
136
  this.closeOnEscape = true;
58
137
  this.closeButtonId = createRandomString();
59
- this.setBody = (element) => {
60
- this.body = element;
61
- this.stopContentEventsInBody();
138
+ this.actionIdPrefix = createRandomString();
139
+ this.renderAction = (action, index) => {
140
+ const id = `${this.actionIdPrefix}-${index}`;
141
+ const Tag = action.href ? 'a' : 'button';
142
+ return (h(Tag, { class: action.type, id: id, href: action.href, target: getLinkTarget(action), "aria-label": action.label, onClick: this.handleClickAction(action) }, h("limel-icon", { name: ACTION_ICONS[action.type], "aria-hidden": "true" }), h("limel-tooltip", { elementId: id, label: action.label, helperLabel: action.helperLabel })));
143
+ };
144
+ /**
145
+ * Components in the content can emit events with the same names as the
146
+ * panel's events, for example `close` from `limel-dialog` or
147
+ * `limel-collapsible-section`. Events from slotted content pass through the
148
+ * element around the slot before they reach the host, so they are stopped
149
+ * there. The panel's own events are dispatched on the host, so they are not
150
+ * affected.
151
+ *
152
+ * Keep the event names in sync with {@link SidePanel.stopContentEventAtHost}.
153
+ *
154
+ * @param element - the element around a slot
155
+ */
156
+ this.stopContentEvents = (element) => {
157
+ // Adding the same listener again has no effect, so re-renders are safe.
158
+ element === null || element === void 0 ? void 0 : element.addEventListener('close', stopEvent);
159
+ element === null || element === void 0 ? void 0 : element.addEventListener('changeWidth', stopEvent);
62
160
  };
63
161
  // Moving the panel in the DOM also disconnects it. It is then connected
64
162
  // again before the microtask runs. Focus only returns when it was lost with
@@ -130,6 +228,13 @@ export class SidePanel {
130
228
  }
131
229
  this.widthRange = this.measureWidthRange();
132
230
  };
231
+ this.handleClickAction = (action) => (event) => {
232
+ var _a;
233
+ this.selectAction.emit({
234
+ id: (_a = action.id) !== null && _a !== void 0 ? _a : action.type,
235
+ event: event,
236
+ });
237
+ };
133
238
  this.handleClickClose = () => {
134
239
  this.close.emit();
135
240
  };
@@ -152,37 +257,35 @@ export class SidePanel {
152
257
  queueMicrotask(this.restoreFocus);
153
258
  }
154
259
  render() {
155
- return (h(Host, { key: '1c18f40fb45731bdf157d651d4e649446de5230b' }, h("section", { key: 'e8b41bded39732750ae3db9b1daf14dc742e1cb7', ref: this.setPanel, role: "dialog", "aria-label": this.label, tabindex: "-1" }, this.renderHandle(), h("div", { key: '3cba5523e0cbe45fa328daaa40136c063a8c3ac3', class: "content" }, h("div", { key: 'b5cbc1260b4e6f31ac789179ab4b5a0281b9133b', class: "toolbar" }, this.renderCloseButton()), this.renderBody())), h("span", { key: '5fe872f05a6cc49027f84d71a765baa59a53adb9', "data-limit": "min", ref: this.setMinLimit }), h("span", { key: '5042e7437e61ae5308e13b1a30f5df24747e2cdf', "data-limit": "max", ref: this.setMaxLimit })));
260
+ return (h(Host, { key: 'debf8ec5b8e179cbb69c2d87fdd0db7089707dfb' }, h("section", { key: 'd6165d2ff25cad3b9bebe807065b6a83bca24da2', ref: this.setPanel, role: "dialog", "aria-label": this.label, tabindex: "-1" }, this.renderHandle(), h("div", { key: 'b95d937af7fc5799abee8a6042ea2be6087c1b0b', class: "content" }, h("div", { key: 'e258c9475715b00fd1e56a9975db6d0f7e5d19fc', class: "toolbar" }, this.renderCloseButton(), this.renderActions()), this.renderHeader(), this.renderBody())), h("span", { key: '6b7793d7c02f632d630ac4936524104f81a10e23', "data-limit": "min", ref: this.setMinLimit }), h("span", { key: '6196bc2f8549ddd9b8c8bb43bcf221a43f62c98a', "data-limit": "max", ref: this.setMaxLimit })));
156
261
  }
157
262
  // A native button and not `limel-icon-button`, because the hover animation
158
263
  // styles the icon, which is inside the shadow root of `limel-icon-button`.
159
264
  renderCloseButton() {
160
265
  return (h("button", { class: "close", id: this.closeButtonId, "aria-label": this.closeLabel, onClick: this.handleClickClose }, h("limel-icon", { name: "multiply", "aria-hidden": "true" }), h("limel-tooltip", { elementId: this.closeButtonId, label: this.closeLabel })));
161
266
  }
267
+ renderActions() {
268
+ if (this.actions.length === 0) {
269
+ return;
270
+ }
271
+ return (h("div", { class: "actions" }, this.actions.map((action, index) => this.renderAction(action, index))));
272
+ }
273
+ renderHeader() {
274
+ const entry = this.stack.at(-1);
275
+ if (!entry) {
276
+ return;
277
+ }
278
+ return (h("limel-header", { class: { header: true, 'has-color': !!entry.color }, style: { '--header-background-color': entry.color }, icon: entry.icon, heading: entry.heading, subheading: entry.subheading }, h("div", { class: "header-actions", slot: "actions", ref: this.stopContentEvents }, h("slot", { name: "header-actions" }))));
279
+ }
162
280
  renderHandle() {
163
281
  var _a, _b, _c;
164
282
  return (h("limel-resize-handle", { class: "handle", label: this.resizeLabel, value: (_a = this.widthRange) === null || _a === void 0 ? void 0 : _a.now, min: (_b = this.widthRange) === null || _b === void 0 ? void 0 : _b.min, max: (_c = this.widthRange) === null || _c === void 0 ? void 0 : _c.max, onChangeSize: this.handleChangeSize, onEndResize: this.handleEndResize }));
165
283
  }
166
284
  renderBody() {
167
- return (h("div", { class: "body", ref: this.setBody }, h("slot", null)));
168
- }
169
- /**
170
- * Components in the content can emit events with the same names as the
171
- * panel's events, for example `close` from `limel-dialog` or
172
- * `limel-collapsible-section`. Events from slotted content pass through
173
- * `.body` before they reach the host, so they are stopped there. The panel's
174
- * own events are dispatched on the host, so they are not affected.
175
- *
176
- * Keep the event names in sync with {@link SidePanel.stopContentEventAtHost}.
177
- */
178
- stopContentEventsInBody() {
179
- var _a, _b;
180
- // Adding the same listener again has no effect, so re-renders are safe.
181
- (_a = this.body) === null || _a === void 0 ? void 0 : _a.addEventListener('close', stopEvent);
182
- (_b = this.body) === null || _b === void 0 ? void 0 : _b.addEventListener('changeWidth', stopEvent);
285
+ return (h("div", { class: "body", ref: this.stopContentEvents }, h("slot", null)));
183
286
  }
184
287
  /**
185
- * Fallback for {@link SidePanel.stopContentEventsInBody}.
288
+ * Fallback for {@link SidePanel.stopContentEvents}.
186
289
  * Stops `close` and `changeWidth` events that reach the host from any
187
290
  * element other than the host.
188
291
  *
@@ -334,6 +437,56 @@ export class SidePanel {
334
437
  "attribute": "close-label",
335
438
  "defaultValue": "'Close'"
336
439
  },
440
+ "stack": {
441
+ "type": "unknown",
442
+ "mutable": false,
443
+ "complexType": {
444
+ "original": "SidePanelEntry[]",
445
+ "resolved": "SidePanelEntry[]",
446
+ "references": {
447
+ "SidePanelEntry": {
448
+ "location": "import",
449
+ "path": "./side-panel.types",
450
+ "id": "src/components/side-panel/side-panel.types.ts::SidePanelEntry",
451
+ "referenceLocation": "SidePanelEntry"
452
+ }
453
+ }
454
+ },
455
+ "required": false,
456
+ "optional": false,
457
+ "docs": {
458
+ "tags": [],
459
+ "text": "The panels in the stack, outermost first. The last entry is the panel\nthat is displayed. Its heading, subheading and icon are shown in the\nheader."
460
+ },
461
+ "getter": false,
462
+ "setter": false,
463
+ "defaultValue": "[]"
464
+ },
465
+ "actions": {
466
+ "type": "unknown",
467
+ "mutable": false,
468
+ "complexType": {
469
+ "original": "SidePanelAction[]",
470
+ "resolved": "SidePanelAction[]",
471
+ "references": {
472
+ "SidePanelAction": {
473
+ "location": "import",
474
+ "path": "./side-panel.types",
475
+ "id": "src/components/side-panel/side-panel.types.ts::SidePanelAction",
476
+ "referenceLocation": "SidePanelAction"
477
+ }
478
+ }
479
+ },
480
+ "required": false,
481
+ "optional": false,
482
+ "docs": {
483
+ "tags": [],
484
+ "text": "Buttons in the toolbar, to the right of the close button."
485
+ },
486
+ "getter": false,
487
+ "setter": false,
488
+ "defaultValue": "[]"
489
+ },
337
490
  "resizeLabel": {
338
491
  "type": "string",
339
492
  "mutable": false,
@@ -397,6 +550,28 @@ export class SidePanel {
397
550
  "resolved": "void",
398
551
  "references": {}
399
552
  }
553
+ }, {
554
+ "method": "selectAction",
555
+ "name": "selectAction",
556
+ "bubbles": true,
557
+ "cancelable": true,
558
+ "composed": true,
559
+ "docs": {
560
+ "tags": [],
561
+ "text": "Emitted when an action in the toolbar is clicked. The detail has the `id`\nof the action, or its `type` if it has no `id`, and the click event."
562
+ },
563
+ "complexType": {
564
+ "original": "SidePanelActionSelect",
565
+ "resolved": "SidePanelActionSelect",
566
+ "references": {
567
+ "SidePanelActionSelect": {
568
+ "location": "import",
569
+ "path": "./side-panel.types",
570
+ "id": "src/components/side-panel/side-panel.types.ts::SidePanelActionSelect",
571
+ "referenceLocation": "SidePanelActionSelect"
572
+ }
573
+ }
574
+ }
400
575
  }, {
401
576
  "method": "changeWidth",
402
577
  "name": "changeWidth",
@@ -0,0 +1,57 @@
1
+ const entries = new WeakMap();
2
+ /**
3
+ * Set the `aria-description` of an element on behalf of an owner. The
4
+ * description that the element had before is kept, for when the last owner
5
+ * stops describing it.
6
+ *
7
+ * @param element
8
+ * @param owner
9
+ * @param text
10
+ */
11
+ export function setDescription(element, owner, text) {
12
+ let entry = entries.get(element);
13
+ if (!entry) {
14
+ entry = {
15
+ original: element.getAttribute('aria-description'),
16
+ texts: new Map(),
17
+ };
18
+ entries.set(element, entry);
19
+ }
20
+ entry.texts.delete(owner);
21
+ entry.texts.set(owner, text);
22
+ element.setAttribute('aria-description', text);
23
+ }
24
+ /**
25
+ * Stop describing an element on behalf of an owner. The text of the owner
26
+ * that described it most recently stays, and when none is left, the
27
+ * description that the element had before is put back. Nothing is changed
28
+ * if the attribute has been changed by someone else.
29
+ *
30
+ * @param element
31
+ * @param owner
32
+ */
33
+ export function clearDescription(element, owner) {
34
+ const entry = entries.get(element);
35
+ const text = entry === null || entry === void 0 ? void 0 : entry.texts.get(owner);
36
+ if (!entry || text === undefined) {
37
+ return;
38
+ }
39
+ entry.texts.delete(owner);
40
+ const isCurrent = element.getAttribute('aria-description') === text;
41
+ const remaining = [...entry.texts.values()].at(-1);
42
+ if (remaining !== undefined) {
43
+ if (isCurrent) {
44
+ element.setAttribute('aria-description', remaining);
45
+ }
46
+ return;
47
+ }
48
+ entries.delete(element);
49
+ if (!isCurrent) {
50
+ return;
51
+ }
52
+ if (entry.original === null) {
53
+ element.removeAttribute('aria-description');
54
+ return;
55
+ }
56
+ element.setAttribute('aria-description', entry.original);
57
+ }
@@ -0,0 +1,42 @@
1
+ import { isAppleDevice } from "../../util/device";
2
+ import { normalizeHotkeyString } from "../../util/hotkeys";
3
+ import { getHotkeyAriaLabel } from "../hotkey/get-hotkey-aria-label";
4
+ /**
5
+ * Get the text that a screen reader should read as the description of the
6
+ * element that a tooltip describes. The label and the helper label are left
7
+ * out when they only repeat the name of the element, or the text of one of
8
+ * its parts, so that they are not read twice.
9
+ *
10
+ * @param element
11
+ * @param label
12
+ * @param helperLabel
13
+ * @param hotkey
14
+ */
15
+ export function getDescription(element, label, helperLabel, hotkey) {
16
+ const names = new Set(getNames(element).map(normalize));
17
+ const parts = [label, helperLabel].filter((text) => text && !names.has(normalize(text)));
18
+ parts.push(getHotkeyText(hotkey));
19
+ return parts.filter(Boolean).join('. ');
20
+ }
21
+ function getHotkeyText(hotkey) {
22
+ const normalized = normalizeHotkeyString(hotkey);
23
+ return normalized
24
+ ? getHotkeyAriaLabel(normalized, isAppleDevice())
25
+ : undefined;
26
+ }
27
+ function getNames(element) {
28
+ var _a;
29
+ const ariaLabel = (_a = element.getAttribute('aria-label')) === null || _a === void 0 ? void 0 : _a.trim();
30
+ if (ariaLabel) {
31
+ return [ariaLabel];
32
+ }
33
+ const labelled = [...element.querySelectorAll('[aria-label]')];
34
+ return [
35
+ element.textContent,
36
+ ...[...element.children].map((part) => part.textContent),
37
+ ...labelled.map((labelledElement) => labelledElement.getAttribute('aria-label')),
38
+ ].filter(Boolean);
39
+ }
40
+ function normalize(text) {
41
+ return text.replaceAll(/\s+/g, ' ').trim().toLowerCase();
42
+ }
@@ -1,5 +1,8 @@
1
1
  import { h } from "@stencil/core";
2
2
  import { createRandomString } from "../../util/random-string";
3
+ import { clearDescription, setDescription } from "./description-registry";
4
+ import { getDescription } from "./get-description";
5
+ import { getFocusTarget } from "../../util/get-focus-target";
3
6
  import { getOwnerElement } from "./get-owner-element";
4
7
  import { TooltipTimer } from "./tooltip-timer";
5
8
  const DEFAULT_MAX_LENGTH = 50;
@@ -48,6 +51,7 @@ export class Tooltip {
48
51
  * Decides the tooltip's location in relation to its trigger.
49
52
  */
50
53
  this.openDirection = 'top';
54
+ this.latestUpdate = 0;
51
55
  this.showTooltip = () => {
52
56
  this.tooltipTimer.showAfterDelay();
53
57
  };
@@ -55,27 +59,53 @@ export class Tooltip {
55
59
  this.tooltipTimer.hide();
56
60
  };
57
61
  this.portalId = createRandomString();
58
- this.tooltipId = createRandomString();
59
62
  this.tooltipTimer = new TooltipTimer(() => (this.open = true), () => (this.open = false));
60
63
  }
61
- connectedCallback() {
64
+ async connectedCallback() {
62
65
  this.ownerElement = getOwnerElement(this.elementId, this.host);
63
- this.setOwnerAriaDescribedby();
64
66
  this.addListeners();
67
+ await this.updateDescription();
65
68
  }
66
69
  disconnectedCallback() {
67
70
  this.removeListeners();
71
+ this.latestUpdate++;
72
+ this.restoreDescription();
73
+ }
74
+ async watchDescription() {
75
+ await this.updateDescription();
68
76
  }
69
77
  render() {
70
78
  const tooltipZIndex = getComputedStyle(this.host).getPropertyValue('--tooltip-z-index');
71
- return (h("div", { key: '11a9db0e1b1e615ddbdeea5eb2c5089e1f9eadeb', class: "trigger-anchor" }, h("limel-portal", { key: '28e7ffeb63ff24978dd2bd1ed0b0b2aa278abbde', openDirection: this.openDirection, visible: this.open, containerId: this.portalId, containerStyle: {
79
+ return (h("div", { key: 'f77ad0d2453d4abda766785d0ccfe96154df71bb', class: "trigger-anchor" }, h("limel-portal", { key: 'e7f314f76039381ebe5c3add9e675a53650ad8f1', openDirection: this.openDirection, visible: this.open, containerId: this.portalId, containerStyle: {
72
80
  'z-index': tooltipZIndex,
73
81
  'pointer-events': 'none',
74
- }, anchor: this.ownerElement }, h("limel-tooltip-content", { key: 'abc48a7948a932c081a44fb1d617bc3b351d452c', label: this.label, helperLabel: this.helperLabel, hotkey: this.hotkey, maxlength: this.maxlength, role: "tooltip", "aria-hidden": !this.open, id: this.tooltipId }))));
82
+ }, anchor: this.ownerElement }, h("limel-tooltip-content", { key: 'afbd8bf04d7070fa8afe22f14496c2c24cdf222e', label: this.label, helperLabel: this.helperLabel, hotkey: this.hotkey, maxlength: this.maxlength, role: "tooltip", "aria-hidden": !this.open }))));
83
+ }
84
+ async updateDescription() {
85
+ if (!this.ownerElement) {
86
+ return;
87
+ }
88
+ const update = ++this.latestUpdate;
89
+ const element = await getFocusTarget(this.ownerElement);
90
+ if (!this.host.isConnected || update !== this.latestUpdate) {
91
+ return;
92
+ }
93
+ if (element !== this.describedElement) {
94
+ this.restoreDescription();
95
+ this.describedElement = element;
96
+ }
97
+ const description = getDescription(element, this.label, this.helperLabel, this.hotkey);
98
+ if (!description) {
99
+ this.restoreDescription();
100
+ return;
101
+ }
102
+ setDescription(element, this, description);
75
103
  }
76
- setOwnerAriaDescribedby() {
77
- var _a;
78
- (_a = this.ownerElement) === null || _a === void 0 ? void 0 : _a.setAttribute('aria-describedby', this.tooltipId);
104
+ restoreDescription() {
105
+ if (this.describedElement) {
106
+ clearDescription(this.describedElement, this);
107
+ }
108
+ this.describedElement = undefined;
79
109
  }
80
110
  addListeners() {
81
111
  var _a, _b, _c, _d;
@@ -136,7 +166,7 @@ export class Tooltip {
136
166
  "optional": false,
137
167
  "docs": {
138
168
  "tags": [],
139
- "text": "Short descriptive text of the owner element."
169
+ "text": "Short descriptive text of the owner element.\nScreen readers read it as a part of the description of the element,\nunless it only repeats the name that the element already has."
140
170
  },
141
171
  "getter": false,
142
172
  "setter": false,
@@ -155,7 +185,7 @@ export class Tooltip {
155
185
  "optional": true,
156
186
  "docs": {
157
187
  "tags": [],
158
- "text": "Additional helper text for the element."
188
+ "text": "Additional helper text for the element.\nScreen readers read it as a part of the description of the element."
159
189
  },
160
190
  "getter": false,
161
191
  "setter": false,
@@ -174,7 +204,7 @@ export class Tooltip {
174
204
  "optional": true,
175
205
  "docs": {
176
206
  "tags": [],
177
- "text": "Keyboard shortcut to visualize inside the tooltip, e.g. `\"ctrl+f\"`.\nDisplay-only: the tooltip does not listen for the keystroke.\nCatching the hotkey is the consumer's responsibility."
207
+ "text": "Keyboard shortcut to visualize inside the tooltip, e.g. `\"ctrl+f\"`.\nScreen readers read it, written out in words, as a part of the\ndescription of the element.\nDisplay-only: the tooltip does not listen for the keystroke.\nCatching the hotkey is the consumer's responsibility."
178
208
  },
179
209
  "getter": false,
180
210
  "setter": false,
@@ -236,4 +266,16 @@ export class Tooltip {
236
266
  };
237
267
  }
238
268
  static get elementRef() { return "host"; }
269
+ static get watchers() {
270
+ return [{
271
+ "propName": "label",
272
+ "methodName": "watchDescription"
273
+ }, {
274
+ "propName": "helperLabel",
275
+ "methodName": "watchDescription"
276
+ }, {
277
+ "propName": "hotkey",
278
+ "methodName": "watchDescription"
279
+ }];
280
+ }
239
281
  }
@@ -34,6 +34,7 @@ export * from './components/picker/searcher.types';
34
34
  export * from './components/picker/value.types';
35
35
  export * from './components/progress-flow/progress-flow.types';
36
36
  export * from './components/select/option.types';
37
+ export * from './components/side-panel/side-panel.types';
37
38
  export * from './components/spinner/spinner.types';
38
39
  export * from './components/tab-bar/tab.types';
39
40
  export * from './components/tab-panel/tab-panel.types';
@@ -0,0 +1,69 @@
1
+ const CONTROLS = ['button', 'input:not([type="hidden"])', 'select', 'textarea'];
2
+ const FOCUSABLE = [
3
+ 'a[href]',
4
+ ...CONTROLS.map((selector) => `${selector}:not([disabled])`),
5
+ 'summary',
6
+ '[contenteditable]:not([contenteditable="false"])',
7
+ '[tabindex]',
8
+ ]
9
+ .map((selector) => `${selector}:not([tabindex="-1"])`)
10
+ .join(', ');
11
+ const DISABLED = CONTROLS.map((selector) => `${selector}[disabled]:not([tabindex="-1"])`).join(', ');
12
+ /**
13
+ * Get the element that a screen reader describes for the owner element of a
14
+ * tooltip. That is the owner itself if it can be focused, otherwise the first
15
+ * element that can be focused inside it, also inside shadow roots. A disabled
16
+ * control is only used when there is nothing that can be focused, and if
17
+ * there is no control at all, the owner itself is used.
18
+ *
19
+ * An element that passes the focus on to an element in its shadow root is
20
+ * never used itself, since the screen reader lands on the element inside.
21
+ *
22
+ * @param owner
23
+ */
24
+ export async function getFocusTarget(owner) {
25
+ var _a, _b;
26
+ const search = {};
27
+ return (_b = (_a = (await find(owner, search))) !== null && _a !== void 0 ? _a : search.disabled) !== null && _b !== void 0 ? _b : owner;
28
+ }
29
+ async function find(element, search) {
30
+ if (isFocusable(element)) {
31
+ return element;
32
+ }
33
+ rememberDisabled(element, search);
34
+ return findInside(element, search);
35
+ }
36
+ async function findInside(element, search) {
37
+ if (isStencilComponent(element)) {
38
+ await element.componentOnReady();
39
+ }
40
+ const inShadowRoot = element.shadowRoot
41
+ ? await findAmong(element.shadowRoot.children, search)
42
+ : undefined;
43
+ return inShadowRoot !== null && inShadowRoot !== void 0 ? inShadowRoot : findAmong(element.children, search);
44
+ }
45
+ async function findAmong(elements, search) {
46
+ for (const element of elements) {
47
+ if (element.localName === 'limel-tooltip') {
48
+ continue;
49
+ }
50
+ const found = await find(element, search);
51
+ if (found) {
52
+ return found;
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ function isFocusable(element) {
58
+ var _a;
59
+ return !((_a = element.shadowRoot) === null || _a === void 0 ? void 0 : _a.delegatesFocus) && element.matches(FOCUSABLE);
60
+ }
61
+ function rememberDisabled(element, search) {
62
+ if (!search.disabled && element.matches(DISABLED)) {
63
+ search.disabled = element;
64
+ }
65
+ }
66
+ function isStencilComponent(element) {
67
+ return ('componentOnReady' in element &&
68
+ typeof element.componentOnReady === 'function');
69
+ }