@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.
- package/dist/actions/autoscroll.d.ts +7 -0
- package/dist/actions/autoscroll.js +7 -0
- package/dist/actions/focus-trap.d.ts +7 -0
- package/dist/actions/focus-trap.js +8 -3
- package/dist/actions/typeahead.svelte.js +40 -4
- package/dist/components/Carousel/Carousel.svelte +9 -2
- package/dist/components/Carousel/README.md +8 -2
- package/dist/components/Cart/Cart.svelte +3 -0
- package/dist/components/Cart/README.md +18 -1
- package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
- package/dist/components/Checkout/README.md +184 -0
- package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
- package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
- package/dist/components/Checkout/index.d.ts +1 -1
- package/dist/components/Checkout/index.js +1 -1
- package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
- package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
- package/dist/components/CronInput/CronInput.svelte +44 -9
- package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
- package/dist/components/CronInput/README.md +145 -0
- package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
- package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
- package/dist/components/CronInput/index.css +0 -8
- package/dist/components/DataTable/DataTable.svelte +99 -62
- package/dist/components/DataTable/DataTable.svelte.d.ts +13 -3
- package/dist/components/DataTable/README.md +79 -25
- package/dist/components/DataTable/index.css +7 -0
- package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
- package/dist/components/DropdownMenu/README.md +37 -9
- package/dist/components/Input/FieldAssets.svelte +9 -7
- package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
- package/dist/components/Input/FieldFile.svelte +13 -7
- package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
- package/dist/components/Input/FieldInput.svelte +10 -8
- package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
- package/dist/components/Input/FieldInputLocalized.svelte +8 -7
- package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
- package/dist/components/Input/FieldKeyValues.svelte +8 -7
- package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
- package/dist/components/Input/FieldLikeButton.svelte +9 -7
- package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
- package/dist/components/Input/FieldObject.svelte +8 -7
- package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
- package/dist/components/Input/FieldOptions.svelte +9 -7
- package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
- package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
- package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
- package/dist/components/Input/FieldSelect.svelte +9 -8
- package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
- package/dist/components/Input/FieldSwitch.svelte +9 -7
- package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
- package/dist/components/Input/FieldTextarea.svelte +7 -8
- package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
- package/dist/components/Input/README.md +20 -0
- package/dist/components/Input/_internal/InputWrap.svelte +2 -10
- package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
- package/dist/components/Input/types.d.ts +28 -0
- package/dist/components/Nav/Nav.svelte +5 -4
- package/dist/components/Nav/Nav.svelte.d.ts +2 -2
- package/dist/components/Nav/README.md +2 -2
- package/dist/components/Nav/index.css +4 -0
- package/dist/components/Tree/README.md +189 -0
- package/dist/components/Tree/Tree.svelte +46 -2
- package/dist/components/Tree/Tree.svelte.d.ts +5 -0
- package/dist/utils/input-history.svelte.d.ts +12 -0
- package/dist/utils/input-history.svelte.js +12 -0
- package/dist/utils/observe-exists.svelte.d.ts +1 -0
- package/dist/utils/observe-exists.svelte.js +11 -3
- package/dist/utils/switch.svelte.d.ts +12 -0
- package/dist/utils/switch.svelte.js +12 -1
- package/docs/architecture.md +0 -1
- package/docs/testing.md +72 -0
- package/docs/upgrading.md +281 -0
- 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(
|
|
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 {
|
|
@@ -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
|
-
|
|
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);
|
package/docs/architecture.md
CHANGED
|
@@ -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 |
|
package/docs/testing.md
ADDED
|
@@ -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.
|