uix(sidebar): review adversarial · 31 hallazgos confirmados, arreglados

Review de 6 dimensiones × 3 verificadores escépticos (135 agentes): 43
hallazgos brutos → 31 confirmados, 12 rechazados. Todos los confirmados
arreglados y verificados en navegador real.

Estado (los dos ejes)
- `collapsible='none'` ya no deja el sidebar INALCANZABLE en móvil: la
  inercia del modo se limita al escritorio, que es donde «nunca colapsa»
  significa algo. En móvil el panel es un Drawer que arranca cerrado, el raíl
  está oculto y el drawer no tiene trigger propio: con el toggle inerte no
  había forma humana de abrir la navegación en un teléfono.
- `'none'` PINTA el estado a expandido: antes `data-state`, `aria-expanded` y
  `useSidebar().open` podían decir «colapsado» sobre un panel plenamente
  visible, y ninguna regla del recipe ni el toggle podían reconciliarlo.
- El estado del drawer móvil se DESCARTA al salir de la presentación móvil:
  abrirlo en un teléfono y rotar dos veces remontaba el Drawer ya abierto,
  con overlay, foco atrapado y scroll bloqueado sin tocar nada.

Submenú flotante (era el nudo con más hallazgos)
- Cierra también por PUNTERO: se abría en `pointerenter` de la fila, pero el
  único cierre por puntero vivía en el propio flyout — salir hacia la página
  sin cruzarlo lo dejaba pintado para siempre.
- Escape funciona desde donde el foco ESTÁ (la fila), no solo dentro del sub.
- Entrar/salir se resuelve en el `menu-item`, que contiene fila Y sub, así que
  viajar entre ambos no lo cierra.
- El flag se limpia al cambiar de presentación: expandir y volver a colapsar
  reabría un flyout que nadie había tocado.
- El motor flotante se engancha solo cuando el sub está ABIERTO, no por modo:
  antes `autoUpdate` (rAF + observers) corría por cada fila del raíl.
- `hasSub` deja de latir a true: se limpia en el teardown del sub.

A11y
- Las filas del raíl recuperan NOMBRE: un tooltip solo describe
  (`aria-describedby`), así que con la etiqueta oculta el texto de `tooltip`
  pasa también a `aria-label` — incluidas las filas con submenú, que no llevan
  tooltip visible.
- El panel off-canvas colapsado sale del tab order y del árbol de
  accesibilidad (`visibility: hidden` con la transición retrasada para que el
  deslizamiento siga animando).
- El diálogo móvil recibe el mismo nombre que el landmark.
- El raíl se llama «Contraer barra lateral» (lo que hace) en vez de
  «Redimensionar», que prometía un arrastre fuera de alcance; cursor de
  puntero en vez de `ew-resize`.

Recipe
- El signo del off-canvas se deriva de side × dirección: en RTL la fila flex
  se invierte, así que el negativo fijo barría el panel POR ENCIMA de la
  página en vez de sacarlo por su borde.
- El flyout declara banda `z-index` (nuevo token `--sidebar-sub-z`); era la
  única superficie flotante del ecosistema sin ella.
- La transición de `inline-size` (layout por frame) queda SOLO bajo el modo
  icono; la base anima únicamente `transform`.
- El `Drawer` es el único dueño del ancho móvil (dos dueños dejaban una banda
  de cromo en dos tonos) y se compone con `dragToDismiss`.
- Las reglas de modo icono ya no alcanzan el subárbol flotante: sus filas
  conservan badges y acciones.
- Bloque `prefers-reduced-motion` — la traslación del panel es la mayor del
  sistema.

Demo
- Copias corregidas donde afirmaban de más: el tooltip ahora sí NOMBRA (y se
  explica por qué), y los anchos son rem fijos a propósito (no los alcanza el
  escalado; sí el interior).
- Tabla de eventos y de teclado en la pestaña morfo; `onOpenChange` y una
  tabla de props por parte en API.
- Los chips de estado se inhabilitan en `collapsible='none'`, que es lo que
  el componente hace; botón «show me» que pone icon + colapsado de una vez.
- El trigger pasa a ser solo icono: el morfo le da `aria-label`, que pisaba
  cualquier texto visible.

Verificado en navegador: nombres accesibles en el raíl · flyout que cierra
por puntero, por foco y por Escape (con retorno de foco) y que no resucita al
cambiar de modo · off-canvas fuera del tab order · RTL sacando el panel por su
propio borde · z-index 80 en el flyout · drawer móvil nombrado, a ancho
completo y con el panel a ras · `collapsible='none'` abrible en un teléfono.
Gates: audit PASS 0E/0W · eidos-lint 47 morfo-backed / 0 invalid ·
svelte-check 0 errores propios · vitest src/uix/eidos 353/353 ·
contracts.test solo con los 3 fallos ajenos conocidos.

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

