@sveltia/ui 0.73.0 → 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.
@@ -49,9 +49,10 @@
49
49
  /** @type {HTMLElement | undefined} */
50
50
  let grid = $state();
51
51
 
52
- // Start the keyboard cursor on the selected day. The group service tracks the cursor with the
53
- // `focused` class and `aria-activedescendant`, and keeps it as the selection moves; it only
54
- // needs a starting point here, where nothing has been focused yet.
52
+ // Keep the keyboard cursor on the selected day. The group service tracks the cursor with the
53
+ // `focused` class and `aria-activedescendant`, and moves both along with the selection it makes
54
+ // itself; this covers the starting point and a `value` set from outside, which may land in
55
+ // another month while the cell the cursor was on is still on the grid.
55
56
  $effect(() => {
56
57
  void value;
57
58
 
@@ -64,16 +65,17 @@
64
65
  const listbox = grid;
65
66
 
66
67
  tick().then(() => {
67
- if (listbox.querySelector('.focused')) {
68
- return;
69
- }
70
-
71
68
  const option = listbox.querySelector('[role="option"][aria-selected="true"]');
72
69
 
73
- if (option) {
74
- option.classList.add('focused');
75
- listbox.setAttribute('aria-activedescendant', option.id);
70
+ if (!option || option.classList.contains('focused')) {
71
+ return;
76
72
  }
73
+
74
+ listbox.querySelectorAll('.focused').forEach((element) => {
75
+ element.classList.remove('focused');
76
+ });
77
+ option.classList.add('focused');
78
+ listbox.setAttribute('aria-activedescendant', option.id);
77
79
  });
78
80
  });
79
81
  </script>
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
3
3
  */
4
- export const UI_VERSION: "0.73.0";
4
+ export const UI_VERSION: "0.73.1";
@@ -3,4 +3,4 @@
3
3
  /**
4
4
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
5
5
  */
6
- export const UI_VERSION = '0.73.0';
6
+ export const UI_VERSION = '0.73.1';
@@ -12,6 +12,11 @@ export class Group {
12
12
  constructor(parent: HTMLElement, { clickToSelect }?: {
13
13
  clickToSelect?: boolean | undefined;
14
14
  });
15
+ /**
16
+ * Whether {@link activate} has run. Until then, members are left as they are rendered.
17
+ * @type {boolean}
18
+ */
19
+ activated: boolean;
15
20
  parent: HTMLElement;
16
21
  role: string;
17
22
  multi: boolean;
@@ -56,6 +61,13 @@ export class Group {
56
61
  * items would take three tab presses to step over.
57
62
  */
58
63
  updateTabStop(): void;
64
+ /**
65
+ * Count the columns of a grid layout from where the members sit, rather than from their widths:
66
+ * a row of a data grid spans the full width, a tile in a grid listbox doesn’t, and a header row
67
+ * may have no box at all.
68
+ * @returns {number} Number of members per visual row, at least 1.
69
+ */
70
+ get columnCount(): number;
59
71
  /**
60
72
  * CSS selector to retrieve the members.
61
73
  * @type {string}
@@ -32,6 +32,17 @@ export const normalize = (value) => {
32
32
  return value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
33
33
  };
34
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
+
35
46
  /**
36
47
  * @type {{ [role: string]: {
37
48
  * orientation: 'vertical' | 'horizontal',
@@ -49,7 +60,9 @@ const config = {
49
60
  childRoles: ['row'],
50
61
  childSelectedAttr: 'aria-selected',
51
62
  focusChild: true,
52
- 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,
53
66
  controlsPanel: false,
54
67
  rovingTabStop: 'selected',
55
68
  },
@@ -178,6 +191,12 @@ const getMenuOpener = (element) => {
178
191
  * Implement keyboard and mouse interactions for a grouping composite widget.
179
192
  */
180
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
+
181
200
  /**
182
201
  * Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
183
202
  * @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
@@ -302,8 +321,15 @@ export class Group {
302
321
  // The members can be added, removed, disabled or hidden at any time, which is what invalidates
303
322
  // the cached lists. Only the attributes that decide membership are watched, so the group’s own
304
323
  // writes — the selected state and the roving `tabindex` — don’t needlessly discard the cache.
305
- this.observer = new globalThis.MutationObserver(() => {
324
+ this.observer = new globalThis.MutationObserver((records) => {
306
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
+ }
307
333
  });
308
334
 
309
335
  this.observer.observe(parent, {
@@ -326,6 +352,8 @@ export class Group {
326
352
  activate() {
327
353
  const { parent, allMembers, selected: defaultSelected } = this;
328
354
 
355
+ this.activated = true;
356
+
329
357
  allMembers.forEach((element, index) => {
330
358
  // Select the first one if no member has the `selected` attribute
331
359
  const isSelected =
@@ -385,18 +413,55 @@ export class Group {
385
413
  return;
386
414
  }
387
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
+
388
428
  const tabStop =
389
- this.rovingTabStop === 'selected'
390
- ? (activeMembers.find(
391
- (element) => element.getAttribute(this.childSelectedAttr) === 'true',
392
- ) ?? activeMembers[0])
393
- : 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]));
394
437
 
395
438
  allMembers.forEach((element) => {
396
- 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);
397
443
  });
398
444
  }
399
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
+
400
465
  /**
401
466
  * CSS selector to retrieve the members.
402
467
  * @type {string}
@@ -728,18 +793,17 @@ export class Group {
728
793
  });
729
794
 
730
795
  if (this.focusChild) {
731
- // Wait a bit before the elements are rerendered. A single frame serves the whole group;
732
- // scheduling a callback per member would queue thousands of them on a large widget.
733
- globalThis.requestAnimationFrame(() => {
734
- affected.forEach((element) => {
735
- element.tabIndex = element === newTarget ? 0 : -1;
736
- });
737
-
738
- if (targetAffected) {
739
- newTarget.focus();
740
- newTarget.dispatchEvent(new CustomEvent('Focus'));
741
- }
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);
742
801
  });
802
+
803
+ if (targetAffected) {
804
+ newTarget.focus();
805
+ newTarget.dispatchEvent(new CustomEvent('Focus'));
806
+ }
743
807
  }
744
808
 
745
809
  this.parent.dispatchEvent(
@@ -793,16 +857,27 @@ export class Group {
793
857
  const { allMembers, activeMembers } = this;
794
858
 
795
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
+
796
871
  const currentTarget = (() => {
797
872
  if (!this.focusChild) {
798
873
  return activeMembers.find((member) => member.matches('.focused'));
799
874
  }
800
875
 
801
- if (target.matches(this.selector)) {
802
- return target;
803
- }
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));
804
879
 
805
- return undefined;
880
+ return member && this.parent.contains(member) ? member : undefined;
806
881
  })();
807
882
 
808
883
  if (['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
@@ -917,17 +992,31 @@ export class Group {
917
992
  let newTarget;
918
993
 
919
994
  if (this.grid) {
920
- const colCount = Math.floor(this.parent.clientWidth / activeMembers[0].clientWidth);
995
+ const colCount = this.columnCount;
996
+ const lastIndex = allMembers.length - 1;
921
997
  const _isRTL = isRTL();
922
998
 
923
999
  index = currentTarget ? allMembers.indexOf(currentTarget) : -1;
924
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
+
925
1013
  if (key === 'ArrowUp' && index > 0) {
926
- newTarget = allMembers[index - colCount];
1014
+ newTarget = allMembers[Math.max(index - colCount, 0)];
927
1015
  }
928
1016
 
929
- if (key === 'ArrowDown' && index < allMembers.length - 1) {
930
- 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)];
931
1020
  }
932
1021
 
933
1022
  // In RTL, ArrowLeft moves right (next), ArrowRight moves left (previous)
@@ -935,7 +1024,7 @@ export class Group {
935
1024
  newTarget = allMembers[index + (_isRTL ? 1 : -1)];
936
1025
  }
937
1026
 
938
- if (key === 'ArrowRight' && index < allMembers.length - 1) {
1027
+ if (key === 'ArrowRight' && index !== -1 && index < lastIndex) {
939
1028
  newTarget = allMembers[index + (_isRTL ? -1 : 1)];
940
1029
  }
941
1030
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.73.0",
3
+ "version": "0.73.1",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",