@sveltia/ui 0.72.2 → 0.73.1

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 (90) hide show
  1. package/dist/components/alert/alert.svelte +29 -5
  2. package/dist/components/alert/alert.svelte.d.ts +8 -4
  3. package/dist/components/alert/infobar.svelte +21 -5
  4. package/dist/components/alert/infobar.svelte.d.ts +10 -4
  5. package/dist/components/button/button.svelte +5 -1
  6. package/dist/components/button/select-button-group.svelte +1 -1
  7. package/dist/components/calendar/calendar.d.ts +1 -4
  8. package/dist/components/calendar/calendar.js +17 -0
  9. package/dist/components/calendar/calendar.svelte +69 -11
  10. package/dist/components/checkbox/checkbox-group.svelte +3 -1
  11. package/dist/components/checkbox/checkbox.svelte +3 -1
  12. package/dist/components/dialog/dialog.svelte +14 -2
  13. package/dist/components/dialog/prompt-dialog.svelte +9 -2
  14. package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -2
  15. package/dist/components/disclosure/disclosure.svelte +2 -1
  16. package/dist/components/drawer/drawer.svelte +28 -3
  17. package/dist/components/drawer/drawer.svelte.d.ts +16 -2
  18. package/dist/components/grid/grid-body.svelte +3 -1
  19. package/dist/components/grid/grid-foot.svelte +3 -1
  20. package/dist/components/grid/grid-head.svelte +3 -1
  21. package/dist/components/listbox/option-group.svelte +2 -1
  22. package/dist/components/listbox/option.svelte +4 -0
  23. package/dist/components/menu/menu-item-group.svelte +3 -1
  24. package/dist/components/radio/radio.svelte +21 -3
  25. package/dist/components/radio/radio.svelte.d.ts +12 -2
  26. package/dist/components/resizable-pane/resizable-handle.svelte +12 -0
  27. package/dist/components/select/combobox.svelte +69 -1
  28. package/dist/components/select/select-tags.svelte +34 -23
  29. package/dist/components/slider/slider.d.ts +6 -0
  30. package/dist/components/slider/slider.js +40 -0
  31. package/dist/components/slider/slider.svelte +45 -11
  32. package/dist/components/slider/slider.svelte.d.ts +14 -2
  33. package/dist/components/switch/switch.svelte +1 -1
  34. package/dist/components/table/table-body.svelte +3 -1
  35. package/dist/components/table/table-foot.svelte +3 -1
  36. package/dist/components/table/table-head.svelte +3 -1
  37. package/dist/components/text-editor/code-editor.svelte +22 -1
  38. package/dist/components/text-editor/code-editor.svelte.d.ts +28 -0
  39. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  40. package/dist/components/text-editor/shiki/version.js +1 -1
  41. package/dist/components/text-editor/text-editor.svelte +23 -0
  42. package/dist/components/text-editor/text-editor.svelte.d.ts +28 -0
  43. package/dist/components/text-field/number-input.svelte +7 -17
  44. package/dist/components/text-field/search-bar.svelte +1 -0
  45. package/dist/components/text-field/text-input.svelte +1 -1
  46. package/dist/components/toast/toast.svelte +58 -4
  47. package/dist/components/toast/toast.svelte.d.ts +4 -2
  48. package/dist/components/util/app-shell.svelte +289 -5
  49. package/dist/components/util/popup.svelte +1 -1
  50. package/dist/locales/ar.yaml +20 -0
  51. package/dist/locales/bg.yaml +20 -0
  52. package/dist/locales/ca.yaml +20 -0
  53. package/dist/locales/cs.yaml +20 -0
  54. package/dist/locales/da.yaml +20 -0
  55. package/dist/locales/de.yaml +20 -0
  56. package/dist/locales/el.yaml +20 -0
  57. package/dist/locales/en-CA.yaml +20 -0
  58. package/dist/locales/en-GB.yaml +20 -0
  59. package/dist/locales/en-US.yaml +20 -0
  60. package/dist/locales/es-CO.yaml +20 -0
  61. package/dist/locales/fa.yaml +20 -0
  62. package/dist/locales/fi.yaml +20 -0
  63. package/dist/locales/fr.yaml +20 -0
  64. package/dist/locales/hr.yaml +20 -0
  65. package/dist/locales/it.yaml +20 -0
  66. package/dist/locales/ja.yaml +20 -0
  67. package/dist/locales/ko.yaml +20 -0
  68. package/dist/locales/nl.yaml +20 -0
  69. package/dist/locales/pl.yaml +20 -0
  70. package/dist/locales/pt-BR.yaml +20 -0
  71. package/dist/locales/pt-PT.yaml +20 -0
  72. package/dist/locales/ru.yaml +20 -0
  73. package/dist/locales/sv.yaml +20 -0
  74. package/dist/locales/tr.yaml +20 -0
  75. package/dist/locales/uk.yaml +20 -0
  76. package/dist/locales/vi.yaml +20 -0
  77. package/dist/locales/zh-CN.yaml +20 -0
  78. package/dist/locales/zh-TW.yaml +20 -0
  79. package/dist/services/group.svelte.d.ts +12 -0
  80. package/dist/services/group.svelte.js +177 -28
  81. package/dist/services/popup.svelte.js +24 -1
  82. package/dist/services/select.svelte.d.ts +5 -0
  83. package/dist/services/select.svelte.js +8 -0
  84. package/dist/services/type-ahead.d.ts +26 -0
  85. package/dist/services/type-ahead.js +81 -0
  86. package/dist/styles/core.scss +14 -3
  87. package/dist/styles/variables.scss +118 -1
  88. package/dist/typedefs.d.ts +7 -1
  89. package/dist/typedefs.js +4 -1
  90. package/package.json +3 -3
