sheer-ui 0.4.0 → 0.4.3

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 (96) hide show
  1. package/package.json +1 -1
  2. package/src/lib/components/accordion/components/accordion-item.svelte +3 -3
  3. package/src/lib/components/accordion/components/accordion.svelte +29 -13
  4. package/src/lib/components/accordion/types.ts +3 -3
  5. package/src/lib/components/avatar/avatar.svelte.ts +34 -37
  6. package/src/lib/components/avatar/components/avatar-fallback.svelte +9 -6
  7. package/src/lib/components/avatar/components/avatar-image.svelte +18 -9
  8. package/src/lib/components/avatar/components/avatar.svelte +20 -16
  9. package/src/lib/components/checkbox/checkbox.svelte.ts +45 -55
  10. package/src/lib/components/checkbox/components/checkbox-group-label.svelte +9 -6
  11. package/src/lib/components/checkbox/components/checkbox-group.svelte +25 -14
  12. package/src/lib/components/checkbox/components/checkbox.svelte +39 -21
  13. package/src/lib/components/combobox/components/combobox.svelte +2 -2
  14. package/src/lib/components/combobox/select/components/select.svelte +2 -2
  15. package/src/lib/components/context-menu/components/context-menu-trigger.svelte +1 -1
  16. package/src/lib/components/date-field/date-field.svelte.ts +2 -3
  17. package/src/lib/components/dialog/components/dialog-close.svelte +13 -8
  18. package/src/lib/components/dialog/components/dialog-content-headless.svelte +14 -11
  19. package/src/lib/components/dialog/components/dialog-description.svelte +9 -6
  20. package/src/lib/components/dialog/components/dialog-overlay.svelte +10 -7
  21. package/src/lib/components/dialog/components/dialog-title.svelte +12 -7
  22. package/src/lib/components/dialog/components/dialog-trigger.svelte +12 -7
  23. package/src/lib/components/dialog/components/dialog.svelte +10 -4
  24. package/src/lib/components/dialog/components/modal-surface.svelte +11 -8
  25. package/src/lib/components/dialog/dialog.svelte.ts +73 -61
  26. package/src/lib/components/drawer/drawer.svelte +8 -3
  27. package/src/lib/components/link-preview/components/link-preview-content.svelte +15 -8
  28. package/src/lib/components/link-preview/components/link-preview-trigger.svelte +9 -6
  29. package/src/lib/components/link-preview/components/link-preview.svelte +19 -11
  30. package/src/lib/components/link-preview/link-preview.svelte.ts +42 -43
  31. package/src/lib/components/menu/components/menu-checkbox-group.svelte +6 -4
  32. package/src/lib/components/menu/components/menu-checkbox-item.svelte +11 -7
  33. package/src/lib/components/menu/menu.svelte.ts +29 -19
  34. package/src/lib/components/menubar/menubar.svelte.ts +7 -6
  35. package/src/lib/components/meter/components/meter.svelte +18 -9
  36. package/src/lib/components/meter/meter.svelte.ts +15 -18
  37. package/src/lib/components/navigation-menu/components/navigation-menu-content.svelte +1 -1
  38. package/src/lib/components/navigation-menu/components/navigation-menu-indicator.svelte +2 -2
  39. package/src/lib/components/navigation-menu/components/navigation-menu-trigger.svelte +1 -1
  40. package/src/lib/components/navigation-menu/navigation-menu.svelte.ts +57 -60
  41. package/src/lib/components/pagination/components/pagination-next-button.svelte +12 -7
  42. package/src/lib/components/pagination/components/pagination-page.svelte +15 -8
  43. package/src/lib/components/pagination/components/pagination-prev-button.svelte +12 -7
  44. package/src/lib/components/pagination/components/pagination.svelte +31 -18
  45. package/src/lib/components/pagination/pagination.svelte.ts +48 -64
  46. package/src/lib/components/pin-input/pin-input.svelte.ts +7 -8
  47. package/src/lib/components/pin-input/usePasswordManager.svelte.ts +2 -2
  48. package/src/lib/components/popover/components/popover-close.svelte +9 -6
  49. package/src/lib/components/popover/components/popover-content.svelte +12 -7
  50. package/src/lib/components/popover/components/popover-trigger.svelte +21 -10
  51. package/src/lib/components/popover/components/popover.svelte +9 -8
  52. package/src/lib/components/popover/popover.svelte.ts +68 -74
  53. package/src/lib/components/progress/components/progress.svelte +18 -9
  54. package/src/lib/components/progress/progress.svelte.ts +14 -17
  55. package/src/lib/components/scroll-area/scroll-area.svelte.ts +9 -10
  56. package/src/lib/components/separator/components/separator.svelte +15 -8
  57. package/src/lib/components/separator/separator.svelte.ts +12 -15
  58. package/src/lib/components/sidebar/sidebar-mobile-surface.svelte +8 -4
  59. package/src/lib/components/slider/components/slider.svelte +2 -1
  60. package/src/lib/components/tabs/components/tabs-content.svelte +12 -7
  61. package/src/lib/components/tabs/components/tabs-list.svelte +9 -6
  62. package/src/lib/components/tabs/components/tabs-trigger.svelte +15 -8
  63. package/src/lib/components/tabs/components/tabs.svelte +28 -17
  64. package/src/lib/components/tabs/tabs.svelte.ts +51 -67
  65. package/src/lib/components/time-field/time-field.svelte.ts +2 -2
  66. package/src/lib/components/toggle/components/toggle.svelte +19 -14
  67. package/src/lib/components/toggle/toggle.svelte.ts +15 -20
  68. package/src/lib/components/toggle-group/components/toggle-group-item.svelte +15 -8
  69. package/src/lib/components/toggle-group/components/toggle-group.svelte +28 -16
  70. package/src/lib/components/toggle-group/toggle-group.svelte.ts +33 -35
  71. package/src/lib/components/toolbar/components/toolbar-group-item.svelte +15 -8
  72. package/src/lib/components/toolbar/components/toolbar-group.svelte +19 -14
  73. package/src/lib/components/toolbar/toolbar.svelte.ts +28 -30
  74. package/src/lib/components/tooltip/components/tooltip-content.svelte +15 -8
  75. package/src/lib/components/tooltip/components/tooltip-provider.svelte +18 -7
  76. package/src/lib/components/tooltip/components/tooltip-trigger.svelte +21 -10
  77. package/src/lib/components/tooltip/components/tooltip.svelte +34 -21
  78. package/src/lib/components/tooltip/tooltip.svelte.ts +101 -125
  79. package/src/lib/internal/dismissible-layer/use-dismissable-layer.svelte.ts +3 -4
  80. package/src/lib/internal/escape-layer/use-escape-layer.svelte.ts +13 -29
  81. package/src/lib/internal/floating-layer/use-floating-layer.svelte.ts +26 -30
  82. package/src/lib/internal/group-value.svelte.ts +11 -10
  83. package/src/lib/internal/native-popover.svelte.ts +6 -6
  84. package/src/lib/internal/{roving-focus-group.ts → roving-focus-group.svelte.ts} +17 -19
  85. package/src/lib/internal/roving-focus-item.svelte.ts +12 -17
  86. package/src/lib/internal/selection.svelte.ts +55 -41
  87. package/src/lib/internal/{state-machine.ts → state-machine.svelte.ts} +6 -8
  88. package/src/lib/internal/timeout-fn.svelte.ts +6 -4
  89. package/src/lib/internal/tools/box.svelte.ts +5 -114
  90. package/src/lib/internal/tools/index.ts +1 -11
  91. package/src/lib/internal/tools/types.ts +0 -3
  92. package/src/lib/internal/typeahead.svelte.ts +9 -13
  93. package/src/lib/internal/types.ts +6 -0
  94. package/src/lib/internal/vaul/use-drawer-content.svelte.ts +2 -2
  95. package/src/lib/internal/vaul/use-drawer-root.svelte.ts +12 -7
  96. package/src/lib/components/accordion/accordion.svelte.ts +0 -43
