@marianmeres/stuic 3.116.0 → 3.117.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.
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ import { focusTrap } from "./focus-trap.js";
3
+
4
+ let {
5
+ enabled = true,
6
+ autoFocusFirst = true,
7
+ }: { enabled?: boolean; autoFocusFirst?: boolean } = $props();
8
+ </script>
9
+
10
+ <!-- A minimal focusable set to exercise the trap's auto-focus + Tab wrap-around. -->
11
+ <div use:focusTrap={{ enabled, autoFocusFirst }} data-testid="trap">
12
+ <button data-testid="first">First</button>
13
+ <input data-testid="middle" type="text" />
14
+ <button data-testid="last">Last</button>
15
+ </div>
16
+ <button data-testid="outside">Outside</button>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ enabled?: boolean;
3
+ autoFocusFirst?: boolean;
4
+ };
5
+ declare const FocusTrap: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type FocusTrap = ReturnType<typeof FocusTrap>;
7
+ export default FocusTrap;
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
3
+ // CommandMenu is imperative-only (open()/close() via a ref; it builds on
4
+ // ModalDialog which has no bindable `visible` prop), so a `.svelte.test.ts`
5
+ // file can't drive it directly. This fixture holds the `bind:this` ref and
6
+ // exposes an opener button that calls `.open()`. `getOptions` (a required
7
+ // async prop) is forwarded; `value` is bound so a test can inspect the
8
+ // selected option after a pick.
9
+ import CommandMenu from "./CommandMenu.svelte";
10
+
11
+ let cmd = $state<CommandMenu>();
12
+ let { getOptions, value = $bindable() } = $props();
13
+ </script>
14
+
15
+ <button data-testid="opener" onclick={() => cmd?.open()}>open</button>
16
+
17
+ <!--
18
+ Mirror the bound `value` into the DOM so a test can observe what the menu
19
+ selected (render() from vitest-browser-svelte does not hand back bound props).
20
+ The selected option is an item-collection Item; we surface its `id`.
21
+ -->
22
+ <span data-testid="selected">{value?.id ?? ""}</span>
23
+
24
+ <CommandMenu bind:this={cmd} bind:value {getOptions} />
@@ -0,0 +1,7 @@
1
+ import CommandMenu from "./CommandMenu.svelte";
2
+ declare const CommandMenu: import("svelte").Component<{
3
+ getOptions: any;
4
+ value?: any;
5
+ }, {}, "value">;
6
+ type CommandMenu = ReturnType<typeof CommandMenu>;
7
+ export default CommandMenu;
@@ -65,6 +65,11 @@
65
65
 
66
66
  <script lang="ts">
67
67
  import Button from "../Button/Button.svelte";
68
+ import {
69
+ normalizeAndGroupOptions,
70
+ renderOptionLabelOf,
71
+ sortByOptgroupLabel,
72
+ } from "./_internal/command-menu-utils.js";
68
73
 
69
74
  const clog = createClog("CommandMenu");
70
75
 
@@ -86,15 +91,14 @@
86
91
  unstyled = false,
87
92
  }: Props = $props();
88
93
 
94
+ // Pure helpers extracted to _internal/command-menu-utils.ts (node-tested). These
95
+ // thin wrappers keep reading the live props/closures and delegate the logic.
89
96
  function _renderOptionLabel(item: Item): string {
90
- return renderOptionLabel?.(item) || `${item[itemIdPropName]}`;
97
+ return renderOptionLabelOf(item, renderOptionLabel, itemIdPropName);
91
98
  }
92
99
 
93
100
  function sortFn(a: Item, b: Item) {
94
- const withOptGroup = (i: Item) => `${i.optgroup || ""}__${_renderOptionLabel(i)}`;
95
- return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
96
- sensitivity: "base",
97
- });
101
+ return sortByOptgroupLabel(a, b, _renderOptionLabel);
98
102
  }
99
103
 
100
104
  let modalDialog: ModalDialog = $state()!;
@@ -155,14 +159,7 @@
155
159
 
156
160
  //
157
161
  function _normalize_and_group_options(opts: Item[]): Map<string, Item[]> {
158
- const groupped = new Map<string, Item[]>();
159
- opts.forEach((o) => {
160
- const optgLabel = renderOptionGroup(o.optgroup || "");
161
- if (!groupped.has(optgLabel)) groupped.set(optgLabel, []);
162
- const optgroup = groupped.get(optgLabel);
163
- optgroup!.push(o);
164
- });
165
- return groupped;
162
+ return normalizeAndGroupOptions(opts, renderOptionGroup);
166
163
  }
