Complete component, green on all static gates (audit PASS, eidos-lint 20/0, svelte-check 0, contracts clean for its parts). NOT tree-view: APG Disclosure Navigation (<nav> landmark + native links + disclosure groups), data-driven (E-1) — the app passes `nodes` + `activeHref`, the provider resolves the active node and auto-expands its ancestor trail. - morfo: nav/list/item/trigger/link/group; emerge-expand/collapse events - soma: NavTreeProvider (shared state, pure isExpanded query) + per-node NavTreeItemProvider (menubar pattern, recursive render) - sema pack: soft emerge on group disclosure (mirrors collapsible) - eidos: rail + per-depth indent recipe; own bespoke rows (not composed Link), Badge deferred to a Gap; aria-current="page" from data-active Remaining before F1.8 closes: demo + browser verify + adversarial review. Handoff: docs/process/CONTINUE-nav-tree.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
931a98b7c4
commit
e835163103
@ -0,0 +1,92 @@
|
||||
# CONTINUE — F1.8 `nav-tree` (handoff 2026-07-22)
|
||||
|
||||
Estado al parar: **componente COMPLETO y en verde en todos los gates
|
||||
estáticos; falta demo + verificación en navegador + review adversarial +
|
||||
commit final + registro**. Plan maestro: `docs/process/PLAN-blocks.md` §F1.8.
|
||||
|
||||
## Qué es (decisiones ya tomadas y ratificadas en fase 0)
|
||||
|
||||
Árbol de navegación **data-driven** (E-1) para docs-shell: el app pasa
|
||||
`nodes` (datos) + `activeHref` (su URL); el componente resuelve el nodo activo,
|
||||
deriva el trail de ancestros y **auto-expande** el trail. APG **Disclosure
|
||||
Navigation** (landmark `<nav>` + links nativos + botones disclosure), NO
|
||||
`tree-view` (que es un WIDGET de selección `role=tree`).
|
||||
|
||||
**Frontera vs `tree-view`**: rol + interacción + modelo de datos distintos →
|
||||
componente separado, no extensión. (Scope-approval cerrado.)
|
||||
|
||||
**Decisiones de diseño** (todas implementadas):
|
||||
- **Disclosure propio** (no compone Collapsible): el estado `open` es un getter
|
||||
de soma (el expand-set) que alimenta `aria-expanded`/`data-state`; el toggle
|
||||
dispara `emerge-expand`/`emerge-collapse` vía `runtime.trigger` (sema +
|
||||
`setExpanded`). NO hay máquina de eventos que sostenga el estado.
|
||||
- **`emerge` + pack de sema**: corregido en fase 5 — el audit A-3.1 exige
|
||||
eventos en un componente interactivo; el toggle de disclosure ES un `emerge`
|
||||
canónico (espeja collapsible/tree-view). scope = `['soma','sema','eidos']`.
|
||||
- **Filas propias** (`<a>`/`<button>` estilizados por el recipe), NO `Link`/
|
||||
`Button` compuestos — como anchor-nav; el contrato a11y es de nav-tree.
|
||||
- **`isExpanded` es query PURA** (sin `$effect` que escriba estado): abierto si
|
||||
no-colapsado-por-usuario Y (expandido-por-usuario O en-trail O `defaultOpen`).
|
||||
- **Profundidad = CSS var** `--_nav-tree-depth` (no attr por nivel).
|
||||
- **Nodo padre navegable** = link + chevron (patrón Fumadocs); sin href = botón.
|
||||
|
||||
## Ficheros (todos escritos, type-clean)
|
||||
|
||||
- Morfo: `src/uix/morfo/components/nav-tree.ts` (6 partes; eventos emerge;
|
||||
`expression:'pack'`).
|
||||
- Soma: `src/uix/soma/components/nav-tree/` — `nav-tree-provider.svelte.ts`
|
||||
(`NavTreeProvider` raíz + `NavTreeItemProvider` por nodo), `types.ts`,
|
||||
`langs.ts`, `components/nav-tree.svelte` (Provider), `components/nav-tree-node.svelte`
|
||||
(recursivo), `exports.ts`, `index.ts`, `README.md`.
|
||||
- Eidos: `src/uix/eidos/components/nav-tree/` — `nav-tree.svelte` (forward),
|
||||
`nav-tree.css` (recipe), `types.ts`, `index.ts`, `README.md`.
|
||||
- Sema pack: `src/uix/sema/components/nav-tree.ts`.
|
||||
- Langs: `src/uix/langs/components/nav-tree.ts`.
|
||||
- Registros: `langs/components/index.ts`, `soma/components/index.ts`,
|
||||
`sema/components/index.ts`, `eidos/lib/recipes/base.ts` (token `indent`),
|
||||
`eidos/generated/base.css` (generado), `web/routes/uix/+layout@.svelte`
|
||||
(pack `navTreeSema` registrado).
|
||||
|
||||
## Gates que YA pasan
|
||||
|
||||
- `npx tsx scripts/component-audit.ts --only nav-tree` → **PASS**.
|
||||
- `npx tsx scripts/eidos-lint.ts nav-tree` → 20 morfo-backed, 0 invalid.
|
||||
- `npx svelte-check` → 0 errores en nav-tree + pack.
|
||||
- `npx vitest run src/uix/contracts.test.ts` → mis partes limpias.
|
||||
⚠️ Quedan **2 fallos AJENOS** (menubar-provider DOM-write · radio-group
|
||||
`data-ready`) de sesiones paralelas SIN commitear — NO son de nav-tree.
|
||||
|
||||
## Qué FALTA (orden para mañana)
|
||||
|
||||
1. **Demo** `web/routes/uix/components/nav-tree/+page.svelte` — canónica, misma
|
||||
profundidad que anchor-nav (9 pestañas: live/system/motion/sema/services/
|
||||
api/morfo/recipe/a11y; harness `SystemAxes`/`MotionPanel`/`SemaPanel`/
|
||||
`DemoTrace`; `type Tab` en UNA línea con `// prettier-ignore`). Contenido
|
||||
live: un árbol REAL de ~40 nodos y 3 niveles (p. ej. el mapa de `docs/`),
|
||||
control de `activeHref` (select o los propios links moviéndolo), trail vivo.
|
||||
El pack de sema YA está registrado en el layout.
|
||||
2. **Entrada de nav**: añadir `nav-tree` a la lista de componentes en
|
||||
`web/routes/uix/+layout@.svelte` (donde se añadió `anchor-nav`).
|
||||
3. **Verificar en NAVEGADOR** (obligatorio, es UI): árbol renderiza; trail
|
||||
auto-expandido al cambiar `activeHref`; chevron rota en `data-state=open`;
|
||||
link activo toma color primary + peso; grupo colapsado = `hidden`;
|
||||
`aria-current="page"` + `aria-expanded`/`aria-controls` correctos; DARK
|
||||
(`colorScheme:'dark'` vía Playwright) y RTL. ⚠️ El Browser pane SUSPENDIDO
|
||||
da `getComputedStyle` de color OBSOLETO (lección anchor-nav) — si el color
|
||||
activo se ve mal, verifícalo en chromium foreground, NO es bug.
|
||||
4. **Review adversarial** (workflow, como sticky/anchor-nav): dimensiones
|
||||
correctness-scrollspy/trail · reactividad-lifecycle · morfo-a11y ·
|
||||
css-recipe. Verificador escéptico por hallazgo. Arregla confirmados.
|
||||
5. **Commit final + registro** en `PLAN-blocks.md` §7 (F1.8 HECHA) + memoria
|
||||
`project_blocks_tier_2026-07-21.md` (F1 = 7/8) + `MEMORY.md`.
|
||||
|
||||
## Gotchas / lecciones de esta tanda
|
||||
|
||||
- **Correr `contracts.test.ts`**, no solo `npm run check` — destapa READMEs de
|
||||
soma faltantes (le pasó a anchor-nav) y attrs/DOM-writes hardcodeados.
|
||||
- `aria-expanded` necesita `stateRef` (da `'true'/'false'`); un `propRef`
|
||||
booleano NO (el resolver solo convierte booleanos en aria para `stateRef`).
|
||||
- El estado de un part se puede alimentar con un getter (`states:{open:()=>…}`)
|
||||
sin evento; los eventos emerge son para la sema (espeja collapsible).
|
||||
- `resolveProps(bindings)` es puro pero NO incluye el marker/id; el patrón
|
||||
data-driven blessed es **providers per-nodo con `renderProps()`** (menubar).
|
||||
@ -0,0 +1,16 @@
|
||||
// NavTree — data-driven hierarchical navigation tree (docs page trees).
|
||||
//
|
||||
// import { NavTree } from '$uix/eidos/components/nav-tree';
|
||||
// import type { NavTreeNode } from '$uix/eidos/components/nav-tree';
|
||||
//
|
||||
// const nodes: NavTreeNode[] = [
|
||||
// { label: 'Getting started', href: '/start' },
|
||||
// { label: 'Guides', children: [{ label: 'Theming', href: '/guides/theming' }] }
|
||||
// ];
|
||||
//
|
||||
// <NavTree {nodes} activeHref={page.url.pathname} />
|
||||
import NavTree from './nav-tree.svelte';
|
||||
|
||||
export { NavTree };
|
||||
export default NavTree;
|
||||
export type { NavTreeProps, NavTreeNode } from './types';
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<NavTree>` — the docs rail recipe over Soma's data-driven
|
||||
* navigation tree. Thin: it brings the CSS (per-depth indent, active
|
||||
* segment, expanded chevron, active-trail rail) and forwards every prop to
|
||||
* `NavTree.Provider`, which owns the tree, the active-trail and the
|
||||
* disclosure state.
|
||||
*
|
||||
* <NavTree nodes={docsTree} activeHref={page.url.pathname} />
|
||||
*
|
||||
* `nodes` is the tree as data; `activeHref` is the app's current URL (the
|
||||
* component matches it — it never reads the router).
|
||||
*/
|
||||
import './nav-tree.css';
|
||||
import * as NavTree from '$soma/components/nav-tree';
|
||||
import type { NavTreeProps } from './types';
|
||||
|
||||
let { child, ...rest }: NavTreeProps = $props();
|
||||
</script>
|
||||
|
||||
<NavTree.Provider {...rest} {child} />
|
||||
@ -0,0 +1,11 @@
|
||||
import type { ProviderProps as SomaNavTreeProps, NavTreeNode } from '$soma/components/nav-tree';
|
||||
|
||||
export type { NavTreeNode };
|
||||
|
||||
/**
|
||||
* NavTree is behavioral: the eidos layer adds only the rail + indent recipe
|
||||
* (depth offset, active/expanded/trail states) and passes every prop through
|
||||
* to the soma provider, which owns the data-driven tree, the active-trail and
|
||||
* the disclosure state.
|
||||
*/
|
||||
export type NavTreeProps = SomaNavTreeProps;
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const navTreeLangs = {
|
||||
label: {
|
||||
es: 'Navegación',
|
||||
en: 'Navigation'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,167 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* NavTree — a data-driven hierarchical NAVIGATION tree (docs page trees, app
|
||||
* section maps). The app passes the tree as data (`nodes`); the soma computes
|
||||
* the active trail from an `activeHref` seam (it NEVER knows the router),
|
||||
* auto-expands the ancestors of the active node, and the eidos renders the
|
||||
* nodes recursively. Built as F1.8 of the blocks tier — the deep left rail of
|
||||
* the docs-shell (F4). Reference floor: `RESEARCH-blocks-references.md` §P5.
|
||||
*
|
||||
* NOT `tree-view`. `tree-view` is an APG Tree View WIDGET (`role=tree`,
|
||||
* roving arrows, Enter/Space selection, compositional). NavTree is APG
|
||||
* Disclosure Navigation: a `<nav>` landmark of native links + disclosure
|
||||
* buttons. You NAVIGATE it (follow a link), you don't SELECT in it. The
|
||||
* frontier is role + interaction + data model, so it is a distinct component,
|
||||
* not an extension (scope-approval, 2026-07-22).
|
||||
*
|
||||
* A11y leadership (dossier §P5): shadcn/Radix/Base ship no `<nav>` landmark and
|
||||
* no `aria-current`. NavTree stamps `aria-current="page"` on the current link
|
||||
* from the SAME prop that stamps `data-active`, inside a named `<nav>`
|
||||
* landmark, with disclosure buttons carrying `aria-expanded` / `aria-controls`.
|
||||
*
|
||||
* Membership: soma (active-trail + expand state + scroll-into-view) + sema
|
||||
* (emerge on group expand/collapse — the canonical disclosure signature,
|
||||
* mirrors collapsible / tree-view) + eidos (indent / active+expanded+trail
|
||||
* states). Navigation itself is native (like anchor-nav); only the disclosure
|
||||
* toggle carries the perceptual cue. Depth is a tokenized CSS custom-property
|
||||
* (`--_nav-tree-depth`), never an attr per level. `apg: disclosure` — a nav
|
||||
* landmark with disclosure groups; no widget pattern applies.
|
||||
*/
|
||||
export const navTreeMorfo = {
|
||||
name: 'NavTree',
|
||||
kebab: 'nav-tree',
|
||||
scope: ['soma', 'sema', 'eidos'],
|
||||
// expression resolved via the sema pack at
|
||||
// `src/uix/sema/components/nav-tree.ts` — soft emerge on group expand,
|
||||
// soft exit on collapse (mirrors collapsible / tree-view). Navigation is
|
||||
// native and unsonified; only the disclosure toggle signals.
|
||||
expression: 'pack',
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ (Disclosure Navigation) — a nav landmark of links + disclosure groups; no WAI-ARIA widget pattern applies',
|
||||
texts: {
|
||||
label: '#?components.nav-tree.label|Navigation'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
// A group reveals its children — book cap. 26 `emerge.expand`. Target
|
||||
// is `group` (the revealed content), so the `data-event-*` stamp and
|
||||
// the sema pack selector land on the children container.
|
||||
name: 'emerge-expand',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'expand',
|
||||
target: v.partRef('group'),
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
{
|
||||
// A group hides its children — `emerge.collapse`. `post` so the
|
||||
// content closes immediately; the exit cue plays alongside.
|
||||
name: 'emerge-collapse',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'collapse',
|
||||
target: v.partRef('group'),
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
// The <nav> landmark. `kebab: 'provider'` → bare `data-nav-tree`.
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'nav',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.propRef('ariaLabel'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// A node list (the root list, and each group's child list). Plain
|
||||
// structural <ul>; no role (native list semantics suffice).
|
||||
name: 'List',
|
||||
kebab: 'list',
|
||||
kind: 'public',
|
||||
defaultElement: 'ul',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// A node row wrapper (<li>). `data-trail` marks an ancestor of the
|
||||
// active node so the eidos rail can trace the active path.
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'li',
|
||||
optional: false,
|
||||
data: [{ attr: 'data-trail', value: v.propRef('trail'), severity: 'optional' }],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// The disclosure toggle for a parent node (<button>). Carries the
|
||||
// APG disclosure contract: aria-expanded + aria-controls (the id of
|
||||
// the group it shows/hides). `data-state` (open/closed) drives the
|
||||
// chevron. The `open` state is a soma getter (the expand-set), not
|
||||
// an event machine — the toggle is a plain onclick, so NavTree stays
|
||||
// sema-free (navigation, not an evaluative commit).
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{ attr: 'aria-controls', value: v.propRef('controls'), severity: 'recommended' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// A navigable link (<a href>). `aria-current="page"` and
|
||||
// `data-active` come from the SAME `active` prop (the dossier §P5
|
||||
// superación). Composes Link via the eidos child.
|
||||
name: 'Link',
|
||||
kebab: 'link',
|
||||
kind: 'public',
|
||||
defaultElement: 'a',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' }],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-current',
|
||||
value: v.literal('page'),
|
||||
severity: 'recommended',
|
||||
condition: { when: 'prop-truthy', prop: 'active' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// The collapsible children container (<ul>, role=group). Its id is
|
||||
// the sibling Trigger's aria-controls target; `data-state` mirrors
|
||||
// the disclosure. Hidden while closed (the soma sets `hidden`). No
|
||||
// archetype: a bare nested list, not a floating/surface content.
|
||||
name: 'Group',
|
||||
kebab: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'ul',
|
||||
role: 'group',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,36 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { navTreeMorfo } from '$uix/morfo/components/nav-tree';
|
||||
import { soundTuning } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* NavTree perceptual defaults — SOFT EMERGE (mirrors collapsible / tree-view).
|
||||
*
|
||||
* NavTree's only sonified surface is the group disclosure; navigation itself
|
||||
* is a native link with no cue. Coherence with the sibling disclosures
|
||||
* (accordion / collapsible / tree-view) demands the same signature:
|
||||
* - `emerge-expand` on `group` — `emerge.soft` (a group reveals its
|
||||
* children, ascending family base).
|
||||
* - `emerge-collapse` on `group` — `emerge.exit.soft`, the descending
|
||||
* direction discipline shared with menus / dialogs / drawers.
|
||||
*
|
||||
* No haptic — expanding a docs section is a deliberate pointer/keyboard
|
||||
* gesture on a trigger; tactile feedback on top would be noise.
|
||||
*/
|
||||
|
||||
const onGroup = (matchers?: Parameters<typeof semaSelector<typeof navTreeMorfo>>[2]) =>
|
||||
semaSelector(navTreeMorfo, 'group', matchers);
|
||||
|
||||
export const navTreeSema: Sema = {
|
||||
name: 'nav-tree',
|
||||
cascade: [
|
||||
{
|
||||
selector: onGroup({ eventName: 'emerge-expand' }),
|
||||
sound: soundTuning('emerge.soft')
|
||||
},
|
||||
{
|
||||
selector: onGroup({ eventName: 'emerge-collapse' }),
|
||||
sound: soundTuning('emerge.exit.soft')
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
# NavTree (soma)
|
||||
|
||||
Headless data-driven navigation tree: the app passes the tree as `nodes`, the
|
||||
provider renders it recursively, resolves the active node from `activeHref`,
|
||||
and auto-expands its ancestor trail. The rail + indent recipe lives in
|
||||
[`eidos/components/nav-tree`](../../../eidos/components/nav-tree/README.md).
|
||||
Built as F1.8 of the blocks tier — the deep left rail of the docs-shell (F4).
|
||||
|
||||
## The contract
|
||||
|
||||
- **Parts**: `provider` (`<nav>` landmark → bare `data-nav-tree`) + `list`
|
||||
(root `<ul>`) + `item` (`<li>`, `data-trail` on active-trail ancestors) +
|
||||
`trigger` (`<button>` disclosure → `data-state`, `aria-expanded`,
|
||||
`aria-controls`) + `link` (`<a>` → `data-active`, `aria-current="page"`) +
|
||||
`group` (children `<ul role="group">` → `data-state`, `hidden` while
|
||||
collapsed). Depth rides `--_nav-tree-depth` (a CSS var), never an attr.
|
||||
- **Data-driven, per-node providers**: `NavTreeProvider` owns the shared state
|
||||
(processed tree, active key, trail, expand-set) and the `<nav>` landmark;
|
||||
each rendered node is a `NavTreeItemProvider` on its OWN runtime (the
|
||||
anchor-nav / menubar per-node-provider pattern, recursive — no part-key
|
||||
collision across nodes).
|
||||
- **Active trail**: `activeHref` (the app's current URL — the component NEVER
|
||||
reads the router) is matched to a node; its ancestor chain is the trail.
|
||||
`isExpanded` is a PURE query, not a reactive write: open when not
|
||||
user-collapsed AND (user-expanded OR on the trail OR `defaultOpen`). So
|
||||
navigation auto-expands, yet the user can collapse/expand freely.
|
||||
- **Disclosure = state getter + emerge event**: the `open` state is a soma
|
||||
getter (the expand-set) driving `aria-expanded` / `data-state`; the toggle
|
||||
fires `emerge-expand` / `emerge-collapse` via `runtime.trigger` (the sema
|
||||
cue + `setExpanded`). No event machine holds the state — the getter does.
|
||||
- **Scroll-active-into-view**: an `$effect` on the active key defers a
|
||||
`scrollIntoView({ block: 'nearest' })` to a `dom.raf` frame (after
|
||||
`data-active` is applied — never a sync read after a write).
|
||||
|
||||
## API
|
||||
|
||||
`<NavTree nodes={tree} activeHref={url} aria-label?>` — `nodes` is the tree as
|
||||
data (`NavTreeNode[]`: `{ id?, label, href?, children?, badge?, defaultOpen?,
|
||||
disabled? }`), `activeHref` the app's current URL, `aria-label` the landmark
|
||||
name (falls back to the localized «Navigation»). `bind:ref` for the `<nav>`.
|
||||
`child` snippet for asChild composition. Data-driven: there are no authored
|
||||
sub-components.
|
||||
|
||||
## Sema events
|
||||
|
||||
`emerge-expand` / `emerge-collapse` on the `group` part (soft emerge / soft
|
||||
exit — mirrors collapsible / tree-view). Navigation is native and unsonified;
|
||||
only the disclosure toggle signals. Pack:
|
||||
`src/uix/sema/components/nav-tree.ts`.
|
||||
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Headless recursive tree node (internal — NavTree is data-driven, so this
|
||||
* is never authored by the consumer). Renders a `<li>` whose row is either a
|
||||
* native link (leaf), a disclosure button (a group with no href), or a link
|
||||
* plus a chevron toggle (a group that is itself navigable). A group also
|
||||
* renders its children `<ul role="group">`, `hidden` while collapsed, and
|
||||
* recurses. Depth rides a CSS custom-property (`--_nav-tree-depth`), never an
|
||||
* attr per level.
|
||||
*/
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { NavTreeItemProvider } from '../nav-tree-provider.svelte';
|
||||
import Self from './nav-tree-node.svelte';
|
||||
import type { NavTreeNodeProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let { item }: NavTreeNodeProps = $props();
|
||||
let ref = $state<HTMLElement | null>(null);
|
||||
const id = createId(uid, 'nav-tree-node');
|
||||
|
||||
const provider = NavTreeItemProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
item: readableActive(() => item)
|
||||
});
|
||||
|
||||
const node = $derived(item.node);
|
||||
const href = $derived(node.href);
|
||||
</script>
|
||||
|
||||
<li bind:this={ref} {...provider.itemProps}>
|
||||
{#if item.hasChildren}
|
||||
<div style:--_nav-tree-depth={item.depth}>
|
||||
{#if href}
|
||||
<a {...provider.linkProps}>{node.label}</a>
|
||||
<button {...provider.triggerProps} aria-label={node.label}></button>
|
||||
{:else}
|
||||
<button {...provider.triggerProps}>{node.label}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<ul {...provider.groupProps}>
|
||||
{#each item.children as child (child.key)}
|
||||
<Self item={child} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if href}
|
||||
<a {...provider.linkProps} style:--_nav-tree-depth={item.depth}>{node.label}</a>
|
||||
{:else}
|
||||
<span style:--_nav-tree-depth={item.depth}>{node.label}</span>
|
||||
{/if}
|
||||
</li>
|
||||
@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Headless `<NavTree>` — the `<nav>` landmark + root `<ul>`. Data-driven:
|
||||
* it renders the `nodes` tree recursively (each node is a `NavTreeNode`),
|
||||
* resolves the active node from `activeHref`, auto-expands its ancestors,
|
||||
* and stamps `data-active` / `aria-current` / `aria-expanded`. The eidos
|
||||
* adds the rail + indent recipe over this structure.
|
||||
*/
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { NavTreeProvider } from '../nav-tree-provider.svelte';
|
||||
import NavTreeNode from './nav-tree-node.svelte';
|
||||
import type { NavTreeProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'nav-tree'),
|
||||
nodes,
|
||||
activeHref,
|
||||
'aria-label': ariaLabel,
|
||||
child,
|
||||
...restProps
|
||||
}: NavTreeProps = $props();
|
||||
|
||||
const provider = NavTreeProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
nodes: readableActive(() => nodes),
|
||||
activeHref: readableActive(() => activeHref),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, provider.providerProps));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<nav {...mergedProps}>
|
||||
<ul {...provider.listProps}>
|
||||
{#each provider.tree as item (item.key)}
|
||||
<NavTreeNode {item} />
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
{/if}
|
||||
@ -0,0 +1,3 @@
|
||||
export { default as Provider } from './components/nav-tree.svelte';
|
||||
|
||||
export type { NavTreeProps as ProviderProps, NavTreeNode, NavTreeItem, NavTreeSnippetProps } from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,4 @@
|
||||
/** Idlangref constants for the NavTree component. */
|
||||
export const NAV_TREE_LANGS = {
|
||||
LABEL: '#?components.nav-tree.label|Navigation'
|
||||
} as const;
|
||||
@ -0,0 +1,277 @@
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { readableActive, type Active, type ActiveProps } from '$libs/reactive';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { untrack } from 'svelte';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { navTreeMorfo } from '../../../morfo/components/nav-tree';
|
||||
import { NAV_TREE_LANGS } from './langs';
|
||||
import type { NavTreeNode, NavTreeItem } from './types';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
|
||||
interface NavTreeOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
/** The tree, as data (E-1 data-driven contract). */
|
||||
nodes: NavTreeNode[];
|
||||
/**
|
||||
* The current page's href — the app's URL, passed in. The component
|
||||
* matches it against node hrefs to find the active node and its
|
||||
* ancestor trail; it NEVER reads the router itself.
|
||||
*/
|
||||
activeHref: string | undefined;
|
||||
ariaLabel: string | undefined;
|
||||
}> {}
|
||||
|
||||
/** Processed tree + the lookup maps the selection/expansion logic needs. */
|
||||
interface ProcessedTree {
|
||||
tree: NavTreeItem[];
|
||||
/** key → parent key (undefined for roots). */
|
||||
parentByKey: Map<string, string | undefined>;
|
||||
/** href → key (active-node lookup). */
|
||||
keyByHref: Map<string, string>;
|
||||
/** Keys of groups declared `defaultOpen`. */
|
||||
defaultOpenKeys: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless `NavTree` provider (the `<nav>` landmark). Owns the SHARED tree
|
||||
* state: it processes `nodes` into a keyed tree, resolves the active node from
|
||||
* `activeHref`, derives the ancestor trail, and answers per-node queries
|
||||
* (`isActive` / `isExpanded` / `isTrail` / `toggle`). Each rendered node is a
|
||||
* `NavTreeItemProvider` that reads this shared state from context — the
|
||||
* anchor-nav / menubar per-node-provider pattern, applied to a data tree.
|
||||
*
|
||||
* Expansion model (no `$effect` writing state): `isExpanded` is a pure query —
|
||||
* a node is open when it is not user-collapsed AND (user-expanded OR on the
|
||||
* active trail OR `defaultOpen`). So navigation auto-expands ancestors, yet the
|
||||
* user can still collapse and expand freely.
|
||||
*/
|
||||
export class NavTreeProvider {
|
||||
readonly opts: NavTreeOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly providerPart: SomaRuntimePart;
|
||||
readonly listPart: SomaRuntimePart;
|
||||
|
||||
static readonly ctx = context<NavTreeProvider>('NavTree');
|
||||
static get(): NavTreeProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as NavTreeProvider | undefined;
|
||||
}
|
||||
static require(): NavTreeProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: NavTreeOpts) {
|
||||
return new NavTreeProvider(opts);
|
||||
}
|
||||
|
||||
/** Landmark name — explicit prop, else the localized «Navigation». */
|
||||
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
|
||||
() => this.opts.ariaLabel.current || this.soma.langs.ts(NAV_TREE_LANGS.LABEL) || undefined
|
||||
);
|
||||
|
||||
/** User-explicit expansions / collapses, layered over the auto trail. */
|
||||
private readonly userExpanded = new SvelteSet<string>();
|
||||
private readonly userCollapsed = new SvelteSet<string>();
|
||||
|
||||
private constructor(opts: NavTreeOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(navTreeMorfo, {
|
||||
props: { ariaLabel: () => this.resolvedAriaLabel.current }
|
||||
});
|
||||
this.providerPart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: NavTreeProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
this.listPart = this.runtime.part('list', {
|
||||
id: readableActive(() => `${opts.id.current}-list`)
|
||||
});
|
||||
|
||||
// Scroll the active node into view when it changes (deferred to a frame
|
||||
// so `data-active` is applied first — a layout WRITE, not a sync read).
|
||||
$effect(() => {
|
||||
const key = this.activeKey;
|
||||
const root = this.opts.ref.current;
|
||||
if (!key || !root) return;
|
||||
return this.soma.dom.raf(() => {
|
||||
const el = root.querySelector('[data-nav-tree-link][data-active]');
|
||||
(el as HTMLElement | null)?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Process `nodes` into a keyed tree + the lookup maps (one walk). */
|
||||
private readonly processed = $derived.by<ProcessedTree>(() => {
|
||||
const parentByKey = new Map<string, string | undefined>();
|
||||
const keyByHref = new Map<string, string>();
|
||||
const defaultOpenKeys = new Set<string>();
|
||||
|
||||
const walk = (
|
||||
nodes: readonly NavTreeNode[],
|
||||
depth: number,
|
||||
parentKey: string | undefined
|
||||
): NavTreeItem[] =>
|
||||
nodes.map((node, i) => {
|
||||
const key = node.id ?? node.href ?? `${parentKey ?? 'root'}/${i}`;
|
||||
const hasChildren = !!node.children?.length;
|
||||
parentByKey.set(key, parentKey);
|
||||
if (node.href) keyByHref.set(node.href, key);
|
||||
if (node.defaultOpen) defaultOpenKeys.add(key);
|
||||
const children = hasChildren ? walk(node.children!, depth + 1, key) : [];
|
||||
return { node, key, depth, hasChildren, children };
|
||||
});
|
||||
|
||||
const tree = walk(this.opts.nodes.current ?? [], 0, undefined);
|
||||
return { tree, parentByKey, keyByHref, defaultOpenKeys };
|
||||
});
|
||||
|
||||
/** The processed root nodes — the eidos recurses over this. */
|
||||
readonly tree = $derived.by(() => this.processed.tree);
|
||||
|
||||
/** Key of the node whose href matches `activeHref` (else undefined). */
|
||||
readonly activeKey = $derived.by<string | undefined>(() => {
|
||||
const href = this.opts.activeHref.current;
|
||||
return href ? this.processed.keyByHref.get(href) : undefined;
|
||||
});
|
||||
|
||||
/** The active node plus its ancestors (auto-expanded, trail-highlighted). */
|
||||
private readonly trailKeys = $derived.by<Set<string>>(() => {
|
||||
const trail = new Set<string>();
|
||||
const { parentByKey } = this.processed;
|
||||
let key = this.activeKey;
|
||||
while (key) {
|
||||
trail.add(key);
|
||||
key = parentByKey.get(key);
|
||||
}
|
||||
return trail;
|
||||
});
|
||||
|
||||
isActive(key: string): boolean {
|
||||
return key === this.activeKey;
|
||||
}
|
||||
|
||||
/** An ancestor of the active node (not the active node itself). */
|
||||
isTrail(key: string): boolean {
|
||||
return key !== this.activeKey && this.trailKeys.has(key);
|
||||
}
|
||||
|
||||
isExpanded(key: string): boolean {
|
||||
if (this.userCollapsed.has(key)) return false;
|
||||
return (
|
||||
this.userExpanded.has(key) ||
|
||||
this.trailKeys.has(key) ||
|
||||
this.processed.defaultOpenKeys.has(key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a group's disclosure explicitly — records the user's intent. */
|
||||
setExpanded(key: string, open: boolean): void {
|
||||
untrack(() => {
|
||||
if (open) {
|
||||
this.userExpanded.add(key);
|
||||
this.userCollapsed.delete(key);
|
||||
} else {
|
||||
this.userCollapsed.add(key);
|
||||
this.userExpanded.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readonly providerProps = $derived.by(() =>
|
||||
this.providerPart.assert({ ...this.providerPart.props } as const)
|
||||
);
|
||||
readonly listProps = $derived.by(() => this.listPart.renderProps());
|
||||
}
|
||||
|
||||
// ── Item (per node) ────────────────────────────────────────────────────────────
|
||||
|
||||
interface NavTreeItemOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
/** The processed node this row renders. */
|
||||
item: NavTreeItem;
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* Headless `NavTree` per-node provider (the `<li>` + its link / trigger /
|
||||
* group). Reads the shared state from the parent and registers this node's
|
||||
* morfo parts on its OWN runtime (per-instance — many nodes, no part-key
|
||||
* collision). `data-active` / `aria-current` come from the SAME `active`
|
||||
* query; `aria-expanded` / `data-state` from the `open` state getter (the
|
||||
* expand-set), so no event machine and no sema.
|
||||
*/
|
||||
export class NavTreeItemProvider {
|
||||
readonly opts: NavTreeItemOpts;
|
||||
readonly soma: Soma;
|
||||
readonly parent: NavTreeProvider;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly itemPart: SomaRuntimePart;
|
||||
readonly linkPart: SomaRuntimePart;
|
||||
readonly triggerPart: SomaRuntimePart;
|
||||
readonly groupPart: SomaRuntimePart;
|
||||
|
||||
static create(opts: NavTreeItemOpts) {
|
||||
return new NavTreeItemProvider(opts);
|
||||
}
|
||||
|
||||
private get key(): string {
|
||||
return this.opts.item.current.key;
|
||||
}
|
||||
|
||||
/** Stable id of this node's children group (the Trigger's aria-controls). */
|
||||
readonly groupId: Active<string> = readableActive(() => `${this.opts.id.current}-group`);
|
||||
|
||||
readonly active = $derived.by(() => this.parent.isActive(this.key));
|
||||
readonly trail = $derived.by(() => this.parent.isTrail(this.key));
|
||||
readonly expanded = $derived.by(() => this.parent.isExpanded(this.key));
|
||||
|
||||
private constructor(opts: NavTreeItemOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.parent = NavTreeProvider.require();
|
||||
this.runtime = this.soma.runtime(navTreeMorfo, {
|
||||
states: { open: () => this.expanded },
|
||||
props: {
|
||||
active: () => this.active,
|
||||
trail: () => this.trail,
|
||||
controls: () => this.groupId.current
|
||||
},
|
||||
events: {
|
||||
'emerge-expand': () => this.parent.setExpanded(this.key, true),
|
||||
'emerge-collapse': () => this.parent.setExpanded(this.key, false)
|
||||
}
|
||||
});
|
||||
this.itemPart = this.runtime.part('item', { id: opts.id, ref: opts.ref });
|
||||
this.linkPart = this.runtime.part('link', {
|
||||
id: readableActive(() => `${opts.id.current}-link`)
|
||||
});
|
||||
this.triggerPart = this.runtime.part('trigger', {
|
||||
id: readableActive(() => `${opts.id.current}-trigger`)
|
||||
});
|
||||
this.groupPart = this.runtime.part('group', { id: this.groupId });
|
||||
}
|
||||
|
||||
/** Flip this group's disclosure — fires the emerge event (sema + state). */
|
||||
private toggle(): void {
|
||||
void this.runtime.trigger(this.expanded ? 'emerge-collapse' : 'emerge-expand');
|
||||
}
|
||||
|
||||
readonly itemProps = $derived.by(() => this.itemPart.renderProps());
|
||||
readonly linkProps = $derived.by(() => ({
|
||||
...this.linkPart.renderProps(),
|
||||
href: this.opts.item.current.node.href
|
||||
}));
|
||||
readonly triggerProps = $derived.by(() => ({
|
||||
...this.triggerPart.renderProps(),
|
||||
onclick: () => this.toggle()
|
||||
}));
|
||||
readonly groupProps = $derived.by(() => ({
|
||||
...this.groupPart.renderProps(),
|
||||
hidden: this.expanded ? undefined : true
|
||||
}));
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* A node in the navigation tree. The consumer passes a `nodes` array (the
|
||||
* data-driven contract, E-1); the soma processes it into the rendered tree.
|
||||
* A node with `children` is a collapsible group; a node with `href` is a
|
||||
* navigable link. A node may be both (a section index that also expands).
|
||||
*/
|
||||
export interface NavTreeNode {
|
||||
/**
|
||||
* Stable identity for expand-state + active matching. Defaults to `href`
|
||||
* when omitted; REQUIRED for a group with no href (else its expand state
|
||||
* can't be keyed). Two nodes must not share a key.
|
||||
*/
|
||||
id?: string;
|
||||
/** Visible label. */
|
||||
label: string;
|
||||
/** Navigation target. Its presence makes the node a link. */
|
||||
href?: string;
|
||||
/** Child nodes. Its presence makes the node a collapsible group. */
|
||||
children?: NavTreeNode[];
|
||||
/** Optional trailing badge (composes `Badge`). */
|
||||
badge?: string | number;
|
||||
/** Open this group from the start (else it opens only on the active trail). */
|
||||
defaultOpen?: boolean;
|
||||
/** Render the link/toggle as disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** A processed node — the input node plus its resolved position in the tree. */
|
||||
export interface NavTreeItem {
|
||||
node: NavTreeNode;
|
||||
/** Stable key (`id` ?? `href` ?? synthesized path). */
|
||||
key: string;
|
||||
/** 0-based depth (root nodes are depth 0). Drives the indent token. */
|
||||
depth: number;
|
||||
/** True when the node has children (a collapsible group). */
|
||||
hasChildren: boolean;
|
||||
/** Processed children. */
|
||||
children: NavTreeItem[];
|
||||
}
|
||||
|
||||
export interface NavTreeSnippetProps {
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type NavTreeProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
||||
/** Bindable ref to the `<nav>`. */
|
||||
ref?: HTMLElement | null;
|
||||
id?: string;
|
||||
/** The tree, as data (E-1 data-driven contract). Required. */
|
||||
nodes: NavTreeNode[];
|
||||
/** The current page's href (the app's URL). Matched to node hrefs. */
|
||||
activeHref?: string;
|
||||
/** Accessible name for the landmark. Falls back to the localized «Navigation». */
|
||||
'aria-label'?: string;
|
||||
child?: Snippet<[NavTreeSnippetProps]>;
|
||||
};
|
||||
|
||||
/** Props for the internal recursive node component. */
|
||||
export interface NavTreeNodeProps {
|
||||
item: NavTreeItem;
|
||||
}
|
||||
Loading…
Reference in new issue