@ -46,13 +46,20 @@ real `sidebar.tsx`). Headless behavior:
- **`data-collapsible` siempre estampado** (shadcn solo lo pone colapsado, lo
que obliga a cada regla del recipe a guardar por estado).
- **`rail` alcanzable por teclado**: es un `<button>` real con nombre
localizado, no el `tabIndex=-1` de la referencia.
localizado —«Contraer barra lateral», lo que HACE; no «redimensionar», que
prometería un arrastre fuera de alcance— y cursor de puntero, no el
`tabIndex=-1` de la referencia.
- **`aria-current="page"` del MISMO prop que `data-active`**, en fila y subfila.
- **Submenú flotante en modo icono** (superación): al colapsar, `menu-sub` se
ancla al botón de su fila y se abre por hover/foco; Escape cierra y devuelve
el foco. shadcn los oculta y deja ramas enteras inalcanzables.
- **Móvil = `Drawer` compuesto**, no reimplementado: el foco atrapado, el
bloqueo de scroll y el descarte vienen con él. La decisión `mobile` sale del
bloqueo de scroll y el descarte vienen con él. El diálogo recibe el MISMO
nombre que el landmark (un diálogo sin nombre se anuncia como «diálogo» a
secas), el `Drawer` es el ÚNICO dueño del ancho móvil (dos dueños dejan el
panel flotando dentro de una superficie más ancha, con dos tonos de cromo) y
se compone con `dragToDismiss` para que el gesto de descarte pase por
`onOpenChange` en vez de heredar el redimensionado libre. La decisión `mobile` sale del
breakpoint del SISTEMA (`uix.dom.isAtLeast`), nunca de un `matchMedia` propio.
**Un render de servidor no tiene viewport** (ancho 0): ahí `mobile` es
FALSE por definición — si no, el HTML sale con `data-mobile` y la hidratación
@ -66,10 +73,25 @@ real `sidebar.tsx`). Headless behavior:
- **Tooltip y submenú no compiten**: una fila CON submenú no lleva tooltip en
modo icono — el flyout que abre ese mismo hover ya la nombra, y dos
superficies sobre el mismo ancla se pisan.
- **Movimiento**: off-canvas por `transform` (trabajo de compositor); `width`
solo se anima en modo icono, donde el inset tiene que seguir al raíl.
**Nunca `will-change: transform` en el raíl** — una franja fina tiembla a DPR
fraccionario (memoria NavMenu).
- **Movimiento**: off-canvas por `transform` (trabajo de compositor); la
transición de `inline-size` —que SÍ es layout por frame— solo existe bajo el
modo icono, donde el inset tiene que seguir al raíl. **Nunca
`will-change: transform` en el raíl** (una franja fina tiembla a DPR
fraccionario, memoria NavMenu) y bloque `prefers-reduced-motion` al final: la
traslación del panel es la mayor del sistema.
- **El signo del off-canvas es direccional, no físico**: `transform` no tiene
eje lógico, así que la variable `--_sidebar-offcanvas-x` se deriva de
side × dirección — en RTL la fila flex se invierte, así que `side: left` ya
deja el panel a la derecha física y un negativo fijo lo barrería POR ENCIMA
de la página en vez de sacarlo por su propio borde.
- **Colapsado off-canvas = fuera del tab order**: el panel trasladado sigue
siendo enfocable y sigue en el árbol de accesibilidad, así que la regla añade
`visibility: hidden` con la transición retrasada (`visibility 0s linear
var(--duration-normal)`) para que el deslizamiento siga animando.
- **En modo icono la fila toma NOMBRE, no descripción**: un tooltip solo
describe (`aria-describedby`); con la etiqueta oculta, el texto de `tooltip`
pasa también a `aria-label`. El tooltip visible es la mitad vidente del mismo
hecho y se suprime en filas con submenú.
- **Convención de icono**: en modo icono sobrevive el PRIMER elemento de la
fila. Envuelve la etiqueta en un elemento si usas ese modo (igual que las
referencias); el nombre no se pierde, `tooltip` compone el `Tooltip` canónico.