167
164
 
168
165
  export function close() {
@@ -0,0 +1,22 @@
1
+ import type { Item } from "@marianmeres/item-collection";
2
+ /**
3
+ * Pure option/result helpers for CommandMenu, extracted from the component so they
4
+ * can be unit-tested in the fast node project (no DOM). The actual searchable
5
+ * matching is delegated to `@marianmeres/item-collection`; these are the structural
6
+ * pieces around the results — how a result's label is derived, how results sort, and
7
+ * how they group by optgroup for rendering.
8
+ */
9
+ /** Derive an option's display label: the consumer renderer, else its id prop. */
10
+ export declare function renderOptionLabelOf(item: Item, renderOptionLabel: ((item: Item) => string) | undefined, itemIdPropName: string): string;
11
+ /**
12
+ * Compare two options by "{optgroup}__{label}" using a case-insensitive locale
13
+ * compare, so options sort grouped-then-alphabetical. `renderLabel` derives the
14
+ * label part (typically `renderOptionLabelOf` bound to the component's props).
15
+ */
16
+ export declare function sortByOptgroupLabel(a: Item, b: Item, renderLabel: (item: Item) => string): number;
17
+ /**
18
+ * Group options into a Map keyed by their (rendered) optgroup label, preserving
19
+ * input order within each group. `renderOptionGroup` maps a raw optgroup key to its
20
+ * display label (e.g. underscores → spaces).
21
+ */
22
+ export declare function normalizeAndGroupOptions(opts: Item[], renderOptionGroup: (s: string) => string): Map<string, Item[]>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure option/result helpers for CommandMenu, extracted from the component so they
3
+ * can be unit-tested in the fast node project (no DOM). The actual searchable
4
+ * matching is delegated to `@marianmeres/item-collection`; these are the structural
5
+ * pieces around the results — how a result's label is derived, how results sort, and
6
+ * how they group by optgroup for rendering.
7
+ */
8
+ /** Derive an option's display label: the consumer renderer, else its id prop. */
9
+ export function renderOptionLabelOf(item, renderOptionLabel, itemIdPropName) {
10
+ return renderOptionLabel?.(item) || `${item[itemIdPropName]}`;
11
+ }
12
+ /**
13
+ * Compare two options by "{optgroup}__{label}" using a case-insensitive locale
14
+ * compare, so options sort grouped-then-alphabetical. `renderLabel` derives the
15
+ * label part (typically `renderOptionLabelOf` bound to the component's props).
16
+ */
17
+ export function sortByOptgroupLabel(a, b, renderLabel) {
18
+ const withOptGroup = (i) => `${i.optgroup || ""}__${renderLabel(i)}`;
19
+ return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
20
+ sensitivity: "base",
21
+ });
22
+ }
23
+ /**
24
+ * Group options into a Map keyed by their (rendered) optgroup label, preserving
25
+ * input order within each group. `renderOptionGroup` maps a raw optgroup key to its
26
+ * display label (e.g. underscores → spaces).
27
+ */
28
+ export function normalizeAndGroupOptions(opts, renderOptionGroup) {
29
+ const groupped = new Map();
30
+ opts.forEach((o) => {
31
+ const optgLabel = renderOptionGroup(o.optgroup || "");
32
+ if (!groupped.has(optgLabel))
33
+ groupped.set(optgLabel, []);
34
+ groupped.get(optgLabel).push(o);
35
+ });
36
+ return groupped;
37
+ }
@@ -267,6 +267,10 @@
267
267
  import ListItemButton from "../ListItemButton/ListItemButton.svelte";
268
268
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
269
269
  import { waitForTwoRepaints } from "../../utils/paint.js";
270
+ import {
271
+ extractSearchableItems,
272
+ filterItemsByMatchedIds,
273
+ } from "./_internal/dropdown-menu-search.js";
270
274
 
271
275
  let {
272
276
  items,
@@ -357,20 +361,9 @@
357
361
  return search === true ? defaults : { ...defaults, ...search };
358
362
  });
359
363
 
