uix(nav-tree): `disabled` por nodo — destino no disponible

Decisión del usuario. Un nodo deshabilitado conserva su fila (sigue siendo
parte del mapa) pero pierde el `href`: no hay nada que activar con clic,
Enter ni «abrir en pestaña nueva». Un `pointer-events: none` de CSS solo
habría tapado el ratón.

- morfo: la parte `link` gana `data-disabled` (propRef) y `aria-disabled`
  (literal `true` bajo `prop-truthy: disabled`).
- soma: `disabled` solo aplica con `href` (un grupo sin destino lo ignora);
  el toggle del grupo NUNCA se deshabilita — mostrar/ocultar es control de
  vista, no destino.
- eidos: fila en `--color-content-disabled`, sin puntero y sin hover.
- demo: «Agent» va deshabilitado con badge «Soon» + filas de API y a11y.

Verificado en navegador real: `<a>` sin href, `aria-disabled="true"`,
`data-disabled`, color 0.79 frente a 0.61 de una fila normal, clic sin
efecto, fuera del orden de tabulación (44 focusables en vez de 45), badge
dentro del control. `component:audit` PASS · eidos-lint 28 morfo-backed / 0
invalid · `vitest src/uix/eidos` 353/353 · svelte-check 0 errores míos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent a7e629f9ed
commit ef6bd13be3