@ -1,12 +1,16 @@
<script lang="ts">
/**
* The row. While the panel is collapsed to the icon rail the label is
* hidden by the recipe, so the row would lose its name for sighted users —
* that is what `tooltip` is for: it composes the canonical `Tooltip`, only
* in icon mode (mounting one per row otherwise would be noise).
* The row. While the panel is collapsed to the icon rail the recipe hides the
* label, so the control would be left with NO accessible name — a tooltip
* only *describes* (`aria-describedby`), it never names. So in icon mode the
* `tooltip` text becomes the row's `aria-label` as well; the visible tooltip
* is the sighted half of the same fact, and it is suppressed when the row
* owns a sub-menu (the flyout opens on the same hover and the two surfaces
* would stack on one anchor).
*/
import * as Sidebar from '$soma/components/sidebar';
import { useSidebar, useSidebarItemOr } from '$soma/components/sidebar';
import { mergeProps } from '$soma/props';
import { Tooltip } from '../tooltip';
import type { SidebarMenuButtonProps } from './types';
@ -14,20 +18,26 @@
const sidebar = useSidebar();
const item = useSidebarItemOr();
// A row with a sub-menu already names itself through the flyout that opens
// on the same hover — two surfaces on one anchor would overlap.
const tooltipped = $derived(!!tooltip && sidebar.iconMode && !item?.hasSub);
/** The label is display:none in icon mode — the name has to come from here. */
const iconName = $derived(sidebar.iconMode && tooltip ? tooltip : undefined);
</script>
{#if tooltipped}
<Tooltip>
<Tooltip.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...rest} {...props}>{@render children?.()}</Sidebar.MenuButton>
<!-- mergeProps, not a raw spread: the tooltip's own handlers and the
consumer's must compose instead of one clobbering the other. -->
<Sidebar.MenuButton aria-label={iconName} {...mergeProps(rest, props)}>
{@render children?.()}
</Sidebar.MenuButton>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side={sidebar.side === 'left' ? 'right' : 'left'}>{tooltip}</Tooltip.Content>
</Tooltip>
{:else}
<Sidebar.MenuButton {...rest}>{@render children?.()}</Sidebar.MenuButton>
<Sidebar.MenuButton aria-label={iconName} {...rest}>
{@render children?.()}
</Sidebar.MenuButton>
{/if}