360
- // Extract all searchable items (action + expandable + nested actions)
361
- let allSearchableItems = $derived.by(() => {
362
- const result: (DropdownMenuActionItem | DropdownMenuExpandableItem)[] = [];
363
- for (const item of items) {
364
- if (item.type === "action") result.push(item);
365
- if (item.type === "expandable") {
366
- result.push(item);
367
- for (const child of item.items) {
368
- if (child.type === "action") result.push(child);
369
- }
370
- }
371
- }
372
- return result;
373
- });
364
+ // Extract all searchable items (action + expandable + nested actions).
365
+ // Pure logic lives in _internal/dropdown-menu-search.ts (node-tested).
366
+ let allSearchableItems = $derived(extractSearchableItems(items));
374
367
 
375
368
  // Searchable collection (recreate when items or config changes)
376
369
  let searchableCollection = $derived.by(() => {
@@ -389,19 +382,7 @@
389
382
  const results = searchableCollection.search(searchQuery, searchConfig.strategy);
390
383
  const matchedIds = new Set(results.map((r) => r.id));
391
384
 
392
- return items.filter((item) => {
393
- if (item.type === "divider" || item.type === "header" || item.type === "custom") {
394
- return false; // Hide during search
395
- }
396
- if (item.type === "action") return matchedIds.has(item.id);
397
- if (item.type === "expandable") {
398
- return (
399
- matchedIds.has(item.id) ||
400
- item.items.some((c) => c.type === "action" && matchedIds.has(c.id))
401
- );
402
- }
403
- return false;
404
- });
385
+ return filterItemsByMatchedIds(items, matchedIds);
405
386
  });
406
387
 
407
388
  // Matched IDs for use in template filtering during search
