@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.
- package/dist/actions/focus-trap.fixture.svelte +16 -0
- package/dist/actions/focus-trap.fixture.svelte.d.ts +7 -0
- package/dist/components/CommandMenu/CommandMenu.fixture.svelte +24 -0
- package/dist/components/CommandMenu/CommandMenu.fixture.svelte.d.ts +7 -0
- package/dist/components/CommandMenu/CommandMenu.svelte +10 -13
- package/dist/components/CommandMenu/_internal/command-menu-utils.d.ts +22 -0
- package/dist/components/CommandMenu/_internal/command-menu-utils.js +37 -0
- package/dist/components/DropdownMenu/DropdownMenu.svelte +8 -27
- package/dist/components/DropdownMenu/_internal/dropdown-menu-search.d.ts +21 -0
- package/dist/components/DropdownMenu/_internal/dropdown-menu-search.js +47 -0
- package/dist/components/Input/node_modules/.vite/vitest/d2a04d71301a8915217dd5faf81d12cffd6cd958/_svelte_metadata.json +1 -0
- package/dist/components/Input/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/_svelte_metadata.json +1 -0
- package/dist/components/ModalDialog/ModalDialog.fixture.svelte +19 -0
- package/dist/components/ModalDialog/ModalDialog.fixture.svelte.d.ts +4 -0
- package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte +20 -0
- package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte.d.ts +6 -0
- package/docs/component-testing/PROGRESS.md +92 -4
- package/package.json +1 -1
|
@@ -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,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} />
|
|
@@ -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
|
|
97
|
+
return renderOptionLabelOf(item, renderOptionLabel, itemIdPropName);
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
function sortFn(a: Item, b: Item) {
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
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,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>
|
|
@@ -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.
|