@ -7,17 +7,23 @@
*
* The soma provider decides `mobile` from the SYSTEM breakpoint; this layer
* only reads it (`useSidebar`) and picks the presentation. The `<aside>`
* landmark and its name travel into the Drawer either way, so the a11y
* contract does not change with the viewport.
* landmark and its name travel into the Drawer either way, and the DIALOG
* gets the same name — an unnamed dialog is announced as just «dialog».
*/
import * as Sidebar from '$soma/components/sidebar';
import { useSidebar } from '$soma/components/sidebar';
import { getActiveUix } from '$active-uix';
import { Drawer } from '../drawer';
import type { SidebarPanelProps } from './types';
let { children, ...rest }: SidebarPanelProps = $props();
const uix = getActiveUix();
const sidebar = useSidebar();
const dialogName = $derived(
(rest['aria-label'] as string | undefined) ??
uix.langs.ts('#?components.sidebar.label|Sidebar')
);
</script>
{#if sidebar.mobile}
@ -25,9 +31,16 @@
open={sidebar.open}
onOpenChange={(open) => sidebar.setOpen(open)}
direction={sidebar.side}
dragToDismiss
>
<Drawer.Overlay />
<Drawer.Content size="full">
<!-- The Drawer owns the mobile width (one owner, or the panel floats
inside a wider surface); the sidebar's token supplies the value. -->
<Drawer.Content
width="var(--sidebar-width-mobile)"
aria-label={dialogName}
style="--_drawer-padding: 0;"
>
<Sidebar.Panel {...rest}>{@render children?.()}</Sidebar.Panel>
</Drawer.Content>
</Drawer>

@ -35,6 +35,24 @@
flex-direction: row-reverse;
}
/* Which way «out» is. `transform` has no logical axis, so the SIGN has to be
derived from side × direction: in RTL the flex row reverses, so `side: left`
already puts the panel on the physical right and a hard-coded negative
translate would sweep it ACROSS the page instead of off its own edge.
Order matters — each rule below is at least as specific as the one before. */
[data-sidebar] {
--_sidebar-offcanvas-x: calc(var(--sidebar-width) * -1);
}
[data-sidebar][data-side='right'] {
--_sidebar-offcanvas-x: var(--sidebar-width);
}
[dir='rtl'] [data-sidebar] {
--_sidebar-offcanvas-x: var(--sidebar-width);
}
[dir='rtl'] [data-sidebar][data-side='right'] {
--_sidebar-offcanvas-x: calc(var(--sidebar-width) * -1);
}
/* ── Panel ─────────────────────────────────────────────────────────────── */
[data-sidebar-panel] {
@ -47,9 +65,10 @@
background: var(--sidebar-bg);
border-inline-end: var(--border-width) solid var(--sidebar-border-color);
overflow: hidden;
transition:
inline-size var(--duration-normal) var(--ease-default),
transform var(--duration-normal) var(--ease-default);
/* `transform` only in the base: sliding is compositor work. The `inline-size`
transition — which IS layout every frame — is added ONLY under the icon
mode below, where the inset genuinely has to follow the rail. */
transition: transform var(--duration-normal) var(--ease-default);
}
[data-sidebar][data-side='right'] [data-sidebar-panel] {
@ -58,17 +77,28 @@
}
/* Off-canvas: the panel slides out and stops taking room. Two properties
because a transform alone would leave a 16rem hole in the layout. */
because a transform alone would leave a 16rem hole in the layout, plus
`visibility` — a translated panel is still focusable and still in the
accessibility tree, so without it Tab walks into an invisible sidebar and
a screen reader reads a navigation that is not there. The visibility switch
is delayed by the transition's duration so the slide still animates out. */
[data-sidebar][data-collapsible='offcanvas'][data-state='collapsed'] [data-sidebar-panel] {
inline-size: 0;
transform: translateX(calc(var(--sidebar-width) * -1));
}
[data-sidebar][data-collapsible='offcanvas'][data-state='collapsed'][data-side='right']
[data-sidebar-panel] {
transform: translateX(var(--sidebar-width));
transform: translateX(var(--_sidebar-offcanvas-x));
visibility: hidden;
transition:
inline-size var(--duration-normal) var(--ease-default),
transform var(--duration-normal) var(--ease-default),
visibility 0s linear var(--duration-normal);
}
/* Icon rail: the panel narrows; labels hide, glyphs stay. */
/* Icon rail: the panel narrows; labels hide, glyphs stay. This is the one mode
whose transition has to animate a layout property — the inset follows. */
[data-sidebar][data-collapsible='icon'] [data-sidebar-panel] {
transition:
inline-size var(--duration-normal) var(--ease-default),
transform var(--duration-normal) var(--ease-default);
}
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-panel] {
inline-size: var(--sidebar-width-icon);
}
@ -92,7 +122,9 @@
border: 0;
padding: 0;
background: transparent;
cursor: ew-resize;
/* A pointer, not `ew-resize`: the rail TOGGLES, it does not resize — the
resize cursor promised a drag that is out of scope by design. */
cursor: pointer;
/* Deliberately NO `will-change: transform`: a thin rail jitters at
fractional DPR when it gets its own layer. */
transition: background var(--duration-fast) var(--ease-default);
@ -259,14 +291,18 @@
/* ── Icon mode ─────────────────────────────────────────────────────────── */
/* Everything but the first glyph hides; the row becomes a square. */
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-group-label],
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-badge],
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-action] {
/* Everything but the first glyph hides; the row becomes a square. The FLOATING
sub-menu is excluded: it is a full-width surface beside the rail, so its own
rows keep their badges and actions — the rail's narrowness is not theirs. */
[data-sidebar][data-collapsible='icon'][data-state='collapsed']
:is([data-sidebar-group-label], [data-sidebar-menu-badge], [data-sidebar-menu-action]):not(
[data-sidebar-menu-sub][data-floating] *
) {
display: none;
}
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-button] {
[data-sidebar][data-collapsible='icon'][data-state='collapsed']
[data-sidebar-menu-button]:not([data-sidebar-menu-sub][data-floating] *) {
justify-content: center;
padding-inline: 0;
}
@ -276,7 +312,7 @@
exactly as the references do; a bare text node cannot be hidden by CSS. The
name is not lost: `tooltip` on MenuButton composes the canonical Tooltip. */
[data-sidebar][data-collapsible='icon'][data-state='collapsed']
[data-sidebar-menu-button]
[data-sidebar-menu-button]:not([data-sidebar-menu-sub][data-floating] *)
> *:not(:first-child) {
display: none;
}
@ -291,6 +327,10 @@
padding: var(--sidebar-padding);
padding-inline-start: var(--sidebar-padding);
border-radius: var(--radius-lg);
/* The floating layer mirrors the CONTENT's computed z-index onto its
positioner wrapper, so the band has to be declared here — every other
floating surface in the ecosystem does the same. */
z-index: var(--sidebar-sub-z);
/* Elevation (surface · border · shadow) comes from the `overlay` depth
plane the eidos stamps — the ecosystem's single floating language. */
}
@ -306,9 +346,11 @@
display: block;
}
/* The Drawer owns the mobile WIDTH (the eidos passes `--sidebar-width-mobile`
to its Content): a second width here would leave the panel floating inside a
wider surface, painting two tones of chrome. The panel just fills it. */
[data-sidebar][data-mobile] [data-sidebar-panel] {
inline-size: var(--sidebar-width-mobile);
max-inline-size: 100%;
inline-size: 100%;
border: 0;
transform: none;
block-size: 100%;
@ -317,3 +359,17 @@
[data-sidebar][data-mobile] [data-sidebar-rail] {
display: none;
}
/* ── Reduced motion ────────────────────────────────────────────────────── */
/* The panel's slide is the largest translation in the system; a user who asked
for less motion gets the state change without the journey. */
@media (prefers-reduced-motion: reduce) {
[data-sidebar-panel],
[data-sidebar-rail],
[data-sidebar-menu-button],
[data-sidebar-menu-sub-button],
[data-sidebar][data-collapsible='icon'] [data-sidebar-panel] {
transition: none;
}
}

@ -3247,6 +3247,7 @@
--sidebar-row-radius: var(--radius-md);
--sidebar-row-gap: var(--space-2);
--sidebar-sub-indent: var(--space-4);
--sidebar-sub-z: var(--z-index-overlay-floating);
--sidebar-bg: var(--color-surface-muted);
--sidebar-border-color: var(--color-border-subtle);
--callout-gap: var(--space-3);

@ -4548,6 +4548,9 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'row-radius': 'var(--radius-md)',
'row-gap': 'var(--space-2)',
'sub-indent': 'var(--space-4)',
// Band of the icon-mode flyout. The floating layer mirrors the content's
// computed z-index onto its positioner wrapper (the dropdown-menu note).
'sub-z': 'var(--z-index-overlay-floating)',
bg: 'var(--color-surface-muted)',
'border-color': 'var(--color-border-subtle)'
},

