@signal9/era-ui 24.0.3 → 25.1.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.
@@ -39,20 +39,34 @@
39
39
  provider/axis-relay.svelte.ts. Portalled content is a child of <body>,
40
40
  so without this it inherits the page's density, surface, corners, font,
41
41
  motion and theme rather than the subtree's. -->
42
+ <!-- A HEIGHT CEILING, because a dialog taller than the viewport is not
43
+ degraded, it is unusable. Centred with -translate-y-1/2 and no ceiling, a
44
+ tall panel overflows in BOTH directions with nothing to scroll: a
45
+ consumer measured a 1642px dialog in a 900px viewport whose title, input
46
+ and submit button were all off-screen, with no scroll and no keyboard
47
+ route to the button. It typechecks, lints and builds perfectly happily.
48
+
49
+ Sheet.Content already had this exact treatment; Dialog and AlertDialog
50
+ never got it. 85dvh matches Sheet's, deliberately — one ceiling for every
51
+ floating panel, not three opinions.
52
+
53
+ The BODY scrolls, not the panel, so a long form's footer stays put
54
+ instead of scrolling away with the fields. -->
42
55
  <Dialog.Content
43
56
  bind:ref
44
57
  {...relay?.attrs}
45
58
  class={cn(
46
59
  relay?.schemeClass,
47
- 'fixed top-1/2 left-1/2 z-overlay flex w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col border border-divider bg-well shadow-lg glass-blur',
48
- header ? 'rounded-control' : 'gap-gutter rounded-bar p-card',
60
+ 'fixed top-1/2 left-1/2 z-overlay flex max-h-[85dvh] w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col border border-divider bg-well shadow-lg glass-blur',
61
+ // No header: the panel itself is the body, so the panel scrolls.
62
+ header ? 'rounded-control' : 'gap-gutter overflow-y-auto rounded-bar p-card',
49
63
  className
50
64
  )}
51
65
  {...restProps}
52
66
  >
