@marianmeres/stuic 3.66.0 → 3.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/actions/autoscroll.d.ts +7 -0
  2. package/dist/actions/autoscroll.js +7 -0
  3. package/dist/actions/focus-trap.d.ts +7 -0
  4. package/dist/actions/focus-trap.js +8 -3
  5. package/dist/actions/typeahead.svelte.js +40 -4
  6. package/dist/components/Carousel/Carousel.svelte +9 -2
  7. package/dist/components/Carousel/README.md +8 -2
  8. package/dist/components/Cart/Cart.svelte +3 -0
  9. package/dist/components/Cart/README.md +18 -1
  10. package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
  11. package/dist/components/Checkout/README.md +184 -0
  12. package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
  13. package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
  14. package/dist/components/Checkout/index.d.ts +1 -1
  15. package/dist/components/Checkout/index.js +1 -1
  16. package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
  17. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
  18. package/dist/components/CronInput/CronInput.svelte +44 -9
  19. package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
  20. package/dist/components/CronInput/README.md +145 -0
  21. package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
  22. package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
  23. package/dist/components/CronInput/index.css +0 -8
  24. package/dist/components/DataTable/DataTable.svelte +99 -62
  25. package/dist/components/DataTable/DataTable.svelte.d.ts +13 -3
  26. package/dist/components/DataTable/README.md +79 -25
  27. package/dist/components/DataTable/index.css +7 -0
  28. package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
  29. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
  30. package/dist/components/DropdownMenu/README.md +37 -9
  31. package/dist/components/Input/FieldAssets.svelte +9 -7
  32. package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
  33. package/dist/components/Input/FieldFile.svelte +13 -7
  34. package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
  35. package/dist/components/Input/FieldInput.svelte +10 -8
  36. package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
  37. package/dist/components/Input/FieldInputLocalized.svelte +8 -7
  38. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
  39. package/dist/components/Input/FieldKeyValues.svelte +8 -7
  40. package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
  41. package/dist/components/Input/FieldLikeButton.svelte +9 -7
  42. package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
  43. package/dist/components/Input/FieldObject.svelte +8 -7
  44. package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
  45. package/dist/components/Input/FieldOptions.svelte +9 -7
  46. package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
  47. package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
  48. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
  49. package/dist/components/Input/FieldSelect.svelte +9 -8
  50. package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
  51. package/dist/components/Input/FieldSwitch.svelte +9 -7
  52. package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
  53. package/dist/components/Input/FieldTextarea.svelte +7 -8
  54. package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
  55. package/dist/components/Input/README.md +20 -0
  56. package/dist/components/Input/_internal/InputWrap.svelte +2 -10
  57. package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
  58. package/dist/components/Input/types.d.ts +28 -0
  59. package/dist/components/Nav/Nav.svelte +5 -4
  60. package/dist/components/Nav/Nav.svelte.d.ts +2 -2
  61. package/dist/components/Nav/README.md +2 -2
  62. package/dist/components/Nav/index.css +4 -0
  63. package/dist/components/Tree/README.md +189 -0
  64. package/dist/components/Tree/Tree.svelte +46 -2
  65. package/dist/components/Tree/Tree.svelte.d.ts +5 -0
  66. package/dist/utils/input-history.svelte.d.ts +12 -0
  67. package/dist/utils/input-history.svelte.js +12 -0
  68. package/dist/utils/observe-exists.svelte.d.ts +1 -0
  69. package/dist/utils/observe-exists.svelte.js +11 -3
  70. package/dist/utils/switch.svelte.d.ts +12 -0
  71. package/dist/utils/switch.svelte.js +12 -1
  72. package/docs/architecture.md +0 -1
  73. package/docs/testing.md +72 -0
  74. package/docs/upgrading.md +281 -0
  75. package/package.json +18 -19
