diff --git a/src/uix/eidos/components/menubar/README.md b/src/uix/eidos/components/menubar/README.md index 399ba982e..08c90e08b 100644 --- a/src/uix/eidos/components/menubar/README.md +++ b/src/uix/eidos/components/menubar/README.md @@ -40,7 +40,8 @@ las partes internas por-menú ya no son propias — son re-exports de - Partes propias: root (`role="menubar"`, horizontal), `Menu` (scope por menú), `Trigger` (`role="menuitem"`, `data-state`/`data-highlighted`/ - `data-menubar-value`), `Content` (panel). + `data-menubar-value`), `Content` (panel de menú) y `Panel` (superficie + `role="dialog"` para controles de formulario — alternativa a Content). - Partes re-exportadas de DropdownMenu: `Item`, `Group`, `GroupHeading`, `Separator`, `CheckboxItem`, `CheckboxGroup`, `RadioGroup`, `RadioItem`, `Sub`, `SubTrigger`, `SubContent`, `Arrow`. @@ -62,6 +63,13 @@ las partes internas por-menú ya no son propias — son re-exports de | Checkbox/Radio items + submenús | ✓ | ✓ | ✓ | ✓ | — | | `loop` + RTL | ✓ | ✓ | ✓ | ✓ | — | | Evento perceptivo en el select | ✓ (`commit-select`) | No | No | No | — | +| **Superficie panel (role=dialog) por entrada** | ✓ (`Menubar.Panel`) | No | No | No | No | + +`Menubar.Panel` es una EXTENSIÓN sobre las referencias headless (ninguna lo +modela): los precedentes son las barras reales que mezclan menús y paneles — +los menu extras de macOS (Wi-Fi / Control Center: trigger en barra, panel +no-menu, Esc cierra, las flechas operan el panel), las galerías del ribbon de +Office y los paneles de barra de Canva. Referencias: [Radix Menubar](https://www.radix-ui.com/primitives/docs/components/menubar) · [Bits UI Menubar](https://bits-ui.com/docs/components/menubar) · @@ -82,6 +90,53 @@ Referencias: [Radix Menubar](https://www.radix-ui.com/primitives/docs/components automáticamente y un fix visual del dropdown alcanza al menubar sin drift. - **`data-menubar-value` en el trigger** enlaza trigger↔menú para el estado controlado (`value`/`onValueChange`). +- **`Menubar.Panel` — superficie de formulario por entrada** (2026-07-16, + encargo del editor palabras `panelMode='menubar'` v2, componente genérico). + Motivo: el Content de menú intercepta ↑↓/Home/End + typeahead (navegación + de items) y eso rompe cualquier control de formulario compuesto dentro. El + Panel es un part propio del morfo (`data-menubar-panel`, `role="dialog"`, + `aria-labelledby`→trigger; el trigger pasa a `aria-haspopup="dialog"`) que + NO añade teclado propio: las teclas dentro pertenecen a sus controles (las + flechas ←→ NO cruzan menús desde dentro — mueven carets/sliders; Esc + devuelve a la barra). La coordinación de barra (roving, hover-slide, + keyboard-slide, `commit-select`, un-abierto-a-la-vez) queda intacta porque + vive en el Trigger/Provider. Presencia = tipo (N-7): componer `` en + vez de `` declara la superficie. Cableado: reusa el shell flotante + del DropdownMenu anidado (anchor del trigger, presence, canal + `contentId`/`setContentRef`), con FocusScope/Dismissal propios y SIN + TextSelection (los inputs necesitan selección) ni ScrollLock. Foco: + apertura por teclado → primer control; por puntero → la superficie (hover + jamás roba el foco a un input). Elevación vía `data-depth="overlay"` + + tokens públicos `--menubar-panel-*` (z · min-width · max-height · padding · + radius). +- **Mega-panel = `customAnchor={barra}` a secas** (2026-07-17). El ancho es + automático: soma pre-mide el anchor (`--menubar-panel-anchor-width` en el + elemento) y la recipe dimensiona la superficie `data-anchored` con + `contain: inline-size` — el positioner flotante mide el WRAPPER + (`min-width: max-content`) y la intrínseca de un grid (suma de tracks) lo + inflaba, volteando el placement; el containment fija la contribución al + ancho usado (en modo anclado el ancho viene del anchor, nunca del + contenido). Regla de consumo: el `style` del Panel viste al POSITIONER + (mismo plumbing que todo Content del catálogo) — el layout del contenido + (grid/flex) va en un elemento INTERNO. +- **La apertura sigue al roving por teclado** (2026-07-16): con un menú + abierto, ArrowLeft/Right/Home/End sobre los triggers deslizan la apertura al + destino — el espejo de teclado del hover-follow (la doctrina del + `commit-select` ya lo prometía). El slide vive en la navegación explícita + del provider, no en `onfocus`: un listener de focus no distingue el roving + del focus-return del FocusScope al cerrarse el menú que se desliza. Ese + focus-return se suprime en `` (patrón Radix: `preventDefault` + en `onCloseAutoFocus` cuando otro menú del menubar queda abierto); el cierre + simple (Escape / outside / select) conserva el retorno de foco normal. +- **Toda apertura por teclado aterriza en el PRIMER ITEM activo** (2026-07-16, + decisión de usuario tras confusión real: un panel abierto sin opción activa + no dice cómo navegar). Cubre ArrowDown/Enter/Space en el trigger, el slide + ←→/Home/End entre triggers y el cross-menu desde dentro de un panel — la + señal viaja como `openReason: 'keyboard'` del menubar al DropdownMenu + interno, cuyo autofocus enfoca `getItems()[0]` en vez del panel (APG "focus + first item"; Word hace lo mismo en el slide). Las aperturas por PUNTERO + (click, hover-follow) mantienen el foco en el panel: ahí resalta el hover y + un item pre-activado sería una fila fantasma bajo el cursor. ## Gaps @@ -90,6 +145,8 @@ Referencias: [Radix Menubar](https://www.radix-ui.com/primitives/docs/components | Tests del wrapper eidos (el soma tiene suite ✓) | **diferir** | Pasada SYS-2 conjunta. | | API extendida sobre las referencias (p. ej. trigger-on-hover configurable) | **diferir** | Paridad Radix/Bits ya alcanzada; sin caso real. | | Intent del `commit-select` fijo en `affirm` | **descartar** | Seleccionar una entrada de menú no carga evaluación variable; el intent por-evento es intrínseco (doctrina per-event intent). | +| Typeahead a nivel de barra (menubar cerrado salta al trigger por inicial) | **diferir** | APG lo lista; ni Radix ni Bits lo implementan (el typeahead vive dentro del menú abierto, ✓ aquí). Paridad mantenida; sin caso real. | +| `ArrowUp` en el trigger abre enfocando el último item | **diferir** | Radix lo soporta; exige que el DropdownMenu compuesto acepte "open with last item highlighted". Se aborda en el dropdown, no con un hack local. | ## Referencias diff --git a/src/uix/eidos/components/menubar/index.ts b/src/uix/eidos/components/menubar/index.ts index 5a6d799e2..bd0cc810e 100644 --- a/src/uix/eidos/components/menubar/index.ts +++ b/src/uix/eidos/components/menubar/index.ts @@ -40,6 +40,7 @@ import MenubarComponent from './menubar.svelte'; import Menu from './menubar-menu.svelte'; import Trigger from './menubar-trigger.svelte'; import Content from './menubar-content.svelte'; +import Panel from './menubar-panel.svelte'; // Per-menu inner parts: re-use the eidos DropdownMenu wrappers so the // menu-indicator shared partial (commit a2709476) applies and we don't @@ -54,6 +55,7 @@ type MenubarNamespace = typeof MenubarComponent & { Trigger: typeof Trigger; Portal: typeof Portal; Content: typeof Content; + Panel: typeof Panel; Item: typeof _DropdownMenu.Item; Group: typeof _DropdownMenu.Group; GroupHeading: typeof _DropdownMenu.GroupHeading; @@ -73,6 +75,7 @@ Menubar.Menu = Menu; Menubar.Trigger = Trigger; Menubar.Portal = Portal; Menubar.Content = Content; +Menubar.Panel = Panel; Menubar.Item = _DropdownMenu.Item; Menubar.Group = _DropdownMenu.Group; Menubar.GroupHeading = _DropdownMenu.GroupHeading; @@ -94,6 +97,7 @@ export type { MenubarMenuProps as MenuProps, MenubarTriggerProps as TriggerProps, MenubarContentProps as ContentProps, + MenubarPanelProps as PanelProps, MenubarArrowProps as ArrowProps, MenubarItemProps as ItemProps, MenubarGroupProps as GroupProps, diff --git a/src/uix/eidos/components/menubar/menubar-panel.svelte b/src/uix/eidos/components/menubar/menubar-panel.svelte new file mode 100644 index 000000000..042d20645 --- /dev/null +++ b/src/uix/eidos/components/menubar/menubar-panel.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/menubar/menubar.css b/src/uix/eidos/components/menubar/menubar.css index d4a886554..0b1776b06 100644 --- a/src/uix/eidos/components/menubar/menubar.css +++ b/src/uix/eidos/components/menubar/menubar.css @@ -135,14 +135,86 @@ * BAR — "the space is the menu, not the option". The shared positioner reads * `--floating-gap` off the content; these (0,2,0) beat the foundation * `[data-floating-gap='menu']` (0,1,0). */ -[data-menubar-content][data-size='md'] { +[data-menubar-content][data-size='md'], +[data-menubar-panel][data-size='md'] { --floating-gap: calc(var(--floating-gap-menu) + var(--space-1) + 1px); } -[data-menubar-content][data-size='sm'] { +[data-menubar-content][data-size='sm'], +[data-menubar-panel][data-size='sm'] { --floating-gap: calc(var(--floating-gap-menu) + var(--space-1) * 0.5 + 1px); } -[data-menubar-content][data-size='lg'] { +[data-menubar-content][data-size='lg'], +[data-menubar-panel][data-size='lg'] { --floating-gap: calc(var(--floating-gap-menu) + var(--space-2) + 1px); } + +/* ── Panel — per-entry role=dialog form surface ────────────────────────── + * The alternative to Content for a bar entry: a free surface for form + * controls (Fields, selects, sliders). It does NOT carry + * `data-dropdown-menu-content` (it is the menubar's own part), so the + * dropdown recipe never paints it: elevation (surface · border · shadow) + * comes from the `overlay` plane via `data-depth` (stamped by the eidos + * wrapper, same as Content); everything else is defined here. Portal + * typography rule: font-family + line-height + color anchored on the + * portaled root. */ +[data-menubar-panel] { + /* Portaled root: bring box-sizing along so a consumer-set + * `inline-size: var(--floating-anchor-width)` (mega-panel idiom) matches + * the anchor EDGE to edge, padding and border included. */ + box-sizing: border-box; + z-index: var(--menubar-panel-z); + min-inline-size: var(--menubar-panel-min-width); + /* Cap the INTRINSIC size: the floating wrapper measures the panel's + * max-content (a grid's track sum can dwarf its used inline-size), and an + * inflated measurement makes the positioner flip/clamp the placement. */ + max-inline-size: 100vw; + max-block-size: var(--menubar-panel-max-height); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--menubar-panel-padding); + border-radius: var(--menubar-panel-radius); + font-family: var(--style-label-font-family); + font-size: var(--font-size-md); + line-height: var(--leading-ui); + color: var(--color-content-primary); +} + +[data-menubar-panel][data-size='sm'] { + font-size: var(--font-size-sm); +} + +[data-menubar-panel][data-size='lg'] { + font-size: var(--font-size-lg); +} + +/* Anchored (mega-panel): the panel matches its custom anchor's width. Soma + * pre-measures the anchor and publishes `--menubar-panel-anchor-width` on + * the element; until that lands (one frame on the very first open) the cap + * falls back to the panel's min-width so the positioner's FIRST measurement + * is already small — align start then never flips, and the panel simply + * widens in place when the var arrives. */ +[data-menubar-panel][data-anchored] { + /* The per-size `--floating-gap` rules above compensate the trigger's + * inset INSIDE the bar (bar padding + 1px centering) so a trigger-anchored + * surface reads the canonical menu gap FROM THE BAR. Anchored to the bar + * itself, the positioner already measures from the bar edge — the canon + * applies raw, no compensation. */ + --floating-gap: var(--floating-gap-menu); + inline-size: var(--menubar-panel-anchor-width, var(--menubar-panel-min-width)); + max-inline-size: var(--menubar-panel-anchor-width, var(--menubar-panel-min-width)); + /* The positioner measures the floating WRAPPER (min-width: max-content), + * and a grid child contributes its TRACK-SUM intrinsic size there — width + * caps on the grid itself don't stop it, so an inflated measurement flips + * the placement. Inline-size containment makes this panel's contribution + * its used width: in anchored mode the width comes from the anchor, never + * from the content. */ + contain: inline-size; +} + +/* Focus lives on the panel's controls; the surface itself (focused on + * pointer opens) shows no ring. Same treatment as the menu panels. */ +[data-menubar-panel]:focus-visible { + outline: none; +} diff --git a/src/uix/eidos/components/menubar/types.ts b/src/uix/eidos/components/menubar/types.ts index c1b1997a8..17e14f1b7 100644 --- a/src/uix/eidos/components/menubar/types.ts +++ b/src/uix/eidos/components/menubar/types.ts @@ -4,6 +4,7 @@ import type { MenuProps, TriggerProps, ContentProps, + PanelProps, ArrowProps, ItemProps, GroupProps, @@ -40,6 +41,7 @@ export type MenubarProps = ProviderProps & { export type MenubarMenuProps = MenuProps; export type MenubarTriggerProps = TriggerProps; export type MenubarContentProps = ContentProps; +export type MenubarPanelProps = PanelProps; // Re-exported from DropdownMenu — Menubar's per-menu inner parts // (Item, Group, Separator, Checkbox/RadioItem, Sub-tree) are literally diff --git a/src/uix/morfo/components/menubar.ts b/src/uix/morfo/components/menubar.ts index 939ea3716..f47867efe 100644 --- a/src/uix/morfo/components/menubar.ts +++ b/src/uix/morfo/components/menubar.ts @@ -54,7 +54,10 @@ export const menubarMorfo = { ], aria: [ { attr: 'type', value: v.literal('button') }, - { attr: 'aria-haspopup', value: v.literal('menu') }, + // 'menu' when the entry drops a Content (role=menu), 'dialog' when + // it drops a Panel (role=dialog) — resolved from the surface the + // consumer composed inside this entry's Menu scope. + { attr: 'aria-haspopup', value: v.propRef('haspopup') }, { attr: 'aria-expanded', value: v.stateRef('open') } ], keyboard: [ @@ -66,6 +69,27 @@ export const menubarMorfo = { { key: 'Home', action: 'first-item' }, { key: 'End', action: 'last-item' } ] + }, + { + name: 'Panel', + kebab: 'panel', + archetype: 'content', + kind: 'public', + defaultElement: 'div', + role: 'dialog', + optional: true, + states: ['open', 'closed'], + data: [ + // data-side / data-align are floating-layer attrs (same as the + // dropdown's Content — the layer emits them, the morfo doesn't + // model them). + { attr: 'data-state', values: ['open', 'closed'] }, + // Present when the panel is anchored to a custom element (the + // mega-panel idiom) — the recipe caps the intrinsic size from it. + { attr: 'data-anchored', severity: 'optional' } + ], + aria: [{ attr: 'aria-labelledby', value: v.partRef('trigger') }], + keyboard: [{ key: 'Escape', action: 'close' }] } ] } as const satisfies Morfo; diff --git a/web/routes/uix/components/menubar/+page.svelte b/web/routes/uix/components/menubar/+page.svelte index 8cc18af23..7935f19b9 100644 --- a/web/routes/uix/components/menubar/+page.svelte +++ b/web/routes/uix/components/menubar/+page.svelte @@ -3,6 +3,9 @@ Menubar, type MenubarSize } from '$uix/eidos/components/menubar'; + import { Field } from '$uix/eidos/components/field'; + import { Slider } from '$uix/eidos/components/slider'; + import { Kbd } from '$uix/eidos/components/kbd'; import { compileMorfo } from '$uix/morfo'; import { menubarMorfo } from '@/uix/morfo/components/menubar'; import { getActiveUix } from '$active-uix'; @@ -23,6 +26,38 @@ let theme = $state<'light' | 'dark' | 'system'>('system'); let viewLayers = $state(['ruler', 'grid']); + // Bound state from the Format PANEL surface (form controls, not menu items) + let docTitle = $state('Untitled document'); + let docWidth = $state([60]); + + // Panel anchor: the whole bar (mega-menu, default here so the showcase is + // visible on open) or the entry's trigger (compact form panel; toggle off). + let anchorBar = $state(true); + let barEl = $state(null); + + // Mega-menu link columns: icon + label + sublabel + shortcut. Each row + // traces through `last` (the bar's commit-select fires on the trigger). + const megaColumns = [ + { + heading: 'Text', + items: [ + { label: 'Bold', sub: 'Heavier weight', keys: ['⌘', 'B'], icon: 'M7 5h6a3.5 3.5 0 0 1 0 7H7z M7 12h7a3.5 3.5 0 0 1 0 7H7z' }, + { label: 'Italic', sub: 'Slanted emphasis', keys: ['⌘', 'I'], icon: 'M10 5h6 M8 19h6 M14 5l-4 14' }, + { label: 'Underline', sub: 'Underlined run', keys: ['⌘', 'U'], icon: 'M6 4v6a6 6 0 0 0 12 0V4 M5 20h14' }, + { label: 'Inline code', sub: 'Monospace span', keys: ['⌘', 'E'], icon: 'M8 6l-6 6 6 6 M16 6l6 6-6 6' } + ] + }, + { + heading: 'Paragraph', + items: [ + { label: 'Align left', sub: 'Ragged right', keys: ['⌘', '⇧', 'L'], icon: 'M4 6h16 M4 12h10 M4 18h13' }, + { label: 'Align center', sub: 'Centered block', keys: ['⌘', '⇧', 'E'], icon: 'M4 6h16 M7 12h10 M6 18h12' }, + { label: 'Bulleted list', sub: 'Unordered items', keys: ['⌘', '⇧', '8'], icon: 'M9 6h11 M9 12h11 M9 18h11 M4 6h.01 M4 12h.01 M4 18h.01' }, + { label: 'Blockquote', sub: 'Pull a quote', keys: ['⌘', '⇧', '9'], icon: 'M6 7h5v6a4 4 0 0 1-4 4 M13 7h5v6a4 4 0 0 1-4 4' } + ] + } + ]; + let lastSelected = $state(null); function pickItem(label: string) { return () => { @@ -34,6 +69,10 @@ let trace = $state([]); let stageRef = $state(null); + $effect(() => { + barEl = stageRef?.querySelector('[data-menubar]') ?? null; + }); + $effect(() => { const el = stageRef; if (!el) return; @@ -114,6 +153,25 @@ ' Quit', ' ', ' ', + '', + ' ', + ' ', + ' Format', + anchorBar ? ' ' : false, + anchorBar ? ' ' : ' ', + ' ', + anchorBar ? '
' : '
', + anchorBar && ' ', + anchorBar && '
', + anchorBar && ' …', + anchorBar && '
', + anchorBar && '
…
', + !anchorBar && ' …', + !anchorBar && ' ', + '
', + ' ', + ' ', '' ] .filter(Boolean) @@ -122,6 +180,25 @@
+ {#snippet panelSettings()} + + Title + + + + Typeahead-proof: typing stays in the input. + + + Content width — {docWidth[0]}ch + + + + + + + Arrow keys drive the slider, not the bar. + + {/snippet}
Navigation · Menubar

Menubar

@@ -133,7 +210,11 @@ Item / Sub / Checkbox / Radio chrome is shared with DropdownMenu — same parts, same recipe, same menu-indicator (checkbox/radio glyphs match the standalone - Checkbox / RadioGroup). + Checkbox / RadioGroup). An entry can drop + a Menubar.Panel instead of a Content: a + role="dialog" form surface (see “Format”) with NO + typeahead and NO arrow interception — keys inside it belong to its + controls, while the bar keeps roving, hover-slide and Escape.

@@ -159,7 +240,13 @@
- + File @@ -231,6 +318,74 @@ + + Format + + + + {#if anchorBar} + +
+ + + {#each megaColumns as col (col.heading)} +
+ {col.heading} + {#each col.items as it (it.label)} + + {/each} +
+ {/each} +
+ {:else} +
+ {@render panelSettings()} +
+ {/if} +
+
+ Help @@ -263,7 +418,7 @@
@@ -353,6 +515,14 @@ {/each} + +
@@ -379,7 +549,7 @@

API reference

- Three menubar-specific parts (Provider, Menu, Trigger, Content) + Menubar-specific parts (Provider, Menu, Trigger, Content, Panel) plus DropdownMenu's full inner surface for Item / Sub / Checkbox / Radio. The inner parts share recipes with DropdownMenu — the @@ -434,6 +604,21 @@

+
Panel
+
+ + + + + + + + + + +
PropTypeDefaultDescription
(surface)role="dialog"—Form surface alternative to Content: NO typeahead, NO arrow interception — keys belong to its controls. Trigger flips to aria-haspopup="dialog". Escape closes + returns focus to the trigger.
side · sideOffset · align · alignOffset · avoidCollisions · …FloatingLayerProps—Same floating surface as Content (anchored to the bar trigger).
customAnchorHTMLElement | nullnullAnchor override. Pass the BAR element (mega-panel) and the panel spans it edge-to-edge automatically (pre-measured width + intrinsic containment via data-anchored). Content layout goes on an INNER element — the Panel's style prop dresses the floating positioner.
forceMountbooleanfalseKeep mounted while closed (exit animations).
onOpenAutoFocus / onCloseAutoFocus / onEscapeKeydown / onInteractOutside(e) => void—Cancelable hooks, same shape as Popover's.
data-size eidosautofrom Menubar rootStamped via context, like Content.
+
+
Inner parts (shared with DropdownMenu)

Item, Group, GroupHeading, @@ -645,3 +830,159 @@ {/if}

+ +