@ -987,8 +987,12 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
renderiza el snippet recibido (sin él, el valor crudo: sigue headless), el
wrapper de eidos pasa el `Badge` canónico; va DENTRO del control de la fila
para que su texto entre en el nombre accesible («TSC, New, enlace»).
`disabled` se DESCARTA en v1 (no está en el suelo; un enlace de navegación
deshabilitado es semánticamente dudoso) — ambos quedan registrados en Gaps.
`disabled` **también entra**, por decisión explícita del usuario tras
presentarle el criterio: el nodo conserva la fila pero pierde el `href` (no
hay nada que activar con Enter, clic ni «abrir en pestaña nueva» — un
`pointer-events:none` de CSS solo tapa el ratón) y añade `aria-disabled` +
`data-disabled`; el toggle del grupo NUNCA se deshabilita (disclosure es
control de vista, no destino) y un nodo sin `href` lo ignora.
Verificado: `component:audit` **PASS** · eidos-lint **26 morfo-backed / 0
invalid** · `svelte-check` 0 errores en mis archivos · `vitest src/uix/eidos`
353/353 · navegador real (Playwright, el pane suspendido NO sirve: congela

@ -65,6 +65,12 @@ docs-shell (F4). Reference floor: the research dossier
wrapper de eidos pasa por defecto `<Badge size="xs" variant="soft">`. Va
DENTRO del control de la fila, así su texto entra en el nombre accesible
(«TSC, New, enlace») — la lectura en la que convergen las referencias.
- **`disabled` = destino, nunca disclosure**: un nodo deshabilitado conserva su
fila (sigue siendo parte del mapa) pero pierde el `href` — no hay nada que
activar con Enter, clic ni «abrir en pestaña nueva»; un `pointer-events:none`
de CSS solo tapa el ratón. Añade `aria-disabled` + `data-disabled`. El toggle
de un grupo NO se deshabilita (mostrar/ocultar es control de vista, no
destino) y un nodo sin `href` lo ignora.
- **Colapso contextual**: un colapso recuerda el `activeKey` bajo el que se
hizo. Cerrar la sección que estás leyendo se respeta; esa intención caduca en
cuanto navegas DENTRO del grupo (si no, la página actual se quedaría sin fila
@ -99,7 +105,6 @@ nativa NO suena; solo el toggle. Sin haptic. Pack:
| Gap | Disposition |
| --- | --- |
| `disabled` por nodo (fila no navegable) | **descartar v1** — no está en el suelo del dossier §P5 y un enlace de navegación deshabilitado es semánticamente dudoso (los docs omiten la página o la marcan con badge). Si se pide, entra como `data-disabled` + `aria-disabled` en morfo |
| Render perezoso (montar solo los grupos abiertos) | **diferir** — v1 monta todo el árbol + `hidden` en colapsados (APG-correcto: `aria-controls` siempre resuelve). Árboles enormes: lazy en v2 |
| Auto-colapso de hermanos (Docusaurus) | **diferir** — diferenciador; el trail ya abre lo relevante. Si se pide, es política de `setExpanded` |
| Raíl de guía por nivel + raíl-pulgar «clerk» (Fumadocs) | **diferir** — v1 marca el activo por color/peso y el trail por color; las líneas-guía verticales son pulido |

@ -137,6 +137,18 @@
font-weight: var(--font-weight-medium);
}
/* Disabled — the destination is unavailable. The row stays readable (it is
still part of the map) but reads inert and takes no pointer. The soma
already dropped the href, so this is the visual half of the contract. */
[data-nav-tree-link][data-disabled] {
color: var(--color-content-disabled);
pointer-events: none;
cursor: default;
}
[data-nav-tree-link][data-disabled]:hover {
color: var(--color-content-disabled);
}
/* Trail — an ancestor of the current page. */
[data-nav-tree-item][data-trail] > div > [data-nav-tree-link],
[data-nav-tree-item][data-trail] > div > [data-nav-tree-trigger],

@ -147,13 +147,25 @@ export const navTreeMorfo = {
kind: 'public',
defaultElement: 'a',
optional: true,
data: [{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' }],
data: [
{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' },
// A node whose destination is unavailable («coming soon»). It is the
// LINK that carries it — `disabled` is about the destination, never
// about the disclosure, so a group's toggle keeps working.
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{
attr: 'aria-current',
value: v.literal('page'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'active' }
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
},

@ -12,8 +12,9 @@ Built as F1.8 of the blocks tier — the deep left rail of the docs-shell (F4).
(root `<ul>`) + `item` (`<li>`, `data-trail` on active-trail ancestors) +
`trigger` (`<button>` disclosure → `data-state`, `aria-expanded`,
`aria-controls`, `aria-label` when it is the chevron beside a link) + `link`
(`<a>` → `data-active`, `aria-current="page"`) + `badge` (`<span>` trailing
chip, inside the row's control) + `group` (children `<ul role="group">` →
(`<a>` → `data-active`, `aria-current="page"`, `data-disabled` +
`aria-disabled` and NO `href` when the node is disabled) + `badge` (`<span>`
trailing chip, inside the row's control) + `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
@ -45,7 +46,7 @@ Built as F1.8 of the blocks tier — the deep left rail of the docs-shell (F4).
`<NavTree nodes={tree} activeHref={url} aria-label? badge?>` — `nodes` is the
tree as data (`NavTreeNode[]`: `{ id?, label, href?, children?, badge?,
defaultOpen? }`), `activeHref` the app's current URL, `aria-label` the landmark
defaultOpen?, disabled? }`), `activeHref` the app's current URL, `aria-label` the landmark
name (falls back to the localized «Navigation»), `badge` a snippet deciding how
a node's `badge` value renders (headless: the raw value; the eidos passes the
canonical `Badge`). `bind:ref` for the `<nav>`. The `child` snippet swaps the

@ -257,6 +257,12 @@ export class NavTreeItemProvider {
readonly trail = $derived.by(() => this.parent.isTrail(this.key));
readonly expanded = $derived.by(() => this.parent.isExpanded(this.key));
/** The node's destination is unavailable (only meaningful with an href). */
readonly disabled = $derived.by(() => {
const node = this.opts.item.current.node;
return !!node.disabled && !!node.href;
});
/**
* Accessible name for the chevron toggle that sits BESIDE a link (there the
* button has no text of its own). «{localized purpose} {section}», never the
@ -290,6 +296,7 @@ export class NavTreeItemProvider {
props: {
active: () => this.active,
trail: () => this.trail,
disabled: () => this.disabled,
controls: () => this.groupId.current,
toggleLabel: () => this.toggleLabel
},
@ -325,7 +332,11 @@ export class NavTreeItemProvider {
readonly itemProps = $derived.by(() => this.itemPart.renderProps());
readonly linkProps = $derived.by(() => ({
...this.linkPart.renderProps(),
href: this.opts.item.current.node.href
// A disabled node keeps its row but loses the destination: no `href` means
// nothing to follow by pointer, keyboard or context menu (a CSS-only
// `pointer-events: none` would still let Enter and «open in new tab»
// through). `aria-disabled` — morfo-declared — announces the state.
href: this.disabled ? undefined : this.opts.item.current.node.href
}));
readonly triggerProps = $derived.by(() => ({
...this.triggerPart.renderProps(),

@ -28,6 +28,13 @@ export interface NavTreeNode {
badge?: string | number;
/** Open this group from the start (else it opens only on the active trail). */
defaultOpen?: boolean;
/**
* The node's destination is unavailable («coming soon», gated page). The
* link renders without `href` (nothing to follow, by pointer or keyboard)
* and announces `aria-disabled`. It is about the DESTINATION only: a group's
* disclosure keeps working, and a node with no `href` ignores it.
*/
disabled?: boolean;
}
/** A processed node — the input node plus its resolved position in the tree. */

@ -53,7 +53,7 @@
{ label: 'Active uix', href: '/docs/architecture/active-uix' },
{ label: 'Packs', href: '/docs/architecture/packs' },
{ label: 'Blocks', href: '/docs/architecture/blocks' },
{ label: 'Agent', href: '/docs/architecture/agent' }
{ label: 'Agent', href: '/docs/architecture/agent', badge: 'Soon', disabled: true }
]
},
{
@ -631,6 +631,17 @@
text joins the link's accessible name — «TSC, New, link».
</td>
</tr>
<tr>
<td class="name">disabled</td>
<td class="type">boolean</td>
<td>
The destination is unavailable («coming soon»). The link renders without
<code>href</code> — nothing to follow by pointer, keyboard or context menu — plus
<code>aria-disabled</code> and <code>data-disabled</code>. It is about the
DESTINATION: a group's disclosure keeps working, and a node with no
<code>href</code> ignores it. (See «Agent» in the live tree.)
</td>
</tr>
</tbody>
</table>
</div>
@ -801,6 +812,16 @@
decoration.
</td>
</tr>
<tr>
<td class="name">Disabled node</td>
<td>
A disabled node keeps its row (it is still part of the map) but loses its
<code>href</code>, so there is nothing to activate with Enter, a click or «open in new
tab» — a CSS <code>pointer-events: none</code> alone would still let the keyboard
through. <code>aria-disabled="true"</code> announces the state; the row leaves the tab
order because an anchor with no href is not focusable.
</td>
</tr>
<tr>
<td class="name">Active into view</td>
<td>

Loading…
Cancel
Save

Powered by TurnKey Linux.