53
67
  {#if header}
54
68
  {@render header()}
55
- <div class="flex min-h-0 flex-col gap-gutter p-card">
69
+ <div class="flex min-h-0 flex-1 flex-col gap-gutter overflow-y-auto p-card">
56
70
  {@render children?.()}
57
71
  </div>
58
72
  {:else}
@@ -41,6 +41,7 @@ export * as TimeRangeField from './time-range-field/index.js';
41
41
  export * as Tooltip from './tooltip/index.js';
42
42
  export * as ToggleGroup from './toggle-group/index.js';
43
43
  export * as Timeline from './timeline/index.js';
44
+ export * as Tree from './tree/index.js';
44
45
  export * as Toolbar from './toolbar/index.js';
45
46
  export { Bar } from './bar/index.js';
46
47
  export { Button } from './button/index.js';
package/dist/ui/index.js CHANGED
@@ -41,6 +41,7 @@ export * as TimeRangeField from './time-range-field/index.js';
41
41
  export * as Tooltip from './tooltip/index.js';
42
42
  export * as ToggleGroup from './toggle-group/index.js';
43
43
  export * as Timeline from './timeline/index.js';
44
+ export * as Tree from './tree/index.js';
44
45
  export * as Toolbar from './toolbar/index.js';
45
46
  export { Bar } from './bar/index.js';
46
47
  export { Button } from './button/index.js';
@@ -0,0 +1,22 @@
1
+ import type { TreeNode } from './context.svelte.js';
2
+ /**
3
+ * The ids of every ancestor of `id`, outermost first. Empty if not found.
4
+ *
5
+ * ITS OWN MODULE, WITH NO RUNES, on purpose. The whole claim of this function is
6
+ * that it needs no state, no context and no DOM — the commonest deep-link flow
7
+ * runs BEFORE a tree exists, turning a URL into the `expanded` array the
8
+ * component is handed on first render. Living in context.svelte.ts would have
9
+ * made it unimportable from a plain Node context (that file declares $state
10
+ * fields), and living in the barrel drags every .svelte component in with it.
11
+ * Same reasoning as docs/consumer-import.ts, which is standalone so the node doc
12
+ * builder can load it.
13
+ *
14
+ * A consumer previously had to construct a throwaway TreeState, assign nodes,
15
+ * call revealPath and discard the instance — "reaching around the component for
16
+ * something the component owns", as they put it. The giveaway is that the walk
17
+ * is pure over `nodes`.
18
+ *
19
+ * TreeState.revealPath is the stateful wrapper over this: one implementation,
20
+ * two surfaces.
21
+ */
22
+ export declare function ancestorPath(nodes: TreeNode[], id: string): string[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The ids of every ancestor of `id`, outermost first. Empty if not found.
3
+ *
4
+ * ITS OWN MODULE, WITH NO RUNES, on purpose. The whole claim of this function is
5
+ * that it needs no state, no context and no DOM — the commonest deep-link flow
6
+ * runs BEFORE a tree exists, turning a URL into the `expanded` array the
7
+ * component is handed on first render. Living in context.svelte.ts would have
8
+ * made it unimportable from a plain Node context (that file declares $state
9
+ * fields), and living in the barrel drags every .svelte component in with it.
10
+ * Same reasoning as docs/consumer-import.ts, which is standalone so the node doc
11
+ * builder can load it.
12
+ *
13
+ * A consumer previously had to construct a throwaway TreeState, assign nodes,
14
+ * call revealPath and discard the instance — "reaching around the component for
15
+ * something the component owns", as they put it. The giveaway is that the walk
16
+ * is pure over `nodes`.
17
+ *
18
+ * TreeState.revealPath is the stateful wrapper over this: one implementation,
19
+ * two surfaces.
20
+ */
21
+ export function ancestorPath(nodes, id) {
22
+ const path = [];
23
+ const find = (list, trail) => {
24
+ for (const n of list) {
25
+ if (n.id === id) {
26
+ path.push(...trail);
27
+ return true;
28
+ }
29
+ if (n.children?.length && find(n.children, [...trail, n.id]))
30
+ return true;
31
+ }
32
+ return false;
33
+ };
34
+ find(nodes, []);
35
+ return path;
36
+ }
@@ -0,0 +1,80 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ /**
3
+ * A disclosure hierarchy with real tree semantics.
4
+ *
5
+ * WHY THIS IS A PRIMITIVE AND NOT APP COMPOSITION. A tree looks like nested
6
+ * Collapsibles and is not one. What separates them is the keyboard model:
7
+ * exactly ONE node in the whole tree is tabbable, arrow keys move between
8
+ * VISIBLE nodes across nesting levels, and left/right collapse and expand rather
9
+ * than navigate. Nested Collapsibles give you a tab stop per node and no
10
+ * cross-level movement — which is a different control that happens to look the
11
+ * same, and the difference is invisible until someone uses a keyboard.
12
+ *
13
+ * That is precisely the kind of thing a library should own once instead of
14
+ * having every consumer rediscover it, so bits-ui not shipping one is a gap era
15
+ * fills rather than a signal that it does not belong.
16
+ *
17
+ * SELECTION IS NOT EXPANSION, and they are separate props for that reason. In a
18
+ * catalog browser, clicking a branch selects it (its detail renders elsewhere)
19
+ * without necessarily opening it, and the twisty opens it without selecting.
20
+ * Collapsing them into one piece of state is the mistake that makes a tree
21
+ * unable to show a selected-but-collapsed ancestor.
22
+ */
23
+ export interface TreeNode {
24
+ /** Stable identity. This is what `expanded` and `selected` refer to. */
25
+ id: string;
26
+ label: string;
27
+ children?: TreeNode[];
28
+ /** Present but empty = a branch that has not loaded yet; absent = a leaf. */
29
+ disabled?: boolean;
30
+ }
31
+ /** A node flattened into render order, with the depth it sits at. */
32
+ export interface FlatNode {
33
+ node: TreeNode;
34
+ depth: number;
35
+ /** Has children (or is a not-yet-loaded branch). */
36
+ branch: boolean;
37
+ expanded: boolean;
38
+ parentId: string | null;
39
+ }
40
+ export declare class TreeState {
41
+ nodes: TreeNode[];
42
+ expanded: SvelteSet<string>;
43
+ selected: string | null;
44
+ /** The one tabbable node — the roving tabindex. */
45
+ active: string | null;
46
+ onExpandedChange?: (ids: string[]) => void;
47
+ onSelect?: (id: string) => void;
48
+ /**
49
+ * VISIBLE nodes in render order, which is also keyboard order.
50
+ *
51
+ * The whole tree can be handed to `nodes`; only what is open gets flattened.
52
+ * Measured in the field on the catalog this was built for: 1,196 nodes in,
53
+ * ~58 visible, recomputed per render, comfortable with no windowing. That is
54
+ * a real number from a consumer rather than an estimate — their earlier cap
55
+ * of 162 was an artifact of the hand-rolled sidebar they replaced, which
56
+ * could only mount one family at a time.
57
+ *
58
+ * Arrow keys move through what is on screen, not through the data — a
59
+ * collapsed branch's children are not reachable by ↓ and must not be, or the
60
+ * focus ring disappears into a closed subtree.
61
+ */
62
+ get visible(): FlatNode[];
63
+ /** The node the roving tabindex sits on, defaulting to the first visible. */
64
+ get activeId(): string | null;
65
+ setExpanded(id: string, open: boolean): void;
66
+ toggle(id: string): void;
67
+ select(id: string): void;
68
+ /**
69
+ * Open every ancestor of `id` so a deep link lands on a visible node.
70
+ *
71
+ * Deep-linking is why `expanded` is controllable at all: a URL like
72
+ * /sp800-53r5/AC/ac-2.1 has to open AC and AC-2 before AC-2(1) exists on
73
+ * screen. This is the stateful half — it mutates `expanded` and notifies.
74
+ * The path computation itself is {@link ancestorPath}, which needs no state
75
+ * and is exported for callers who only want the ids.
76
+ */
77
+ revealPath(id: string): string[];
78
+ }
79
+ export declare function setTreeState(state: TreeState): TreeState;
80
+ export declare function getTreeState(): TreeState | null;
@@ -0,0 +1,94 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+ import { ancestorPath } from './ancestor-path.js';
4
+ export class TreeState {
5
+ nodes = $state([]);
6
+ // SvelteSet, so membership reads are tracked per-key rather than the whole
7
+ // set being replaced on every toggle — which matters at catalog scale, where
8
+ // one expand should not invalidate 1,196 rows.
9
+ expanded = new SvelteSet();
10
+ selected = $state(null);
11
+ /** The one tabbable node — the roving tabindex. */
12
+ active = $state(null);
13
+ onExpandedChange;
14
+ onSelect;
15
+ /**
16
+ * VISIBLE nodes in render order, which is also keyboard order.
17
+ *
18
+ * The whole tree can be handed to `nodes`; only what is open gets flattened.
19
+ * Measured in the field on the catalog this was built for: 1,196 nodes in,
20
+ * ~58 visible, recomputed per render, comfortable with no windowing. That is
21
+ * a real number from a consumer rather than an estimate — their earlier cap
22
+ * of 162 was an artifact of the hand-rolled sidebar they replaced, which
23
+ * could only mount one family at a time.
24
+ *
25
+ * Arrow keys move through what is on screen, not through the data — a
26
+ * collapsed branch's children are not reachable by ↓ and must not be, or the
27
+ * focus ring disappears into a closed subtree.
28
+ */
29
+ get visible() {
30
+ const out = [];
31
+ const walk = (list, depth, parentId) => {
32
+ for (const node of list) {
33
+ const branch = node.children !== undefined;
34
+ const expanded = this.expanded.has(node.id);
35
+ out.push({ node, depth, branch, expanded, parentId });
36
+ if (branch && expanded && node.children?.length) {
37
+ walk(node.children, depth + 1, node.id);
38
+ }
39
+ }
40
+ };
41
+ walk(this.nodes, 0, null);
42
+ return out;
43
+ }
44
+ /** The node the roving tabindex sits on, defaulting to the first visible. */
45
+ get activeId() {
46
+ const vis = this.visible;
47
+ if (this.active && vis.some((v) => v.node.id === this.active))
48
+ return this.active;
49
+ return this.selected && vis.some((v) => v.node.id === this.selected)
50
+ ? this.selected
51
+ : (vis[0]?.node.id ?? null);
52
+ }
53
+ setExpanded(id, open) {
54
+ if (open)
55
+ this.expanded.add(id);
56
+ else
57
+ this.expanded.delete(id);
58
+ this.onExpandedChange?.([...this.expanded]);
59
+ }
60
+ toggle(id) {
61
+ this.setExpanded(id, !this.expanded.has(id));
62
+ }
63
+ select(id) {
64
+ this.selected = id;
65
+ this.active = id;
66
+ this.onSelect?.(id);
67
+ }
68
+ /**
69
+ * Open every ancestor of `id` so a deep link lands on a visible node.
70
+ *
71
+ * Deep-linking is why `expanded` is controllable at all: a URL like
72
+ * /sp800-53r5/AC/ac-2.1 has to open AC and AC-2 before AC-2(1) exists on
73
+ * screen. This is the stateful half — it mutates `expanded` and notifies.
74
+ * The path computation itself is {@link ancestorPath}, which needs no state
75
+ * and is exported for callers who only want the ids.
76
+ */
77
+ revealPath(id) {
78
+ const path = ancestorPath(this.nodes, id);
79
+ if (path.length) {
80
+ for (const p of path)
81
+ this.expanded.add(p);
82
+ this.onExpandedChange?.([...this.expanded]);
83
+ }
84
+ return path;
85
+ }
86
+ }
87
+ const KEY = Symbol('era.tree');
88
+ export function setTreeState(state) {
89
+ setContext(KEY, state);
90
+ return state;
91
+ }
92
+ export function getTreeState() {
93
+ return getContext(KEY) ?? null;
94
+ }
@@ -0,0 +1,4 @@
1
+ export { default as Root } from './tree.svelte';
2
+ export { default as Item } from './tree-item.svelte';
3
+ export { TreeState, type TreeNode, type FlatNode } from './context.svelte.js';
4
+ export { ancestorPath } from './ancestor-path.js';
@@ -0,0 +1,4 @@
1
+ export { default as Root } from './tree.svelte';
2
+ export { default as Item } from './tree-item.svelte';
3
+ export { TreeState } from './context.svelte.js';
4
+ export { ancestorPath } from './ancestor-path.js';
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
4
+ import { cn } from '../../utils/index.js';
5
+ import { getTreeState, type FlatNode } from './context.svelte.js';
6
+
7
+ let {
8
+ item,
9
+ trailing,
10
+ class: className
11
+ }: {
12
+ item: FlatNode;
13
+ /** Rendered at the end of the row — a count badge, a status dot. */
14
+ trailing?: Snippet<[FlatNode]>;
15
+ class?: string;
16
+ } = $props();
17
+
18
+ const tree = getTreeState();
19
+ const selected = $derived(tree?.selected === item.node.id);
20
+ const active = $derived(tree?.activeId === item.node.id);
21
+ </script>
22
+
23
+ <!--
24
+ role="treeitem" with aria-level/expanded/selected, and the ROVING TABINDEX:
25
+ exactly one node in the tree is tabbable, so Tab moves past the whole tree the
26
+ way it moves past one control, and arrows move within it. A tab stop per node
27
+ is what nested Collapsibles give you, and it makes a 1,196-node catalog take
28
+ 1,196 tabs to step over.
29
+
30
+ The indent is padding on the row, not a spacer element, so the whole row —
31
+ including the indent — stays one hit target and one highlight.
32
+
33
+ The wall is icon-inset-control, NOT the gutter. A control-tier row holding an
34
+ icon-tier glyph centres it (h-control − h-icon)/2 = 5px vertically, so 5px is
35
+ what the sides owe. Reaching for the gutter (4px) left the row 4 at the sides
36
+ against 5 top and bottom — the same mistake Bar made, caught here by
37
+ layout/wall-is-isotropic, which exists because of it.
38
+
39
+ Enter and Space select the focused node, here. Navigation (arrows, Home/End)
40
+ is handled on the ROOT: it moves BETWEEN nodes, and only the container knows
41
+ the visible order — a per-item handler would have to re-derive the whole
42
+ flattened tree just to find its own neighbour.
43
+ -->
44
+ <li
45
+ role="treeitem"
46
+ aria-level={item.depth + 1}
47
+ aria-expanded={item.branch ? item.expanded : undefined}
48
+ aria-selected={selected}
49
+ aria-disabled={item.node.disabled || undefined}
50
+ tabindex={active ? 0 : -1}
51
+ data-tree-item
52
+ data-id={item.node.id}
53
+ data-selected={selected || undefined}
54
+ data-depth={item.depth}
55
+ style="padding-left: calc(var(--spacing-gutter) + {item.depth} * var(--era-h-control) / 2)"
56
+ class={cn(
57
+ 'flex h-control era-interactive cursor-default items-center gap-gutter rounded-control pr-icon-inset-control text-body',
58
+ 'hover:bg-highlight focus:outline-none focus-visible:bg-highlight',
59
+ selected ? 'bg-fill text-bright' : 'text-fg',
60
+ item.node.disabled && 'pointer-events-none opacity-50',
61
+ className
62
+ )}
63
+ onclick={(e) => {
64
+ e.stopPropagation();
65
+ tree?.select(item.node.id);
66
+ }}
67
+ onkeydown={(e) => {
68
+ if (e.key === 'Enter' || e.key === ' ') {
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ tree?.select(item.node.id);
72
+ }
73
+ }}
74
+ >
75
+ <!--
76
+ The twisty is a SEPARATE hit target from the row, which is the whole point
77
+ of keeping selection and expansion apart: clicking the label selects
78
+ without opening, clicking the chevron opens without selecting. Which is
79
+ why it carries its OWN hover: a target that behaves differently from the
80
+ row it sits in has to say so under the cursor, or the two affordances are
81
+ indistinguishable until you click one. Caught by the surface sweep, which
82
+ hovers every button on the board and found this one inert. A leaf gets
83
+ an empty box of the same size so labels stay on one vertical line — an
84
+ indent that jitters by a glyph width per level is worse than no chevron.
85
+ -->
86
+ {#if item.branch}
87
+ <button
88
+ type="button"
89
+ tabindex="-1"
90
+ aria-hidden="true"
91
+ class="flex size-icon shrink-0 era-interactive items-center justify-center text-muted hover:text-bright"
92
+ onclick={(e) => {
93
+ e.stopPropagation();
94
+ tree?.toggle(item.node.id);
95
+ }}
96
+ >
97
+ <ChevronRight
98
+ class={cn(
99
+ 'size-icon transition-transform duration-base ease-base',
100
+ item.expanded && 'rotate-90'
101
+ )}
102
+ />
103
+ </button>
104
+ {:else}
105
+ <span class="size-icon shrink-0" aria-hidden="true"></span>
106
+ {/if}
107
+
108
+ <span class="min-w-0 flex-1 truncate era-text-trim">{item.node.label}</span>
109
+ {#if trailing}{@render trailing(item)}{/if}
110
+ </li>
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type FlatNode } from './context.svelte.js';
3
+ type $$ComponentProps = {
4
+ item: FlatNode;
5
+ /** Rendered at the end of the row — a count badge, a status dot. */
6
+ trailing?: Snippet<[FlatNode]>;
7
+ class?: string;
8
+ };
9
+ declare const TreeItem: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type TreeItem = ReturnType<typeof TreeItem>;
11
+ export default TreeItem;
@@ -0,0 +1,140 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../../utils/index.js';
5
+ import { TreeState, setTreeState, type FlatNode, type TreeNode } from './context.svelte.js';
6
+ import Item from './tree-item.svelte';
7
+
8
+ let {
9
+ ref = $bindable(null),
10
+ nodes = [],
11
+ expanded = $bindable<string[]>([]),
12
+ selected = $bindable<string | null>(null),
13
+ label = 'Tree',
14
+ item,
15
+ trailing,
16
+ class: className,
17
+ ...restProps
18
+ }: HTMLAttributes<HTMLUListElement> & {
19
+ ref?: HTMLUListElement | null;
20
+ nodes?: TreeNode[];
21
+ /** Controlled: bind it, or drive it from the URL. */
22
+ expanded?: string[];
23
+ selected?: string | null;
24
+ /** Accessible name for the tree landmark. */
25
+ label?: string;
26
+ /** Render your own row. Omitted, the default row is used. */
27
+ item?: Snippet<[FlatNode]>;
28
+ /** End-of-row slot on the default row — a count badge, a status dot. */
29
+ trailing?: Snippet<[FlatNode]>;
30
+ } = $props();
31
+
32
+ const tree = setTreeState(new TreeState());
33
+ $effect(() => {
34
+ tree.nodes = nodes;
35
+ });
36
+ $effect(() => {
37
+ // Reconcile in place rather than replacing the set: the prop is the source
38
+ // of truth, but swapping the instance would detach every membership read
39
+ // the rows have already taken.
40
+ const want = new Set(expanded);
41
+ for (const id of tree.expanded) if (!want.has(id)) tree.expanded.delete(id);
42
+ for (const id of want) tree.expanded.add(id);
43
+ });
44
+ $effect(() => {
45
+ tree.selected = selected;
46
+ });
47
+ tree.onExpandedChange = (ids) => (expanded = ids);
48
+ tree.onSelect = (id) => (selected = id);
49
+
50
+ /**
51
+ * The keyboard model, which is the reason this is a component.
52
+ *
53
+ * It lives on the ROOT because every one of these keys moves BETWEEN nodes,
54
+ * and only the container knows the visible order — the flattened list that
55
+ * skips collapsed subtrees. An item cannot answer "what is below me" without
56
+ * rebuilding that list for itself.
57
+ *
58
+ * Right/Left are asymmetric on purpose, and this is the part hand-rolled
59
+ * trees get wrong: Right on a COLLAPSED branch opens it and stays put, and on
60
+ * an already-open branch descends to the first child. Left on an OPEN branch
61
+ * closes it; on a leaf or a closed branch it jumps to the parent. That is
62
+ * what makes a keyboard user able to climb out of a deep subtree without
63
+ * pressing Up past every sibling.
64
+ */
65
+ function onkeydown(e: KeyboardEvent) {
66
+ const vis = tree.visible;
67
+ if (!vis.length) return;
68
+ const id = tree.activeId;
69
+ const i = vis.findIndex((v) => v.node.id === id);
70
+ if (i < 0) return;
71
+ const cur = vis[i];
72
+
73
+ const focus = (nextId: string | null | undefined) => {
74
+ if (!nextId) return;
75
+ tree.active = nextId;
76
+ // The roving tabindex has to be followed by real DOM focus, or the
77
+ // browser keeps the focus ring on the node we just moved away from.
78
+ queueMicrotask(() => {
79
+ const el = ref?.querySelector<HTMLElement>(
80
+ `[data-tree-item][data-id="${CSS.escape(nextId)}"]`
81
+ );
82
+ el?.focus();
83
+ });
84
+ };
85
+
86
+ switch (e.key) {
87
+ case 'ArrowDown':
88
+ e.preventDefault();
89
+ focus(vis[Math.min(i + 1, vis.length - 1)]?.node.id);
90
+ break;
91
+ case 'ArrowUp':
92
+ e.preventDefault();
93
+ focus(vis[Math.max(i - 1, 0)]?.node.id);
94
+ break;
95
+ case 'ArrowRight':
96
+ e.preventDefault();
97
+ if (cur.branch && !cur.expanded) tree.setExpanded(cur.node.id, true);
98
+ else if (cur.branch && cur.expanded) focus(vis[i + 1]?.node.id);
99
+ break;
100
+ case 'ArrowLeft':
101
+ e.preventDefault();
102
+ if (cur.branch && cur.expanded) tree.setExpanded(cur.node.id, false);
103
+ else focus(cur.parentId);
104
+ break;
105
+ case 'Home':
106
+ e.preventDefault();
107
+ focus(vis[0]?.node.id);
108
+ break;
109
+ case 'End':
110
+ e.preventDefault();
111
+ focus(vis[vis.length - 1]?.node.id);
112
+ break;
113
+ }
114
+ }
115
+ </script>
116
+
117
+ <!--
118
+ role="tree" over a <ul> of role="treeitem". The nesting is expressed with
119
+ aria-level rather than nested <ul role="group">, because the rows are rendered
120
+ FLAT: a flattened list is what makes arrow-key order and DOM order the same
121
+ thing, and it keeps a 1,196-node catalog from building a matching depth of
122
+ nested elements. aria-level carries the depth that the DOM no longer does.
123
+ -->
124
+ <ul
125
+ bind:this={ref}
126
+ role="tree"
127
+ aria-label={label}
128
+ aria-multiselectable="false"
129
+ {onkeydown}
130
+ class={cn('flex flex-col', className)}
131
+ {...restProps}
132
+ >
133
+ {#each tree.visible as v (v.node.id)}
134
+ {#if item}
135
+ {@render item(v)}
136
+ {:else}
137
+ <Item item={v} {trailing} />
138
+ {/if}
139
+ {/each}
140
+ </ul>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type FlatNode, type TreeNode } from './context.svelte.js';
4
+ type $$ComponentProps = HTMLAttributes<HTMLUListElement> & {
5
+ ref?: HTMLUListElement | null;
6
+ nodes?: TreeNode[];
7
+ /** Controlled: bind it, or drive it from the URL. */
8
+ expanded?: string[];
9
+ selected?: string | null;
10
+ /** Accessible name for the tree landmark. */
11
+ label?: string;
12
+ /** Render your own row. Omitted, the default row is used. */
13
+ item?: Snippet<[FlatNode]>;
14
+ /** End-of-row slot on the default row — a count badge, a status dot. */
15
+ trailing?: Snippet<[FlatNode]>;
16
+ };
17
+ declare const Tree: import("svelte").Component<$$ComponentProps, {}, "ref" | "expanded" | "selected">;
18
+ type Tree = ReturnType<typeof Tree>;
19
+ export default Tree;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "24.0.3",
3
+ "version": "25.1.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",
@@ -25,13 +25,14 @@
25
25
  "check:classes": "node --experimental-strip-types scripts/check-classes.ts",
26
26
  "check:twmerge": "node --experimental-strip-types scripts/check-twmerge.ts",
27
27
  "build:llm-docs": "node --experimental-strip-types scripts/build-llm-docs.ts",
28
- "prebuild": "npm run check:surfaces && npm run check:tokens && npm run check:routes && npm run check:canonical && npm run check:layers && npm run check:themes && npm run check:classes && npm run check:twmerge && npm run check:doc-tokens && npm run check:audit-controls && npm run check:specimen-coverage && npm run check:eslint-rules && npm run build:llm-docs",
29
- "validate": "npm run check && npm run lint && npm run format:check && npm run check:surfaces && npm run check:tokens && npm run check:routes && npm run check:canonical && npm run check:layers && npm run check:themes && npm run check:classes && npm run check:twmerge && npm run check:doc-tokens && npm run check:audit-controls && npm run check:specimen-coverage && npm run check:eslint-rules",
28
+ "prebuild": "npm run check:surfaces && npm run check:tokens && npm run check:routes && npm run check:canonical && npm run check:layers && npm run check:themes && npm run check:classes && npm run check:twmerge && npm run check:doc-tokens && npm run check:audit-controls && npm run check:specimen-coverage && npm run check:eslint-rules && npm run check:package-name && npm run build:llm-docs",
29
+ "validate": "npm run check && npm run lint && npm run format:check && npm run check:surfaces && npm run check:tokens && npm run check:routes && npm run check:canonical && npm run check:layers && npm run check:themes && npm run check:classes && npm run check:twmerge && npm run check:doc-tokens && npm run check:audit-controls && npm run check:specimen-coverage && npm run check:eslint-rules && npm run check:package-name",
30
30
  "test": "npm run test:visual",
31
31
  "check:doc-tokens": "node --experimental-strip-types scripts/check-doc-tokens.ts",
32
32
  "check:audit-controls": "node --experimental-strip-types scripts/check-audit-controls.ts",
33
33
  "check:specimen-coverage": "node --experimental-strip-types scripts/check-specimen-coverage.ts",
34
- "check:eslint-rules": "node --experimental-strip-types scripts/check-eslint-rules.ts"
34
+ "check:eslint-rules": "node --experimental-strip-types scripts/check-eslint-rules.ts",
35
+ "check:package-name": "node --experimental-strip-types scripts/check-package-name.ts"
35
36
  },
36
37
  "files": [
37
38
  "dist",
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: era-ui
3
3
  description: |
4
- Design-system methodology for building UIs with @sig-nine/era-ui (Svelte 5 +
4
+ Design-system methodology for building UIs with @signal9/era-ui (Svelte 5 +
5
5
  Tailwind v4). Use when: writing or reviewing any component, layout, or page
6
6
  in a project that uses era-ui — sizing controls, spacing/padding, radii,
7
7
  surfaces, motion, or composing library components. Covers: the named class
@@ -167,7 +167,7 @@ Not everything era exposes is a component — these ride along with the styleshe
167
167
  and need no import. Reach for one before hand-rolling the same styling or bending
168
168
  a component into the role. Full reference (including every named scale above):
169
169
  `/utilities.md`; machine-readable list: `/utilities.json` (or
170
- `@sig-nine/era-ui/utilities.json`).
170
+ `@signal9/era-ui/utilities.json`).
171
171
 
172
172
  | Class | Use it for |
173
173
  | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
@@ -183,8 +183,8 @@ a component into the role. Full reference (including every named scale above):
183
183
 
184
184
  ```svelte
185
185
  <script lang="ts">
186
- import { Button, Badge, OS, AI } from '@sig-nine/era-ui';
187
- import * as Select from '@sig-nine/era-ui'; // namespaced parts: Select.Root…
186
+ import { Button, Badge, OS, AI } from '@signal9/era-ui';
187
+ import * as Select from '@signal9/era-ui'; // namespaced parts: Select.Root…
188
188
  </script>
189
189
  ```
190
190
 
@@ -192,7 +192,7 @@ a component into the role. Full reference (including every named scale above):
192
192
  through; style via `class` (merged with tailwind-merge, consumer wins — the
193
193
  named classes conflict-resolve against internals, so `class="h-bar"` on a
194
194
  control-tier control just works).
195
- - `OS` (or `@sig-nine/era-ui/os`): WindowManager + workspaces, Desktop,
195
+ - `OS` (or `@signal9/era-ui/os`): WindowManager + workspaces, Desktop,
196
196
  Window, Taskbar (built-in command bar). `AI` (or `/ai`): Conversation,
197
197
  Message, Response, PromptInput, Tool/Reasoning/Task over the Step primitive.
198
198
  - List-item keyboard highlight is `data-[highlighted]:bg-hover` — never