@@ -0,0 +1,21 @@
1
+ import type { DropdownMenuItem, DropdownMenuActionItem, DropdownMenuExpandableItem } from "../DropdownMenu.svelte";
2
+ /**
3
+ * Pure search/filter logic for DropdownMenu, extracted from the component so it can
4
+ * be unit-tested in the fast node project (no DOM). The component delegates the actual
5
+ * fuzzy/prefix matching to `@marianmeres/item-collection`; these helpers are the
6
+ * structural pieces around it — which items are searchable, and which survive a match.
7
+ */
8
+ /**
9
+ * Flatten the menu's item list to the set that participates in search: top-level
10
+ * action items, expandable section headers, and the action children nested inside
11
+ * expandables. Dividers, headers and custom items are not searchable.
12
+ */
13
+ export declare function extractSearchableItems(items: DropdownMenuItem[]): (DropdownMenuActionItem | DropdownMenuExpandableItem)[];
14
+ /**
15
+ * Given the set of ids a search matched, return the items that should remain visible:
16
+ * - dividers / headers / custom items are hidden entirely during an active search,
17
+ * - action items survive iff their id matched,
18
+ * - expandable sections survive iff the section itself matched OR any of its action
19
+ * children matched.
20
+ */
21
+ export declare function filterItemsByMatchedIds(items: DropdownMenuItem[], matchedIds: Set<string | number>): DropdownMenuItem[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pure search/filter logic for DropdownMenu, extracted from the component so it can
3
+ * be unit-tested in the fast node project (no DOM). The component delegates the actual
4
+ * fuzzy/prefix matching to `@marianmeres/item-collection`; these helpers are the
5
+ * structural pieces around it — which items are searchable, and which survive a match.
6
+ */
7
+ /**
8
+ * Flatten the menu's item list to the set that participates in search: top-level
9
+ * action items, expandable section headers, and the action children nested inside
10
+ * expandables. Dividers, headers and custom items are not searchable.
11
+ */
12
+ export function extractSearchableItems(items) {
13
+ const result = [];
14
+ for (const item of items) {
15
+ if (item.type === "action")
16
+ result.push(item);
17
+ if (item.type === "expandable") {
18
+ result.push(item);
19
+ for (const child of item.items) {
20
+ if (child.type === "action")
21
+ result.push(child);
22
+ }
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+ /**
28
+ * Given the set of ids a search matched, return the items that should remain visible:
29
+ * - dividers / headers / custom items are hidden entirely during an active search,
30
+ * - action items survive iff their id matched,
31
+ * - expandable sections survive iff the section itself matched OR any of its action
32
+ * children matched.
33
+ */
34
+ export function filterItemsByMatchedIds(items, matchedIds) {
35
+ return items.filter((item) => {
36
+ if (item.type === "divider" || item.type === "header" || item.type === "custom") {
37
+ return false;
38
+ }
39
+ if (item.type === "action")
40
+ return matchedIds.has(item.id);
41
+ if (item.type === "expandable") {
42
+ return (matchedIds.has(item.id) ||
43
+ item.items.some((c) => c.type === "action" && matchedIds.has(c.id)));
44
+ }
45
+ return false;
46
+ });
47
+ }
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"css":"external","dev":true,"hmr":false},"configFile":false,"extensions":[".svelte"],"preprocess":{"script":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\t\tif (basename.startsWith('+page.') || basename.startsWith('+layout.')) {\n\t\t\tconst match = content.match(options_regex);\n\t\t\tif (match && match.index !== undefined && !should_ignore(content, match.index)) {\n\t\t\t\tconst fixed = basename.replace('.svelte', '(.server).js/ts');\n\n\t\t\t\tconst message =\n\t\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t\t`\\`${match[1]}\\` will be ignored — move it to ${fixed} instead. See https://svelte.dev/docs/kit/page-options for more information.`;\n\n\t\t\t\tif (!warned.has(message)) {\n\t\t\t\t\tconsole.log(message);\n\t\t\t\t\twarned.add(message);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}","markup":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\n\t\tif (basename.startsWith('+layout.') && !has_children(content, isSvelte5Plus())) {\n\t\t\tconst message =\n\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t`\\`<slot />\\`${isSvelte5Plus() ? ' or `{@render ...}` tag' : ''}` +\n\t\t\t\t' missing — inner content will not be rendered';\n\n\t\t\tif (!warned.has(message)) {\n\t\t\t\tconsole.log(message);\n\t\t\t\twarned.add(message);\n\t\t\t}\n\t\t}\n\t}"}}
@@ -0,0 +1 @@
1
+ {"compilerOptions":{"css":"external","dev":true,"hmr":false},"configFile":false,"extensions":[".svelte"],"preprocess":{"script":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\t\tif (basename.startsWith('+page.') || basename.startsWith('+layout.')) {\n\t\t\tconst match = content.match(options_regex);\n\t\t\tif (match && match.index !== undefined && !should_ignore(content, match.index)) {\n\t\t\t\tconst fixed = basename.replace('.svelte', '(.server).js/ts');\n\n\t\t\t\tconst message =\n\t\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t\t`\\`${match[1]}\\` will be ignored — move it to ${fixed} instead. See https://svelte.dev/docs/kit/page-options for more information.`;\n\n\t\t\t\tif (!warned.has(message)) {\n\t\t\t\t\tconsole.log(message);\n\t\t\t\t\twarned.add(message);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}","markup":"({ content, filename }) => {\n\t\tif (!filename) return;\n\n\t\tconst basename = path.basename(filename);\n\n\t\tif (basename.startsWith('+layout.') && !has_children(content, isSvelte5Plus())) {\n\t\t\tconst message =\n\t\t\t\t`\\n${colors.bold().red(path.relative('.', filename))}\\n` +\n\t\t\t\t`\\`<slot />\\`${isSvelte5Plus() ? ' or `{@render ...}` tag' : ''}` +\n\t\t\t\t' missing — inner content will not be rendered';\n\n\t\t\tif (!warned.has(message)) {\n\t\t\t\tconsole.log(message);\n\t\t\t\twarned.add(message);\n\t\t\t}\n\t\t}\n\t}"}}
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
3
+ // ModalDialog is imperative-only (open()/close() via a ref; no bindable
4
+ // `visible` prop), so a `.svelte.test.ts` file can't drive it directly. This
5
+ // fixture holds the `bind:this` ref and exposes an opener button that calls
6
+ // `.open()`; ModalDialog props are forwarded through `...rest`. The default
7
+ // content (an "inside" button) is the required `children` snippet — it also
8
+ // gives the focus trap a real focusable element to auto-focus.
9
+ import ModalDialog from "./ModalDialog.svelte";
10
+
11
+ let dialog = $state<ModalDialog>();
12
+ let { ...rest } = $props();
13
+ </script>
14
+
15
+ <button data-testid="opener" onclick={() => dialog?.open()}>open</button>
16
+
17
+ <ModalDialog bind:this={dialog} {...rest}>
18
+ <button data-testid="inside">Inside</button>
19
+ </ModalDialog>
@@ -0,0 +1,4 @@
1
+ import ModalDialog from "./ModalDialog.svelte";
2
+ declare const ModalDialog: import("svelte").Component<Record<string, any>, {}, "">;
3
+ type ModalDialog = ReturnType<typeof ModalDialog>;
4
+ export default ModalDialog;
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import SlidingPanels from "./SlidingPanels.svelte";
3
+
4
+ // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
5
+ // SlidingPanels exposes the panel content via snippets that receive an
6
+ // imperative `show(panel)` fn. A `.svelte.test.ts` file can't host markup that
7
+ // wires snippet args to a button, so we compose the real component here and
8
+ // drive the async transition by clicking the rendered buttons. `duration` is
9
+ // kept small (60ms default) so the transition resolves well within testTimeout.
10
+ let { duration = 60 } = $props();
11
+ </script>
12
+
13
+ <SlidingPanels {duration}>
14
+ {#snippet panelA({ show })}
15
+ <div>Panel A <button onclick={() => show("B")}>go B</button></div>
16
+ {/snippet}
17
+ {#snippet panelB({ show })}
18
+ <div>Panel B <button onclick={() => show("A")}>go A</button></div>
19
+ {/snippet}
20
+ </SlidingPanels>
@@ -0,0 +1,6 @@
1
+ import SlidingPanels from "./SlidingPanels.svelte";
2
+ declare const SlidingPanels: import("svelte").Component<{
3
+ duration?: number;
4
+ }, {}, "">;
5
+ type SlidingPanels = ReturnType<typeof SlidingPanels>;
6
+ export default SlidingPanels;
@@ -35,16 +35,104 @@ Branch: `feat/component-testing`
35
35
 
36
36
  | Rank | Task | Source | Status |
37
37
  | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------ |
38
- | 14 | Rest of Tier 1 (Separator already smoke-tested · H, KbdShortcut, ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible) | [03](./03-component-coverage-roadmap.md) #10–17 | |
39
- | 15 | Tier 2 — `FieldInput` first, then the Field\* family + OtpInput, Nav, etc. | [03](./03-component-coverage-roadmap.md) | |
40
- | 16 | Portals/focus-traps in browser mode (Modal, ModalDialog, Backdrop, Drawer, AlertConfirmPrompt) | [04](./04-hard-cases-and-e2e.md) | |
41
- | 17 | Anchor-positioned menus (DropdownMenu, CommandMenu, UserAvatarMenu) + extract search logic to `_internal` | [04](./04-hard-cases-and-e2e.md) | |
38
+ | 14 | Rest of Tier 1 (Separator already smoke-tested · H, KbdShortcut, ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible) | [03](./03-component-coverage-roadmap.md) #10–17 | |
39
+ | 15 | Tier 2 — `FieldInput` first, then the Field\* family + OtpInput, Nav, etc. | [03](./03-component-coverage-roadmap.md) | 🚧 |
40
+ | 16 | Portals/focus-traps in browser mode (Modal, ModalDialog, Backdrop, Drawer, AlertConfirmPrompt) | [04](./04-hard-cases-and-e2e.md) | |
41
+ | 17 | Anchor-positioned menus (DropdownMenu, CommandMenu, UserAvatarMenu) + extract search logic to `_internal` | [04](./04-hard-cases-and-e2e.md) | |
42
42
  | 18 | Standalone Playwright E2E layer (drag: Tree/FieldOptions/FieldFile; Milkdown; Checkout/auth flows) | [04](./04-hard-cases-and-e2e.md) | ⏭️ |
43
43
  | 19 | Clear the repo's **pre-existing lint debt** (8 eslint errors + 119 prettier files), then add a **`pnpm lint`** CI job. (`pnpm check` already runs in CI as of task 13.) | [05](./05-ci.md) | ✅ |
44
44
  | 20 | (Maybe) visual-regression via `toMatchScreenshot`; multi-browser matrix | [00](./00-overview-and-roadmap.md) | ⏭️ |
45
45
 
46
+ ### Task 14 — Tier 1 components (one commit each)
47
+
48
+ - [x] H — `H.svelte.test.ts` (8 tests)
49
+ - [x] KbdShortcut — `KbdShortcut.svelte.test.ts` (6 tests)
50
+ - [x] ButtonGroupRadio — `ButtonGroupRadio.svelte.test.ts`
51
+ - [x] ListItemButton — `ListItemButton.svelte.test.ts`
52
+ - [x] Card — `Card.svelte.test.ts`
53
+ - [x] TabbedMenu — `TabbedMenu.svelte.test.ts`
54
+ - [x] IconSwap — `IconSwap.svelte.test.ts`
55
+ - [x] Collapsible — `Collapsible.svelte.test.ts` (the browser-only star: real `scrollHeight > clientHeight`)
56
+
57
+ ### Task 15 — Tier 2 (Field\* family first, then OtpInput, Nav, etc.)
58
+
59
+ Field\* family (all under `Input/`, co-located `*.svelte.test.ts`):
60
+
61
+ - [x] FieldInput — flagship; label/for, value/type/required/disabled, trim, validate (10 tests)
62
+ - [x] FieldTextarea — textarea + trim/validate + autogrow (11 tests)
63
+ - [x] FieldCheckbox — native checkbox, checked/toggle, validate-on-interaction (8 tests)
64
+ - [x] FieldSwitch — wraps Switch in InputWrap; checked/toggle (off-center click), disabled (9 tests)
65
+ - [x] FieldRadios — radio group, value binding, selection moves on click (7 tests)
66
+ - [x] FieldSelect — select options, value binding via selectOptions, validate, optgroup (9 tests)
67
+ - [x] FieldLikeButton — hidden input + Button, JSON render, built-in JSON/required validation (7 tests)
68
+ - [x] FieldPhoneNumber — tel input + prefix picker, compose to E.164 hidden value, validation (13 tests)
69
+ - [x] FieldKeyValues — add/remove rows, key/value → serialized JSON hidden value (9 tests)
70
+ - [x] FieldObject — JSON tree view ↔ edit-mode textarea toggle, hidden value round-trip (7 tests)
71
+
72
+ Other Tier 2:
73
+
74
+ - [x] OtpInput — slots, value binding, **focus jump**, onComplete, numeric sanitize (12 tests)
75
+ - [x] TypeaheadInput — input + typeahead action (combobox), value binding, getOptions called (7 tests)
76
+ - [x] ColorScheme — store class: toggle/reset, localStorage + `<html>.dark` class (9 tests)
77
+ - [x] ImageCycler — single-image static contract: role=img, aria-label, data-fit, bg, snippets (5 tests)
78
+ - [x] PricingTable — list/tiers, billing toggle switches prices, data flags, CTA callback (11 tests)
79
+ - [x] SlidingPanels — fixture-driven imperative `show()`; post-transition panel destroy (3 tests)
80
+ - [ ] Nav (expand/collapse) — ⏭️ _postponed: 856-line component; needs a focused session_
81
+ - [ ] ThemePreview — ⏭️ _postponed: largely visual/presentational; low behavioral yield_
82
+ - [ ] AppShell / AppShellSimple — ⏭️ _postponed: layout-heavy, low behavioral yield_
83
+ - [ ] AssetsPreview — ⏭️ _postponed: heavy; pure-logic already covered by assets-preview-utils tests_
84
+ - [ ] Notifications — ⏭️ _postponed: borderline Tier 3 (portal + timers); has a node test already_
85
+
86
+ **Task 15 status:** core done — 16 components (FieldInput + 6 Field family + 3 complex Field +
87
+ OtpInput, TypeaheadInput, ColorScheme, ImageCycler, PricingTable, SlidingPanels). 5 postponed
88
+ (Nav, ThemePreview, AppShell/AppShellSimple, AssetsPreview, Notifications) — heaviest / lowest
89
+ behavioral yield; revisit in a focused follow-up. Moving on to backlog #16/#17.
90
+
91
+ ### Task 16 — Portals / focus-traps
92
+
93
+ - [x] focus-trap action — the deferred hard proof: auto-focus first + Tab/Shift+Tab wrap (5 tests)
94
+ - [x] Backdrop — visible render, backdrop-click/escape callbacks, focus-trap, defaultPrevented guard (9 tests)
95
+ - [x] Modal — opens dialog (role), children/header/footer, aria, Escape→onEscape+close (6 tests)
96
+ - [x] Drawer — role=dialog/aria-modal, escape + outside callbacks, position (7 tests)
97
+ - [x] ModalDialog — fixture + imperative open/close, focus-trap, Escape, click-outside (8 tests)
98
+ - [x] AlertConfirmPrompt — stack-driven alert/confirm/prompt: render, focus, click resolves promise (11 tests)
99
+
100
+ ### Task 17 — Anchor-positioned menus + search-logic extraction
101
+
102
+ - [x] Extract search logic to `_internal` — `DropdownMenu/_internal/dropdown-menu-search.ts` +
103
+ `CommandMenu/_internal/command-menu-utils.ts`, node-tested (14 tests); components refactored to import
104
+ - [x] DropdownMenu (browser) — trigger/aria-expanded, role=menu + menuitems, select+close, Escape, search filter (7 tests)
105
+ - [x] CommandMenu (browser) — fixture + imperative open, search box, type→debounced getOptions→options, select (4 tests)
106
+ - [x] UserAvatarMenu (browser) — authed/unauth trigger labels, header tile, Logout/Login/Register + color-scheme item (8 tests)
107
+
46
108
  ## Decisions log
47
109
 
110
+ - **2026-06-08** — **Backlog #15/#16/#17 done (this session).** Tier-2 core (16 components), portals/
111
+ focus-traps (#16: focus-trap action proof + Backdrop/Modal/Drawer/ModalDialog/AlertConfirmPrompt), and
112
+ anchor menus (#17: DropdownMenu/CommandMenu/UserAvatarMenu) — all browser-mode, drafted+adversarially-
113
+ reviewed in parallel workflows, then verified against real Chromium + full-suite gate before per-component
114
+ commits. **426 tests** green; `pnpm check`/`lint`/`test` clean. Key learnings: (a) **`page.elementLocator`
115
+ snapshots an element by its text** — it goes stale if the element's content changes (PricingTable toggle);
116
+ poll the live node's `textContent` instead. (b) **A Playwright `.click()` on an element that synchronously
117
+ removes itself orphans a "Cancelled" rejection** (non-zero exit) — use a native `el.click()` for
118
+ self-closing menuitems; ModalDialog-based closes are async so they're unaffected. (c) `getByRole("list")/
119
+ ("listitem")` is ambiguous when a `<ul>`/`<li>` is nested — scope by class. (d) **Task 15: 5 heavy/low-yield
120
+ components postponed** (Nav, ThemePreview, AppShell/AppShellSimple, AssetsPreview, Notifications). (e)
121
+ **#17 refactor:** pure search logic extracted to `DropdownMenu/_internal/dropdown-menu-search.ts` +
122
+ `CommandMenu/_internal/command-menu-utils.ts`, node-tested (14 tests).
123
+ - **2026-06-08** — **Backlog #14 done (rest of Tier 1).** 8 components, one commit each: H, KbdShortcut,
124
+ ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible (+64 tests → **210 total**,
125
+ `pnpm test`/`check`/`lint` all green). Drafted+adversarially-reviewed in parallel via a subagent
126
+ workflow, then verified against real Chromium + a full-suite gate before committing each. Notable
127
+ findings: (1) **the browser test env loads NO component/Tailwind CSS** (setupFiles is only
128
+ `vitest-browser-svelte`; nothing imports the stuic aggregator stylesheet) — only inline styles the
129
+ component emits directly are reliable. **Collapsible** therefore injects the `line-clamp-{n}` rules
130
+ Tailwind would generate (via `beforeAll` + a `<style>` tag) so its genuine browser-only measurement
131
+ (`scrollHeight > clientHeight`) can run; everything else asserts `data-*`/class presence, never
132
+ computed external-class styles. (2) **Card**'s `horizontal` variant auto-switches to `vertical` below
133
+ `horizontalThreshold` (480px) — tests pass `horizontalThreshold={0}` to assert the raw variant.
134
+ (3) **IconSwap** omits the motion-dependent `300ms` duration assertion (no per-test
135
+ `prefers-reduced-motion` API — cf. the Skeleton decision); asserts `duration:0`/easing CSS vars instead.
48
136
  - **2026-06-08** — Adopt **Vitest 4 Browser Mode + `vitest-browser-svelte` + `@vitest/browser-playwright` (Chromium)** — verified the right default for a component library whose value is DOM/layout/focus behavior the current node/server-build setup can't test.
49
137
  - **2026-06-08** — **Take the vitest 3→4 major upgrade now** (gating prerequisite) — `vitest-browser-svelte@^2` peer-requires `vitest ^4`; the vitest-3-compatible `0.1.0` is a dead-end.
50
138
  - **2026-06-08** — **Scope:** easy warm-up (Button/Pill/Switch/…) then **one** hard proof — not full coverage up front.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.116.0",
3
+ "version": "3.117.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",