@@ -0,0 +1,189 @@
1
+ # Tree
2
+
3
+ A generic hierarchical tree view with drag-and-drop reordering, keyboard navigation, expand/collapse state, optional `localStorage` persistence, and full ARIA `treeview` semantics.
4
+
5
+ Backed by [`@marianmeres/tree`](https://www.npmjs.com/package/@marianmeres/tree) for the data model — pass `tree.toJSON().children` or any compatible `TreeNodeDTO[]`.
6
+
7
+ ## Usage
8
+
9
+ ### Basic
10
+
11
+ ```svelte
12
+ <script lang="ts">
13
+ import { Tree } from "@marianmeres/stuic";
14
+
15
+ const items = [
16
+ {
17
+ id: "root",
18
+ value: "Projects",
19
+ children: [
20
+ { id: "a", value: "Alpha", children: [] },
21
+ { id: "b", value: "Beta", children: [] },
22
+ ],
23
+ },
24
+ ];
25
+ </script>
26
+
27
+ <Tree {items} activeId="a" onSelect={(item) => console.log(item.id)} />
28
+ ```
29
+
30
+ ### Drag & Drop
31
+
32
+ ```svelte
33
+ <Tree
34
+ {items}
35
+ draggable
36
+ onMove={({ source, target, position }) => {
37
+ // Mutate your data to move `source` relative to `target`.
38
+ // Return `false` (or throw) to reject the move.
39
+ moveNode(source.id, target.id, position);
40
+ }}
41
+ isDraggable={(item) => !item.data?.locked}
42
+ isDropTarget={(item) => item.data?.acceptsChildren !== false}
43
+ />
44
+ ```
45
+
46
+ `onMove` receives `{ source, target, position: "before" | "after" | "inside" }`. The component does **not** mutate items itself — you own the data. Return `false` or throw to reject.
47
+
48
+ ### Expansion Control
49
+
50
+ Four priority tiers resolve a node's initial expansion state, highest wins:
51
+
52
+ 1. `localStorage` value (when `persistState={true}`)
53
+ 2. `expandedIds` prop (explicit initial set)
54
+ 3. Auto-expanded if any descendant is active
55
+ 4. `defaultExpanded` prop
56
+
57
+ ```svelte
58
+ <Tree
59
+ {items}
60
+ defaultExpanded
61
+ persistState
62
+ storageKeyPrefix="my-app-tree"
63
+ onToggle={(item, expanded) => console.log(item.id, expanded)}
64
+ />
65
+ ```
66
+
67
+ Collapsing a branch also collapses all of its descendants.
68
+
69
+ ### Custom Rendering
70
+
71
+ ```svelte
72
+ <Tree {items}>
73
+ {#snippet renderIcon(item, depth, isExpanded)}
74
+ <MyIcon type={item.data.kind} />
75
+ {/snippet}
76
+ {#snippet renderItem(item, depth, isExpanded)}
77
+ <span class="font-medium">{item.value}</span>
78
+ {#if item.data?.count}
79
+ <span class="ml-auto text-xs opacity-60">{item.data.count}</span>
80
+ {/if}
81
+ {/snippet}
82
+ </Tree>
83
+ ```
84
+
85
+ ## Props
86
+
87
+ | Prop | Type | Default | Description |
88
+ | ------------------- | ---------------------------------------------------- | ----------------- | ------------------------------------------------------------------------ |
89
+ | `items` | `TreeNodeDTO<T>[]` | required | Tree data (e.g. from `tree.toJSON().children`) |
90
+ | `activeId` | `string` | - | ID of the currently active/selected node |
91
+ | `isActive` | `(item) => boolean` | - | Alternative to `activeId` for custom active detection |
92
+ | `onSelect` | `(item) => void` | - | Called when a node is selected (click or Enter/Space) |
93
+ | `onToggle` | `(item, expanded) => void` | - | Called when a branch is expanded/collapsed |
94
+ | `sort` | `(a, b) => number` | - | Per-level sort comparator |
95
+ | `defaultExpanded` | `boolean` | `false` | Default expansion state for branches |
96
+ | `expandedIds` | `Set<string>` | - | Initially-expanded branch IDs |
97
+ | `persistState` | `boolean` | `false` | Persist expand/collapse to `localStorage` |
98
+ | `storageKeyPrefix` | `string` | `"stuic-tree"` | Prefix used for `localStorage` keys |
99
+ | `draggable` | `boolean` | `false` | Enable drag-and-drop reordering |
100
+ | `isDraggable` | `(item) => boolean` | - | Return `false` to block dragging a specific node |
101
+ | `isDropTarget` | `(item) => boolean` | - | Return `false` to block dropping onto a specific node |
102
+ | `onMove` | `(event) => void \| false \| Promise<void \| false>` | - | Called when a valid drop happens; return `false` to reject |
103
+ | `onError` | `(err) => void` | - | Called when `onMove` throws |
104
+ | `dragExpandDelay` | `number` | `800` | ms before auto-expanding a collapsed branch hovered during drag |
105
+ | `t` | `TranslateFn` | built-in | Optional translation function (used for drag-drop a11y announcements) |
106
+ | `getNodeLabel` | `(item) => string` | `String(v.value)` | String used in a11y announcements when a node is moved |
107
+ | `renderItem` | `Snippet<[item, depth, isExpanded]>` | - | Custom node label renderer |
108
+ | `renderIcon` | `Snippet<[item, depth, isExpanded]>` | - | Custom node icon renderer |
109
+ | `unstyled` | `boolean` | `false` | Skip default styling |
110
+ | `class` | `string` | - | Classes for wrapper |
111
+ | `classItem` | `string` | - | Classes for each item button |
112
+ | `classItemActive` | `string` | - | Extra classes when an item is active |
113
+ | `classIcon` | `string` | - | Classes for the icon wrapper |
114
+ | `classLabel` | `string` | - | Classes for the label wrapper |
115
+ | `classChevron` | `string` | - | Classes for the expand/collapse chevron |
116
+ | `classChildren` | `string` | - | Classes for the children container |
117
+ | `el` | `HTMLElement` | - | Bindable wrapper reference |
118
+
119
+ ## Keyboard Navigation
120
+
121
+ | Key | Action |
122
+ | --------------------- | --------------------------------------------------------------------- |
123
+ | `ArrowDown` | Focus next visible node |
124
+ | `ArrowUp` | Focus previous visible node |
125
+ | `ArrowRight` | Expand a collapsed branch, or move to first child of an expanded one |
126
+ | `ArrowLeft` | Collapse an expanded branch, or move to parent |
127
+ | `Home` | Focus first visible node |
128
+ | `End` | Focus last visible node |
129
+ | `Enter` / `Space` | Toggle expansion (if branch) and fire `onSelect` |
130
+
131
+ Focus follows the **roving tabindex** pattern: only the currently-focused node has `tabindex=0`, all others are `-1`.
132
+
133
+ ## Accessibility
134
+
135
+ - Root has `role="tree"`; each node has `role="treeitem"`.
136
+ - Branches have `aria-expanded`; active nodes have `aria-selected`; all nodes have `aria-level`.
137
+ - Children wrappers have `role="group"`.
138
+ - Successful `onMove` calls announce the change via a visually-hidden `aria-live="polite"` region. Translate via the `t` prop — keys: `move_before`, `move_after`, `move_inside` with `{source}` and `{target}` placeholders.
139
+
140
+ ### Drag-drop limitations
141
+
142
+ HTML5 drag-and-drop is **mouse-only** and not keyboard accessible. Touch support is device-dependent and unreliable. If keyboard/touch reordering matters for your app, layer your own UI on top (e.g. a context menu with "Move up / Move down / Move into…" actions that call your move logic directly).
143
+
144
+ ## CSS Variables
145
+
146
+ Override globally in `:root` or locally via `style=""`. Radius / border-width / transition use the standard STUIC fallback pattern and inherit from their shared structural tokens unless overridden.
147
+
148
+ ### Structure
149
+
150
+ | Variable | Default | Description |
151
+ | ---------------------------------- | ------------------------ | --------------------------------- |
152
+ | `--stuic-tree-indent` | `1.25rem` | Indentation per depth level |
153
+ | `--stuic-tree-item-padding-x` | `0.375rem` | Item horizontal padding |
154
+ | `--stuic-tree-item-padding-y` | `0.125rem` | Item vertical padding |
155
+ | `--stuic-tree-item-height` | `1.75rem` | Item row height |
156
+ | `--stuic-tree-item-font-size` | `var(--text-sm)` | Item font size |
157
+ | `--stuic-tree-item-gap` | `0.25rem` | Gap between chevron, icon, label |
158
+ | `--stuic-tree-chevron-size` | `14px` | Chevron icon size |
159
+ | `--stuic-tree-chevron-opacity` | `0.5` | Chevron opacity |
160
+ | `--stuic-tree-icon-opacity` | `0.7` | Icon opacity |
161
+ | `--stuic-tree-item-radius` | `var(--stuic-radius)` | Item border radius |
162
+ | `--stuic-tree-transition` | `var(--stuic-transition)`| Transition duration |
163
+
164
+ ### Colors
165
+
166
+ | Variable | Default | Description |
167
+ | ------------------------------------ | -------------------------------------- | ------------------------- |
168
+ | `--stuic-tree-item-bg` | `transparent` | Item background |
169
+ | `--stuic-tree-item-text` | `inherit` | Item text color |
170
+ | `--stuic-tree-item-bg-hover` | `rgb(0 0 0 / 0.06)` | Hover background |
171
+ | `--stuic-tree-item-bg-focus` | `rgb(0 0 0 / 0.06)` | Keyboard-focus background |
172
+ | `--stuic-tree-item-bg-active` | `var(--stuic-color-primary)` | Active/selected bg |
173
+ | `--stuic-tree-item-text-active` | `var(--stuic-color-primary-foreground)`| Active/selected text |
174
+
175
+ ### Drag & drop
176
+
177
+ | Variable | Default | Description |
178
+ | --------------------------------------- | ---------------------------------- | ------------------------------- |
179
+ | `--stuic-tree-item-opacity-dragging` | `0.4` | Opacity of the dragged item |
180
+ | `--stuic-tree-drop-indicator-color` | `var(--stuic-color-primary)` | Before/after drop line color |
181
+ | `--stuic-tree-drop-indicator-height` | `2px` | Before/after drop line height |
182
+ | `--stuic-tree-item-bg-dragover` | `rgb(0 0 0 / 0.04)` | "Inside"-drop highlight |
183
+
184
+ ## Limitations
185
+
186
+ - **No multi-select / checkbox selection.** Single active node only.
187
+ - **No lazy loading.** All nodes must be present in `items`.
188
+ - **Expansion state is private.** Observe via `onToggle`; set initial via `expandedIds`. There is currently no way to read the full set of expanded IDs from outside.
189
+ - **Drag-drop is mouse-only.** See the a11y section above.
@@ -2,9 +2,31 @@
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
3
  import type { TreeNodeDTO } from "@marianmeres/tree";
4
4
  import type { Snippet } from "svelte";
5
+ import type { TranslateFn } from "../../types.js";
6
+ import { isPlainObject } from "../../utils/is-plain-object.js";
7
+ import { replaceMap } from "../../utils/replace-map.js";
5
8
 
6
9
  export type TreeDropPosition = "before" | "after" | "inside";
7
10
 
11
+ function t_default(
12
+ k: string,
13
+ values: false | null | undefined | Record<string, string | number> = null,
14
+ fallback: string | boolean = "",
15
+ _i18nSpanWrap: boolean = true
16
+ ) {
17
+ const m: Record<string, string> = {
18
+ move_before: "Moved {source} above {target}",
19
+ move_after: "Moved {source} below {target}",
20
+ move_inside: "Moved {source} into {target}",
21
+ };
22
+ let out = m[k] ?? fallback ?? k;
23
+ return isPlainObject(values)
24
+ ? replaceMap(out, values as any, {
25
+ preSearchKeyTransform: (k) => `{${k}}`,
26
+ })
27
+ : out;
28
+ }
29
+
8
30
  export interface TreeMoveEvent<T = unknown> {
9
31
  /** The node being dragged */
10
32
  source: TreeNodeDTO<T>;
@@ -72,6 +94,12 @@
72
94
  /** Delay in ms before auto-expanding a collapsed branch on drag-over (default: 800) */
73
95
  dragExpandDelay?: number;
74
96
 
97
+ /** Optional translate function (used for drag-drop screen reader announcements) */
98
+ t?: TranslateFn;
99
+
100
+ /** Derive a screen-reader label for a node. Defaults to `String(item.value)`. */
101
+ getNodeLabel?: (item: TreeNodeDTO<T>) => string;
102
+
75
103
  /** Skip all default styling */
76
104
  unstyled?: boolean;
77
105
 
@@ -128,6 +156,8 @@
128
156
  onMove,
129
157
  onError,
130
158
  dragExpandDelay = 800,
159
+ t = t_default,
160
+ getNodeLabel = (item) => String(item.value),
131
161
  unstyled = false,
132
162
  class: classProp,
133
163
  el = $bindable(),
@@ -310,8 +340,10 @@
310
340
 
311
341
  function focusItem(id: string) {
312
342
  focusedId = id;
313
- // Focus the DOM element
314
- const itemEl = el?.querySelector(`[data-tree-id="${id}"]`) as HTMLElement | null;
343
+ // Focus the DOM element. CSS.escape() protects against ids containing quotes/specials.
344
+ const itemEl = el?.querySelector(
345
+ `[data-tree-id="${CSS.escape(id)}"]`
346
+ ) as HTMLElement | null;
315
347
  itemEl?.focus();
316
348
  }
317
349
 
@@ -409,6 +441,7 @@
409
441
  let dropTargetId = $state<string | null>(null);
410
442
  let dropPos = $state<TreeDropPosition | null>(null);
411
443
  let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;
444
+ let liveAnnouncement = $state("");
412
445
 
413
446
  function findNodeById(nodeItems: TreeNode<T>[], id: string): TreeNode<T> | null {
414
447
  for (const item of nodeItems) {
@@ -545,6 +578,14 @@
545
578
  try {
546
579
  const result = await onMove(event);
547
580
  if (result === false) return;
581
+ liveAnnouncement = t(
582
+ `move_${event.position}`,
583
+ {
584
+ source: getNodeLabel(event.source),
585
+ target: getNodeLabel(event.target),
586
+ },
587
+ ""
588
+ ) as string;
548
589
  } catch (err) {
549
590
  onError?.(err);
550
591
  }
@@ -658,4 +699,7 @@
658
699
  {#each sortedItems(items) as item (item.id)}
659
700
  {@render renderNode(item, 0)}
660
701
  {/each}
702
+
703
+ <!-- Screen-reader-only live region for drag-drop move announcements -->
704
+ <div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
661
705
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { HTMLAttributes } from "svelte/elements";
2
2
  import type { TreeNodeDTO } from "@marianmeres/tree";
3
3
  import type { Snippet } from "svelte";
4
+ import type { TranslateFn } from "../../types.js";
4
5
  export type TreeDropPosition = "before" | "after" | "inside";
5
6
  export interface TreeMoveEvent<T = unknown> {
6
7
  /** The node being dragged */
@@ -47,6 +48,10 @@ export interface Props<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>,
47
48
  onError?: (error: unknown) => void;
48
49
  /** Delay in ms before auto-expanding a collapsed branch on drag-over (default: 800) */
49
50
  dragExpandDelay?: number;
51
+ /** Optional translate function (used for drag-drop screen reader announcements) */
52
+ t?: TranslateFn;
53
+ /** Derive a screen-reader label for a node. Defaults to `String(item.value)`. */
54
+ getNodeLabel?: (item: TreeNodeDTO<T>) => string;
50
55
  /** Skip all default styling */
51
56
  unstyled?: boolean;
52
57
  /** Classes for the wrapper element */
@@ -14,6 +14,15 @@ export interface InputHistoryOptions {
14
14
  /**
15
15
  * A reactive input history manager with localStorage persistence and arrow key navigation.
16
16
  *
17
+ * @remarks
18
+ * Every constructed instance registers its storage key in a **process-wide static Set**
19
+ * (`InputHistory.#registeredKeys`). The Set grows as new instances are created and is
20
+ * only trimmed by `clearAllMatching(prefix)` / `clearAll()` — there is no per-instance
21
+ * deregistration. In practice this is fine: each entry is a short string and the cost
22
+ * is negligible. Typical lifecycle: create instances as users navigate, call
23
+ * `InputHistory.clearAllMatching("<app-prefix>")` on logout to wipe stored keys and
24
+ * clear the registry in one shot.
25
+ *
17
26
  * @example
18
27
  * ```ts
19
28
  * const history = new InputHistory({
@@ -34,6 +43,9 @@ export interface InputHistoryOptions {
34
43
  *
35
44
  * // Reset navigation when user starts typing
36
45
  * history.reset();
46
+ *
47
+ * // On logout (wipes localStorage + internal registry for matching keys):
48
+ * InputHistory.clearAllMatching("joy:");
37
49
  * ```
38
50
  */
39
51
  export declare class InputHistory {
@@ -2,6 +2,15 @@ import { localStorageState } from "./persistent-state.svelte.js";
2
2
  /**
3
3
  * A reactive input history manager with localStorage persistence and arrow key navigation.
4
4
  *
5
+ * @remarks
6
+ * Every constructed instance registers its storage key in a **process-wide static Set**
7
+ * (`InputHistory.#registeredKeys`). The Set grows as new instances are created and is
8
+ * only trimmed by `clearAllMatching(prefix)` / `clearAll()` — there is no per-instance
9
+ * deregistration. In practice this is fine: each entry is a short string and the cost
10
+ * is negligible. Typical lifecycle: create instances as users navigate, call
11
+ * `InputHistory.clearAllMatching("<app-prefix>")` on logout to wipe stored keys and
12
+ * clear the registry in one shot.
13
+ *
5
14
  * @example
6
15
  * ```ts
7
16
  * const history = new InputHistory({
@@ -22,6 +31,9 @@ import { localStorageState } from "./persistent-state.svelte.js";
22
31
  *
23
32
  * // Reset navigation when user starts typing
24
33
  * history.reset();
34
+ *
35
+ * // On logout (wipes localStorage + internal registry for matching keys):
36
+ * InputHistory.clearAllMatching("joy:");
25
37
  * ```
26
38
  */
27
39
  export class InputHistory {
@@ -28,5 +28,6 @@ export declare function observeExists(selector: string, options?: Partial<{
28
28
  }>): {
29
29
  readonly current: boolean;
30
30
  disconnect(): void;
31
+ /** Re-run the selector check immediately and update `current`. */
31
32
  forceCheck(): void;
32
33
  };
@@ -42,13 +42,20 @@ export function observeExists(selector, options = {}) {
42
42
  break;
43
43
  }
44
44
  }
45
+ else if (mutation.type === "attributes") {
46
+ // an attribute changed on an existing element (e.g. class toggle) —
47
+ // may flip whether `selector` matches
48
+ shouldCheck = true;
49
+ break;
50
+ }
45
51
  }
46
52
  if (shouldCheck)
47
53
  current = check();
48
54
  });
49
- // start observing now
55
+ // start observing now — include attributes so selectors like ".active" or
56
+ // "[data-busy]" react when classes/attrs toggle on existing elements
50
57
  clogDebug(`connecting...`);
51
- observer.observe(rootElement, { childList: true, subtree: true });
58
+ observer.observe(rootElement, { childList: true, subtree: true, attributes: true });
52
59
  return {
53
60
  get current() {
54
61
  return current;
@@ -57,8 +64,9 @@ export function observeExists(selector, options = {}) {
57
64
  clogDebug(`disconnecting...`);
58
65
  observer.disconnect();
59
66
  },
67
+ /** Re-run the selector check immediately and update `current`. */
60
68
  forceCheck() {
61
- check();
69
+ current = check();
62
70
  },
63
71
  };
64
72
  }
@@ -33,8 +33,20 @@ export declare class SwitchState<T> {
33
33
  #private;
34
34
  readonly key: string;
35
35
  readonly storageType: "memory" | "local" | "session";
36
+ /**
37
+ * One-shot callback fired the next time the switch transitions off (via `off()`,
38
+ * `toggle()` to off, or `reset()`). **Cleared after firing** — assign again before
39
+ * the next off-transition if you want it to fire repeatedly. Useful for "run X the
40
+ * next time this modal closes" patterns.
41
+ */
36
42
  onOff: ((data: T, self: SwitchState<T>) => void) | undefined | null;
37
43
  constructor(key: string, initial?: boolean | null, storageType?: "memory" | "local" | "session", initialData?: T | null);
44
+ /**
45
+ * @internal
46
+ * Low-level state setter. Prefer `on()`, `off()`, `toggle()`, or `reset()` — they
47
+ * cover every expected case. Kept public only because removing it would be a BC break;
48
+ * future code should treat this as private.
49
+ */
38
50
  __set(value: boolean | null, data?: T | null | undefined): void;
39
51
  on(data?: T | null | undefined): void;
40
52
  off(data?: T | null | undefined): void;
@@ -43,6 +43,12 @@ export class SwitchState {
43
43
  // arbitrary data associated with the switch
44
44
  #data = $state(null);
45
45
  #storage;
46
+ /**
47
+ * One-shot callback fired the next time the switch transitions off (via `off()`,
48
+ * `toggle()` to off, or `reset()`). **Cleared after firing** — assign again before
49
+ * the next off-transition if you want it to fire repeatedly. Useful for "run X the
50
+ * next time this modal closes" patterns.
51
+ */
46
52
  onOff = null;
47
53
  constructor(key, initial = null, storageType = "memory", initialData = null) {
48
54
  this.key = key;
@@ -59,7 +65,12 @@ export class SwitchState {
59
65
  console.warn(`Unknown storageType "${this.storageType}"`);
60
66
  }
61
67
  }
62
- // still public, but should not be used directly unless necessary for some reason
68
+ /**
69
+ * @internal
70
+ * Low-level state setter. Prefer `on()`, `off()`, `toggle()`, or `reset()` — they
71
+ * cover every expected case. Kept public only because removing it would be a BC break;
72
+ * future code should treat this as private.
73
+ */
63
74
  __set(value, data) {
64
75
  if (value !== null && typeof value !== "boolean")
65
76
  value = Boolean(value);
@@ -112,7 +112,6 @@ Props → Component → Data Attributes → CSS Selectors
112
112
  | ------------------------------ | ----------------------------- |
113
113
  | `tailwind-merge` | CSS class conflict resolution |
114
114
  | `runed` | Svelte 5 reactive utilities |
115
- | `esm-env` | Environment detection |
116
115
  | `@marianmeres/icons-fns` | Icon SVG generation |
117
116
  | `@marianmeres/item-collection` | Collection management |
118
117
  | `@marianmeres/ticker` | Animation timing |
@@ -0,0 +1,72 @@
1
+ # Testing
2
+
3
+ STUIC has a **small, focused test suite** that's intentionally narrow. This doc explains what we test, what we don't, and how to add a new test.
4
+
5
+ ## Philosophy
6
+
7
+ This is a component library. Most of its correctness guarantees come from:
8
+
9
+ 1. **TypeScript + `svelte-check`** — API contracts, prop types, snippet shapes.
10
+ 2. **`publint`** — package export hygiene.
11
+ 3. **The build** — every component compiles, every export resolves.
12
+ 4. **Manual/visual review** — styling, animation, keyboard interaction, a11y cues.
13
+
14
+ Unit tests are for what those tools can't see: **pure deterministic logic where a regression silently corrupts data**. We explicitly don't try to test everything.
15
+
16
+ ## What we test
17
+
18
+ ### ✅ High value
19
+
20
+ - **Validation helpers** — `validateEmail`, `validateAddress`, `validateCustomerForm`, `validateLoginForm`, `validatePhoneNumber`, `addressesEqual`.
21
+ - **State-machine classes** — `NotificationsStack`, `AlertConfirmPromptStack`, `SwitchState`, `InputHistory`. Tri-state transitions, dedupe, ordering, cleanup semantics.
22
+ - **Pure utilities** — `replace-map`, `tr`, `storage-abstraction`, and anything else with non-trivial input/output logic.
23
+
24
+ ### ⚠️ Maybe, if motivated by a regression
25
+
26
+ - **Logic extracted from a `.svelte` into a sibling `_internal/*.ts`** — once extracted, same rules as utilities apply. (Examples that would be good candidates if we ever extract them: Tree's `calcDropPosition()` and `isDescendantOf()`, CronInput's `cronToHuman()` and `fieldsToExpression()`.)
27
+
28
+ ### ❌ We don't test
29
+
30
+ - **Full component rendering** via `@testing-library/svelte`. 50+ components × prop combinations = slow suite with tiny yield. Rendering is already gated by `svelte-check` + `publint` + the build.
31
+ - **Visual regression**. That's a separate project (Playwright + screenshot diffing) — not part of `vitest --dir src/`.
32
+ - **Interactive behavior** (keyboard nav, drag-drop, scroll snap) unless the underlying math is extracted to a pure function.
33
+ - **Coverage % targets**. They're the wrong goal for a component library.
34
+
35
+ ## Running tests
36
+
37
+ ```bash
38
+ pnpm run test
39
+ ```
40
+
41
+ Vitest is configured to run everything under `src/`. Tests live next to the code they test: `foo.ts` → `foo.test.ts`.
42
+
43
+ ## Writing a test
44
+
45
+ Match the style of existing tests — plain `vitest` with `assert`, no test framework wrapper ceremony.
46
+
47
+ ```ts
48
+ // src/lib/utils/my-thing.test.ts
49
+ import { assert, test } from "vitest";
50
+ import { myThing } from "./my-thing.js";
51
+
52
+ test("myThing handles the empty case", () => {
53
+ assert.equal(myThing(""), null);
54
+ });
55
+
56
+ test("myThing handles the happy path", () => {
57
+ assert.equal(myThing("foo"), "FOO");
58
+ });
59
+ ```
60
+
61
+ For things that take a `TranslateFn`, inject an identity stub so the output is just the key:
62
+
63
+ ```ts
64
+ import type { TranslateFn } from "$lib/types.js";
65
+ const t: TranslateFn = (k) => k;
66
+ ```
67
+
68
+ ## When in doubt
69
+
70
+ - **Logic in a `.ts` file with clear input/output?** Write a test.
71
+ - **A regression just bit you in production?** Write a test for that specific case before fixing.
72
+ - **Anything else?** Probably don't bother.