diff --git a/src/uix/eidos/archetypes.css b/src/uix/eidos/archetypes.css index c3634578c..32bbb4bf9 100644 --- a/src/uix/eidos/archetypes.css +++ b/src/uix/eidos/archetypes.css @@ -125,11 +125,16 @@ [data-archetype='option'] { cursor: pointer; user-select: none; - /* Row height = line-height (scales with the font-size per size) + this - padding, a `--space-*` token that scales with the density mode (compact - 0.84 / comfortable 1 / spacious 1.16) and `--scaling`. No fixed control - height, no `em`. The inter-row gap is set per list (also a `--space-*`). */ - padding-block: var(--space-1-5); + /* Height: a CONTROL-HEIGHT FLOOR (not a clamp), so a list row scales 1:1 + with the control sizes — same model as the menu / listbox surfaces. The + `--list-item-*` tokens are set by `lib/list-surface.css` when the row lives + under a `[data-list-surface][data-size]` panel; the var() fallbacks keep + the prior font-driven behaviour for any non-list `item` consumer where + `--list-item-*` is unset (zero regression off a list-surface). `min-block- + size` (FLOOR) lets multi-line / description options still grow taller. The + padding is internal rhythm; the inter-row gap is set per list. */ + min-block-size: var(--list-item-height, auto); + padding-block: var(--list-item-py, var(--space-1-5)); } /* Canonical "active option" highlight — hover, keyboard highlight diff --git a/src/uix/eidos/components/combobox/combobox-content.svelte b/src/uix/eidos/components/combobox/combobox-content.svelte index c7d4057cc..1843dea43 100644 --- a/src/uix/eidos/components/combobox/combobox-content.svelte +++ b/src/uix/eidos/components/combobox/combobox-content.svelte @@ -3,6 +3,7 @@ import * as Combobox from '$soma/components/combobox'; import { composeInlineStyle } from '$uix/eidos/lib/style'; import { getComboboxVisualContext } from './context.svelte'; + import '../../lib/list-surface.css'; import type { ComboboxContentProps } from './types'; let { @@ -37,6 +38,7 @@ {...rest} customAnchor={anchorEl} style={composedStyle} + data-list-surface="" data-size={resolvedSize} data-variant={resolvedVariant} data-color={resolvedColor} diff --git a/src/uix/eidos/components/command/command.svelte b/src/uix/eidos/components/command/command.svelte index 6f68a659b..1f7878da6 100644 --- a/src/uix/eidos/components/command/command.svelte +++ b/src/uix/eidos/components/command/command.svelte @@ -37,6 +37,7 @@ import * as Command from '$soma/components/command'; import type { CommandProps } from './types'; import { bindCommandShortcut } from './command-shortcut.svelte'; + import '../../lib/list-surface.css'; let { size = 'md', @@ -68,7 +69,7 @@ }); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/context-menu/context-menu-content.svelte b/src/uix/eidos/components/context-menu/context-menu-content.svelte index d5741dc83..9616f4977 100644 --- a/src/uix/eidos/components/context-menu/context-menu-content.svelte +++ b/src/uix/eidos/components/context-menu/context-menu-content.svelte @@ -4,14 +4,25 @@ * (carrying `data-animation-style`) fade in staggered on open. Same mechanism * as DropdownMenu; see its README → Motion. The rhythm `--motion-stagger-each` * comes from the recipe; the per-item index from `:nth-child` (structure). + * + * `size` is the menu's typographic + density scale (`data-size`, 1:1 with the + * control sizes). It tints the recipe's per-size tokens on the items and rides + * the size context to every fly-out submenu (which portal out of this subtree). */ + import { ActiveEidos } from '$uix/eidos'; import * as ContextMenu from '$soma/components/context-menu'; + import { setListSurfaceSizeContext } from '../../lib/list-surface-context'; + import '../../lib/list-surface.css'; import type { ContextMenuContentProps } from './types'; - let { children: bodyContent, ...rest }: ContextMenuContentProps = $props(); + let { size = 'md', children: bodyContent, ...rest }: ContextMenuContentProps = $props(); + + const eidos = ActiveEidos.require(); + const resolvedSize = $derived(eidos.resolve(size, 'md')); + setListSurfaceSizeContext({ getSize: () => resolvedSize }); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/context-menu/context-menu-sub-content.svelte b/src/uix/eidos/components/context-menu/context-menu-sub-content.svelte index d53a5ef5d..a7dc47867 100644 --- a/src/uix/eidos/components/context-menu/context-menu-sub-content.svelte +++ b/src/uix/eidos/components/context-menu/context-menu-sub-content.svelte @@ -2,14 +2,25 @@ /** * `data-stagger`: a fly-out submenu cascades its items in on open, same as the * root Content. See the DropdownMenu README -> Motion. + * + * `size` matches the root menu by default — it reads the size context the + * Content publishes (the submenu portals out of the Content's subtree, so CSS + * inheritance can't carry it). An explicit `size` prop overrides per submenu. */ + import { ActiveEidos } from '$uix/eidos'; import * as ContextMenu from '$soma/components/context-menu'; + import { getListSurfaceSizeContext } from '../../lib/list-surface-context'; + import '../../lib/list-surface.css'; import type { ContextMenuSubContentProps } from './types'; - let { children: bodyContent, ...rest }: ContextMenuSubContentProps = $props(); + let { size, children: bodyContent, ...rest }: ContextMenuSubContentProps = $props(); + + const eidos = ActiveEidos.require(); + const sizeCtx = getListSurfaceSizeContext(); + const resolvedSize = $derived(eidos.resolve(size ?? sizeCtx?.getSize(), 'md')); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/context-menu/context-menu.css b/src/uix/eidos/components/context-menu/context-menu.css index 6f19d2128..69ecde6d1 100644 --- a/src/uix/eidos/components/context-menu/context-menu.css +++ b/src/uix/eidos/components/context-menu/context-menu.css @@ -40,6 +40,27 @@ outline: none; } +/* ── Size scale → shared list-surface ─────────────────────────────────── + * + * The size rhythm (font / item-height / padding / gap / content-padding / + * radius, 1:1 with the control sizes) lives in `lib/list-surface.css` — the + * ONE source shared by every menu + selection popup. The panel carries + * `data-list-surface` + `data-size` (stamped by the eidos Content/SubContent), + * so that block sets `--list-*`; here we just BRIDGE the panel's own public + * tokens to it, keeping them as the per-component override knobs. Mirrors the + * DropdownMenu bridge — both menus read identically. + */ +[data-context-menu-content], +[data-context-menu-sub-content] { + --context-menu-content-font-size: var(--list-font-size); + --context-menu-item-height: var(--list-item-height); + --context-menu-item-py: var(--list-item-py); + --context-menu-item-px: var(--list-item-px); + --context-menu-item-gap: var(--list-item-gap); + --context-menu-content-padding: var(--list-content-padding); + --context-menu-content-radius: var(--list-content-radius); +} + /* Event-driven item cascade rhythm — the Content wrapper marks itself `[data-stagger]`; each direct-child item carries `data-animation-style` and gets its structural index from `:nth-child`. `var(--motion-stagger)` is the diff --git a/src/uix/eidos/components/context-menu/types.ts b/src/uix/eidos/components/context-menu/types.ts index afcaa76e3..cf5c27662 100644 --- a/src/uix/eidos/components/context-menu/types.ts +++ b/src/uix/eidos/components/context-menu/types.ts @@ -23,10 +23,23 @@ import type { SubTriggerProps, SubContentProps } from '$soma/components/context-menu'; +import type { ButtonSize } from '$uix/eidos/components/button'; +import type { ResponsiveProp } from '$uix/eidos/lib/types'; + +/** + * Typographic + density scale for the menu. `size N → control N` (1:1) — a menu + * is content, so it scales like a Field input, not one step below. The scale + * flows to the items and (via context) to every fly-out submenu. Mirrors + * DropdownMenu — both ride the shared `list-surface` size layer. @default 'md' + */ +export type ContextMenuSize = ButtonSize; export type ContextMenuProps = ProviderProps; export type ContextMenuTriggerProps = TriggerProps; -export type ContextMenuContentProps = ContentProps; +export type ContextMenuContentProps = ContentProps & { + /** Menu scale; propagates to items + submenus. @default 'md' */ + size?: ResponsiveProp; +}; export type ContextMenuArrowProps = ArrowProps; export type ContextMenuItemProps = ItemProps; export type ContextMenuGroupProps = GroupProps; @@ -37,4 +50,7 @@ export type ContextMenuRadioGroupProps = RadioGroupProps; export type ContextMenuRadioItemProps = RadioItemProps; export type ContextMenuSubProps = SubProps; export type ContextMenuSubTriggerProps = SubTriggerProps; -export type ContextMenuSubContentProps = SubContentProps; +export type ContextMenuSubContentProps = SubContentProps & { + /** Override the inherited menu size for this submenu. @default inherited */ + size?: ResponsiveProp; +}; diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte index 0d08fd294..d5849a829 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-content.svelte @@ -12,17 +12,29 @@ * index from `:nth-child` and enter together when the menu opens. The rhythm * is the recipe's `--motion-stagger-each` (var(--motion-stagger), 0 = parallel). * Nobody writes the index; the appearance flows from the `open` (emerge) event. + * + * `size` is the menu's typographic + density scale (`data-size`, 1:1 with the + * control sizes). It tints the recipe's per-size tokens on the items and rides + * the size context to every fly-out submenu (which portal out of this subtree). */ + import { ActiveEidos } from '$uix/eidos'; import * as DropdownMenu from '$soma/components/dropdown-menu'; + import { setListSurfaceSizeContext } from '../../lib/list-surface-context'; + import '../../lib/list-surface.css'; import type { DropdownMenuContentProps } from './types'; let { + size = 'md', children: bodyContent, ...rest }: DropdownMenuContentProps = $props(); + + const eidos = ActiveEidos.require(); + const resolvedSize = $derived(eidos.resolve(size, 'md')); + setListSurfaceSizeContext({ getSize: () => resolvedSize }); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte index 35593ae47..7fa4b182e 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu-sub-content.svelte @@ -3,14 +3,25 @@ * `data-stagger`: a fly-out submenu cascades its items in on open, same as the * root Content (its items also carry `data-animation-style="fade"`). See the * DropdownMenu README -> Motion. + * + * `size` matches the root menu by default — it reads the size context the + * Content publishes (the submenu portals out of the Content's subtree, so CSS + * inheritance can't carry it). An explicit `size` prop overrides per submenu. */ + import { ActiveEidos } from '$uix/eidos'; import * as DropdownMenu from '$soma/components/dropdown-menu'; + import { getListSurfaceSizeContext } from '../../lib/list-surface-context'; + import '../../lib/list-surface.css'; import type { DropdownMenuSubContentProps } from './types'; - let { children: bodyContent, ...rest }: DropdownMenuSubContentProps = $props(); + let { size, children: bodyContent, ...rest }: DropdownMenuSubContentProps = $props(); + + const eidos = ActiveEidos.require(); + const sizeCtx = getListSurfaceSizeContext(); + const resolvedSize = $derived(eidos.resolve(size ?? sizeCtx?.getSize(), 'md')); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 0cb7e9522..6eca8f335 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -30,6 +30,7 @@ * content (e.g. selected toggle-group chips with z-index 1). The soma * floating layer reads this computed z-index and mirrors it onto the * positioner wrapper. */ + z-index: var(--dropdown-menu-content-z); min-inline-size: var(--dropdown-menu-content-min-width, 12rem); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); @@ -50,6 +51,26 @@ outline: none; } +/* ── Size scale → shared list-surface ─────────────────────────────────── + * + * The size rhythm (font / item-height / padding / gap / content-padding / + * radius, 1:1 with the control sizes) lives in `lib/list-surface.css` — the + * ONE source shared by every menu + selection popup. The panel carries + * `data-list-surface` + `data-size` (stamped by the eidos Content/SubContent), + * so that block sets `--list-*`; here we just BRIDGE the panel's own public + * tokens to it, keeping them as the per-component override knobs. + */ +[data-dropdown-menu-content], +[data-dropdown-menu-sub-content] { + --dropdown-menu-content-font-size: var(--list-font-size); + --dropdown-menu-item-height: var(--list-item-height); + --dropdown-menu-item-py: var(--list-item-py); + --dropdown-menu-item-px: var(--list-item-px); + --dropdown-menu-item-gap: var(--list-item-gap); + --dropdown-menu-content-padding: var(--list-content-padding); + --dropdown-menu-content-radius: var(--list-content-radius); +} + /* Event-driven item cascade rhythm. The Content wrapper marks itself `[data-stagger]`; each direct-child item carries `data-animation-style` and gets its structural index from `:nth-child`. This is the per-item diff --git a/src/uix/eidos/components/dropdown-menu/types.ts b/src/uix/eidos/components/dropdown-menu/types.ts index 80fc65687..53adedca6 100644 --- a/src/uix/eidos/components/dropdown-menu/types.ts +++ b/src/uix/eidos/components/dropdown-menu/types.ts @@ -81,7 +81,17 @@ export type DropdownMenuTriggerProps = TriggerProps & { /** Trailing icon snippet; collapsed when iconOnly is on. */ endIcon?: EidosButtonProps['endIcon']; }; -export type DropdownMenuContentProps = ContentProps; +/** + * Typographic + density scale for the menu. `size N → control N` (1:1) — a menu + * is content, so it scales like a Field input, not one step below. The scale + * flows to the items and (via context) to every fly-out submenu. @default 'md' + */ +export type DropdownMenuSize = ButtonSize; + +export type DropdownMenuContentProps = ContentProps & { + /** Menu scale; propagates to items + submenus. @default 'md' */ + size?: ResponsiveProp; +}; export type DropdownMenuArrowProps = ArrowProps; export type DropdownMenuItemProps = ItemProps; export type DropdownMenuGroupProps = GroupProps; @@ -93,4 +103,7 @@ export type DropdownMenuRadioGroupProps = RadioGroupProps; export type DropdownMenuRadioItemProps = RadioItemProps; export type DropdownMenuSubProps = SubProps; export type DropdownMenuSubTriggerProps = SubTriggerProps; -export type DropdownMenuSubContentProps = SubContentProps; +export type DropdownMenuSubContentProps = SubContentProps & { + /** Override the inherited menu size for this submenu. @default inherited */ + size?: ResponsiveProp; +}; diff --git a/src/uix/eidos/components/listbox/listbox.css b/src/uix/eidos/components/listbox/listbox.css index 4bbff5d3a..bb4895d70 100644 --- a/src/uix/eidos/components/listbox/listbox.css +++ b/src/uix/eidos/components/listbox/listbox.css @@ -18,9 +18,17 @@ */ [data-listbox] { - --_listbox-item-height: var(--control-height-md); - --_listbox-item-px: var(--space-3); - --_listbox-item-font-size: var(--font-size-sm); + /* Size rhythm bridged to the shared list-surface scale (1:1 with the control + * sizes) — the SINGLE source in `lib/list-surface.css`. The root carries + * `data-list-surface` + `data-size` (stamped by the eidos wrapper), so that + * block sets `--list-*`; the private knobs below just adopt them. This aligns + * md to the list rhythm (font sm→md, px space-3→space-2) — shared with the + * menu surfaces. */ + --_listbox-item-height: var(--list-item-height); + --_listbox-item-px: var(--list-item-px); + --_listbox-item-py: var(--list-item-py); + --_listbox-item-gap: var(--list-item-gap); + --_listbox-item-font-size: var(--list-font-size); --_listbox-max-height: min(60vh, 22rem); --_listbox-accent: var(--color-primary-solid); --_listbox-accent-soft: var(--color-primary-element); @@ -80,30 +88,9 @@ border-color: transparent; } -/* ── Sizes ─ */ -[data-listbox][data-size='xs'] { - --_listbox-item-height: var(--control-height-xs); - --_listbox-item-px: var(--space-2); - --_listbox-item-font-size: var(--font-size-xs); -} - -[data-listbox][data-size='sm'] { - --_listbox-item-height: var(--control-height-sm); - --_listbox-item-px: var(--space-2); - --_listbox-item-font-size: var(--font-size-sm); -} - -[data-listbox][data-size='lg'] { - --_listbox-item-height: var(--control-height-lg); - --_listbox-item-px: var(--space-4); - --_listbox-item-font-size: var(--font-size-md); -} - -[data-listbox][data-size='xl'] { - --_listbox-item-height: var(--control-height-xl); - --_listbox-item-px: var(--space-5); - --_listbox-item-font-size: var(--font-size-lg); -} +/* Sizes — the xs..xl rhythm is bridged from `lib/list-surface.css` via the + * private `--_listbox-*` knobs in the root block above; no per-size block here. + */ /* ── Color cascades ─ */ [data-listbox][data-color='secondary'] { @@ -154,9 +141,9 @@ [data-listbox-item] { display: flex; align-items: center; - gap: var(--space-2); + gap: var(--_listbox-item-gap); min-block-size: var(--_listbox-item-height); - padding-block: var(--space-1); + padding-block: var(--_listbox-item-py); padding-inline: var(--_listbox-item-px); border-radius: var(--radius-sm); color: var(--color-content-primary); diff --git a/src/uix/eidos/components/listbox/listbox.svelte b/src/uix/eidos/components/listbox/listbox.svelte index 60b45b534..1cf70e32a 100644 --- a/src/uix/eidos/components/listbox/listbox.svelte +++ b/src/uix/eidos/components/listbox/listbox.svelte @@ -21,6 +21,8 @@ */ import { ActiveEidos } from '$uix/eidos'; import * as Listbox from '$soma/components/listbox'; + import { setListSurfaceSizeContext } from '../../lib/list-surface-context'; + import '../../lib/list-surface.css'; import type { ListboxProps } from './types'; let { @@ -38,11 +40,16 @@ const resolvedVariant = $derived(eidos.resolve(variant, 'surface')); const resolvedColor = $derived(eidos.resolve(color, 'primary')); const resolvedBlock = $derived(eidos.resolve(block, false)); + + // Publish the resolved size on the shared list-surface context so any nested + // list panel matches this listbox's scale (parity with the menu surfaces). + setListSurfaceSizeContext({ getSize: () => resolvedSize }); - + {#snippet children(snippetProps)} {@render bodyContent?.(snippetProps)} {/snippet} diff --git a/src/uix/eidos/components/select/select-content.svelte b/src/uix/eidos/components/select/select-content.svelte index a1dd83c55..f80593ce7 100644 --- a/src/uix/eidos/components/select/select-content.svelte +++ b/src/uix/eidos/components/select/select-content.svelte @@ -3,6 +3,7 @@ import * as Select from '$soma/components/select'; import { composeInlineStyle } from '$uix/eidos/lib/style'; import { getSelectVisualContext } from './context.svelte'; + import '../../lib/list-surface.css'; import type { SelectContentProps } from './types'; let { @@ -32,6 +33,7 @@ ; + +export interface ListSurfaceSizeContext { + /** The resolved list-surface size; nested panels read it to match the root. */ + getSize(): ListSurfaceSize; +} + +export function setListSurfaceSizeContext(ctx: ListSurfaceSizeContext): ListSurfaceSizeContext { + setContext(LIST_SURFACE_SIZE_CONTEXT, ctx); + return ctx; +} + +export function getListSurfaceSizeContext(): ListSurfaceSizeContext | undefined { + return getContext(LIST_SURFACE_SIZE_CONTEXT); +} diff --git a/src/uix/eidos/lib/list-surface.css b/src/uix/eidos/lib/list-surface.css new file mode 100644 index 000000000..b4ce48656 --- /dev/null +++ b/src/uix/eidos/lib/list-surface.css @@ -0,0 +1,71 @@ +/* + * List-surface size scale — the SINGLE SOURCE for the menu / listbox size rhythm. + * + * Shared by every floating surface that holds a vertical list of rows that scale + * 1:1 with the control sizes: + * - action menus (DropdownMenu / ContextMenu / Menubar — role=menu) + * - selection popups (Select / Combobox / Listbox / Command — role=listbox) + * The ARIA role differs; the size rhythm is identical, so it lives here ONCE. + * + * Each surface STAMPS `data-list-surface=""` + `data-size` on its eidos Content + * wrapper (a VISUAL-LAYER identity stamped by the wrapper — NOT declared in the + * morfo, unlike toggle-group's DNA-level `data-toggle`), + * then BRIDGES its own `--{component}-*` size tokens to the `--list-*` tokens set + * here. Fly-out / nested panels portal out of the DOM subtree, so they can't + * inherit by CSS — they ride `list-surface-context.ts` to read the root size. + * + * Doctrine: a list row is content → it scales 1:1 with the control sizes + * (`size N → --font-size-N` + `--control-height-N`), per the field-input + * doctrine — NOT one step below. md is the baseline; the steps move + * font / height / padding / gap together. (Radius only softens one step at xs; + * sm–xl keep the panel's md radius — preserved from the verified menu look.) + */ +[data-list-surface][data-size='xs'] { + --list-font-size: var(--font-size-xs); + --list-item-height: var(--control-height-xs); + --list-item-py: var(--space-0-5); + --list-item-px: var(--space-1-5); + --list-item-gap: var(--space-1-5); + --list-content-padding: var(--space-0-5); + --list-content-radius: var(--radius-sm); +} + +[data-list-surface][data-size='sm'] { + --list-font-size: var(--font-size-sm); + --list-item-height: var(--control-height-sm); + --list-item-py: var(--space-1); + --list-item-px: var(--space-2); + --list-item-gap: var(--space-1-5); + --list-content-padding: var(--space-1); + --list-content-radius: var(--radius-md); +} + +[data-list-surface][data-size='md'] { + --list-font-size: var(--font-size-md); + --list-item-height: var(--control-height-md); + --list-item-py: var(--space-1); + --list-item-px: var(--space-2); + --list-item-gap: var(--space-2); + --list-content-padding: var(--space-1); + --list-content-radius: var(--radius-md); +} + +[data-list-surface][data-size='lg'] { + --list-font-size: var(--font-size-lg); + --list-item-height: var(--control-height-lg); + --list-item-py: var(--space-1-5); + --list-item-px: var(--space-2-5); + --list-item-gap: var(--space-2-5); + --list-content-padding: var(--space-1-5); + --list-content-radius: var(--radius-md); +} + +[data-list-surface][data-size='xl'] { + --list-font-size: var(--font-size-xl); + --list-item-height: var(--control-height-xl); + --list-item-py: var(--space-2); + --list-item-px: var(--space-3); + --list-item-gap: var(--space-3); + --list-content-padding: var(--space-2); + --list-content-radius: var(--radius-md); +} diff --git a/src/uix/morfo/components/context-menu.ts b/src/uix/morfo/components/context-menu.ts index b6b7ffcec..24ca2db15 100644 --- a/src/uix/morfo/components/context-menu.ts +++ b/src/uix/morfo/components/context-menu.ts @@ -19,7 +19,9 @@ export const contextMenuMorfo = { focus: { initial: 'first-focusable', - trap: true, + // Non-modal menu: focus enters for arrow-key nav but is NOT trapped — + // Tab / outside-click exit and close. A hard trap is dialog behaviour. + trap: false, return: 'previous', restore: true }, diff --git a/src/uix/morfo/components/dropdown-menu.ts b/src/uix/morfo/components/dropdown-menu.ts index 93556a1cf..952cd5db3 100644 --- a/src/uix/morfo/components/dropdown-menu.ts +++ b/src/uix/morfo/components/dropdown-menu.ts @@ -21,7 +21,10 @@ export const dropdownMenuMorfo = { focus: { initial: 'first-focusable', - trap: true, + // Non-modal menu: focus enters for arrow-key nav, but it is NOT trapped — + // Tab / outside-click exit and close (the dismissal owns that). A hard trap + // is dialog behaviour; menus stay navigable like the rest of the page. + trap: false, return: 'trigger', restore: true }, diff --git a/src/uix/soma/components/context-menu/components/context-menu-content.svelte b/src/uix/soma/components/context-menu/components/context-menu-content.svelte index aa8750ceb..5237bfd04 100644 --- a/src/uix/soma/components/context-menu/components/context-menu-content.svelte +++ b/src/uix/soma/components/context-menu/components/context-menu-content.svelte @@ -30,7 +30,7 @@ updatePositionStrategy = 'optimized', strategy = 'fixed', loop = false, - trapFocus = true, + trapFocus = false, preventScroll = false, onOpenAutoFocus = () => {}, onCloseAutoFocus = () => {}, diff --git a/src/uix/soma/components/context-menu/types.ts b/src/uix/soma/components/context-menu/types.ts index cc40f3c62..e82e2bb6f 100644 --- a/src/uix/soma/components/context-menu/types.ts +++ b/src/uix/soma/components/context-menu/types.ts @@ -59,7 +59,8 @@ export type ContextMenuContentProps = WithChild< /** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always'; /** @default 'fixed' */ strategy?: 'fixed' | 'absolute'; /** Wrap keyboard navigation at boundaries. @default false */ loop?: boolean; - /** @default true */ trapFocus?: boolean; + /** Trap focus inside the menu (modal-style). Non-modal by default: focus enters for + * arrow-key nav, but Tab / outside-click exit and close. @default false */ trapFocus?: boolean; /** Body scroll lock. Context menus don't lock by default. @default false */ preventScroll?: boolean; onOpenAutoFocus?: (e: Event) => void; onCloseAutoFocus?: (e: Event) => void; diff --git a/src/uix/soma/components/dropdown-menu/components/dropdown-menu-content.svelte b/src/uix/soma/components/dropdown-menu/components/dropdown-menu-content.svelte index d9260a456..40fbff1e4 100644 --- a/src/uix/soma/components/dropdown-menu/components/dropdown-menu-content.svelte +++ b/src/uix/soma/components/dropdown-menu/components/dropdown-menu-content.svelte @@ -32,8 +32,8 @@ updatePositionStrategy = 'optimized', strategy = 'fixed', loop = false, - trapFocus = true, - preventScroll = true, + trapFocus = false, + preventScroll = false, onOpenAutoFocus = () => {}, onCloseAutoFocus = () => {}, onEscapeKeydown = () => {}, @@ -43,6 +43,7 @@ onFocusOutside = () => {}, dir, style = null, + customAnchor = null, children, child, ...restProps @@ -81,7 +82,8 @@ dir: readableActive( () => dir ?? parentMenu?.opts.dir.current ?? soma?.prefs.getDir() ?? 'ltr' ), - style: readableActive(() => style) + style: readableActive(() => style), + customAnchor: readableActive(() => customAnchor) }); const mergedProps = $derived(mergeProps(restProps, state.props)); diff --git a/src/uix/soma/components/dropdown-menu/types.ts b/src/uix/soma/components/dropdown-menu/types.ts index 3817b42d6..8185adf1a 100644 --- a/src/uix/soma/components/dropdown-menu/types.ts +++ b/src/uix/soma/components/dropdown-menu/types.ts @@ -51,8 +51,9 @@ export type MenuContentProps = WithChild< /** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always'; /** @default 'fixed' */ strategy?: 'fixed' | 'absolute'; /** Wrap keyboard navigation at boundaries. @default false */ loop?: boolean; - /** @default true */ trapFocus?: boolean; - /** @default true */ preventScroll?: boolean; + /** Trap focus inside the menu (modal-style). Non-modal by default: focus enters for + * arrow-key nav, but Tab / outside-click exit and close. @default false */ trapFocus?: boolean; + /** Lock body scroll while open (modal-style). @default false */ preventScroll?: boolean; onOpenAutoFocus?: (e: Event) => void; onCloseAutoFocus?: (e: Event) => void; onEscapeKeydown?: (e: KeyboardEvent) => void; @@ -62,6 +63,14 @@ export type MenuContentProps = WithChild< onFocusOutside?: (e: FocusEvent) => void; dir?: Direction; style?: StyleProperties | string | null; + /** + * Anchor the floating panel to this element instead of the Trigger. + * Lets a composing component (e.g. SplitButton) position the menu + * against a wider surface than the bare ▾ trigger — the popup then + * aligns to that element and `--floating-anchor-width` reports its + * width. Null → anchor = the registered Trigger (default). + */ + customAnchor?: HTMLElement | null; }, { open: boolean } > & diff --git a/web/routes/uix/components/context-menu/+page.svelte b/web/routes/uix/components/context-menu/+page.svelte index d8b175d97..2cfd16a2a 100644 --- a/web/routes/uix/components/context-menu/+page.svelte +++ b/web/routes/uix/components/context-menu/+page.svelte @@ -20,7 +20,7 @@ let alignOffset = $state(0); let avoidCollisions = $state(true); let loop = $state(false); - let trapFocus = $state(true); + let trapFocus = $state(false); let preventScroll = $state(false); // Soma · Item-level let disableDelete = $state(false); diff --git a/web/routes/uix/components/dropdown-menu/+page.svelte b/web/routes/uix/components/dropdown-menu/+page.svelte index d7879831e..7b7067972 100644 --- a/web/routes/uix/components/dropdown-menu/+page.svelte +++ b/web/routes/uix/components/dropdown-menu/+page.svelte @@ -21,8 +21,8 @@ let alignOffset = $state(0); let avoidCollisions = $state(true); let loop = $state(false); - let trapFocus = $state(true); - let preventScroll = $state(true); + let trapFocus = $state(false); + let preventScroll = $state(false); // Item-level let disableLogout = $state(false); let closeOnSelect = $state(true); @@ -36,6 +36,7 @@ // consistent with the rest of the form-control surface. let triggerVariant = $state<'surface' | 'outline' | 'ghost'>('surface'); let triggerSize = $state<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md'); + let menuSize = $state<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md'); let triggerIntent = $state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'>( 'neutral' ); @@ -174,7 +175,7 @@ dir !== 'ltr' && ` dir="${dir}"`, '>', ` ${triggerLabel}`, - ` `, + ` `, showGroupHeading && ' ', showGroupHeading && ' Account', showGroupHeading && ' Profile', @@ -255,6 +256,7 @@ {triggerLabel}
+