@@ -5,10 +5,7 @@ import {
5
5
  getWindow,
6
6
  styleToString,
7
7
  type ReadableBoxedValues,
8
- type ReadableBox,
9
8
  type RefAttachment,
10
- simpleBox,
11
- boxWith,
12
9
  } from '../tools/index.js';
13
10
  import type { WithRefOpts } from '../types.js';
14
11
  import type { Direction, StyleProperties } from '../index.js';
@@ -60,10 +57,16 @@ export class FloatingRootState {
60
57
  return tooltip ? setFloatingTooltipRoot(root) : setFloatingRoot(root);
61
58
  }
62
59
  readonly anchorName: string;
63
- #customAnchorSource = $state<ReadableBox<CustomAnchorNode>>(simpleBox(null));
64
- triggerSource = $state<ReadableBox<AnchorNode>>(simpleBox(null));
65
- anchorNode: ReadableBox<AnchorNode> = boxWith(() => {
66
- const customAnchor = this.#customAnchorSource.current;
60
+ // Getters over whoever owns the anchor (trigger attachment, customAnchor, pointer rect): a derived reading through them tracks the owner.
61
+ #customAnchorSource: () => CustomAnchorNode = $state.raw(() => null);
62
+ triggerSource: () => AnchorNode = $state.raw(() => null);
63
+
64
+ constructor(anchorName: string) {
65
+ this.anchorName = anchorName;
66
+ }
67
+
68
+ get anchorNode(): AnchorNode {
69
+ const customAnchor = this.#customAnchorSource();
67
70
 
68
71
  if (customAnchor) {
69
72
  if (typeof customAnchor === 'string') {
@@ -73,18 +76,14 @@ export class FloatingRootState {
73
76
  return customAnchor;
74
77
  }
75
78
 
76
- return this.triggerSource.current;
77
- });
78
-
79
- constructor(anchorName: string) {
80
- this.anchorName = anchorName;
79
+ return this.triggerSource();
81
80
  }
82
81
 
83
- get triggerNode() {
84
- return this.triggerSource;
82
+ get triggerNode(): AnchorNode {
83
+ return this.triggerSource();
85
84
  }
86
85
 
87
- setCustomAnchorSource(source: ReadableBox<CustomAnchorNode>) {
86
+ setCustomAnchorSource(source: () => CustomAnchorNode) {
88
87
  this.#customAnchorSource = source;
89
88
  }
90
89
  }
@@ -134,12 +133,9 @@ export class FloatingContentState {
134
133
  #arrowWidth = $state(0);
135
134
  #arrowHeight = $state(0);
136
135
 
137
- // nodes
138
- contentRef = simpleBox<HTMLElement | null>(null);
139
- arrowRef = simpleBox<HTMLElement | null>(null);
140
- readonly contentAttachment = attachRef(this.contentRef);
136
+ contentRef = $state.raw<HTMLElement | null>(null);
137
+ readonly contentAttachment = attachRef<HTMLElement>((v) => (this.contentRef = v));
141
138
  readonly arrowAttachment = {
142
- ...attachRef(this.arrowRef),
143
139
  [createAttachmentKey()]: ((node) => this.#measureArrow(node)) satisfies Attachment<HTMLElement>,
144
140
  };
145
141
 
@@ -244,7 +240,7 @@ export class FloatingContentState {
244
240
  /** A virtual anchor (a rect, no element) is rendered as a zero-size fixed box carrying the anchor name. */
245
241
  virtualAnchorStyle = $derived.by(() => {
246
242
  if (!this.opts.present.current) return null;
247
- const anchor = this.root.anchorNode.current;
243
+ const anchor = this.root.anchorNode;
248
244
  // SSR-safe: on the server there is no HTMLElement (and no virtual anchor either)
249
245
  if (!anchor || typeof HTMLElement === 'undefined' || anchor instanceof HTMLElement) return null;
250
246
  const rect = anchor.getBoundingClientRect();
@@ -300,12 +296,12 @@ export class FloatingContentState {
300
296
  constructor(opts: FloatingContentStateOpts, root: FloatingRootState) {
301
297
  this.opts = opts;
302
298
  this.root = root;
303
- this.root.setCustomAnchorSource(opts.customAnchor);
299
+ this.root.setCustomAnchorSource(() => opts.customAnchor.current);
304
300
 
305
301
  // An element anchor that is not the trigger (a `customAnchor`) needs the anchor name too.
306
302
  $effect(() => {
307
- const anchor = this.root.anchorNode.current;
308
- if (!(anchor instanceof HTMLElement) || anchor === this.root.triggerNode.current) return;
303
+ const anchor = this.root.anchorNode;
304
+ if (!(anchor instanceof HTMLElement) || anchor === this.root.triggerNode) return;
309
305
  const previous = anchor.style.anchorName;
310
306
  anchor.style.anchorName = this.root.anchorName;
311
307
  return () => {
@@ -326,7 +322,7 @@ export class FloatingContentState {
326
322
  this.#measuredPlacement = null;
327
323
  return;
328
324
  }
329
- const content = this.contentRef.current;
325
+ const content = this.contentRef;
330
326
  if (!content) return;
331
327
  const win = getWindow(content);
332
328
  let raf = win.requestAnimationFrame(() => this.#measurePlacement());
@@ -345,8 +341,8 @@ export class FloatingContentState {
345
341
  }
346
342
 
347
343
  #measurePlacement() {
348
- const content = this.contentRef.current;
349
- const anchor = this.root.anchorNode.current;
344
+ const content = this.contentRef;
345
+ const anchor = this.root.anchorNode;
350
346
  if (!content || !anchor) return;
351
347
  const c = content.getBoundingClientRect();
352
348
  const a = anchor.getBoundingClientRect();
@@ -444,7 +440,7 @@ export class FloatingArrowState {
444
440
  * tracking the pointer, which the content renders as a zero-size anchor element. Reads the
445
441
  * floating-root context, so call it during component init.
446
442
  */
447
- export function setFloatingAnchor(source: ReadableBox<AnchorNode>, tooltip = false) {
443
+ export function setFloatingAnchor(source: () => AnchorNode, tooltip = false) {
448
444
  const root = tooltip ? getFloatingTooltipRoot() : getFloatingRoot();
449
445
  root.triggerSource = source;
450
446
  }
@@ -460,11 +456,11 @@ export function floatingAnchor(tooltip = false): RefAttachment {
460
456
  return {
461
457
  [createAttachmentKey()]: (node) => {
462
458
  const el = node as HTMLElement;
463
- root.triggerSource = simpleBox(el);
459
+ root.triggerSource = () => el;
464
460
  el.style.anchorName = root.anchorName;
465
461
  return () => {
466
462
  el.style.removeProperty('anchor-name');
467
- root.triggerSource = simpleBox(null);
463
+ root.triggerSource = () => null;
468
464
  };
469
465
  },
470
466
  };
@@ -1,13 +1,14 @@
1
1
  import { untrack } from 'svelte';
2
- import type { ReadableBox, WritableBox } from './tools/index.js';
3
2
 
3
+ /** The group's bindable value, as an accessor on the group's options. */
4
4
  interface GroupValue {
5
- opts: { value: WritableBox<string[]> };
5
+ value: string[];
6
6
  }
7
7
 
8
+ /** The item's `value` and `checked` as accessors over its props. */
8
9
  interface GroupItem {
9
- value: ReadableBox<string | undefined>;
10
- checked: WritableBox<boolean>;
10
+ readonly value: string | undefined;
11
+ readonly checked: boolean;
11
12
  }
12
13
 
13
14
  /**
@@ -20,17 +21,17 @@ export function joinGroup(group: GroupValue | null, item: GroupItem): () => bool
20
21
  if (!group) return () => undefined;
21
22
 
22
23
  $effect(() => {
23
- const checked = item.checked.current;
24
+ const checked = item.checked;
24
25
  untrack(() => {
25
- const value = item.value.current;
26
- const values = group.opts.value.current;
26
+ const value = item.value;
27
+ const values = group.value;
27
28
  if (!value || checked === values.includes(value)) return;
28
- group.opts.value.current = checked ? [...values, value] : values.filter((v) => v !== value);
29
+ group.value = checked ? [...values, value] : values.filter((v) => v !== value);
29
30
  });
30
31
  });
31
32
 
32
33
  return () => {
33
- const value = item.value.current;
34
- return value ? group.opts.value.current.includes(value) : undefined;
34
+ const value = item.value;
35
+ return value ? group.value.includes(value) : undefined;
35
36
  };
36
37
  }
@@ -18,13 +18,13 @@ type NativePopoverAnchor = HTMLElement | string | null | undefined | object;
18
18
  interface NativePopoverContentState {
19
19
  readonly root: {
20
20
  readonly opts: {
21
- readonly open: { readonly current: boolean };
22
- readonly onOpenChangeComplete: { readonly current: (open: boolean) => void };
21
+ readonly open: boolean;
22
+ readonly onOpenChangeComplete: (open: boolean) => void;
23
23
  };
24
24
  readonly triggerNode: HTMLElement | null;
25
25
  };
26
26
  readonly opts: {
27
- readonly ref: { readonly current: HTMLElement | null };
27
+ readonly ref: HTMLElement | null;
28
28
  };
29
29
  onEscapeKeydown?: (event: KeyboardEvent) => void;
30
30
  onInteractOutside?: (event: PointerEvent) => void;
@@ -60,8 +60,8 @@ export function useNativePopoverLifecycle(
60
60
  options: { anchor?: Getter<NativePopoverAnchor>; mode?: 'auto' | 'manual' } = {},
61
61
  ) {
62
62
  const mode = options.mode ?? 'manual';
63
- const ref = () => state.opts.ref.current;
64
- const open = () => state.root.opts.open.current;
63
+ const ref = () => state.opts.ref;
64
+ const open = () => state.root.opts.open;
65
65
 
66
66
  // The surface's own animations gate the deferred hide; a long-running descendant
67
67
  // animation (a chart entry inside a popover) must not hold the top layer.
@@ -131,7 +131,7 @@ export function useNativePopoverLifecycle(
131
131
  });
132
132
  });
133
133
 
134
- useOpenChangeComplete(open, ref, (isOpen) => state.root.opts.onOpenChangeComplete.current(isOpen));
134
+ useOpenChangeComplete(open, ref, (isOpen) => state.root.opts.onOpenChangeComplete(isOpen));
135
135
 
136
136
  if (mode === 'auto') {
137
137
  // The UA closes the popover itself (light dismiss, Escape, another auto popover opening);
@@ -1,4 +1,4 @@
1
- import { type Box, type ReadableBox, simpleBox } from './tools/index.js';
1
+ import type { Getter } from './tools/index.js';
2
2
  import { getDirectionalKeys, kbd } from './kbd.js';
3
3
  import type { Direction, Orientation } from './index.js';
4
4
  import { isHTMLElement } from './tools/utils/dom.js';
@@ -30,21 +30,19 @@ type RovingFocusGroupOptions = (
30
30
  candidateSelector?: undefined;
31
31
  }
32
32
  ) & {
33
- /**
34
- * The id of the root node
35
- */
36
- rootNode: Box<HTMLElement | null>;
33
+ /** The node the candidates live under. */
34
+ rootNode: Getter<HTMLElement | null>;
37
35
 
38
36
  /**
39
37
  * Whether to loop through the candidates when reaching the end.
40
38
  */
41
- loop: ReadableBox<boolean>;
39
+ loop: Getter<boolean>;
42
40
 
43
41
  /**
44
42
  * The orientation of the roving focus group. Used
45
43
  * to determine how keyboard navigation should work.
46
44
  */
47
- orientation: ReadableBox<Orientation>;
45
+ orientation: Getter<Orientation>;
48
46
 
49
47
  /**
50
48
  * A callback function called when a candidate is focused.
@@ -54,7 +52,7 @@ type RovingFocusGroupOptions = (
54
52
 
55
53
  export class RovingFocusGroup {
56
54
  readonly #opts: RovingFocusGroupOptions;
57
- readonly #currentTabStopId = simpleBox<string | null>(null);
55
+ #currentTabStopId = $state.raw<string | null>(null);
58
56
  /** What a candidate of an attribute-marked group stamps on itself; empty for the other kinds. */
59
57
  readonly candidateAttrs: Record<string, ''>;
60
58
 
@@ -64,7 +62,7 @@ export class RovingFocusGroup {
64
62
  }
65
63
 
66
64
  getCandidateNodes() {
67
- const rootNode = this.#opts.rootNode.current;
65
+ const rootNode = this.#opts.rootNode();
68
66
  if (!BROWSER || !rootNode) return [];
69
67
  if (this.#opts.candidateNodes) return this.#opts.candidateNodes(rootNode);
70
68
 
@@ -79,7 +77,7 @@ export class RovingFocusGroup {
79
77
  }
80
78
 
81
79
  handleKeydown(node: HTMLElement | null | undefined, e: KeyboardEvent, both: boolean = false) {
82
- const rootNode = this.#opts.rootNode.current;
80
+ const rootNode = this.#opts.rootNode();
83
81
  if (!rootNode || !node) return;
84
82
 
85
83
  const items = this.getCandidateNodes();
@@ -87,8 +85,8 @@ export class RovingFocusGroup {
87
85
 
88
86
  const currentIndex = items.indexOf(node);
89
87
  const dir = (rootNode.ownerDocument.defaultView ?? window).getComputedStyle(rootNode).direction as Direction;
90
- const { nextKey, prevKey } = getDirectionalKeys(dir, this.#opts.orientation.current);
91
- const loop = this.#opts.loop.current;
88
+ const { nextKey, prevKey } = getDirectionalKeys(dir, this.#opts.orientation());
89
+ const loop = this.#opts.loop();
92
90
 
93
91
  const keyToIndex = {
94
92
  [nextKey]: currentIndex + 1,
@@ -117,19 +115,19 @@ export class RovingFocusGroup {
117
115
  const itemToFocus = items[itemIndex];
118
116
  if (!itemToFocus) return;
119
117
  itemToFocus.focus();
120
- this.#currentTabStopId.current = itemToFocus.id;
118
+ this.#currentTabStopId = itemToFocus.id;
121
119
  this.#opts.onCandidateFocus?.(itemToFocus);
122
120
  return itemToFocus;
123
121
  }
124
122
 
125
123
  getTabIndex(node: HTMLElement | null | undefined) {
126
124
  const items = this.getCandidateNodes();
127
- const anyActive = this.#currentTabStopId.current !== null;
125
+ const anyActive = this.#currentTabStopId !== null;
128
126
 
129
127
  if (node && !anyActive && items[0] === node) {
130
- this.#currentTabStopId.current = node.id;
128
+ this.#currentTabStopId = node.id;
131
129
  return 0;
132
- } else if (node?.id === this.#currentTabStopId.current) {
130
+ } else if (node?.id === this.#currentTabStopId) {
133
131
  return 0;
134
132
  }
135
133
 
@@ -137,13 +135,13 @@ export class RovingFocusGroup {
137
135
  }
138
136
 
139
137
  setCurrentTabStopId(id: string) {
140
- this.#currentTabStopId.current = id;
138
+ this.#currentTabStopId = id;
141
139
  }
142
140
 
143
141
  focusCurrentTabStop() {
144
- const currentTabStopId = this.#currentTabStopId.current;
142
+ const currentTabStopId = this.#currentTabStopId;
145
143
  if (!currentTabStopId) return;
146
- const currentTabStop = this.#opts.rootNode.current?.querySelector(`#${currentTabStopId}`);
144
+ const currentTabStop = this.#opts.rootNode()?.querySelector(`#${currentTabStopId}`);
147
145
  if (!currentTabStop || !isHTMLElement(currentTabStop)) return;
148
146
  currentTabStop.focus();
149
147
  }
@@ -1,21 +1,16 @@
1
- import { type ReadableBox, type RefAttachment, type WritableBox, attachRef } from './tools/index.js';
2
- import type { RovingFocusGroup } from './roving-focus-group.js';
3
-
4
- type MaybeReadableBoolean = boolean | ReadableBox<boolean>;
1
+ import { type Getter, type RefAttachment, attachRef } from './tools/index.js';
2
+ import type { RovingFocusGroup } from './roving-focus-group.svelte.js';
5
3
 
6
4
  type RovingFocusItemOptions<T extends HTMLElement = HTMLElement> = {
7
5
  group: RovingFocusGroup;
8
- ref: WritableBox<T | null>;
9
- enabled?: MaybeReadableBoolean;
10
- both?: MaybeReadableBoolean;
6
+ ref: Getter<T | null>;
7
+ /** Receives the node from the attachment; the owner keeps it wherever its `ref` reads from. */
8
+ setRef: (node: T | null) => void;
9
+ /** Whether arrow keys move from this item; absent means always. */
10
+ enabled?: Getter<boolean>;
11
11
  onRefChange?: (node: T | null) => void;
12
12
  };
13
13
 
14
- function readBoolean(value: MaybeReadableBoolean | undefined, fallback: boolean) {
15
- if (value === undefined) return fallback;
16
- return typeof value === 'boolean' ? value : value.current;
17
- }
18
-
19
14
  export class RovingFocusItem<T extends HTMLElement = HTMLElement> {
20
15
  readonly #opts: RovingFocusItemOptions<T>;
21
16
  readonly attachment: RefAttachment<T>;
@@ -23,22 +18,22 @@ export class RovingFocusItem<T extends HTMLElement = HTMLElement> {
23
18
 
24
19
  constructor(opts: RovingFocusItemOptions<T>) {
25
20
  this.#opts = opts;
26
- this.attachment = attachRef(opts.ref, opts.onRefChange);
21
+ this.attachment = attachRef<T>(opts.setRef, opts.onRefChange);
27
22
  this.handleKeydown = this.handleKeydown.bind(this);
28
23
 
29
24
  $effect(() => {
30
- if (!readBoolean(this.#opts.enabled, true)) {
25
+ if (!(this.#opts.enabled?.() ?? true)) {
31
26
  this.#tabIndex = 0;
32
27
  return;
33
28
  }
34
29
 
35
- this.#tabIndex = this.#opts.group.getTabIndex(this.#opts.ref.current);
30
+ this.#tabIndex = this.#opts.group.getTabIndex(this.#opts.ref());
36
31
  });
37
32
  }
38
33
 
39
34
  handleKeydown(e: KeyboardEvent) {
40
- if (!readBoolean(this.#opts.enabled, true)) return;
41
- return this.#opts.group.handleKeydown(this.#opts.ref.current, e, readBoolean(this.#opts.both, false));
35
+ if (!(this.#opts.enabled?.() ?? true)) return;
36
+ return this.#opts.group.handleKeydown(this.#opts.ref(), e);
42
37
  }
43
38
 
44
39
  readonly props = $derived.by(
@@ -1,83 +1,95 @@
1
- import type { ReadableBox, ReadableBoxedValues, WritableBox } from './tools/index.js';
2
1
  import { boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef, getAriaChecked } from './attrs.js';
3
2
  import { kbd } from './kbd.js';
4
3
  import type { Orientation } from './index.js';
5
- import type { BitsKeyboardEvent, BitsMouseEvent, WithRefOpts } from './types.js';
6
- import type { RovingFocusGroup } from './roving-focus-group.js';
4
+ import type { BitsKeyboardEvent, BitsMouseEvent } from './types.js';
5
+ import type { RovingFocusGroup } from './roving-focus-group.svelte.js';
7
6
  import { RovingFocusItem } from './roving-focus-item.svelte.js';
8
7
 
9
8
  export type SelectionType = 'single' | 'multiple';
10
9
 
11
- export const emptySelection = (type: SelectionType): string | string[] => (type === 'single' ? '' : []);
10
+ export type Selection = string | string[];
11
+
12
+ export const emptySelection = (type: SelectionType): Selection => (type === 'single' ? '' : []);
12
13
 
13
14
  /**
14
- * Membership over a bindable that is a string in single mode and a string array in multiple
15
- * mode. The mode is fixed at construction, so a write always keeps the shape the consumer's
16
- * prop was declared with.
15
+ * Membership over a consumer's bindable that is a string in single mode and a string array
16
+ * in multiple mode. `value` reads and writes the root's prop through the closures it was
17
+ * built with; the mode is fixed at construction, so `with` always keeps the declared shape.
17
18
  */
18
19
  export class SelectionValue {
19
20
  readonly isMulti: boolean;
20
- readonly #value: WritableBox<string | string[]>;
21
+ readonly #read: () => Selection;
22
+ readonly #write: (value: Selection) => void;
21
23
 
22
- constructor(type: SelectionType, value: WritableBox<string | string[]>) {
24
+ constructor(type: SelectionType, read: () => Selection, write: (value: Selection) => void) {
23
25
  this.isMulti = type === 'multiple';
24
- this.#value = value;
26
+ this.#read = read;
27
+ this.#write = write;
28
+ }
29
+
30
+ get value(): Selection {
31
+ return this.#read();
32
+ }
33
+
34
+ set value(next: Selection) {
35
+ this.#write(next);
25
36
  }
26
37
 
27
38
  includes(item: string): boolean {
28
- const value = this.#value.current;
39
+ const value = this.#read();
29
40
  return this.isMulti ? (value as string[]).includes(item) : value === item;
30
41
  }
31
42
 
32
- /** Flips membership and returns whether the item is selected afterwards. */
33
- toggle(item: string): boolean {
34
- const selected = !this.includes(item);
35
- if (this.isMulti) {
36
- const value = this.#value.current as string[];
37
- this.#value.current = selected ? [...value, item] : value.filter((v) => v !== item);
38
- } else {
39
- this.#value.current = selected ? item : '';
40
- }
41
- return selected;
43
+ /** The value with `item` present or absent; the same reference when it already is. */
44
+ with(item: string, selected: boolean): Selection {
45
+ const value = this.#read();
46
+ const present = this.isMulti ? (value as string[]).includes(item) : value === item;
47
+ if (present === selected) return value;
48
+ if (!this.isMulti) return selected ? item : '';
49
+ return selected ? [...(value as string[]), item] : (value as string[]).filter((v) => v !== item);
42
50
  }
43
51
  }
44
52
 
45
- /** What a selection item needs from the group that owns it. */
53
+ /**
54
+ * What a selection item needs from the group that owns it. Reactive members are accessors on the
55
+ * implementing class, read at use, never copied out.
56
+ */
46
57
  export interface SelectionGroup {
47
58
  readonly selection: SelectionValue;
48
- readonly disabled: ReadableBox<boolean>;
49
- readonly orientation: ReadableBox<Orientation>;
59
+ readonly disabled: boolean;
60
+ readonly orientation: Orientation;
50
61
  readonly rovingFocusGroup: RovingFocusGroup;
51
62
  /** Whether arrow keys move between the items; absent means always. */
52
- readonly rovingFocus?: ReadableBox<boolean>;
63
+ readonly rovingFocus?: boolean;
53
64
  /** A selection takes the roving tab stop in a toggle group; a toolbar's stays where it is. */
54
65
  readonly selectionTakesTabStop: boolean;
55
66
  /** Beyond the roving group's candidate attribute, which every item carries. */
56
67
  readonly extraItemAttrs?: Record<string, ''>;
57
68
  }
58
69
 
59
- export interface SelectionItemOpts
60
- extends
61
- WithRefOpts,
62
- ReadableBoxedValues<{
63
- value: string;
64
- disabled: boolean;
65
- }> {}
70
+ /** The item component's props as accessors over its `$props()`; `ref` writes back to its bindable. */
71
+ export interface SelectionItemOpts {
72
+ readonly id: string;
73
+ readonly value: string;
74
+ readonly disabled: boolean;
75
+ ref: HTMLElement | null;
76
+ }
66
77
 
67
78
  export class SelectionItemState {
68
79
  readonly opts: SelectionItemOpts;
69
80
  readonly group: SelectionGroup;
70
81
  readonly rovingItem: RovingFocusItem;
71
- readonly #isDisabled = $derived.by(() => this.opts.disabled.current || this.group.disabled.current);
72
- readonly isPressed = $derived.by(() => this.group.selection.includes(this.opts.value.current));
82
+ readonly #isDisabled = $derived.by(() => this.opts.disabled || this.group.disabled);
83
+ readonly isPressed = $derived.by(() => this.group.selection.includes(this.opts.value));
73
84
 
74
85
  constructor(opts: SelectionItemOpts, group: SelectionGroup) {
75
86
  this.opts = opts;
76
87
  this.group = group;
77
88
  this.rovingItem = new RovingFocusItem({
78
89
  group: group.rovingFocusGroup,
79
- ref: opts.ref,
80
- enabled: group.rovingFocus,
90
+ ref: () => opts.ref,
91
+ setRef: (v) => (opts.ref = v),
92
+ enabled: () => group.rovingFocus ?? true,
81
93
  });
82
94
 
83
95
  this.onclick = this.onclick.bind(this);
@@ -86,8 +98,10 @@ export class SelectionItemState {
86
98
 
87
99
  #activate() {
88
100
  if (this.#isDisabled) return;
89
- const selected = this.group.selection.toggle(this.opts.value.current);
90
- if (selected && this.group.selectionTakesTabStop) this.group.rovingFocusGroup.setCurrentTabStopId(this.opts.id.current);
101
+ const item = this.opts.value;
102
+ const selected = !this.group.selection.includes(item);
103
+ this.group.selection.value = this.group.selection.with(item, selected);
104
+ if (selected && this.group.selectionTakesTabStop) this.group.rovingFocusGroup.setCurrentTabStopId(this.opts.id);
91
105
  }
92
106
 
93
107
  onclick(_: BitsMouseEvent) {
@@ -109,12 +123,12 @@ export class SelectionItemState {
109
123
  readonly props = $derived.by(
110
124
  () =>
111
125
  ({
112
- id: this.opts.id.current,
126
+ id: this.opts.id,
113
127
  role: this.group.selection.isMulti ? undefined : 'radio',
114
- 'data-orientation': this.group.orientation.current,
128
+ 'data-orientation': this.group.orientation,
115
129
  'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
116
130
  'data-state': this.isPressed ? 'on' : 'off',
117
- 'data-value': this.opts.value.current,
131
+ 'data-value': this.opts.value,
118
132
  'aria-pressed': this.group.selection.isMulti ? boolToStr(this.isPressed) : undefined,
119
133
  'aria-checked': this.group.selection.isMulti ? undefined : getAriaChecked(this.isPressed, false),
120
134
  disabled: boolToTrueOrUndef(this.#isDisabled),
@@ -1,5 +1,3 @@
1
- import { simpleBox, type WritableBox } from './tools/index.js';
2
-
3
1
  interface Machine<S> {
4
2
  [k: string]: { [k: string]: S };
5
3
  }
@@ -11,22 +9,22 @@ type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
11
9
  type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
12
10
 
13
11
  export class StateMachine<M> {
14
- readonly state: WritableBox<MachineState<M>>;
12
+ state: MachineState<M>;
15
13
  readonly #machine: M & Machine<MachineState<M>>;
16
14
 
17
15
  constructor(initialState: MachineState<M>, machine: M & Machine<MachineState<M>>) {
18
- this.state = simpleBox(initialState);
16
+ this.state = $state.raw(initialState);
19
17
  this.#machine = machine;
20
18
  this.dispatch = this.dispatch.bind(this);
21
19
  }
22
20
 
23
21
  #reducer(event: MachineEvent<M>) {
24
- // @ts-expect-error state.current is keyof M
25
- const nextState = this.#machine[this.state.current][event];
26
- return nextState ?? this.state.current;
22
+ // @ts-expect-error state is keyof M
23
+ const nextState = this.#machine[this.state][event];
24
+ return nextState ?? this.state;
27
25
  }
28
26
 
29
27
  dispatch(event: MachineEvent<M>) {
30
- this.state.current = this.#reducer(event);
28
+ this.state = this.#reducer(event);
31
29
  }
32
30
  }
@@ -4,20 +4,22 @@ import type { AnyFn } from './types.js';
4
4
  * A self-cancelling timeout. `start()` (re)schedules `cb` after `getInterval()` ms,
5
5
  * `stop()` clears it; auto-stops when the owning component/effect is destroyed.
6
6
  */
7
- export function createEffectTimeout<T extends AnyFn>(cb: T, getDelay: () => number) {
7
+ export function createEffectTimeout<T extends AnyFn>(cb: T, getDelay: () => number, getWindow: () => typeof globalThis = () => globalThis) {
8
8
  let timer: ReturnType<typeof setTimeout> | undefined;
9
+ let timerWindow: typeof globalThis;
9
10
 
10
11
  const stop = () => {
11
12
  if (timer === undefined) return;
12
13
 
13
- clearTimeout(timer);
14
+ timerWindow.clearTimeout(timer);
14
15
  timer = undefined;
15
16
  };
16
17
 
17
18
  const start = (...args: Parameters<T>) => {
18
19
  stop();
19
20
 
20
- timer = setTimeout(() => {
21
+ timerWindow = getWindow();
22
+ timer = timerWindow.setTimeout(() => {
21
23
  timer = undefined;
22
24
  cb(...args);
23
25
  }, getDelay());
@@ -33,4 +35,4 @@ export function createEffectTimeout<T extends AnyFn>(cb: T, getDelay: () => numb
33
35
  return timer !== undefined;
34
36
  },
35
37
  };
36
- }
38
+ }