@limetech/lime-elements 39.44.5 → 39.44.6
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.
- package/CHANGELOG.md +8 -0
- package/dist/cjs/{keycodes-B75-uKOK.js → keycodes-k-EdzcMX.js} +4 -0
- package/dist/cjs/lime-elements.cjs.js +1 -1
- package/dist/cjs/limel-breadcrumbs_8.cjs.entry.js +20 -1
- package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
- package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
- package/dist/cjs/limel-file-viewer.cjs.entry.js +84 -8
- package/dist/cjs/limel-picker.cjs.entry.js +1 -1
- package/dist/cjs/limel-popover_2.cjs.entry.js +1 -1
- package/dist/cjs/limel-select.cjs.entry.js +377 -13
- package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
- package/dist/cjs/loader.cjs.js +1 -1
- package/dist/collection/components/list/list.js +19 -0
- package/dist/collection/components/select/select.js +215 -12
- package/dist/collection/components/select/select.template.js +17 -3
- package/dist/collection/util/keycodes.js +3 -0
- package/dist/collection/util/typeahead.js +159 -0
- package/dist/esm/{keycodes-rI0IeKpx.js → keycodes-C4_9qUMP.js} +4 -1
- package/dist/esm/lime-elements.js +1 -1
- package/dist/esm/limel-breadcrumbs_8.entry.js +20 -1
- package/dist/esm/limel-chip-set.entry.js +1 -1
- package/dist/esm/limel-chip_2.entry.js +1 -1
- package/dist/esm/limel-file-viewer.entry.js +84 -8
- package/dist/esm/limel-picker.entry.js +1 -1
- package/dist/esm/limel-popover_2.entry.js +1 -1
- package/dist/esm/limel-select.entry.js +378 -14
- package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
- package/dist/esm/loader.js +1 -1
- package/dist/lime-elements/lime-elements.esm.js +1 -1
- package/dist/lime-elements/{p-a089b580.entry.js → p-03fd2181.entry.js} +1 -1
- package/dist/lime-elements/{p-fc43fb46.entry.js → p-12fe8fe1.entry.js} +6 -6
- package/dist/lime-elements/{p-bf3c78a8.entry.js → p-2b01c1f2.entry.js} +1 -1
- package/dist/lime-elements/{p-758939be.entry.js → p-2e5d5e79.entry.js} +1 -1
- package/dist/lime-elements/p-9152d7e1.entry.js +23 -0
- package/dist/lime-elements/{p-1e3cdfa1.entry.js → p-9a04a686.entry.js} +1 -1
- package/dist/lime-elements/p-C4_9qUMP.js +1 -0
- package/dist/lime-elements/{p-6fbf20c6.entry.js → p-e8df7a2e.entry.js} +1 -1
- package/dist/lime-elements/p-f51b5651.entry.js +1 -0
- package/dist/types/components/select/select.d.ts +76 -1
- package/dist/types/components/select/select.template.d.ts +21 -1
- package/dist/types/components.d.ts +36 -0
- package/dist/types/util/keycodes.d.ts +3 -0
- package/dist/types/util/typeahead.d.ts +101 -0
- package/package.json +2 -2
- package/dist/lime-elements/p-552d0733.entry.js +0 -1
- package/dist/lime-elements/p-8f4273e4.entry.js +0 -23
- package/dist/lime-elements/p-rI0IeKpx.js +0 -1
|
@@ -50,6 +50,25 @@ export class List {
|
|
|
50
50
|
this.listElement.removeEventListener('mousedown', this.handleItemMouseDown);
|
|
51
51
|
this.listElement.addEventListener('mousedown', this.handleItemMouseDown);
|
|
52
52
|
this.mdcList = new MDCList(element);
|
|
53
|
+
// NOTE: This has no effect. It is kept as a marker rather than
|
|
54
|
+
// removed, so that its absence isn't mistaken for an oversight.
|
|
55
|
+
//
|
|
56
|
+
// MDC indexes each row for typeahead by looking for
|
|
57
|
+
// `.mdc-deprecated-list-item__primary-text` inside it, while
|
|
58
|
+
// `limel-list-item` renders its label as `<span class="label">`. MDC
|
|
59
|
+
// therefore builds an empty index, and typing does nothing.
|
|
60
|
+
//
|
|
61
|
+
// Do not "fix" this by adding MDC's class name to `limel-list-item`.
|
|
62
|
+
// That revives MDC's typeahead along with three behaviors that cannot
|
|
63
|
+
// be configured from the outside: a hard coded 300ms buffer, `Enter`
|
|
64
|
+
// being ignored for as long as that buffer lives (its `notifyAction`
|
|
65
|
+
// is guarded by `isTypeaheadInProgress`), and the space bar being
|
|
66
|
+
// excluded from matches. It would also collide with `limel-menu`,
|
|
67
|
+
// where single characters are used as item hotkeys.
|
|
68
|
+
//
|
|
69
|
+
// `limel-select` implements its own typeahead instead, see
|
|
70
|
+
// `src/util/typeahead.ts`. It intercepts typed characters in the
|
|
71
|
+
// capture phase on this very element, so they never reach MDC.
|
|
53
72
|
this.mdcList.hasTypeahead = true;
|
|
54
73
|
};
|
|
55
74
|
/**
|
|
@@ -2,11 +2,23 @@ import { MDCFloatingLabel } from "@material/floating-label";
|
|
|
2
2
|
import { MDCSelectHelperText } from "@material/select/helper-text";
|
|
3
3
|
import { h, } from "@stencil/core";
|
|
4
4
|
import { isMobileDevice } from "../../util/device";
|
|
5
|
-
import { ENTER,
|
|
5
|
+
import { ENTER, SPACEBAR } from "../../util/keycodes";
|
|
6
6
|
import { isMultiple } from "../../util/multiple";
|
|
7
7
|
import { createRandomString } from "../../util/random-string";
|
|
8
|
-
import {
|
|
8
|
+
import { findTypeaheadMatch, isTypeaheadKey, NO_TYPEAHEAD_MATCH, TypeaheadBuffer, } from "../../util/typeahead";
|
|
9
|
+
import { createMenuItems, SelectTemplate, triggerIconColorWarning, } from "./select.template";
|
|
9
10
|
/**
|
|
11
|
+
* ## Keyboard
|
|
12
|
+
*
|
|
13
|
+
* Typing characters jumps to the option whose text starts with them, the way
|
|
14
|
+
* a native `<select>` does. Characters accumulate for a short while, so typing
|
|
15
|
+
* `n`, `e` finds "Netherlands" rather than the next option starting with `n`.
|
|
16
|
+
* Pressing the same character repeatedly cycles through all options starting
|
|
17
|
+
* with it. This works both while the dropdown is open, and while the closed
|
|
18
|
+
* component has focus — in which case the dropdown opens with the match
|
|
19
|
+
* highlighted. Typing only moves the highlight; the value is not changed until
|
|
20
|
+
* the option is picked with `Enter` or a click.
|
|
21
|
+
*
|
|
10
22
|
* @exampleComponent limel-example-select-basic
|
|
11
23
|
* @exampleComponent limel-example-select-with-icons
|
|
12
24
|
* @exampleComponent limel-example-select-with-separators
|
|
@@ -26,6 +38,15 @@ export class Select {
|
|
|
26
38
|
updateHasPrimaryComponent() {
|
|
27
39
|
this.hasPrimaryComponentMemo = this.computeHasPrimaryComponent();
|
|
28
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* `options` and `required` are the inputs that decide which rows the
|
|
43
|
+
* dropdown renders, and therefore which row an index refers to. When
|
|
44
|
+
* either changes, a buffered typeahead match no longer points at what the
|
|
45
|
+
* user was aiming for.
|
|
46
|
+
*/
|
|
47
|
+
resetTypeaheadOnItemsChange() {
|
|
48
|
+
this.resetTypeahead();
|
|
49
|
+
}
|
|
29
50
|
constructor() {
|
|
30
51
|
/**
|
|
31
52
|
* Set to `true` to make the field disabled.
|
|
@@ -57,10 +78,51 @@ export class Select {
|
|
|
57
78
|
this.hasChanged = false;
|
|
58
79
|
this.hasPrimaryComponentMemo = false;
|
|
59
80
|
this.checkValid = false;
|
|
81
|
+
this.typeaheadBuffer = new TypeaheadBuffer();
|
|
82
|
+
this.handleMenuTriggerKeyDown = (event) => {
|
|
83
|
+
// Typeahead runs first, so that a space extends an ongoing typeahead
|
|
84
|
+
// instead of just opening the dropdown. A *bare* space is declined by
|
|
85
|
+
// `handleTypeaheadKey`, and keeps its usual meaning below.
|
|
86
|
+
if (this.handleTypeaheadKey(event, NO_TYPEAHEAD_MATCH)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const isEnter = event.key === ENTER;
|
|
90
|
+
const isSpace = event.key === SPACEBAR;
|
|
91
|
+
if (!this.menuOpen && (isSpace || isEnter)) {
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
// `preventDefault` cancels the activation click that the trigger
|
|
95
|
+
// `button` would otherwise synthesize, so the menu has to be
|
|
96
|
+
// opened here rather than through the click handler.
|
|
97
|
+
this.openMenu();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
this.setListElement = (element) => {
|
|
101
|
+
var _a, _b;
|
|
102
|
+
if (this.list === element) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
(_a = this.list) === null || _a === void 0 ? void 0 : _a.removeEventListener('keydown', this.handleListKeyDownCapture, true);
|
|
106
|
+
this.list = element;
|
|
107
|
+
(_b = this.list) === null || _b === void 0 ? void 0 : _b.addEventListener('keydown', this.handleListKeyDownCapture, true);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Key handler for the dropdown, in the capture phase.
|
|
111
|
+
*
|
|
112
|
+
* `MDCList` listens for `keydown` on the `ul` inside `limel-list`'s shadow
|
|
113
|
+
* root, so capturing on the `limel-list` element itself is the only place
|
|
114
|
+
* a typed character can be stopped before MDC sees it. It has to be
|
|
115
|
+
* stopped: MDC would add it to its own typeahead buffer, which suppresses
|
|
116
|
+
* selection with `Enter` for as long as that buffer lives, and it treats
|
|
117
|
+
* the space bar as a selection.
|
|
118
|
+
*
|
|
119
|
+
* @param event - the key that was pressed
|
|
120
|
+
*/
|
|
121
|
+
this.handleListKeyDownCapture = (event) => {
|
|
122
|
+
this.handleTypeaheadKey(event, this.getFocusedMenuItemIndex());
|
|
123
|
+
};
|
|
60
124
|
this.handleMenuChange = this.handleMenuChange.bind(this);
|
|
61
125
|
this.handleNativeChange = this.handleNativeChange.bind(this);
|
|
62
|
-
this.handleMenuTriggerKeyPress =
|
|
63
|
-
this.handleMenuTriggerKeyPress.bind(this);
|
|
64
126
|
this.openMenu = this.openMenu.bind(this);
|
|
65
127
|
this.closeMenu = this.closeMenu.bind(this);
|
|
66
128
|
this.portalId = createRandomString();
|
|
@@ -96,6 +158,7 @@ export class Select {
|
|
|
96
158
|
}
|
|
97
159
|
disconnectedCallback() {
|
|
98
160
|
this.cancelPendingFocus();
|
|
161
|
+
this.resetTypeahead();
|
|
99
162
|
if (this.mdcFloatingLabel) {
|
|
100
163
|
this.mdcFloatingLabel.destroy();
|
|
101
164
|
}
|
|
@@ -110,7 +173,7 @@ export class Select {
|
|
|
110
173
|
}
|
|
111
174
|
render() {
|
|
112
175
|
const dropdownZIndex = getComputedStyle(this.host).getPropertyValue('--dropdown-z-index');
|
|
113
|
-
return (h(SelectTemplate, { key: '
|
|
176
|
+
return (h(SelectTemplate, { key: '97e6b0b033a487e26ab7e4abe7165837ebe08911', id: this.portalId, disabled: this.disabled || this.readonly, readonly: this.readonly, required: this.required, invalid: this.invalid, label: this.label, helperText: this.helperText, value: this.value, options: this.options, onMenuChange: this.handleMenuChange, onNativeChange: this.handleNativeChange, onTriggerKeyDown: this.handleMenuTriggerKeyDown, listRef: this.setListElement, multiple: this.multiple, isOpen: this.menuOpen, open: this.openMenu, close: this.closeMenu, checkValid: this.checkValid, native: this.shouldRenderNative(), dropdownZIndex: dropdownZIndex, anchor: this.getAnchorElement() }));
|
|
114
177
|
}
|
|
115
178
|
watchOpen(newValue, oldValue) {
|
|
116
179
|
if (this.checkValid) {
|
|
@@ -145,6 +208,21 @@ export class Select {
|
|
|
145
208
|
if (!this.menuOpen) {
|
|
146
209
|
return;
|
|
147
210
|
}
|
|
211
|
+
// Consumed on read, so that a later, unrelated re-render
|
|
212
|
+
// cannot resurrect a stale index and yank focus.
|
|
213
|
+
const typeaheadIndex = this.pendingTypeaheadIndex;
|
|
214
|
+
this.pendingTypeaheadIndex = undefined;
|
|
215
|
+
if (typeaheadIndex !== undefined &&
|
|
216
|
+
this.focusMenuItemAtIndex(list, typeaheadIndex)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Picking an option while `multiple` re-renders the dropdown
|
|
220
|
+
// without closing it, which brings us back here with a row
|
|
221
|
+
// already focused. Moving to the first option then would lose
|
|
222
|
+
// the user's place in the list after every pick.
|
|
223
|
+
if (this.getFocusedMenuItemIndex() !== NO_TYPEAHEAD_MATCH) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
148
226
|
this.focusFirstMenuItem(list);
|
|
149
227
|
});
|
|
150
228
|
this.focusObserver.observe(list);
|
|
@@ -167,6 +245,30 @@ export class Select {
|
|
|
167
245
|
firstItem.focus({ preventScroll: true });
|
|
168
246
|
}
|
|
169
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Focus the row at a given index in the dropdown.
|
|
250
|
+
*
|
|
251
|
+
* The rows only become focusable once `limel-list` has set up `MDCList`,
|
|
252
|
+
* which is what gives them a `tabindex`. Callers use the return value to
|
|
253
|
+
* fall back to `pendingTypeaheadIndex` when that has not happened yet.
|
|
254
|
+
*
|
|
255
|
+
* @param list - the `limel-list` of the dropdown
|
|
256
|
+
* @param index - the index of the row, as rendered in its `data-index`
|
|
257
|
+
* @returns whether the row could be focused
|
|
258
|
+
*/
|
|
259
|
+
focusMenuItemAtIndex(list, index) {
|
|
260
|
+
var _a;
|
|
261
|
+
const item = (_a = list === null || list === void 0 ? void 0 : list.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector(`[data-index="${index}"]`);
|
|
262
|
+
if (!item) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
// Scrolled explicitly, and only within the dropdown: letting `focus()`
|
|
266
|
+
// scroll would scroll the page, since the dropdown is rendered into a
|
|
267
|
+
// portal that is absolutely positioned on the `body`.
|
|
268
|
+
item.focus({ preventScroll: true });
|
|
269
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
270
|
+
return list.shadowRoot.activeElement === item;
|
|
271
|
+
}
|
|
170
272
|
setTriggerFocus() {
|
|
171
273
|
const trigger = this.host.shadowRoot.querySelector('.limel-select-trigger');
|
|
172
274
|
trigger.focus();
|
|
@@ -218,6 +320,7 @@ export class Select {
|
|
|
218
320
|
this.change.emit(option);
|
|
219
321
|
this.menuOpen = false;
|
|
220
322
|
this.cancelPendingFocus();
|
|
323
|
+
this.resetTypeahead();
|
|
221
324
|
this.setTriggerFocus();
|
|
222
325
|
}
|
|
223
326
|
openMenu() {
|
|
@@ -251,17 +354,111 @@ export class Select {
|
|
|
251
354
|
closeMenu() {
|
|
252
355
|
this.menuOpen = false;
|
|
253
356
|
this.cancelPendingFocus();
|
|
357
|
+
this.resetTypeahead();
|
|
254
358
|
this.setTriggerFocus();
|
|
255
359
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Move the highlight to the option matching the characters typed so far,
|
|
362
|
+
* the way a native `<select>` does. Never changes the value — the option
|
|
363
|
+
* still has to be picked with `Enter` or a click.
|
|
364
|
+
*
|
|
365
|
+
* @param event - the key that was pressed
|
|
366
|
+
* @param focusedIndex - the row that currently has focus, if any
|
|
367
|
+
* @returns whether the key was handled as typeahead
|
|
368
|
+
*/
|
|
369
|
+
handleTypeaheadKey(event, focusedIndex) {
|
|
370
|
+
// The native dropdown on mobile devices does its own typeahead. Note
|
|
371
|
+
// that `setMenuFocus` bails out on mobile too, so a pending index
|
|
372
|
+
// would never be consumed there.
|
|
373
|
+
if (this.isMobileDevice || !isTypeaheadKey(event)) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
// A space only continues an ongoing typeahead. On its own it keeps its
|
|
377
|
+
// usual meaning of opening the dropdown, or selecting the focused
|
|
378
|
+
// option.
|
|
379
|
+
if (event.key === SPACEBAR && this.typeaheadBuffer.isEmpty) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
event.stopPropagation();
|
|
384
|
+
// Derived per key press rather than cached, so that the indices always
|
|
385
|
+
// refer to the rows the dropdown renders right now.
|
|
386
|
+
const items = createMenuItems(this.options, this.value, this.required);
|
|
387
|
+
const candidates = items.map((item) => ('separator' in item ? null : item));
|
|
388
|
+
const buffer = this.typeaheadBuffer.append(event.key);
|
|
389
|
+
const currentIndex = this.resolveTypeaheadIndex(items, focusedIndex);
|
|
390
|
+
const index = findTypeaheadMatch(candidates, buffer, currentIndex);
|
|
391
|
+
if (index !== NO_TYPEAHEAD_MATCH) {
|
|
392
|
+
this.focusTypeaheadMatch(index);
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* @param items - the items of the dropdown
|
|
398
|
+
* @param focusedIndex - the row that currently has focus, if any
|
|
399
|
+
* @returns the index a typeahead search should start from
|
|
400
|
+
*/
|
|
401
|
+
resolveTypeaheadIndex(items, focusedIndex) {
|
|
402
|
+
if (focusedIndex !== NO_TYPEAHEAD_MATCH) {
|
|
403
|
+
return focusedIndex;
|
|
404
|
+
}
|
|
405
|
+
// Typed again before focus had time to land in the dropdown.
|
|
406
|
+
if (this.pendingTypeaheadIndex !== undefined) {
|
|
407
|
+
return this.pendingTypeaheadIndex;
|
|
408
|
+
}
|
|
409
|
+
// Falls back to the selected option, so that typing continues from
|
|
410
|
+
// wherever the highlight already is. `findIndex` returning `-1` for a
|
|
411
|
+
// select without a value is exactly the "no current row" that
|
|
412
|
+
// `findTypeaheadMatch` expects.
|
|
413
|
+
return items.findIndex((item) => !('separator' in item) && item.selected);
|
|
414
|
+
}
|
|
415
|
+
focusTypeaheadMatch(index) {
|
|
416
|
+
// Overwrites any index still pending from an earlier character, even
|
|
417
|
+
// when this one can be focused right away. Opening the dropdown queues
|
|
418
|
+
// a `setMenuFocus` that waits for it to become visible, and someone
|
|
419
|
+
// typing quickly gets the next character in before that resolves;
|
|
420
|
+
// leaving the earlier index in place would let the queued focus apply
|
|
421
|
+
// it on top of this newer match.
|
|
422
|
+
this.pendingTypeaheadIndex = index;
|
|
423
|
+
if (this.menuOpen && this.focusMenuItemAtIndex(this.list, index)) {
|
|
424
|
+
// Focus landed synchronously, so this index has already done its
|
|
425
|
+
// job. Clearing it — rather than leaving it for `setMenuFocus` to
|
|
426
|
+
// consume later — stops it from being replayed by some unrelated
|
|
427
|
+
// future re-render, such as picking an option in a `multiple`
|
|
428
|
+
// select, after the user has since moved focus elsewhere with the
|
|
429
|
+
// arrow keys. A `setMenuFocus` still queued from opening the
|
|
430
|
+
// dropdown a moment ago is unaffected: it finds this row already
|
|
431
|
+
// focused and leaves it alone, below.
|
|
432
|
+
this.pendingTypeaheadIndex = undefined;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (this.menuOpen) {
|
|
436
|
+
// Already open, so no re-render is coming that would trigger
|
|
437
|
+
// `componentDidUpdate`. Schedule the focus explicitly.
|
|
438
|
+
this.setMenuFocus();
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
this.openMenu();
|
|
263
442
|
}
|
|
264
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* @returns the index of the focused row of the dropdown, or
|
|
446
|
+
* `NO_TYPEAHEAD_MATCH` when no row has focus
|
|
447
|
+
*/
|
|
448
|
+
getFocusedMenuItemIndex() {
|
|
449
|
+
var _a, _b, _c;
|
|
450
|
+
// Deliberately not `event.target`: a listener on the `limel-list`
|
|
451
|
+
// element sees events from inside its shadow root retargeted to the
|
|
452
|
+
// element itself, never to the row that was actually focused.
|
|
453
|
+
const focused = (_b = (_a = this.list) === null || _a === void 0 ? void 0 : _a.shadowRoot) === null || _b === void 0 ? void 0 : _b.activeElement;
|
|
454
|
+
const row = focused === null || focused === void 0 ? void 0 : focused.closest('[data-index]');
|
|
455
|
+
const index = Number.parseInt((_c = row === null || row === void 0 ? void 0 : row.dataset.index) !== null && _c !== void 0 ? _c : '', 10);
|
|
456
|
+
return Number.isNaN(index) ? NO_TYPEAHEAD_MATCH : index;
|
|
457
|
+
}
|
|
458
|
+
resetTypeahead() {
|
|
459
|
+
this.typeaheadBuffer.clear();
|
|
460
|
+
this.pendingTypeaheadIndex = undefined;
|
|
461
|
+
}
|
|
265
462
|
handleNativeChange(event) {
|
|
266
463
|
event.stopPropagation();
|
|
267
464
|
const element = this.host.shadowRoot.querySelector('select.limel-select__native-control');
|
|
@@ -543,6 +740,12 @@ export class Select {
|
|
|
543
740
|
}, {
|
|
544
741
|
"propName": "options",
|
|
545
742
|
"methodName": "updateHasPrimaryComponent"
|
|
743
|
+
}, {
|
|
744
|
+
"propName": "options",
|
|
745
|
+
"methodName": "resetTypeaheadOnItemsChange"
|
|
746
|
+
}, {
|
|
747
|
+
"propName": "required",
|
|
748
|
+
"methodName": "resetTypeaheadOnItemsChange"
|
|
546
749
|
}, {
|
|
547
750
|
"propName": "menuOpen",
|
|
548
751
|
"methodName": "watchOpen"
|
|
@@ -52,7 +52,7 @@ const SelectValue = (props) => {
|
|
|
52
52
|
'limel-select-trigger': true,
|
|
53
53
|
'limel-select--focused': props.isOpen,
|
|
54
54
|
};
|
|
55
|
-
return (h("button", { slot: "content", class: anchorClassList, onClick: props.open,
|
|
55
|
+
return (h("button", { slot: "content", class: anchorClassList, onClick: props.open, onKeyDown: props.onTriggerKeyDown, "aria-haspopup": "listbox", "aria-expanded": props.isOpen, "aria-controls": props.id, "aria-labelledby": "s-label s-selected-text", "aria-required": props.required, disabled: props.disabled || props.readonly }, h("span", { class: "mdc-select__selected-text-container limel-select__selected-option" }, getSelectedIcon(props.value), getSelectedPrimaryComponent(props.value), h("span", { id: "s-selected-text", class: "mdc-select__selected-text limel-select__selected-option__text" }, getSelectedText(props.value))), h(ShowIcon, Object.assign({}, props, { isValid: props.isValid })), h("span", { class: "mdc-select__dropdown-icon" }, h("svg", { class: "mdc-select__dropdown-icon-graphic", viewBox: "7 10 10 5", focusable: "false" }, h("polygon", { stroke: "none", "fill-rule": "evenodd", points: "7 10 12 15 17 10" })))));
|
|
56
56
|
};
|
|
57
57
|
const ShowIcon = (props) => {
|
|
58
58
|
if (props.isValid) {
|
|
@@ -80,7 +80,7 @@ const MenuDropdown = (props) => {
|
|
|
80
80
|
display: 'flex',
|
|
81
81
|
'min-width': '100%',
|
|
82
82
|
width: 'fit-content',
|
|
83
|
-
} }, h("limel-list", { items: items, type: props.multiple ? 'checkbox' : 'selectable', onChange: props.onMenuChange }))));
|
|
83
|
+
} }, h("limel-list", { ref: props.listRef, items: items, type: props.multiple ? 'checkbox' : 'selectable', onChange: props.onMenuChange }))));
|
|
84
84
|
};
|
|
85
85
|
const NativeDropdown = (props) => {
|
|
86
86
|
const options = props.options
|
|
@@ -103,7 +103,21 @@ function isSelected(option, value) {
|
|
|
103
103
|
}
|
|
104
104
|
return option.value === value.value;
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Create the items for the dropdown.
|
|
108
|
+
*
|
|
109
|
+
* The returned array is index-aligned with the `data-index` attribute that
|
|
110
|
+
* `limel-list` renders on each row. Separators are included, and occupy an
|
|
111
|
+
* index. Anything that needs to address a row by index — such as the
|
|
112
|
+
* typeahead in `select.tsx` — must derive its indices from this same array.
|
|
113
|
+
*
|
|
114
|
+
* @param options - the options of the select
|
|
115
|
+
* @param value - the currently selected option or options
|
|
116
|
+
* @param selectIsRequired - whether the select requires a value, in which
|
|
117
|
+
* case the "empty" option is left out
|
|
118
|
+
* @returns the items to render in the dropdown
|
|
119
|
+
*/
|
|
120
|
+
export function createMenuItems(options, value, selectIsRequired = false) {
|
|
107
121
|
const menuOptionFilter = getMenuOptionFilter(selectIsRequired);
|
|
108
122
|
return options.filter(menuOptionFilter).map((option) => {
|
|
109
123
|
if ('separator' in option) {
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
export const TAB = 'Tab';
|
|
4
4
|
export const ENTER = 'Enter';
|
|
5
5
|
export const ESCAPE = 'Escape';
|
|
6
|
+
/** `KeyboardEvent.code` for the space bar. NOT a `KeyboardEvent.key`. */
|
|
6
7
|
export const SPACE = 'Space';
|
|
8
|
+
/** `KeyboardEvent.key` for the space bar. */
|
|
9
|
+
export const SPACEBAR = ' ';
|
|
7
10
|
export const BACKSPACE = 'Backspace';
|
|
8
11
|
export const DELETE = 'Delete';
|
|
9
12
|
export const ARROW_UP = 'ArrowUp';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These helpers implement "type to jump", the behavior a native `<select>` has:
|
|
3
|
+
* typing characters moves to the option whose text starts with those
|
|
4
|
+
* characters. Characters accumulate into a short-lived buffer, so typing
|
|
5
|
+
* `n`, `e` finds "Netherlands" instead of re-matching on `n` every time.
|
|
6
|
+
*
|
|
7
|
+
* The matching itself is pure and stateless — all the state lives in a
|
|
8
|
+
* `TypeaheadBuffer`, which owns the timer that discards the buffer once the
|
|
9
|
+
* user stops typing.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* How long a buffer of typed characters is kept before it is discarded, in
|
|
13
|
+
* milliseconds. Roughly matches a native `<select>`.
|
|
14
|
+
*/
|
|
15
|
+
export const TYPEAHEAD_BUFFER_TIMEOUT = 1000;
|
|
16
|
+
/**
|
|
17
|
+
* Returned by `findTypeaheadMatch` when nothing matched.
|
|
18
|
+
*/
|
|
19
|
+
export const NO_TYPEAHEAD_MATCH = -1;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a keyboard event should be treated as a typed character.
|
|
22
|
+
*
|
|
23
|
+
* The space bar counts as a character here. Whether a *bare* space should
|
|
24
|
+
* start a buffer, or keep whatever meaning it has in the consuming component,
|
|
25
|
+
* is the consumer's decision.
|
|
26
|
+
*
|
|
27
|
+
* `shiftKey` is allowed, since capital letters are typed with it, and
|
|
28
|
+
* `event.repeat` is allowed, since holding a key down cycles through matches
|
|
29
|
+
* in a native `<select>` too.
|
|
30
|
+
*
|
|
31
|
+
* @param event - the keyboard event to inspect
|
|
32
|
+
* @returns `true` when the event represents a typed character
|
|
33
|
+
*/
|
|
34
|
+
export function isTypeaheadKey(event) {
|
|
35
|
+
return (event.key.length === 1 &&
|
|
36
|
+
!event.altKey &&
|
|
37
|
+
!event.ctrlKey &&
|
|
38
|
+
!event.metaKey &&
|
|
39
|
+
!event.isComposing);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Find the candidate to move to for a given buffer of typed characters.
|
|
43
|
+
*
|
|
44
|
+
* Two different searches are used, mirroring a native `<select>`:
|
|
45
|
+
* - When the buffer is a single character, possibly repeated (`b`, `bb`,
|
|
46
|
+
* `bbb`), only that character is matched and the search starts *after*
|
|
47
|
+
* `currentIndex`. Pressing the same key repeatedly therefore cycles through
|
|
48
|
+
* all candidates starting with it.
|
|
49
|
+
* - Otherwise the whole buffer is matched and the search starts *at*
|
|
50
|
+
* `currentIndex`, so continuing to type on an already-matching candidate
|
|
51
|
+
* keeps it rather than jumping to the next one.
|
|
52
|
+
*
|
|
53
|
+
* Both searches wrap around the end of the list.
|
|
54
|
+
*
|
|
55
|
+
* @param candidates - the candidates to search, index-aligned with the rows
|
|
56
|
+
* they are rendered as. Use `null` for rows that can never match, such as
|
|
57
|
+
* separators, so that they still occupy their index.
|
|
58
|
+
* @param buffer - the characters typed so far
|
|
59
|
+
* @param currentIndex - the index to search from. Anything outside
|
|
60
|
+
* `candidates` is treated as "no current candidate", and the search starts
|
|
61
|
+
* from the beginning.
|
|
62
|
+
* @returns the index of the matching candidate, or `NO_TYPEAHEAD_MATCH`
|
|
63
|
+
*/
|
|
64
|
+
export function findTypeaheadMatch(candidates, buffer, currentIndex) {
|
|
65
|
+
const count = candidates.length;
|
|
66
|
+
const query = buffer.toLowerCase();
|
|
67
|
+
if (!query || count === 0) {
|
|
68
|
+
return NO_TYPEAHEAD_MATCH;
|
|
69
|
+
}
|
|
70
|
+
const characters = [...query];
|
|
71
|
+
const cycles = isSingleDistinctCharacter(characters);
|
|
72
|
+
const needle = cycles ? characters[0] : query;
|
|
73
|
+
// Cycling starts one past the current candidate, so that pressing the same
|
|
74
|
+
// key again advances. Matching several characters starts at it, so that
|
|
75
|
+
// continuing to type keeps a candidate that already matches.
|
|
76
|
+
const offset = cycles ? 1 : 0;
|
|
77
|
+
const hasCurrent = currentIndex >= 0 && currentIndex < count;
|
|
78
|
+
const start = hasCurrent ? (currentIndex + offset) % count : 0;
|
|
79
|
+
for (let step = 0; step < count; step += 1) {
|
|
80
|
+
const index = (start + step) % count;
|
|
81
|
+
if (candidateMatches(candidates[index], needle)) {
|
|
82
|
+
return index;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return NO_TYPEAHEAD_MATCH;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether a string consists of a single character, possibly repeated. Also
|
|
89
|
+
* `true` for one character on its own, which is what makes a single key press
|
|
90
|
+
* advance past the current candidate rather than re-matching it.
|
|
91
|
+
*
|
|
92
|
+
* Takes the characters already split apart, so that the caller and this
|
|
93
|
+
* function agree on what "a character" is for anything outside the basic
|
|
94
|
+
* multilingual plane.
|
|
95
|
+
*
|
|
96
|
+
* @param characters - the characters of the string
|
|
97
|
+
* @returns `true` when every character is the same
|
|
98
|
+
*/
|
|
99
|
+
function isSingleDistinctCharacter(characters) {
|
|
100
|
+
return characters.every((character) => character === characters[0]);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* @param candidate - the candidate to test, or `null` for a row that can
|
|
104
|
+
* never match
|
|
105
|
+
* @param needle - the lower cased characters to match
|
|
106
|
+
* @returns `true` when the candidate starts with the given characters
|
|
107
|
+
*/
|
|
108
|
+
function candidateMatches(candidate, needle) {
|
|
109
|
+
var _a;
|
|
110
|
+
if (!candidate || candidate.disabled) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const text = (_a = candidate.text) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase();
|
|
114
|
+
return !!text && text.startsWith(needle);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Accumulates typed characters, and discards them again once the user stops
|
|
118
|
+
* typing for `TYPEAHEAD_BUFFER_TIMEOUT`.
|
|
119
|
+
*/
|
|
120
|
+
export class TypeaheadBuffer {
|
|
121
|
+
constructor(timeout = TYPEAHEAD_BUFFER_TIMEOUT) {
|
|
122
|
+
this.buffer = '';
|
|
123
|
+
this.timeout = timeout;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The characters typed so far.
|
|
127
|
+
*/
|
|
128
|
+
get value() {
|
|
129
|
+
return this.buffer;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether nothing has been typed, or the buffer has been discarded.
|
|
133
|
+
*/
|
|
134
|
+
get isEmpty() {
|
|
135
|
+
return this.buffer === '';
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Append a character, and restart the timer that discards the buffer.
|
|
139
|
+
*
|
|
140
|
+
* @param character - the character to append
|
|
141
|
+
* @returns the buffer, including the appended character
|
|
142
|
+
*/
|
|
143
|
+
append(character) {
|
|
144
|
+
this.buffer += character;
|
|
145
|
+
clearTimeout(this.resetTimeoutId);
|
|
146
|
+
this.resetTimeoutId = setTimeout(() => {
|
|
147
|
+
this.clear();
|
|
148
|
+
}, this.timeout);
|
|
149
|
+
return this.buffer;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Discard the buffer, and cancel the timer that would have discarded it.
|
|
153
|
+
*/
|
|
154
|
+
clear() {
|
|
155
|
+
this.buffer = '';
|
|
156
|
+
clearTimeout(this.resetTimeoutId);
|
|
157
|
+
this.resetTimeoutId = undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
const TAB = 'Tab';
|
|
4
4
|
const ENTER = 'Enter';
|
|
5
5
|
const ESCAPE = 'Escape';
|
|
6
|
+
/** `KeyboardEvent.code` for the space bar. NOT a `KeyboardEvent.key`. */
|
|
6
7
|
const SPACE = 'Space';
|
|
8
|
+
/** `KeyboardEvent.key` for the space bar. */
|
|
9
|
+
const SPACEBAR = ' ';
|
|
7
10
|
const BACKSPACE = 'Backspace';
|
|
8
11
|
const DELETE = 'Delete';
|
|
9
12
|
const ARROW_UP = 'ArrowUp';
|
|
@@ -11,4 +14,4 @@ const ARROW_DOWN = 'ArrowDown';
|
|
|
11
14
|
const ARROW_LEFT = 'ArrowLeft';
|
|
12
15
|
const ARROW_RIGHT = 'ArrowRight';
|
|
13
16
|
|
|
14
|
-
export { ARROW_UP as A, BACKSPACE as B, DELETE as D, ESCAPE as E,
|
|
17
|
+
export { ARROW_UP as A, BACKSPACE as B, DELETE as D, ESCAPE as E, SPACEBAR as S, TAB as T, ENTER as a, ARROW_DOWN as b, ARROW_LEFT as c, ARROW_RIGHT as d, SPACE as e };
|