@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.
- package/CHANGELOG.md +29 -0
- package/dist/era-ui.css +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/ui/alert-dialog/alert-dialog-content.svelte +14 -1
- package/dist/ui/dialog/dialog-content.svelte +17 -3
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/tree/ancestor-path.d.ts +22 -0
- package/dist/ui/tree/ancestor-path.js +36 -0
- package/dist/ui/tree/context.svelte.d.ts +80 -0
- package/dist/ui/tree/context.svelte.js +94 -0
- package/dist/ui/tree/index.d.ts +4 -0
- package/dist/ui/tree/index.js +4 -0
- package/dist/ui/tree/tree-item.svelte +110 -0
- package/dist/ui/tree/tree-item.svelte.d.ts +11 -0
- package/dist/ui/tree/tree.svelte +140 -0
- package/dist/ui/tree/tree.svelte.d.ts +19 -0
- package/package.json +5 -4
- package/skill/SKILL.md +5 -5
|
@@ -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
|
|
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}
|
package/dist/ui/index.d.ts
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';
|
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,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": "
|
|
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 @
|
|
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
|
-
`@
|
|
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 '@
|
|
187
|
-
import * as Select from '@
|
|
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 `@
|
|
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
|