vira 33.0.0 → 33.1.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.
@@ -207,6 +207,15 @@ export const ViraMenuItem = defineViraElement()({
207
207
  }, {
208
208
  capture: true,
209
209
  }),
210
+ listenTo(host, 'keydown', (event) => {
211
+ if (event instanceof KeyboardEvent &&
212
+ event.target === host &&
213
+ (event.key === 'Enter' || event.key === ' ')) {
214
+ /** Prevents Space from scrolling the menu. */
215
+ event.preventDefault();
216
+ host.click();
217
+ }
218
+ }),
210
219
  listenTo(host, 'mouseenter', () => {
211
220
  if (!inputs.disabled) {
212
221
  host.focus();
@@ -45,5 +45,6 @@ export declare const ViraPopoverTrigger: import("element-vir").DeclarativeElemen
45
45
  openChange: import("element-vir").DefineElementEvent<boolean>;
46
46
  init: import("element-vir").DefineElementEvent<{
47
47
  navController: NavController;
48
+ popoverManager: PopoverManager;
48
49
  }>;
49
50
  }, "vira-popover-trigger-disabled" | "vira-popover-trigger-inside-focus" | "vira-popover-trigger-outside-focus", "vira-popover-trigger-offset-vertical" | "vira-popover-trigger-offset-left" | "vira-popover-trigger-offset-right", readonly ["vira-popover-trigger-trigger", "vira-popover-trigger-popover"], readonly []>;
@@ -165,8 +165,13 @@ export const ViraPopoverTrigger = defineViraElement()({
165
165
  it flip upwards when there is not much room below the trigger.
166
166
  */
167
167
  min-height: 200px;
168
- /* Margins are not included in the percentage. */
169
- max-height: calc(100% - ${cssVars['vira-popover-trigger-offset-vertical'].value});
168
+ /*
169
+ Margins are not included in the percentage. The extra 4px keeps the popover off
170
+ the window's top or bottom edge.
171
+ */
172
+ max-height: calc(
173
+ 100% - ${cssVars['vira-popover-trigger-offset-vertical'].value} - 4px
174
+ );
170
175
  width: max-content;
171
176
  min-width: calc(
172
177
  anchor-size(width) - ${cssVars['vira-popover-trigger-offset-left'].value} -
@@ -225,6 +230,7 @@ export const ViraPopoverTrigger = defineViraElement()({
225
230
  dispatch(new events.init({
226
231
  detail: {
227
232
  navController: state.navController,
233
+ popoverManager: state.popoverManager,
228
234
  },
229
235
  }));
230
236
  },
@@ -1,6 +1,8 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
+ import { type NavController } from 'device-navigation';
2
3
  import { type AttributeValues, type HtmlInterpolation } from 'element-vir';
3
4
  import { type ViraIconSvg } from '../icons/icon-svg.js';
5
+ import { type PopoverManager } from '../util/popover-manager.js';
4
6
  import { type ViraDropdownOption, type ViraDropdownOptionGroup } from '../util/vira-dropdown-option.js';
5
7
  /**
6
8
  * A dropdown element that uses popover menus.
@@ -26,6 +28,11 @@ export declare const ViraDropdown: import("element-vir").DeclarativeElementDefin
26
28
  * multiple.
27
29
  */
28
30
  isMultiSelect: boolean;
31
+ /**
32
+ * Replaces the selection text with a text input. Opening the dropdown focuses it and typing
33
+ * into it filters the options to those whose labels fuzzy match the typed text.
34
+ */
35
+ isSearchable: boolean;
29
36
  icon: ViraIconSvg;
30
37
  selectionPrefix: string;
31
38
  isDisabled: boolean;
@@ -53,9 +60,13 @@ export declare const ViraDropdown: import("element-vir").DeclarativeElementDefin
53
60
  * provided.
54
61
  */
55
62
  randomId: string;
63
+ /** The text typed into the search input while the dropdown is open. */
64
+ searchText: string;
65
+ navController: undefined | NavController;
66
+ popoverManager: undefined | PopoverManager;
56
67
  }, {
57
68
  /** Emits all currently selected values. */
58
69
  selectedValuesChange: import("element-vir").DefineElementEvent<string[]>;
59
70
  /** `true` when the popover just opened, `false` when it just closed. */
60
71
  openChange: import("element-vir").DefineElementEvent<boolean>;
61
- }, "vira-dropdown-", "vira-dropdown-", readonly [], readonly ["leadingIcon", "prefixText", "trigger"]>;
72
+ }, "vira-dropdown-", "vira-dropdown-", readonly [], readonly ["leadingIcon", "prefixText", "searchInput", "trigger"]>;
@@ -1,12 +1,15 @@
1
1
  import { check } from '@augment-vir/assert';
2
2
  import { filterMap, randomString } from '@augment-vir/common';
3
3
  import { extractEventTarget } from '@augment-vir/web';
4
+ import { nav, NavDirection } from 'device-navigation';
4
5
  import { attributes, classMap, css, defineElementEvent, html, ifDefined, listen, nothing, testId, } from 'element-vir';
5
6
  import { ChevronUp16Icon } from '../icons/index.js';
7
+ import { createFocusStyles } from '../styles/focus.js';
6
8
  import { viraFormCssVars } from '../styles/form-styles.js';
7
9
  import { ViraSize, viraSizeHeights } from '../styles/form-variants.js';
8
- import { noUserSelect, viraAnimationDurations } from '../styles/index.js';
10
+ import { noNativeFormStyles, noUserSelect, viraAnimationDurations } from '../styles/index.js';
9
11
  import { defineViraElement } from '../util/define-vira-element.js';
12
+ import { fuzzyMatch } from '../util/fuzzy-match.js';
10
13
  import { renderMenuItemEntries } from '../util/menu-helpers.js';
11
14
  import { isViraDropdownOptionGroup, } from '../util/vira-dropdown-option.js';
12
15
  import { ViraMenuItem } from './popover/vira-menu-item.element.js';
@@ -25,6 +28,7 @@ export const ViraDropdown = defineViraElement()({
25
28
  testIds: [
26
29
  'leadingIcon',
27
30
  'prefixText',
31
+ 'searchInput',
28
32
  'trigger',
29
33
  ],
30
34
  styles: css `
@@ -46,6 +50,38 @@ export const ViraDropdown = defineViraElement()({
46
50
  white-space: nowrap;
47
51
  }
48
52
 
53
+ .search-input {
54
+ ${noNativeFormStyles};
55
+ flex-grow: 1;
56
+ min-width: 0;
57
+ align-self: stretch;
58
+ text-overflow: ellipsis;
59
+ cursor: inherit;
60
+ user-select: text;
61
+ -webkit-user-select: text;
62
+ outline: none;
63
+
64
+ &::placeholder {
65
+ color: inherit;
66
+ opacity: 0.4;
67
+ }
68
+ }
69
+
70
+ .open .search-input {
71
+ cursor: text;
72
+ }
73
+
74
+ .dropdown-trigger:has(.search-input:focus-visible:not([disabled])) {
75
+ position: relative;
76
+
77
+ &::after {
78
+ ${createFocusStyles({
79
+ elementBorderSize: '1px',
80
+ noNesting: true,
81
+ })}
82
+ }
83
+ }
84
+
49
85
  .trigger-icon {
50
86
  width: 24px;
51
87
  aspect-ratio: 1;
@@ -155,9 +191,13 @@ export const ViraDropdown = defineViraElement()({
155
191
  * provided.
156
192
  */
157
193
  randomId: randomString(32),
194
+ /** The text typed into the search input while the dropdown is open. */
195
+ searchText: '',
196
+ navController: undefined,
197
+ popoverManager: undefined,
158
198
  };
159
199
  },
160
- render({ state, inputs, dispatch, events, updateState, testIds }) {
200
+ render({ state, inputs, dispatch, events, updateState, testIds, host }) {
161
201
  const flatOptions = inputs.options.flatMap((entry) => {
162
202
  return isViraDropdownOptionGroup(entry) ? entry.options : [entry];
163
203
  });
@@ -197,6 +237,42 @@ export const ViraDropdown = defineViraElement()({
197
237
  detail: newSelectedValues,
198
238
  }));
199
239
  }
240
+ const filteredOptions = state.searchText
241
+ ? filterMap(inputs.options, (entry) => {
242
+ return isViraDropdownOptionGroup(entry)
243
+ ? {
244
+ ...entry,
245
+ options: entry.options.filter((option) => {
246
+ return fuzzyMatch({
247
+ search: state.searchText,
248
+ text: option.label,
249
+ });
250
+ }),
251
+ }
252
+ : entry;
253
+ }, (entry) => {
254
+ return isViraDropdownOptionGroup(entry)
255
+ ? !!entry.options.length
256
+ : fuzzyMatch({
257
+ search: state.searchText,
258
+ text: entry.label,
259
+ });
260
+ })
261
+ : inputs.options;
262
+ const filteredFlatOptions = filteredOptions.flatMap((entry) => {
263
+ return isViraDropdownOptionGroup(entry) ? entry.options : [entry];
264
+ });
265
+ function findSearchInput() {
266
+ const searchInput = host.shadowRoot.querySelector('.search-input');
267
+ return searchInput instanceof HTMLInputElement ? searchInput : undefined;
268
+ }
269
+ function clearSearch(searchInput) {
270
+ /** Set directly because the `.value` binding skips values it has already committed. */
271
+ searchInput.value = '';
272
+ updateState({
273
+ searchText: '',
274
+ });
275
+ }
200
276
  function toggleGroup(group) {
201
277
  const enabledGroupOptions = group.options.filter((option) => !option.disabled);
202
278
  const isGroupSelected = enabledGroupOptions.every((option) => {
@@ -220,6 +296,7 @@ export const ViraDropdown = defineViraElement()({
220
296
  disablePointerStyles: true,
221
297
  })}
222
298
  class="option-group-label"
299
+ ${state.navController ? nav(state.navController) : nothing}
223
300
  ${listen('click', () => {
224
301
  toggleGroup(group);
225
302
  })}
@@ -232,7 +309,7 @@ export const ViraDropdown = defineViraElement()({
232
309
  `;
233
310
  }
234
311
  /** Drag-select actions in the same order as the rendered menu items. */
235
- const menuItemActions = inputs.options.flatMap((entry) => {
312
+ const menuItemActions = filteredOptions.flatMap((entry) => {
236
313
  const options = isViraDropdownOptionGroup(entry) ? entry.options : [entry];
237
314
  const optionActions = options.map((option) => {
238
315
  return option.disabled
@@ -260,9 +337,9 @@ export const ViraDropdown = defineViraElement()({
260
337
  disabled: option.disabled,
261
338
  selected: selectedOptions.includes(option),
262
339
  };
263
- }));
340
+ }), state.navController);
264
341
  }
265
- const isMenuShown = state.isOpen && (!!flatOptions.length || !!inputs.noOptionsText);
342
+ const isMenuShown = state.isOpen && (!!filteredFlatOptions.length || !!inputs.noOptionsText);
266
343
  const menuTemplate = html `
267
344
  <${ViraMenu.assign({
268
345
  cornerStyle: ViraMenuCornerStyle.Round,
@@ -283,8 +360,8 @@ export const ViraDropdown = defineViraElement()({
283
360
  }
284
361
  })}
285
362
  >
286
- ${flatOptions.length
287
- ? inputs.options.map((entry) => {
363
+ ${filteredFlatOptions.length
364
+ ? filteredOptions.map((entry) => {
288
365
  return isViraDropdownOptionGroup(entry)
289
366
  ? html `
290
367
  ${renderGroupLabel(entry)} ${renderOptionEntries(entry.options)}
@@ -305,6 +382,12 @@ export const ViraDropdown = defineViraElement()({
305
382
  vertical: -1,
306
383
  right: 24,
307
384
  },
385
+ })}
386
+ ${listen(ViraPopoverTrigger.events.init, (event) => {
387
+ updateState({
388
+ navController: event.detail.navController,
389
+ popoverManager: event.detail.popoverManager,
390
+ });
308
391
  })}
309
392
  ${listen(ViraPopoverTrigger.events.openChange, (event) => {
310
393
  if (state.isOpen !== event.detail) {
@@ -315,6 +398,37 @@ export const ViraDropdown = defineViraElement()({
315
398
  updateState({
316
399
  isOpen: event.detail,
317
400
  });
401
+ const searchInput = findSearchInput();
402
+ if (!searchInput) {
403
+ return;
404
+ }
405
+ else if (event.detail) {
406
+ searchInput.focus();
407
+ }
408
+ else {
409
+ clearSearch(searchInput);
410
+ }
411
+ })}
412
+ ${listen('keydown', (event) => {
413
+ const searchInput = findSearchInput();
414
+ const isSearchKey = event.key === 'Backspace' ||
415
+ (event.key.length === 1 &&
416
+ event.key !== ' ' &&
417
+ !event.ctrlKey &&
418
+ !event.metaKey &&
419
+ !event.altKey);
420
+ if (!searchInput || !isSearchKey) {
421
+ return;
422
+ }
423
+ if (!state.isOpen) {
424
+ clearSearch(searchInput);
425
+ }
426
+ /**
427
+ * Focusing during keydown makes the browser type the pressed key into the
428
+ * search input, even when the key was pressed on the trigger or a menu item.
429
+ */
430
+ searchInput.focus();
431
+ state.popoverManager?.show();
318
432
  })}
319
433
  ${listen('mouseup', () => {
320
434
  if (state.shouldSelectOnMouseUp) {
@@ -342,14 +456,93 @@ export const ViraDropdown = defineViraElement()({
342
456
  })}
343
457
  >
344
458
  ${leadingIconTemplate}
345
- <span
346
- class="selection-display ${classMap({
347
- 'using-placeholder': shouldUsePlaceholder,
348
- })}"
349
- title=${ifDefined(shouldUsePlaceholder ? undefined : selectionDisplay)}
350
- >
351
- ${prefixTemplate} ${selectionDisplay}
352
- </span>
459
+ ${inputs.isSearchable
460
+ ? html `
461
+ ${state.isOpen ? nothing : prefixTemplate}
462
+ <input
463
+ class="search-input"
464
+ ${testId(testIds.searchInput)}
465
+ tabindex="-1"
466
+ autocomplete="off"
467
+ spellcheck="false"
468
+ aria-label=${ifDefined((check.isString(inputs.label) && inputs.label) || undefined)}
469
+ ?disabled=${!!inputs.isDisabled}
470
+ title=${ifDefined(shouldUsePlaceholder || state.isOpen
471
+ ? undefined
472
+ : selectionDisplay)}
473
+ placeholder=${state.isOpen && !shouldUsePlaceholder
474
+ ? selectionDisplay
475
+ : inputs.placeholder || ''}
476
+ .value=${state.isOpen
477
+ ? state.searchText
478
+ : shouldUsePlaceholder
479
+ ? ''
480
+ : selectionDisplay}
481
+ ${listen('mousedown', (event) => {
482
+ /** Keeps the popover open when clicking into the input. */
483
+ if (state.isOpen) {
484
+ event.stopPropagation();
485
+ }
486
+ })}
487
+ ${listen('input', (event) => {
488
+ updateState({
489
+ searchText: extractEventTarget(event, HTMLInputElement)
490
+ .value,
491
+ });
492
+ state.popoverManager?.show();
493
+ })}
494
+ ${listen('keydown', (event) => {
495
+ if (!state.isOpen) {
496
+ return;
497
+ }
498
+ else if (event.key === 'ArrowDown' ||
499
+ event.key === 'ArrowUp') {
500
+ event.preventDefault();
501
+ state.navController?.navigate({
502
+ direction: event.key === 'ArrowDown'
503
+ ? NavDirection.Down
504
+ : NavDirection.Up,
505
+ allowWrapping: false,
506
+ });
507
+ }
508
+ else if (event.key === 'Tab' && !event.shiftKey) {
509
+ /**
510
+ * Without this, browsers move focus from this input past
511
+ * the menu items (sometimes closing the menu).
512
+ */
513
+ const firstMenuItem = Array.from(host.shadowRoot.querySelectorAll(ViraMenuItem.tagName)).find((menuItem) => menuItem.tabIndex >= 0);
514
+ if (firstMenuItem) {
515
+ event.preventDefault();
516
+ firstMenuItem.focus();
517
+ }
518
+ }
519
+ else if (event.key === 'Enter') {
520
+ const firstOption = filteredFlatOptions.find((option) => !option.disabled);
521
+ if (!firstOption) {
522
+ return;
523
+ }
524
+ event.preventDefault();
525
+ selectOption(firstOption);
526
+ if (inputs.isMultiSelect) {
527
+ clearSearch(extractEventTarget(event, HTMLInputElement));
528
+ }
529
+ else {
530
+ state.popoverManager?.hide();
531
+ }
532
+ }
533
+ })}
534
+ />
535
+ `
536
+ : html `
537
+ <span
538
+ class="selection-display ${classMap({
539
+ 'using-placeholder': shouldUsePlaceholder,
540
+ })}"
541
+ title=${ifDefined(shouldUsePlaceholder ? undefined : selectionDisplay)}
542
+ >
543
+ ${prefixTemplate} ${selectionDisplay}
544
+ </span>
545
+ `}
353
546
 
354
547
  <span class="trigger-icon-wrapper">
355
548
  <${ViraIcon.assign({
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Checks if every non-whitespace character of `search` appears in `text`, in order, ignoring case.
3
+ * An empty `search` matches everything.
4
+ *
5
+ * @category Internal
6
+ * @example
7
+ *
8
+ * ```ts
9
+ * fuzzyMatch({search: 'opa', text: 'Option A'}); // true
10
+ * fuzzyMatch({search: 'apo', text: 'Option A'}); // false
11
+ * ```
12
+ */
13
+ export declare function fuzzyMatch({ search, text, }: Readonly<{
14
+ search: string;
15
+ text: string;
16
+ }>): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Checks if every non-whitespace character of `search` appears in `text`, in order, ignoring case.
3
+ * An empty `search` matches everything.
4
+ *
5
+ * @category Internal
6
+ * @example
7
+ *
8
+ * ```ts
9
+ * fuzzyMatch({search: 'opa', text: 'Option A'}); // true
10
+ * fuzzyMatch({search: 'apo', text: 'Option A'}); // false
11
+ * ```
12
+ */
13
+ export function fuzzyMatch({ search, text, }) {
14
+ const lowerText = text.toLowerCase();
15
+ return (search
16
+ .replaceAll(/\s/g, '')
17
+ .toLowerCase()
18
+ .split('')
19
+ .reduce((textIndex, character) => {
20
+ return textIndex < 0
21
+ ? textIndex
22
+ : lowerText.indexOf(character, textIndex) + 1 || -1;
23
+ }, 0) >= 0);
24
+ }
@@ -2,6 +2,7 @@ export * from './anchored-popover.js';
2
2
  export * from './define-table.js';
3
3
  export * from './define-vira-element.js';
4
4
  export * from './dynamic-element.js';
5
+ export * from './fuzzy-match.js';
5
6
  export * from './menu-helpers.js';
6
7
  export * from './overflow-observer.js';
7
8
  export * from './popover-manager.js';
@@ -2,6 +2,7 @@ export * from './anchored-popover.js';
2
2
  export * from './define-table.js';
3
3
  export * from './define-vira-element.js';
4
4
  export * from './dynamic-element.js';
5
+ export * from './fuzzy-match.js';
5
6
  export * from './menu-helpers.js';
6
7
  export * from './overflow-observer.js';
7
8
  export * from './popover-manager.js';
@@ -1,4 +1,5 @@
1
1
  import { type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
2
+ import { type NavController } from 'device-navigation';
2
3
  import { type HtmlInterpolation } from 'element-vir';
3
4
  import { ViraMenuItem } from '../elements/popover/vira-menu-item.element.js';
4
5
  /**
@@ -42,4 +43,6 @@ export type MenuItemClickCallback = (params: Readonly<{
42
43
  *
43
44
  * @category Popover
44
45
  */
45
- export declare function renderMenuItemEntries(items: ReadonlyArray<Readonly<ViraMenuItemEntry>>): import("lit-html").HTMLTemplateResult[];
46
+ export declare function renderMenuItemEntries(items: ReadonlyArray<Readonly<ViraMenuItemEntry>>,
47
+ /** Enables arrow key navigation between the menu items. */
48
+ navController?: NavController | undefined): import("lit-html").HTMLTemplateResult[];
@@ -1,5 +1,6 @@
1
1
  import { filterMap, joinWithFinalConjunction, } from '@augment-vir/common';
2
- import { html, listen } from 'element-vir';
2
+ import { nav } from 'device-navigation';
3
+ import { html, listen, nothing } from 'element-vir';
3
4
  import { ViraMenuItem } from '../elements/popover/vira-menu-item.element.js';
4
5
  /**
5
6
  * Verifies that all items have unique ids.
@@ -26,12 +27,19 @@ export function assertUniqueIdProps(items) {
26
27
  *
27
28
  * @category Popover
28
29
  */
29
- export function renderMenuItemEntries(items) {
30
+ export function renderMenuItemEntries(items,
31
+ /** Enables arrow key navigation between the menu items. */
32
+ navController) {
30
33
  return filterMap(items, (item, index) => {
31
34
  return html `
32
35
  <${ViraMenuItem.assign({
33
36
  ...item,
34
37
  })}
38
+ ${navController
39
+ ? nav(navController, {
40
+ disabled: item.disabled,
41
+ })
42
+ : nothing}
35
43
  ${listen('click', async (event) => {
36
44
  if (item.disabled) {
37
45
  event.stopImmediatePropagation();
@@ -140,6 +140,8 @@ const anchorListeners = {
140
140
  })
141
141
  ?.focus({
142
142
  preventScroll: true,
143
+ /** Otherwise a mouse click shows the keyboard focus ring. */
144
+ focusVisible: false,
143
145
  });
144
146
  }
145
147
  popoverManager.toggle();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vira",
3
- "version": "33.0.0",
3
+ "version": "33.1.0",
4
4
  "description": "A simple and highly versatile design system using element-vir.",
5
5
  "keywords": [
6
6
  "design",