@@ -2,6 +2,7 @@ import { isRTL } from '@sveltia/i18n';
2
2
  import { generateElementId } from '@sveltia/utils/element';
3
3
  import { sleep } from '@sveltia/utils/misc';
4
4
  import { getSelectedItemDetail } from './select.svelte.js';
5
+ import { findTypeAheadMatch, TypeAhead } from './type-ahead.js';
5
6
 
6
7
  /**
7
8
  * @import { Attachment } from 'svelte/attachments';
@@ -31,6 +32,17 @@ export const normalize = (value) => {
31
32
  return value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
32
33
  };
33
34
 
35
+ /**
36
+ * Set an element’s `tabindex` attribute, leaving the DOM alone if it already holds that value.
37
+ * @param {HTMLElement} element Element.
38
+ * @param {number} tabIndex New value.
39
+ */
40
+ const setTabIndex = (element, tabIndex) => {
41
+ if (element.getAttribute('tabindex') !== String(tabIndex)) {
42
+ element.tabIndex = tabIndex;
43
+ }
44
+ };
45
+
34
46
  /**
35
47
  * @type {{ [role: string]: {
36
48
  * orientation: 'vertical' | 'horizontal',
@@ -48,7 +60,9 @@ const config = {
48
60
  childRoles: ['row'],
49
61
  childSelectedAttr: 'aria-selected',
50
62
  focusChild: true,
51
- selectFirst: true,
63
+ // Rows are data, not a choice the widget has to make on the user’s behalf: nothing is
64
+ // selected until the user selects it. The first row is only the tab stop.
65
+ selectFirst: false,
52
66
  controlsPanel: false,
53
67
  rovingTabStop: 'selected',
54
68
  },
@@ -177,6 +191,12 @@ const getMenuOpener = (element) => {
177
191
  * Implement keyboard and mouse interactions for a grouping composite widget.
178
192
  */