@ -14,7 +14,7 @@ export const sidebarLangs = {
en: 'Toggle sidebar'
},
rail: {
es: 'Redimensionar barra lateral',
en: 'Resize sidebar'
es: 'Contraer barra lateral',
en: 'Collapse sidebar'
}
} satisfies LangNode;

@ -55,7 +55,7 @@ export const sidebarMorfo = {
label: '#?components.sidebar.label|Sidebar',
nav: '#?components.sidebar.nav|Main navigation',
trigger: '#?components.sidebar.trigger|Toggle sidebar',
rail: '#?components.sidebar.rail|Resize sidebar'
rail: '#?components.sidebar.rail|Collapse sidebar'
},
events: [
{
@ -205,7 +205,7 @@ export const sidebarMorfo = {
},
{
attr: 'aria-label',
value: v.translationRef('#?components.sidebar.rail|Resize sidebar'),
value: v.translationRef('#?components.sidebar.rail|Collapse sidebar'),
severity: 'recommended'
}
]

@ -14,6 +14,12 @@ compositional app chrome with a shallow fixed anatomy. A docs shell composes
- **Two axes, never one** (the dossier's correction): `data-state`
(`expanded | collapsed`) is the live state, `data-collapsible`
(`offcanvas | icon | none`) is the MODE that decides what collapsing means.
The mode CLAMPS the state where they would contradict: `'none'` reports
expanded on the desktop (no rule collapses the panel there, so announcing
«collapsed» would lie about a visible panel, with no way back since the
toggle is inert). On mobile the mode is irrelevant — and the toggle stays
LIVE, or `collapsible='none'` would leave a phone with no way to open the
drawer at all.
Both live on the provider, plus `data-side` and `data-mobile`.
`data-collapsible` is stamped ALWAYS — shadcn stamps it only while collapsed,
which forces every rule to guard on state.
@ -45,7 +51,9 @@ compositional app chrome with a shallow fixed anatomy. A docs shell composes
- **The mobile panel has its OWN open state**, starting closed: on the desktop
`open` is a layout preference the app persists; on mobile it would mean an
overlay covering the page. `onOpenChange` therefore fires only for the
desktop state.
desktop state, and the mobile flag is DISCARDED when the viewport leaves the
mobile presentation — otherwise rotating a phone twice re-mounts the Drawer
already open, with its overlay, focus trap and scroll lock engaged.
- **`useSidebar()` / `useSidebarItemOr()`**: read-only reactive views of the
sidebar (`open`, `collapsible`, `side`, `mobile`, `iconMode`, `toggle`,
`setOpen`) and of the enclosing row (`hasSub`, `subOpen`, `floating`). The
@ -57,8 +65,14 @@ compositional app chrome with a shallow fixed anatomy. A docs shell composes
on focus leaving the row and its sub, or on Escape — which returns focus to
the button. The focus path matters as much as the pointer one: a keyboard
user opens it by tabbing to the row, tabs THROUGH its links, and tabbing past
them must not strand an open surface over the page. shadcn simply hides
sub-menus in icon mode, stranding whole branches of the app instead.
them must not strand an open surface over the page. Both exits live on the
`menu-item`, which contains the row AND the sub: the pointer can leave the
row without ever crossing the flyout (straight into the page), Escape has to
work from wherever focus IS (normally the row, not the sub), and travelling
between row and flyout must NOT close it. The flag is also dropped whenever
the presentation changes, or expanding and collapsing again would re-open a
flyout nobody touched. shadcn simply hides sub-menus in icon mode, stranding
whole branches of the app instead.
## Cross-instance ids

@ -3,5 +3,5 @@ export const SIDEBAR_LANGS = {
LABEL: '#?components.sidebar.label|Sidebar',
NAV: '#?components.sidebar.nav|Main navigation',
TRIGGER: '#?components.sidebar.trigger|Toggle sidebar',
RAIL: '#?components.sidebar.rail|Resize sidebar'
RAIL: '#?components.sidebar.rail|Collapse sidebar'
} as const;

@ -2,6 +2,7 @@ import { context, type WithRefOpts } from '../../provider';
import {
readableActive,
state,
watch,
type Active,
type ActiveProps,
type State,
@ -107,12 +108,35 @@ export class SidebarProvider {
owner: this,
context: SidebarProvider.ctx
});
// Leaving the mobile presentation DISCARDS the drawer's open state. It is
// a transient «an overlay is covering the page», not a preference: without
// this, opening the drawer on a phone and rotating to landscape and back
// re-mounts the Drawer already open — overlay, focus trap and scroll lock
// engaged with no user action. Reads `mobile`, writes a different cell.
watch(
() => this.mobile,
(mobile) => {
if (!mobile) this.mobileOpenState.current = false;
}
);
}
/** Expanded — the drawer's state on mobile, the rail's on the desktop. */
readonly open = $derived.by(() =>
this.mobile ? this.mobileOpenState.current : this.opts.open.current
);
/**
* Expanded — the drawer's state on mobile, the rail's on the desktop.
*
* `collapsible: 'none'` PINS it open on the desktop: the mode says the panel
* never collapses, so no recipe rule hides it, and reporting `collapsed`
* would make `data-state` / `aria-expanded` / `useSidebar().open` all lie
* about a panel that is plainly visible — with no way back, since `toggle()`
* is inert in that mode. On MOBILE the mode is irrelevant: there the panel
* is a Drawer that is either presented or not.
*/
readonly open = $derived.by(() => {
if (this.mobile) return this.mobileOpenState.current;
if (this.collapsible === 'none') return true;
return this.opts.open.current;
});
readonly collapsible = $derived.by<SidebarCollapsible>(
() => this.opts.collapsible.current ?? 'offcanvas'
@ -165,12 +189,20 @@ export class SidebarProvider {
});
}
/** `collapsible: 'none'` freezes the DESKTOP rail — never the mobile drawer. */
readonly toggleDisabled = $derived.by(() => !this.mobile && this.collapsible === 'none');
/**
* Flip the panel. Public: the app's global shortcut and its persisted state
* drive the sidebar through here. Inert in `collapsible: 'none'`.
* drive the sidebar through here.
*
* Inert only where «never collapses» means something. On mobile the panel is
* a Drawer that starts CLOSED, so an inert toggle there would leave the whole
* navigation unreachable on phones — the rail is hidden, the drawer has no
* trigger of its own, and nothing else could open it.
*/
toggle(): void {
if (this.collapsible === 'none') return;
if (this.toggleDisabled) return;
void this.runtime.trigger(this.open ? 'emerge-collapse' : 'emerge-expand');
}
@ -228,7 +260,7 @@ class SidebarToggleProvider {
...this.runtimePart.renderProps(),
// Per-instance target id — see the note in SidebarPanelProvider.
'aria-controls': this.provider.panelId.current || undefined,
disabled: this.provider.collapsible === 'none' || undefined,
disabled: this.provider.toggleDisabled || undefined,
onclick: () => this.provider.toggle()
}));
}
@ -419,6 +451,16 @@ export class SidebarMenuItemProvider {
});
this.floatingProvider = shell.floatingProvider;
this.contentPresence = shell.contentPresence;
// A flyout is a transient hover / focus surface, so it must not survive a
// change of presentation: expanding the panel (or leaving icon mode) drops
// the flag, or collapsing again would re-open a flyout nobody touched.
watch(
() => this.floating,
() => {
this.subOpenState.current = false;
}
);
}
/** The sub-menu floats only while the panel is an icon rail. */
@ -431,23 +473,44 @@ export class SidebarMenuItemProvider {
if (this.floating) this.subOpenState.current = true;
}
/**
* Close the flyout. The flag is cleared UNCONDITIONALLY — guarding on
* `floating` used to leave it stuck `true` when the panel expanded mid-hover,
* so collapsing again re-opened a flyout nobody touched.
*/
closeSub(options?: { returnFocus?: boolean }): void {
if (!this.floating) return;
this.subOpenState.current = false;
if (options?.returnFocus) this.soma.dom.focus(this.buttonRef.current);
if (options?.returnFocus && this.floating) this.soma.dom.focus(this.buttonRef.current);
}
/** True while the pointer / focus is anywhere inside this row or its sub. */
private isInside(node: EventTarget | null): boolean {
return !!node && contains(this.opts.ref.current, node as Node);
}
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
// A keyboard user opens the flyout by FOCUSING the row, so it has to
// close when focus leaves the row and its sub — otherwise tabbing past
// it strands an open surface over the page (the pointer path closes on
// `pointerleave`, but focus has no equivalent).
// Both entry paths need their own exit. Focus: a keyboard user opens the
// flyout by focusing the row, tabs THROUGH its links, and tabbing past
// them must not strand an open surface. Pointer: the flyout opens on
// `pointerenter` of the row, and the pointer can leave WITHOUT crossing
// the flyout (straight into the page) — the sub's own `pointerleave`
// would never fire. Both are scoped to the item subtree, which contains
// the row AND the floating sub, so travelling between them keeps it open.
onfocusout: (event: FocusEvent) => {
if (!this.floating) return;
const next = event.relatedTarget as HTMLElement | null;
if (next && contains(this.opts.ref.current, next)) return;
if (this.isInside(event.relatedTarget)) return;
this.closeSub();
},
onpointerleave: (event: PointerEvent) => {
if (this.isInside(event.relatedTarget)) return;
this.closeSub();
},
// Escape has to work from wherever focus IS — normally the row button,
// not the sub — so it is handled at the item, which contains both.
onkeydown: (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.floating || !this.subOpenState.current) return;
event.stopPropagation();
this.closeSub({ returnFocus: true });
}
}));
}
@ -556,7 +619,16 @@ export class SidebarMenuSubProvider {
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.item = SidebarMenuItemProvider.get();
if (this.item) this.item.hasSub.current = true;
// Tell the row it owns a sub — and UN-tell it when this sub goes away, or
// a row that loses its sub-menu would never get its tooltip back.
$effect(() => {
const item = this.item;
if (!item) return;
item.hasSub.current = true;
return () => {
item.hasSub.current = false;
};
});
this.runtimePart = this.provider.runtime.part('menu-sub', {
id: opts.id,
ref: opts.ref,
@ -589,7 +661,10 @@ export class SidebarMenuSubProvider {
onPlaced: readableActive(() => () => {}),
dir: readableActive(() => this.provider.soma.prefs?.getDir() ?? 'ltr'),
style: readableActive(() => null),
enabled: readableActive(() => this.isFloating),
// Engaged only while the sub is actually SHOWN as a flyout: gating
// on the mode alone kept `autoUpdate` (rAF + scroll/resize
// observers) running for every row of the rail, open or not.
enabled: readableActive(() => this.isFloating && this.open),
customAnchor: readableActive(() => null)
});
}
@ -606,16 +681,12 @@ export class SidebarMenuSubProvider {
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
...(this.isFloating && this.floating ? this.floating.props : {}),
onpointerleave: () => this.item?.closeSub(),
// The morfo declares `Escape → close-sub` on this part. Runtime keyboard
// PLANS resolve their handler from the runtime-level `actions` map, which
// is shared by every sub in the sidebar — it cannot know WHICH sub fired.
// So the plan is honoured here, per instance, against this sub's own item.
onkeydown: (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.isFloating) return;
event.stopPropagation();
this.item?.closeSub({ returnFocus: true });
}
// The morfo declares `Escape → close-sub` on this part, and the item
// handles it for the whole subtree (focus normally sits on the ROW, not
// here): runtime keyboard PLANS resolve their handler from the shared
// runtime-level `actions` map, which cannot know which sub fired, and a
// handler bound only to this `<ul>` would never see the row's keydown.
// Leaving / entering is likewise owned by the item, which contains both.
}));
}

