@marianmeres/stuic 3.116.0 → 3.118.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,19 @@
1
+ <script lang="ts">
2
+ // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
3
+ // AssetsPreview is imperative-only — it opens a ModalDialog via a ref method
4
+ // open(index?). A *.svelte.test.ts file can't hold a bind:this + a trigger, so
5
+ // this fixture exposes an opener button that calls open(openIndex); all other
6
+ // AssetsPreview props are forwarded through ...rest.
7
+ import type { ComponentProps } from "svelte";
8
+ import AssetsPreview from "./AssetsPreview.svelte";
9
+
10
+ let ref = $state<AssetsPreview>();
11
+ let {
12
+ openIndex,
13
+ ...rest
14
+ }: { openIndex?: number } & ComponentProps<typeof AssetsPreview> = $props();
15
+ </script>
16
+
17
+ <button data-testid="opener" onclick={() => ref?.open(openIndex)}>open</button>
18
+
19
+ <AssetsPreview bind:this={ref} {...rest} />
@@ -0,0 +1,4 @@
1
+ import AssetsPreview from "./AssetsPreview.svelte";
2
+ declare const AssetsPreview: any;
3
+ type AssetsPreview = ReturnType<typeof AssetsPreview>;
4
+ export default AssetsPreview;
@@ -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,130 @@ 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
+ - [x] Nav — section title (interactive vs not), group expand/collapse + onGroupToggle, leaf href/onClick/
81
+ disabled variants, parent-with-children nesting, activeId/isActive auto-expand, isCollapsed (12 tests)
82
+ - [x] ThemePreview — root class, default header vs header/sidebar/footer snippet overrides, showLabels/
83
+ showInputs/compact toggles, embedded sidebar Nav (8 tests)
84
+ - [x] AppShell — slot-driven data-shell regions (conditional vs always-on), children in page-main,
85
+ pageFlexGrow → flex class (7 tests)
86
+ - [x] AppShellSimple — main always renders; header/rail/aside conditional; headerStyle applied (6 tests)
87
+ - [x] AssetsPreview — inline gallery exercises AssetsPreviewContent (name/zoom-state/dots/arrow nav) +
88
+ modal fixture for imperative open()/Close; hermetic 1×1-PNG data URIs (11 tests)
89
+ - [x] Notifications — reactive stack render: role=alert + data-type per intent, dedup count badge,
90
+ close-button removal, noXButton, multiple/empty stacks (8 tests)
91
+
92
+ **Task 15 status:** ✅ DONE — 22 components. Core 16 (FieldInput + 6 Field family + 3 complex Field +
93
+ OtpInput, TypeaheadInput, ColorScheme, ImageCycler, PricingTable, SlidingPanels) plus the final 6
94
+ (Nav, ThemePreview, AppShell, AppShellSimple, AssetsPreview inline+modal, Notifications). The whole
95
+ non-deferred backlog (#14–#17, #19) is now complete; only #18/#20 remain deferred.
96
+
97
+ ### Task 16 — Portals / focus-traps
98
+
99
+ - [x] focus-trap action — the deferred hard proof: auto-focus first + Tab/Shift+Tab wrap (5 tests)
100
+ - [x] Backdrop — visible render, backdrop-click/escape callbacks, focus-trap, defaultPrevented guard (9 tests)
101
+ - [x] Modal — opens dialog (role), children/header/footer, aria, Escape→onEscape+close (6 tests)
102
+ - [x] Drawer — role=dialog/aria-modal, escape + outside callbacks, position (7 tests)
103
+ - [x] ModalDialog — fixture + imperative open/close, focus-trap, Escape, click-outside (8 tests)
104
+ - [x] AlertConfirmPrompt — stack-driven alert/confirm/prompt: render, focus, click resolves promise (11 tests)
105
+
106
+ ### Task 17 — Anchor-positioned menus + search-logic extraction
107
+
108
+ - [x] Extract search logic to `_internal` — `DropdownMenu/_internal/dropdown-menu-search.ts` +
109
+ `CommandMenu/_internal/command-menu-utils.ts`, node-tested (14 tests); components refactored to import
110
+ - [x] DropdownMenu (browser) — trigger/aria-expanded, role=menu + menuitems, select+close, Escape, search filter (7 tests)
111
+ - [x] CommandMenu (browser) — fixture + imperative open, search box, type→debounced getOptions→options, select (4 tests)
112
+ - [x] UserAvatarMenu (browser) — authed/unauth trigger labels, header tile, Logout/Login/Register + color-scheme item (8 tests)
113
+
46
114
  ## Decisions log
47
115
 
116
+ - **2026-06-09** — **Task 15 closed — the final 5 postponed components done.** Nav, ThemePreview,
117
+ AppShell, AppShellSimple, AssetsPreview (inline + modal), Notifications — drafted + adversarially
118
+ reviewed in a parallel workflow, then verified against real Chromium + the full-suite gate before
119
+ per-component commits. **484 tests** green; `pnpm check`/`lint`/`test` all clean. The entire
120
+ non-deferred backlog (#14–#17, #19) is now complete; only #18 (standalone E2E) and #20 (visual
121
+ regression) remain deferred. New gotchas learned: (a) **un-caught image preload rejects on dead
122
+ URLs** — `AssetsPreview`/`AssetsPreviewInline` call `preloadImgs(...)` without `.catch`, so a
123
+ network/`example.com` URL surfaces as an unhandled rejection (and isn't hermetic for CI). Fix: pass
124
+ `AssetPreview` objects with a real 1×1-PNG **data-URI** `url` + explicit `name`/`type:"image"` so the
125
+ preload resolves offline. (b) **The AssetsPreviewContent slide has a ~300ms async tail** (`await
126
+ waitForNextRepaint()` + `sleep(300)`); if the component unmounts mid-slide, the continuation reads
127
+ `$state` on a torn-down scope → **`track_reactivity_loss` unhandled rejection**. Fix: after navigating,
128
+ poll until the slide settles (outgoing panel gone → a single `<img>`) so it finishes while mounted.
129
+ (c) Re-confirmed the `page.elementLocator` staleness rule — it snapshots an element by its current
130
+ text, so a name label that changes during navigation must be read live via `expect.poll(() =>
131
+ el.textContent)`. (d) `AssetsPreview.open(index)` settles back to index 0 (its open-effect reads then
132
+ nulls `_openIdx` and re-runs) — a component quirk; the modal test asserts open()/Close only and leaves
133
+ index-precise nav to the inline test. (e) **Nav persists expand state in localStorage** (persistState
134
+ defaults true) → pass `persistState={false}` + `localStorage.clear()` in beforeEach; and never `.click()`
135
+ a Nav `<a href>` leaf (it navigates the page) — assert its attributes instead.
136
+ - **2026-06-08** — **Backlog #15/#16/#17 done (this session).** Tier-2 core (16 components), portals/
137
+ focus-traps (#16: focus-trap action proof + Backdrop/Modal/Drawer/ModalDialog/AlertConfirmPrompt), and
138
+ anchor menus (#17: DropdownMenu/CommandMenu/UserAvatarMenu) — all browser-mode, drafted+adversarially-
139
+ reviewed in parallel workflows, then verified against real Chromium + full-suite gate before per-component
140
+ commits. **426 tests** green; `pnpm check`/`lint`/`test` clean. Key learnings: (a) **`page.elementLocator`
141
+ snapshots an element by its text** — it goes stale if the element's content changes (PricingTable toggle);
142
+ poll the live node's `textContent` instead. (b) **A Playwright `.click()` on an element that synchronously
143
+ removes itself orphans a "Cancelled" rejection** (non-zero exit) — use a native `el.click()` for
144
+ self-closing menuitems; ModalDialog-based closes are async so they're unaffected. (c) `getByRole("list")/
145
+ ("listitem")` is ambiguous when a `<ul>`/`<li>` is nested — scope by class. (d) **Task 15: 5 heavy/low-yield
146
+ components postponed** (Nav, ThemePreview, AppShell/AppShellSimple, AssetsPreview, Notifications). (e)
147
+ **#17 refactor:** pure search logic extracted to `DropdownMenu/_internal/dropdown-menu-search.ts` +
148
+ `CommandMenu/_internal/command-menu-utils.ts`, node-tested (14 tests).
149
+ - **2026-06-08** — **Backlog #14 done (rest of Tier 1).** 8 components, one commit each: H, KbdShortcut,
150
+ ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible (+64 tests → **210 total**,
151
+ `pnpm test`/`check`/`lint` all green). Drafted+adversarially-reviewed in parallel via a subagent
152
+ workflow, then verified against real Chromium + a full-suite gate before committing each. Notable
153
+ findings: (1) **the browser test env loads NO component/Tailwind CSS** (setupFiles is only
154
+ `vitest-browser-svelte`; nothing imports the stuic aggregator stylesheet) — only inline styles the
155
+ component emits directly are reliable. **Collapsible** therefore injects the `line-clamp-{n}` rules
156
+ Tailwind would generate (via `beforeAll` + a `<style>` tag) so its genuine browser-only measurement
157
+ (`scrollHeight > clientHeight`) can run; everything else asserts `data-*`/class presence, never
158
+ computed external-class styles. (2) **Card**'s `horizontal` variant auto-switches to `vertical` below
159
+ `horizontalThreshold` (480px) — tests pass `horizontalThreshold={0}` to assert the raw variant.
160
+ (3) **IconSwap** omits the motion-dependent `300ms` duration assertion (no per-test
161
+ `prefers-reduced-motion` API — cf. the Skeleton decision); asserts `duration:0`/easing CSS vars instead.
48
162
  - **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
163
  - **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
164
  - **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.118.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",