179
193
  export class Group {
194
+ /**
195
+ * Whether {@link activate} has run. Until then, members are left as they are rendered.
196
+ * @type {boolean}
197
+ */
198
+ activated = false;
199
+
180
200
  /**
181
201
  * Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
182
202
  * @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
@@ -200,6 +220,12 @@ export class Group {
200
220
  */
201
221
  #hiddenState = new WeakMap();
202
222
 
223
+ /**
224
+ * Keystroke buffer for type-ahead.
225
+ * @type {TypeAhead}
226
+ */
227
+ #typeAhead = new TypeAhead();
228
+
203
229
  /**
204
230
  * Get the normalized value a member is searched by, computing it only when the underlying raw
205
231
  * value has changed since the last call.
@@ -295,8 +321,15 @@ export class Group {
295
321
  // The members can be added, removed, disabled or hidden at any time, which is what invalidates
296
322
  // the cached lists. Only the attributes that decide membership are watched, so the group’s own
297
323
  // writes — the selected state and the roving `tabindex` — don’t needlessly discard the cache.
298
- this.observer = new globalThis.MutationObserver(() => {
324
+ this.observer = new globalThis.MutationObserver((records) => {
299
325
  this.#memberCache = undefined;
326
+
327
+ // Members rendered after activation — rows that arrive with the data, say — start out with
328
+ // whatever `tabindex` their component gives them, so the roving tab stop has to be redone
329
+ // for them to become a single stop
330
+ if (this.activated && records.some(({ type }) => type === 'childList')) {
331
+ this.updateTabStop();
332
+ }
300
333
  });
301
334
 
302
335
  this.observer.observe(parent, {
@@ -319,6 +352,8 @@ export class Group {
319
352
  activate() {
320
353
  const { parent, allMembers, selected: defaultSelected } = this;
321
354
 
355
+ this.activated = true;
356
+
322
357
  allMembers.forEach((element, index) => {
323
358
  // Select the first one if no member has the `selected` attribute
324
359
  const isSelected =
@@ -378,18 +413,55 @@ export class Group {
378
413
  return;
379
414
  }
380
415
 
416
+ // The stop stays where the user is. This runs again whenever the subtree changes, so a member
417
+ // the user has moved to must not be handed back to the selected or first one just because a
418
+ // cell re-rendered or a row arrived. Only when there is no single stop — at activation, where
419
+ // every member may render as one, or after new members have — is one picked.
420
+ const { activeElement } = document;
421
+
422
+ const focused = activeMembers.find(
423
+ (element) => element === activeElement || element.contains(activeElement),
424
+ );
425
+
426
+ const holders = activeMembers.filter((element) => element.getAttribute('tabindex') === '0');
427
+
381
428
  const tabStop =
382
- this.rovingTabStop === 'selected'
383
- ? (activeMembers.find(
384
- (element) => element.getAttribute(this.childSelectedAttr) === 'true',
385
- ) ?? activeMembers[0])
386
- : activeMembers[0];
429
+ focused ??
430
+ (holders.length === 1
431
+ ? holders[0]
432
+ : ((this.rovingTabStop === 'selected'
433
+ ? activeMembers.find(
434
+ (element) => element.getAttribute(this.childSelectedAttr) === 'true',
435
+ )
436
+ : undefined) ?? activeMembers[0]));
387
437
 
388
438
  allMembers.forEach((element) => {
389
- element.tabIndex = element === tabStop ? 0 : -1;
439
+ // Only touch the DOM when it changes; this runs on every mutation of a large widget. The
440
+ // attribute is what’s compared: a `<button>` reports a `tabIndex` of 0 without one, and the
441
+ // popup looks the tab stop up by attribute.
442
+ setTabIndex(element, element === tabStop ? 0 : -1);
390
443
  });
391
444
  }
392
445
 
446
+ /**
447
+ * Count the columns of a grid layout from where the members sit, rather than from their widths:
448
+ * a row of a data grid spans the full width, a tile in a grid listbox doesn’t, and a header row
449
+ * may have no box at all.
450
+ * @returns {number} Number of members per visual row, at least 1.
451
+ */
452
+ get columnCount() {
453
+ // Members without a layout box don’t occupy a column
454
+ const laidOut = this.allMembers.filter((member) => member.getClientRects().length);
455
+ const firstTop = laidOut[0]?.getBoundingClientRect().top;
456
+
457
+ const count = laidOut.findIndex(
458
+ (member) => Math.abs(member.getBoundingClientRect().top - firstTop) > 1,
459
+ );
460
+
461
+ // Everything on one visual row when nothing wraps
462
+ return Math.max(1, count === -1 ? laidOut.length : count);
463
+ }
464
+
393
465
  /**
394
466
  * CSS selector to retrieve the members.
395
467
  * @type {string}
@@ -721,18 +793,17 @@ export class Group {
721
793
  });
722
794
 
723
795
  if (this.focusChild) {
724
- // Wait a bit before the elements are rerendered. A single frame serves the whole group;
725
- // scheduling a callback per member would queue thousands of them on a large widget.
726
- globalThis.requestAnimationFrame(() => {
727
- affected.forEach((element) => {
728
- element.tabIndex = element === newTarget ? 0 : -1;
729
- });
730
-
731
- if (targetAffected) {
732
- newTarget.focus();
733
- newTarget.dispatchEvent(new CustomEvent('Focus'));
734
- }
796
+ // Done right away rather than on the next frame: a key that repeats, or a second press
797
+ // before the frame, has to start from the member that was just reached, which it reads
798
+ // from the focus
799
+ affected.forEach((element) => {
800
+ setTabIndex(element, element === newTarget ? 0 : -1);
735
801
  });
802
+
803
+ if (targetAffected) {
804
+ newTarget.focus();
805
+ newTarget.dispatchEvent(new CustomEvent('Focus'));
806
+ }
736
807
  }
737
808
 
738
809
  this.parent.dispatchEvent(
@@ -786,22 +857,85 @@ export class Group {
786
857
  const { allMembers, activeMembers } = this;
787
858
 
788
859
  /** @type {HTMLElement | undefined} */
860
+ // A field the user is typing in keeps its keys: the caret moves, the text changes. Escape and
861
+ // Tab still reach the group, so a menu holding a field can be left the usual ways.
862
+ if (
863
+ target !== this.parent &&
864
+ target.matches('input, textarea, select, [contenteditable]:not([contenteditable="false"])') &&
865
+ key !== 'Escape' &&
866
+ key !== 'Tab'
867
+ ) {
868
+ return;
869
+ }
870
+
789
871
  const currentTarget = (() => {
790
872
  if (!this.focusChild) {
791
873
  return activeMembers.find((member) => member.matches('.focused'));
792
874
  }
793
875
 
794
- if (target.matches(this.selector)) {
795
- return target;
796
- }
876
+ // A key pressed on a control inside a member, such as a checkbox in a grid row, moves from
877
+ // that member
878
+ const member = /** @type {HTMLElement | null} */ (target.closest(this.selector));
797
879
 
798
- return undefined;
880
+ return member && this.parent.contains(member) ? member : undefined;
799
881
  })();
800
882
 
801
883
  if (['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
802
884
  event.preventDefault();
803
885
  }
804
886
 
887
+ // Home, End and type-ahead only apply to keystrokes on the group itself or on a member. A text
888
+ // field nested in a menu, say, keeps them for its own caret and content.
889
+ const fromMember = target === this.parent || target.matches(this.selector);
890
+
891
+ // Home and End jump to the ends of the group, in any layout
892
+ if (key === 'Home' || key === 'End') {
893
+ if (!fromMember) {
894
+ return;
895
+ }
896
+
897
+ event.preventDefault();
898
+
899
+ const newTarget = activeMembers[key === 'Home' ? 0 : activeMembers.length - 1];
900
+
901
+ if (newTarget && newTarget !== currentTarget) {
902
+ this.selectTarget(event, newTarget);
903
+ }
904
+
905
+ return;
906
+ }
907
+
908
+ // Type-ahead: a printable character moves to the next member whose label starts with what has
909
+ // been typed so far, as in a native `<select>` and per the Listbox and Menu patterns. This goes
910
+ // by the visible label, not the search value, which may hold synonyms for filtering — and by
911
+ // the label element rather than the whole text, which would take in icon ligature names.
912
+ if (key.length === 1 && key !== ' ' && fromMember && (this.role === 'listbox' || isMenu)) {
913
+ // Swallow the key whether or not it matches, as a native `<select>` does. Left to the
914
+ // browser, a printable key on a non-editable element starts Firefox’s find-as-you-type.
915
+ event.preventDefault();
916
+
917
+ const labels = activeMembers.map((member) =>
918
+ normalize(
919
+ member.dataset.label ??
920
+ member.querySelector('.label')?.textContent ??
921
+ member.textContent ??
922
+ '',
923
+ ),
924
+ );
925
+
926
+ const index = findTypeAheadMatch(
927
+ labels,
928
+ this.#typeAhead.push(key),
929
+ currentTarget ? activeMembers.indexOf(currentTarget) : -1,
930
+ );
931
+
932
+ if (index !== -1 && activeMembers[index] !== currentTarget) {
933
+ this.selectTarget(event, activeMembers[index]);
934
+ }
935
+
936
+ return;
937
+ }
938
+
805
939
  if (key === 'Enter') {
806
940
  currentTarget?.click(); // Also close the popup if needed
807
941
 
@@ -858,17 +992,31 @@ export class Group {
858
992
  let newTarget;
859
993
 
860
994
  if (this.grid) {
861
- const colCount = Math.floor(this.parent.clientWidth / activeMembers[0].clientWidth);
995
+ const colCount = this.columnCount;
996
+ const lastIndex = allMembers.length - 1;
862
997
  const _isRTL = isRTL();
863
998
 
864
999
  index = currentTarget ? allMembers.indexOf(currentTarget) : -1;
865
1000
 
1001
+ // With nothing focused yet, the arrows start from either end, as in a list
1002
+ if (index === -1) {
1003
+ const forward = key === 'ArrowDown' || key === (_isRTL ? 'ArrowLeft' : 'ArrowRight');
1004
+ const backward = key === 'ArrowUp' || key === (_isRTL ? 'ArrowRight' : 'ArrowLeft');
1005
+
1006
+ if (forward) {
1007
+ [newTarget] = activeMembers;
1008
+ } else if (backward) {
1009
+ newTarget = activeMembers[activeMembers.length - 1];
1010
+ }
1011
+ }
1012
+
866
1013
  if (key === 'ArrowUp' && index > 0) {
867
- newTarget = allMembers[index - colCount];
1014
+ newTarget = allMembers[Math.max(index - colCount, 0)];
868
1015
  }
869
1016
 
870
- if (key === 'ArrowDown' && index < allMembers.length - 1) {
871
- newTarget = allMembers[index + colCount];
1017
+ if (key === 'ArrowDown' && index !== -1 && index < lastIndex) {
1018
+ // A partial last row still gets reached
1019
+ newTarget = allMembers[Math.min(index + colCount, lastIndex)];
872
1020
  }
873
1021
 
874
1022
  // In RTL, ArrowLeft moves right (next), ArrowRight moves left (previous)
@@ -876,7 +1024,7 @@ export class Group {
876
1024
  newTarget = allMembers[index + (_isRTL ? 1 : -1)];
877
1025
  }
878
1026
 
879
- if (key === 'ArrowRight' && index < allMembers.length - 1) {
1027
+ if (key === 'ArrowRight' && index !== -1 && index < lastIndex) {
880
1028
  newTarget = allMembers[index + (_isRTL ? -1 : 1)];
881
1029
  }
882
1030
 
@@ -929,6 +1077,7 @@ export class Group {
929
1077
  * Clean up event listeners.
930
1078
  */
931
1079
  destroy() {
1080
+ this.#typeAhead.reset();
932
1081
  this.observer.disconnect();
933
1082
  this.parent.removeEventListener('click', this._onClick);
934
1083
  this.parent.removeEventListener('keydown', this._onKeyDown);
@@ -115,10 +115,33 @@ class Popup {
115
115
  const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
116
116
  const hasModifier = shiftKey || altKey || ctrlKey || metaKey;
117
117
 
118
- if (!this.isDisabled && !this.isReadOnly && ['Enter', ' '].includes(key) && !hasModifier) {
118
+ if (this.isDisabled || this.isReadOnly) {
119
+ return;
120
+ }
121
+
122
+ if (['Enter', ' '].includes(key) && !hasModifier) {
119
123
  event.preventDefault();
120
124
  event.stopPropagation();
121
125
  this.open = !this.open;
126
+
127
+ return;
128
+ }
129
+
130
+ // The arrow keys open a listbox or menu, the way they do a native `<select>`; Alt+Down is the
131
+ // combobox convention (APG Select-Only Combobox, Menu Button). They never close one: inside
132
+ // the popup they belong to the list. A menu item that opens a submenu is left alone: there
133
+ // the arrows move through the parent menu, and the submenu opens with the inline arrow.
134
+ if (
135
+ ['ArrowDown', 'ArrowUp'].includes(key) &&
136
+ !(shiftKey || ctrlKey || metaKey) &&
137
+ (!altKey || key === 'ArrowDown') &&
138
+ ['listbox', 'menu'].includes(anchorElement.getAttribute('aria-haspopup') ?? '') &&
139
+ !anchorElement.matches('[role^="menuitem"]') &&
140
+ !this.open
141
+ ) {
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ this.open = true;
122
145
  }
123
146
  });
124
147
 
@@ -20,6 +20,11 @@ export class OptionRegistry {
20
20
  * @returns {number} Count.
21
21
  */
22
22
  get count(): number;
23
+ /**
24
+ * Registered options, in registration order.
25
+ * @returns {OptionEntry[]} Entries.
26
+ */
27
+ get entries(): OptionEntry[];
23
28
  /**
24
29
  * The option that is currently marked as selected, if any.
25
30
  * @returns {OptionEntry | undefined} Matching option.
@@ -42,6 +42,14 @@ export class OptionRegistry {
42
42
  return this.#entries.length;
43
43
  }
44
44
 
45
+ /**
46
+ * Registered options, in registration order.
47
+ * @returns {OptionEntry[]} Entries.
48
+ */
49
+ get entries() {
50
+ return this.#entries;
51
+ }
52
+
45
53
  /**
46
54
  * The option that is currently marked as selected, if any.
47
55
  * @returns {OptionEntry | undefined} Matching option.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Type-ahead support shared by the composite widgets: the collapsed select-only combobox, and the
3
+ * listbox and menu groups.
4
+ */
5
+ /**
6
+ * How long, in milliseconds, successive keystrokes keep adding up to one type-ahead prefix.
7
+ */
8
+ export const TYPE_AHEAD_TIMEOUT: 500;
9
+ /**
10
+ * Keystroke buffer behind type-ahead: printable keys typed in quick succession make up a prefix,
11
+ * which is dropped again after a short pause.
12
+ */
13
+ export class TypeAhead {
14
+ /**
15
+ * Add a keystroke to the prefix.
16
+ * @param {string} key Printable key.
17
+ * @returns {string} Current prefix.
18
+ */
19
+ push(key: string): string;
20
+ /**
21
+ * Drop the prefix.
22
+ */
23
+ reset(): void;
24
+ #private;
25
+ }
26
+ export function findTypeAheadMatch(labels: string[], prefix: string, currentIndex: number): number;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Type-ahead support shared by the composite widgets: the collapsed select-only combobox, and the
3
+ * listbox and menu groups.
4
+ */
5
+
6
+ /**
7
+ * How long, in milliseconds, successive keystrokes keep adding up to one type-ahead prefix.
8
+ */
9
+ export const TYPE_AHEAD_TIMEOUT = 500;
10
+
11
+ /**
12
+ * Keystroke buffer behind type-ahead: printable keys typed in quick succession make up a prefix,
13
+ * which is dropped again after a short pause.
14
+ */
15
+ export class TypeAhead {
16
+ /** @type {string} */
17
+ #prefix = '';
18
+
19
+ /** @type {number} */
20
+ #timer = 0;
21
+
22
+ /**
23
+ * Add a keystroke to the prefix.
24
+ * @param {string} key Printable key.
25
+ * @returns {string} Current prefix.
26
+ */
27
+ push(key) {
28
+ globalThis.clearTimeout(this.#timer);
29
+ this.#prefix += key.toLocaleLowerCase();
30
+ this.#timer = /** @type {number} */ (
31
+ /** @type {unknown} */ (
32
+ globalThis.setTimeout(() => {
33
+ this.#prefix = '';
34
+ }, TYPE_AHEAD_TIMEOUT)
35
+ )
36
+ );
37
+
38
+ return this.#prefix;
39
+ }
40
+
41
+ /**
42
+ * Drop the prefix.
43
+ */
44
+ reset() {
45
+ globalThis.clearTimeout(this.#timer);
46
+ this.#prefix = '';
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Find the item that a type-ahead prefix lands on, the way a native `<select>` does. A single
52
+ * character moves to the next item after the current one whose label starts with it, wrapping
53
+ * around, and a repeated character (“aa”, …) keeps cycling through those. A longer prefix refines
54
+ * the match from the current item, which is where the first character landed.
55
+ * @param {string[]} labels Normalized item labels, in order.
56
+ * @param {string} prefix Normalized prefix typed so far.
57
+ * @param {number} currentIndex Index of the current item, or `-1` if there is none.
58
+ * @returns {number} Index of the matching item, or `-1` if nothing matches.
59
+ */
60
+ export const findTypeAheadMatch = (labels, prefix, currentIndex) => {
61
+ if (!prefix) {
62
+ return -1;
63
+ }
64
+
65
+ const cycling = prefix.length > 1 && [...prefix].every((char) => char === prefix[0]);
66
+ const needle = cycling ? prefix[0] : prefix;
67
+ // A single or repeated character is a request to move on, so the search starts after the current
68
+ // item; a longer prefix is refining what the first character found, so it starts there
69
+ const start = prefix.length === 1 || cycling ? currentIndex + 1 : Math.max(currentIndex, 0);
70
+ const { length } = labels;
71
+
72
+ for (let i = 0; i < length; i += 1) {
73
+ const index = (start + i) % length;
74
+
75
+ if (labels[index].startsWith(needle)) {
76
+ return index;
77
+ }
78
+ }
79
+
80
+ return -1;
81
+ };
@@ -46,6 +46,7 @@
46
46
 
47
47
  :focus-visible {
48
48
  outline-color: var(--sui-focus-ring-color);
49
+ outline-offset: var(--sui-focus-ring-offset, 0);
49
50
  z-index: 2;
50
51
  }
51
52
 
@@ -158,19 +159,19 @@
158
159
  padding-inline-start: 12px;
159
160
  }
160
161
 
161
- :is(.disabled, .readonly, [aria-disabled="true"], [aria-readonly="true"], [inert]):not(body) {
162
+ :is(.disabled, [aria-disabled="true"], [inert]):not(body) {
162
163
  cursor: default;
163
164
  pointer-events: none;
164
165
  -webkit-user-select: none;
165
166
  user-select: none;
166
- filter: grayscale(1) opacity(0.35);
167
+ filter: grayscale(1) opacity(var(--sui-disabled-opacity, 0.35));
167
168
 
168
169
  * {
169
170
  filter: grayscale(0) opacity(1); // Maintain the opacity on child nodes
170
171
  }
171
172
  }
172
173
 
173
- :is(.disabled, .readonly, [aria-disabled="true"], [aria-readonly="true"], [inert]) {
174
+ :is(.disabled, [aria-disabled="true"], [inert]) {
174
175
  * {
175
176
  cursor: default;
176
177
  pointer-events: none;
@@ -178,4 +179,14 @@
178
179
  user-select: none;
179
180
  }
180
181
  }
182
+
183
+ // A read-only control is not inactive: its value is content the user reads and copies, so it
184
+ // keeps full contrast and stays selectable (WCAG 1.4.3). Only the cursor changes.
185
+ :is(.readonly, [aria-readonly="true"]):not(body) {
186
+ cursor: default;
187
+
188
+ * {
189
+ cursor: default;
190
+ }
191
+ }
181
192
  }
@@ -72,6 +72,102 @@
72
72
  --sui-alert-border-color-lightness: 38%;
73
73
  }
74
74
 
75
+ // High-contrast variants, layered over the light and dark themes for `prefers-contrast: more` (or
76
+ // `data-contrast="more"`). Every text colour reaches 7:1 and every control boundary 3:1 or better;
77
+ // the translucent and faded treatments the default themes use for hierarchy are made opaque.
78
+ @mixin high-contrast-light {
79
+ --sui-foreground-color-2-hsl: var(--sui-base-hue) 5% 10%; // primary, 17.5:1
80
+ --sui-foreground-color-3-hsl: var(--sui-base-hue) 5% 18%; // secondary, 13.7:1
81
+ --sui-foreground-color-4-hsl: var(--sui-base-hue) 5% 28%; // tertiary, 9.3:1
82
+ --sui-foreground-color-5-hsl: var(--sui-base-hue) 5% 45%; // disabled, 4.8:1
83
+ // The surface levels are spread further apart, so panels read as panels without relying on
84
+ // the faint default steps
85
+ --sui-background-color-3-hsl: var(--sui-base-hue) 5% 93%; // secondary
86
+ --sui-background-color-4-hsl: var(--sui-base-hue) 5% 88%; // tertiary/disabled
87
+ --sui-background-color-5-hsl: var(--sui-base-hue) 5% 70%; // highlight
88
+ --sui-border-color-1-hsl: var(--sui-base-hue) 5% 40%; // control, 5.8:1
89
+ --sui-border-color-2-hsl: var(--sui-base-hue) 5% 55%; // primary, 3.4:1
90
+ --sui-border-color-3-hsl: var(--sui-base-hue) 5% 65%; // secondary
91
+ // Accents
92
+ --sui-primary-accent-color-text: hsl(var(--sui-base-hue) 80% 32%); // 8:1
93
+ --sui-primary-accent-color-light: hsl(var(--sui-base-hue) 80% 37%);
94
+ --sui-primary-accent-color: hsl(var(--sui-base-hue) 80% 32%);
95
+ --sui-primary-accent-color-dark: hsl(var(--sui-base-hue) 80% 26%);
96
+ // Alerts
97
+ --sui-alert-foreground-color-lightness: 18%;
98
+ --sui-alert-border-color-saturation: 55%;
99
+ --sui-alert-border-color-lightness: 40%;
100
+ @include high-contrast-common;
101
+ }
102
+
103
+ @mixin high-contrast-dark {
104
+ --sui-foreground-color-2-hsl: var(--sui-base-hue) 10% 92%; // primary, 15.3:1
105
+ --sui-foreground-color-3-hsl: var(--sui-base-hue) 10% 85%; // secondary, 12.9:1
106
+ --sui-foreground-color-4-hsl: var(--sui-base-hue) 10% 75%; // tertiary, 9.9:1
107
+ --sui-foreground-color-5-hsl: var(--sui-base-hue) 10% 55%; // disabled, 5.4:1
108
+ // Near-black content and input surfaces with distinctly lighter panels, the way macOS treats
109
+ // its own windows with Increase Contrast on
110
+ --sui-background-color-1-hsl: var(--sui-base-hue) 10% 4%; // content/input
111
+ --sui-background-color-2-hsl: var(--sui-base-hue) 10% 7%; // primary
112
+ --sui-background-color-3-hsl: var(--sui-base-hue) 10% 12%; // secondary
113
+ --sui-background-color-4-hsl: var(--sui-base-hue) 10% 18%; // tertiary/disabled
114
+ --sui-background-color-5-hsl: var(--sui-base-hue) 10% 40%; // highlight
115
+ --sui-border-color-1-hsl: var(--sui-base-hue) 10% 60%; // control, 6.4:1
116
+ --sui-border-color-2-hsl: var(--sui-base-hue) 10% 50%; // primary, 4.5:1
117
+ --sui-border-color-3-hsl: var(--sui-base-hue) 10% 40%; // secondary, 3.1:1
118
+ // Accents. The fill stays put: lighter would lose the white label on a primary button, darker
119
+ // would lose the fill against the page. The text accent and the focus ring go lighter instead.
120
+ --sui-primary-accent-color-text: hsl(var(--sui-base-hue) 100% 70%); // 8.3:1
121
+ --sui-focus-ring-color: var(--sui-primary-accent-color-text);
122
+ // Alerts
123
+ --sui-alert-foreground-color-lightness: 85%;
124
+ --sui-alert-border-color-saturation: 55%;
125
+ --sui-alert-border-color-lightness: 55%;
126
+ @include high-contrast-common;
127
+ }
128
+
129
+ // Apply the high-contrast variant matching the active theme, however the theme was chosen
130
+ @mixin high-contrast-themes {
131
+ &[data-theme="light"] {
132
+ @include high-contrast-light;
133
+ }
134
+
135
+ &[data-theme="dark"] {
136
+ @include high-contrast-dark;
137
+ }
138
+
139
+ &:not([data-theme]) {
140
+ @media (prefers-color-scheme: light) {
141
+ @include high-contrast-light;
142
+ }
143
+
144
+ @media (prefers-color-scheme: dark) {
145
+ @include high-contrast-dark;
146
+ }
147
+ }
148
+ }
149
+
150
+ // Shared by both high-contrast variants: solid state backgrounds and boundaries in place of the
151
+ // translucent ones, and placeholder text at full strength
152
+ @mixin high-contrast-common {
153
+ --sui-hover-background-color: hsl(var(--sui-background-color-5-hsl) / 60%);
154
+ --sui-selected-background-color: hsl(var(--sui-background-color-5-hsl));
155
+ --sui-active-background-color: hsl(var(--sui-background-color-5-hsl));
156
+ --sui-primary-background-color-translucent: hsl(var(--sui-background-color-2-hsl));
157
+ --sui-secondary-background-color-translucent: hsl(var(--sui-background-color-3-hsl));
158
+ --sui-tertiary-background-color-translucent: hsl(var(--sui-background-color-4-hsl));
159
+ --sui-popup-shadow-color: hsl(var(--sui-shadow-color) / 70%);
160
+ --sui-popup-backdrop-color: hsl(var(--sui-shadow-color) / 60%);
161
+ // A text box shares the page background, so its border is what marks the field
162
+ --sui-textbox-border-color: hsl(var(--sui-border-color-1-hsl));
163
+ --sui-textbox-placeholder-foreground-color: var(--sui-tertiary-foreground-color);
164
+ --sui-textbox-placeholder-opacity: 1;
165
+ // Disabled controls are dimmed less; the loss of colour still marks them
166
+ --sui-disabled-opacity: 0.6;
167
+ // Dialogs and drawers get an outline, as the OS draws around its own windows
168
+ --sui-modal-border-width: 1px;
169
+ }
170
+
75
171
  :global {
76
172
  :root,
77
173
  :host {
@@ -128,9 +224,16 @@
128
224
  var(--sui-success-color-hue) var(--sui-alert-background-color-saturation)
129
225
  var(--sui-alert-background-color-lightness)
130
226
  );
227
+ // Disabled controls: how far they fade (they also lose their colour)
228
+ --sui-disabled-opacity: 0.35;
229
+ // Dialog & drawer outline; none by default, drawn by the high-contrast theme
230
+ --sui-modal-border-width: 0;
131
231
  // Outline
132
232
  --sui-focus-ring-width: 2px;
133
- --sui-focus-ring-color: var(--sui-primary-accent-color-translucent);
233
+ // Opaque, so the ring reaches 3:1 against the page (WCAG 1.4.11); the offset keeps a sliver of
234
+ // page background between the ring and a control filled with the same accent colour
235
+ --sui-focus-ring-color: var(--sui-primary-accent-color);
236
+ --sui-focus-ring-offset: 1px;
134
237
  // Borders
135
238
  --sui-primary-border-color: hsl(var(--sui-border-color-2-hsl));
136
239
  --sui-secondary-border-color: hsl(var(--sui-border-color-3-hsl));
@@ -290,5 +393,19 @@
290
393
  @include dark-theme;
291
394
  }
292
395
  }
396
+
397
+ // High contrast is a second dimension on top of the light/dark theme, and is resolved the same
398
+ // way: `data-contrast="more"` turns it on, any other value keeps the standard palette, and
399
+ // without the attribute the system setting decides. The selectors here are more specific than
400
+ // the theme blocks above, so these overrides win whichever way the theme was picked.
401
+ &[data-contrast="more"] {
402
+ @include high-contrast-themes;
403
+ }
404
+
405
+ &:not([data-contrast]) {
406
+ @media (prefers-contrast: more) {
407
+ @include high-contrast-themes;
408
+ }
409
+ }
293
410
  }
294
411
  }