@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.
- package/dist/components/alert/alert.svelte +29 -5
- package/dist/components/alert/alert.svelte.d.ts +8 -4
- package/dist/components/alert/infobar.svelte +21 -5
- package/dist/components/alert/infobar.svelte.d.ts +10 -4
- package/dist/components/button/button.svelte +5 -1
- package/dist/components/button/select-button-group.svelte +1 -1
- package/dist/components/calendar/calendar.d.ts +1 -4
- package/dist/components/calendar/calendar.js +17 -0
- package/dist/components/calendar/calendar.svelte +69 -11
- package/dist/components/checkbox/checkbox-group.svelte +3 -1
- package/dist/components/checkbox/checkbox.svelte +3 -1
- package/dist/components/dialog/dialog.svelte +14 -2
- package/dist/components/dialog/prompt-dialog.svelte +9 -2
- package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -2
- package/dist/components/disclosure/disclosure.svelte +2 -1
- package/dist/components/drawer/drawer.svelte +28 -3
- package/dist/components/drawer/drawer.svelte.d.ts +16 -2
- package/dist/components/grid/grid-body.svelte +3 -1
- package/dist/components/grid/grid-foot.svelte +3 -1
- package/dist/components/grid/grid-head.svelte +3 -1
- package/dist/components/listbox/option-group.svelte +2 -1
- package/dist/components/listbox/option.svelte +4 -0
- package/dist/components/menu/menu-item-group.svelte +3 -1
- package/dist/components/radio/radio.svelte +21 -3
- package/dist/components/radio/radio.svelte.d.ts +12 -2
- package/dist/components/resizable-pane/resizable-handle.svelte +12 -0
- package/dist/components/select/combobox.svelte +69 -1
- package/dist/components/select/select-tags.svelte +34 -23
- package/dist/components/slider/slider.d.ts +6 -0
- package/dist/components/slider/slider.js +40 -0
- package/dist/components/slider/slider.svelte +45 -11
- package/dist/components/slider/slider.svelte.d.ts +14 -2
- package/dist/components/switch/switch.svelte +1 -1
- package/dist/components/table/table-body.svelte +3 -1
- package/dist/components/table/table-foot.svelte +3 -1
- package/dist/components/table/table-head.svelte +3 -1
- package/dist/components/text-editor/code-editor.svelte +22 -1
- package/dist/components/text-editor/code-editor.svelte.d.ts +28 -0
- package/dist/components/text-editor/shiki/version.d.ts +1 -1
- package/dist/components/text-editor/shiki/version.js +1 -1
- package/dist/components/text-editor/text-editor.svelte +23 -0
- package/dist/components/text-editor/text-editor.svelte.d.ts +28 -0
- package/dist/components/text-field/number-input.svelte +7 -17
- package/dist/components/text-field/search-bar.svelte +1 -0
- package/dist/components/text-field/text-input.svelte +1 -1
- package/dist/components/toast/toast.svelte +58 -4
- package/dist/components/toast/toast.svelte.d.ts +4 -2
- package/dist/components/util/app-shell.svelte +289 -5
- package/dist/components/util/popup.svelte +1 -1
- package/dist/locales/ar.yaml +20 -0
- package/dist/locales/bg.yaml +20 -0
- package/dist/locales/ca.yaml +20 -0
- package/dist/locales/cs.yaml +20 -0
- package/dist/locales/da.yaml +20 -0
- package/dist/locales/de.yaml +20 -0
- package/dist/locales/el.yaml +20 -0
- package/dist/locales/en-CA.yaml +20 -0
- package/dist/locales/en-GB.yaml +20 -0
- package/dist/locales/en-US.yaml +20 -0
- package/dist/locales/es-CO.yaml +20 -0
- package/dist/locales/fa.yaml +20 -0
- package/dist/locales/fi.yaml +20 -0
- package/dist/locales/fr.yaml +20 -0
- package/dist/locales/hr.yaml +20 -0
- package/dist/locales/it.yaml +20 -0
- package/dist/locales/ja.yaml +20 -0
- package/dist/locales/ko.yaml +20 -0
- package/dist/locales/nl.yaml +20 -0
- package/dist/locales/pl.yaml +20 -0
- package/dist/locales/pt-BR.yaml +20 -0
- package/dist/locales/pt-PT.yaml +20 -0
- package/dist/locales/ru.yaml +20 -0
- package/dist/locales/sv.yaml +20 -0
- package/dist/locales/tr.yaml +20 -0
- package/dist/locales/uk.yaml +20 -0
- package/dist/locales/vi.yaml +20 -0
- package/dist/locales/zh-CN.yaml +20 -0
- package/dist/locales/zh-TW.yaml +20 -0
- package/dist/services/group.svelte.d.ts +12 -0
- package/dist/services/group.svelte.js +177 -28
- package/dist/services/popup.svelte.js +24 -1
- package/dist/services/select.svelte.d.ts +5 -0
- package/dist/services/select.svelte.js +8 -0
- package/dist/services/type-ahead.d.ts +26 -0
- package/dist/services/type-ahead.js +81 -0
- package/dist/styles/core.scss +14 -3
- package/dist/styles/variables.scss +118 -1
- package/dist/typedefs.d.ts +7 -1
- package/dist/typedefs.js +4 -1
- 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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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 =
|
|
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
|
|
871
|
-
|
|
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
|
|
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 (
|
|
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
|
+
};
|
package/dist/styles/core.scss
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
}
|