@ -230,7 +230,15 @@
<Sidebar.Rail />
<Sidebar.Inset>
<div style="padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);">
<Sidebar.Trigger>{open ? 'Collapse' : 'Expand'}</Sidebar.Trigger>
<!-- Icon-only on purpose: the morfo gives the Trigger a localized
`aria-label`, which would override any visible text label. -->
<Sidebar.Trigger>
{#if open}
<Icon.PanelLeftClose size="sm" />
{:else}
<Icon.PanelLeftOpen size="sm" />
{/if}
</Sidebar.Trigger>
<h2 style="margin: 0; font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold);">
{activeHref}
</h2>
@ -289,16 +297,45 @@
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
The two axes are separate props on purpose: <code>open</code> is the live state (bindable,
so app-land can persist it), <code>collapsible</code> decides what collapsing means. Try
<code>icon</code> and then hover «Projects»: its sub-menu floats beside the rail instead of
disappearing, which is what every reference does.
so app-land can persist it), <code>collapsible</code> decides what collapsing means. To see
the flyout you need BOTH: set <code>collapsible</code> to <code>icon</code>
<em>and</em> collapse the panel — only then does «Projects» float its sub-menu beside the
rail instead of hiding it, which is what every reference does.
<button
data-uix-chip
onclick={() => {
collapsible = 'icon';
open = false;
}}
>
show me
</button>
</p>
{#if collapsible === 'none'}
<p data-uix-section-desc>
<strong>none</strong> pins the panel open: the state chips are inert because the component
itself refuses to collapse in this mode, and reporting «collapsed» would make
<code>data-state</code> and <code>aria-expanded</code> lie about a visible panel.
</p>
{/if}
<div data-uix-controls>
<div data-uix-control>
<span data-uix-control-label>open (state)</span>
<div data-uix-chips>
<button data-uix-chip data-active={open} onclick={() => (open = true)}>expanded</button>
<button data-uix-chip data-active={!open} onclick={() => (open = false)}>
<button
data-uix-chip
data-active={open}
disabled={collapsible === 'none'}
onclick={() => (open = true)}
>
expanded
</button>
<button
data-uix-chip
data-active={!open}
disabled={collapsible === 'none'}
onclick={() => (open = false)}
>
collapsed
</button>
</div>
@ -369,8 +406,12 @@
<p data-uix-section-desc>
The recipe is logical throughout (<code>border-inline-end</code>,
<code>padding-inline</code>), so RTL mirrors the whole shell — including which edge the rail
sits on. Widths are tokens (<code>--sidebar-width</code>,
<code>--sidebar-width-icon</code>), so density and scaling reach them too.
sits on and which way the off-canvas panel slides out (a physical translate sign would sweep
it across the page instead). The three widths (<code>--sidebar-width</code>,
<code>--sidebar-width-icon</code>, <code>--sidebar-width-mobile</code>) are deliberately
FIXED rem: a rail whose width drifted with density would reflow the page every time the user
changed a foundation knob. Density and scaling do reach the interior — row height, paddings,
gaps and the rail's own thickness all ride the spacing scale.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
</section>
@ -412,7 +453,7 @@
'#?components.sidebar.label|Sidebar'
)}», «{uix.langs.ts('#?components.sidebar.nav|Main navigation')}», «{uix.langs.ts(
'#?components.sidebar.trigger|Toggle sidebar'
)}», «{uix.langs.ts('#?components.sidebar.rail|Resize sidebar')}» — so a rail with no visible
)}», «{uix.langs.ts('#?components.sidebar.rail|Collapse sidebar')}» — so a rail with no visible
text is still announced in the reader's language. <strong>adom</strong> supplies the
breakpoint tracker that decides <code>data-mobile</code> (<code>uix.dom.isAtLeast</code>) and
the floating engine that anchors a sub-menu to its row in icon mode. <strong>sema</strong>
@ -460,6 +501,52 @@
Resolved by the system tracker, never a local <code>matchMedia</code>.
</td>
</tr>
<tr>
<td class="name">onOpenChange</td>
<td class="type">(open: boolean) =&gt; void</td>
<td>
Fires for the DESKTOP state — the layout preference an app persists. The mobile
drawer's open/closed is a transient overlay, not a preference, so it stays quiet.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Part props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Prop</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">Sidebar.Panel</td>
<td class="type">aria-label</td>
<td>
Name of the complementary landmark (and of the mobile dialog). Falls back to the
localized «{uix.langs.ts('#?components.sidebar.label|Sidebar')}».
</td>
</tr>
<tr>
<td class="name">Sidebar.Content</td>
<td class="type">aria-label</td>
<td>
Name of the <code>&lt;nav&gt;</code> landmark. Falls back to «{uix.langs.ts(
'#?components.sidebar.nav|Main navigation'
)}».
</td>
</tr>
<tr>
<td class="name">Sidebar.MenuAction</td>
<td class="type">aria-label</td>
<td>Required in practice: the action is a glyph with no text of its own.</td>
</tr>
<tr>
<td class="name">every part</td>
<td class="type">id · child</td>
<td>
Explicit id (ids are generated otherwise) and the asChild snippet, which receives the
part's resolved props.
</td>
</tr>
</tbody>
</table>
</div>
@ -490,8 +577,9 @@
<td class="name">tooltip</td>
<td class="type">string</td>
<td>
Name shown in a composed <code>Tooltip</code> while the panel is an icon rail (where
the label is hidden). Eidos-only.
The row's name while the panel is an icon rail (where the label is hidden): it
becomes the <code>aria-label</code> AND, unless the row owns a sub-menu, the text of
a composed <code>Tooltip</code>. Eidos-only.
</td>
</tr>
</tbody>
@ -539,6 +627,40 @@
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>event</th><th>family · verb</th><th>target</th><th>sequence</th></tr></thead>
<tbody>
{#each sidebarMorfo.events as event (event.name)}
<tr>
<td class="name">{event.name}</td>
<td>{event.semantic.family} · {event.semantic.verb}</td>
<td class="type">panel</td>
<td class="default">{event.semantic.sequence}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>part</th><th>key</th><th>action</th></tr></thead>
<tbody>
<tr>
<td class="name">menu-sub</td>
<td><code>Escape</code></td>
<td>
<code>close-sub</code> — dismisses the floating sub-menu and returns focus to its
row. Handled at the <code>menu-item</code>, which contains both the row and the sub:
focus normally sits on the ROW, and the runtime's shared action map cannot tell which
sub fired.
</td>
</tr>
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
<code>separator</code> and the search input are deliberately NOT parts: they compose the
canonical <code>Separator</code> and <code>Field</code>. Two <code>partRef</code>s
@ -630,9 +752,14 @@
<tr>
<td class="name">Icon mode</td>
<td>
The label is hidden visually, so the row's name comes from the composed
<code>Tooltip</code> (<code>tooltip</code> prop). Sub-menus float beside the rail
instead of disappearing, and Escape closes one returning focus to its row.
The label is hidden visually, so the <code>tooltip</code> text becomes the row's
<code>aria-label</code> — a tooltip only <em>describes</em>
(<code>aria-describedby</code>), it never names, so relying on it alone would leave
the control anonymous. The visible tooltip is the sighted half of the same fact, and
it is suppressed on a row that owns a sub-menu (the flyout opens on the same hover;
the name still comes through the label). Sub-menus float beside the rail instead of
disappearing; they close on pointer-leave, on focus leaving the row, and on Escape,
which returns focus to the row.
</td>
</tr>
<tr>

Loading…
Cancel
Save

Powered by TurnKey Linux.