feat(eidos): non-modal menus + unified list-surface size layer (control-height rows)

Non-modal menus:
- DropdownMenu + ContextMenu default to trapFocus=false + preventScroll=false
  (were modal — hard focus-trap + body scroll-lock that made them feel stuck).
  Menus stay navigable while open (Chakra-style); the props remain for an opt-in
  modal menu. morfo focus.trap=false + the demo control defaults aligned.

list-surface — ONE shared size source for list surfaces:
- new lib/list-surface.css (--list-* keyed on [data-list-surface][data-size], 1:1
  with the control sizes) + lib/list-surface-context.ts (shared size context for
  portaled sub-panels). DropdownMenu/ContextMenu/Menubar/Listbox bridge their
  --{c}-* size tokens to --list-*; ContextMenu gained the size axis it lacked.

Row-height doctrine — unified to CONTROL-HEIGHT:
- archetypes.css [data-archetype=item/option] gains a fallback-guarded control-
  height FLOOR (min-block-size: var(--list-item-height, auto) + padding-block:
  var(--list-item-py, var(--space-1-5))) — ZERO regression off a list-surface
  (the ~45 non-list archetype consumers are untouched). Select/Combobox/Command
  stamp data-list-surface so their option rows inherit the floor, matching menu
  items. Verified: select option md 36px / lg 44px = menu items; bare archetype
  item unchanged (0 floor, --space-1-5 padding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent d0312ee94f
commit 599c142124

@ -125,11 +125,16 @@
[data-archetype='option'] { [data-archetype='option'] {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
/* Row height = line-height (scales with the font-size per size) + this /* Height: a CONTROL-HEIGHT FLOOR (not a clamp), so a list row scales 1:1
padding, a `--space-*` token that scales with the density mode (compact with the control sizes — same model as the menu / listbox surfaces. The
0.84 / comfortable 1 / spacious 1.16) and `--scaling`. No fixed control `--list-item-*` tokens are set by `lib/list-surface.css` when the row lives
height, no `em`. The inter-row gap is set per list (also a `--space-*`). */ under a `[data-list-surface][data-size]` panel; the var() fallbacks keep
padding-block: var(--space-1-5); 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 /* Canonical "active option" highlight — hover, keyboard highlight

@ -3,6 +3,7 @@
import * as Combobox from '$soma/components/combobox'; import * as Combobox from '$soma/components/combobox';
import { composeInlineStyle } from '$uix/eidos/lib/style'; import { composeInlineStyle } from '$uix/eidos/lib/style';
import { getComboboxVisualContext } from './context.svelte'; import { getComboboxVisualContext } from './context.svelte';
import '../../lib/list-surface.css';
import type { ComboboxContentProps } from './types'; import type { ComboboxContentProps } from './types';
let { let {
@ -37,6 +38,7 @@
{...rest} {...rest}
customAnchor={anchorEl} customAnchor={anchorEl}
style={composedStyle} style={composedStyle}
data-list-surface=""
data-size={resolvedSize} data-size={resolvedSize}
data-variant={resolvedVariant} data-variant={resolvedVariant}
data-color={resolvedColor} data-color={resolvedColor}

@ -37,6 +37,7 @@
import * as Command from '$soma/components/command'; import * as Command from '$soma/components/command';
import type { CommandProps } from './types'; import type { CommandProps } from './types';
import { bindCommandShortcut } from './command-shortcut.svelte'; import { bindCommandShortcut } from './command-shortcut.svelte';
import '../../lib/list-surface.css';
let { let {
size = 'md', size = 'md',
@ -68,7 +69,7 @@
}); });
</script> </script>
<Command.Provider {...rest} bind:value bind:search data-size={resolvedSize}> <Command.Provider {...rest} bind:value bind:search data-list-surface="" data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -4,14 +4,25 @@
* (carrying `data-animation-style`) fade in staggered on open. Same mechanism * (carrying `data-animation-style`) fade in staggered on open. Same mechanism
* as DropdownMenu; see its README → Motion. The rhythm `--motion-stagger-each` * as DropdownMenu; see its README → Motion. The rhythm `--motion-stagger-each`
* comes from the recipe; the per-item index from `:nth-child` (structure). * 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 * as ContextMenu from '$soma/components/context-menu';
import { setListSurfaceSizeContext } from '../../lib/list-surface-context';
import '../../lib/list-surface.css';
import type { ContextMenuContentProps } from './types'; 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 });
</script> </script>
<ContextMenu.Content data-stagger="" {...rest}> <ContextMenu.Content data-stagger="" data-list-surface="" {...rest} data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -2,14 +2,25 @@
/** /**
* `data-stagger`: a fly-out submenu cascades its items in on open, same as the * `data-stagger`: a fly-out submenu cascades its items in on open, same as the
* root Content. See the DropdownMenu README -> Motion. * 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 * as ContextMenu from '$soma/components/context-menu';
import { getListSurfaceSizeContext } from '../../lib/list-surface-context';
import '../../lib/list-surface.css';
import type { ContextMenuSubContentProps } from './types'; 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'));
</script> </script>
<ContextMenu.SubContent data-stagger="" {...rest}> <ContextMenu.SubContent data-stagger="" data-list-surface="" {...rest} data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -40,6 +40,27 @@
outline: none; 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 /* Event-driven item cascade rhythm — the Content wrapper marks itself
`[data-stagger]`; each direct-child item carries `data-animation-style` and `[data-stagger]`; each direct-child item carries `data-animation-style` and
gets its structural index from `:nth-child`. `var(--motion-stagger)` is the gets its structural index from `:nth-child`. `var(--motion-stagger)` is the

@ -23,10 +23,23 @@ import type {
SubTriggerProps, SubTriggerProps,
SubContentProps SubContentProps
} from '$soma/components/context-menu'; } 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 ContextMenuProps = ProviderProps;
export type ContextMenuTriggerProps = TriggerProps; export type ContextMenuTriggerProps = TriggerProps;
export type ContextMenuContentProps = ContentProps; export type ContextMenuContentProps = ContentProps & {
/** Menu scale; propagates to items + submenus. @default 'md' */
size?: ResponsiveProp<ContextMenuSize>;
};
export type ContextMenuArrowProps = ArrowProps; export type ContextMenuArrowProps = ArrowProps;
export type ContextMenuItemProps = ItemProps; export type ContextMenuItemProps = ItemProps;
export type ContextMenuGroupProps = GroupProps; export type ContextMenuGroupProps = GroupProps;
@ -37,4 +50,7 @@ export type ContextMenuRadioGroupProps = RadioGroupProps;
export type ContextMenuRadioItemProps = RadioItemProps; export type ContextMenuRadioItemProps = RadioItemProps;
export type ContextMenuSubProps = SubProps; export type ContextMenuSubProps = SubProps;
export type ContextMenuSubTriggerProps = SubTriggerProps; export type ContextMenuSubTriggerProps = SubTriggerProps;
export type ContextMenuSubContentProps = SubContentProps; export type ContextMenuSubContentProps = SubContentProps & {
/** Override the inherited menu size for this submenu. @default inherited */
size?: ResponsiveProp<ContextMenuSize>;
};

@ -12,17 +12,29 @@
* index from `:nth-child` and enter together when the menu opens. The rhythm * 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). * is the recipe's `--motion-stagger-each` (var(--motion-stagger), 0 = parallel).
* Nobody writes the index; the appearance flows from the `open` (emerge) event. * 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 * as DropdownMenu from '$soma/components/dropdown-menu';
import { setListSurfaceSizeContext } from '../../lib/list-surface-context';
import '../../lib/list-surface.css';
import type { DropdownMenuContentProps } from './types'; import type { DropdownMenuContentProps } from './types';
let { let {
size = 'md',
children: bodyContent, children: bodyContent,
...rest ...rest
}: DropdownMenuContentProps = $props(); }: DropdownMenuContentProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
setListSurfaceSizeContext({ getSize: () => resolvedSize });
</script> </script>
<DropdownMenu.Content data-stagger="" {...rest}> <DropdownMenu.Content data-stagger="" data-list-surface="" {...rest} data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -3,14 +3,25 @@
* `data-stagger`: a fly-out submenu cascades its items in on open, same as the * `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 * root Content (its items also carry `data-animation-style="fade"`). See the
* DropdownMenu README -> Motion. * 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 * as DropdownMenu from '$soma/components/dropdown-menu';
import { getListSurfaceSizeContext } from '../../lib/list-surface-context';
import '../../lib/list-surface.css';
import type { DropdownMenuSubContentProps } from './types'; 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'));
</script> </script>
<DropdownMenu.SubContent data-stagger="" {...rest}> <DropdownMenu.SubContent data-stagger="" data-list-surface="" {...rest} data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -30,6 +30,7 @@
* content (e.g. selected toggle-group chips with z-index 1). The soma * 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 * floating layer reads this computed z-index and mirrors it onto the
* positioner wrapper. */ * positioner wrapper. */
z-index: var(--dropdown-menu-content-z); z-index: var(--dropdown-menu-content-z);
min-inline-size: var(--dropdown-menu-content-min-width, 12rem); min-inline-size: var(--dropdown-menu-content-min-width, 12rem);
max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem));
@ -50,6 +51,26 @@
outline: none; 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 /* Event-driven item cascade rhythm. The Content wrapper marks itself
`[data-stagger]`; each direct-child item carries `data-animation-style` `[data-stagger]`; each direct-child item carries `data-animation-style`
and gets its structural index from `:nth-child`. This is the per-item and gets its structural index from `:nth-child`. This is the per-item

@ -81,7 +81,17 @@ export type DropdownMenuTriggerProps = TriggerProps & {
/** Trailing icon snippet; collapsed when iconOnly is on. */ /** Trailing icon snippet; collapsed when iconOnly is on. */
endIcon?: EidosButtonProps['endIcon']; 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<DropdownMenuSize>;
};
export type DropdownMenuArrowProps = ArrowProps; export type DropdownMenuArrowProps = ArrowProps;
export type DropdownMenuItemProps = ItemProps; export type DropdownMenuItemProps = ItemProps;
export type DropdownMenuGroupProps = GroupProps; export type DropdownMenuGroupProps = GroupProps;
@ -93,4 +103,7 @@ export type DropdownMenuRadioGroupProps = RadioGroupProps;
export type DropdownMenuRadioItemProps = RadioItemProps; export type DropdownMenuRadioItemProps = RadioItemProps;
export type DropdownMenuSubProps = SubProps; export type DropdownMenuSubProps = SubProps;
export type DropdownMenuSubTriggerProps = SubTriggerProps; export type DropdownMenuSubTriggerProps = SubTriggerProps;
export type DropdownMenuSubContentProps = SubContentProps; export type DropdownMenuSubContentProps = SubContentProps & {
/** Override the inherited menu size for this submenu. @default inherited */
size?: ResponsiveProp<DropdownMenuSize>;
};

@ -18,9 +18,17 @@
*/ */
[data-listbox] { [data-listbox] {
--_listbox-item-height: var(--control-height-md); /* Size rhythm bridged to the shared list-surface scale (1:1 with the control
--_listbox-item-px: var(--space-3); * sizes) — the SINGLE source in `lib/list-surface.css`. The root carries
--_listbox-item-font-size: var(--font-size-sm); * `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-max-height: min(60vh, 22rem);
--_listbox-accent: var(--color-primary-solid); --_listbox-accent: var(--color-primary-solid);
--_listbox-accent-soft: var(--color-primary-element); --_listbox-accent-soft: var(--color-primary-element);
@ -80,30 +88,9 @@
border-color: transparent; border-color: transparent;
} }
/* ── Sizes ─ */ /* Sizes — the xs..xl rhythm is bridged from `lib/list-surface.css` via the
[data-listbox][data-size='xs'] { * private `--_listbox-*` knobs in the root block above; no per-size block here.
--_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);
}
/* ── Color cascades ─ */ /* ── Color cascades ─ */
[data-listbox][data-color='secondary'] { [data-listbox][data-color='secondary'] {
@ -154,9 +141,9 @@
[data-listbox-item] { [data-listbox-item] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--_listbox-item-gap);
min-block-size: var(--_listbox-item-height); min-block-size: var(--_listbox-item-height);
padding-block: var(--space-1); padding-block: var(--_listbox-item-py);
padding-inline: var(--_listbox-item-px); padding-inline: var(--_listbox-item-px);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-content-primary); color: var(--color-content-primary);

@ -21,6 +21,8 @@
*/ */
import { ActiveEidos } from '$uix/eidos'; import { ActiveEidos } from '$uix/eidos';
import * as Listbox from '$soma/components/listbox'; import * as Listbox from '$soma/components/listbox';
import { setListSurfaceSizeContext } from '../../lib/list-surface-context';
import '../../lib/list-surface.css';
import type { ListboxProps } from './types'; import type { ListboxProps } from './types';
let { let {
@ -38,11 +40,16 @@
const resolvedVariant = $derived(eidos.resolve(variant, 'surface')); const resolvedVariant = $derived(eidos.resolve(variant, 'surface'));
const resolvedColor = $derived(eidos.resolve(color, 'primary')); const resolvedColor = $derived(eidos.resolve(color, 'primary'));
const resolvedBlock = $derived(eidos.resolve(block, false)); 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 });
</script> </script>
<Listbox.Provider <Listbox.Provider
{...rest} {...rest}
bind:value bind:value
data-list-surface=""
data-size={resolvedSize} data-size={resolvedSize}
data-variant={resolvedVariant} data-variant={resolvedVariant}
data-color={resolvedColor} data-color={resolvedColor}

@ -30,7 +30,7 @@
`DropdownMenu.Item` `data-animation-style="fade"` (re-used as Menubar.Item) `DropdownMenu.Item` `data-animation-style="fade"` (re-used as Menubar.Item)
already apply — this marker is all that's needed. See dropdown-menu README → Motion. already apply — this marker is all that's needed. See dropdown-menu README → Motion.
--> -->
<Menubar.Content {...passthrough} data-stagger="" data-size={resolvedSize}> <Menubar.Content {...passthrough} data-stagger="" data-list-surface="" data-size={resolvedSize}>
{#snippet children(snippetProps)} {#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)} {@render bodyContent?.(snippetProps)}
{/snippet} {/snippet}

@ -3,6 +3,7 @@
import * as Select from '$soma/components/select'; import * as Select from '$soma/components/select';
import { composeInlineStyle } from '$uix/eidos/lib/style'; import { composeInlineStyle } from '$uix/eidos/lib/style';
import { getSelectVisualContext } from './context.svelte'; import { getSelectVisualContext } from './context.svelte';
import '../../lib/list-surface.css';
import type { SelectContentProps } from './types'; import type { SelectContentProps } from './types';
let { let {
@ -32,6 +33,7 @@
<Select.Content <Select.Content
{...rest} {...rest}
style={composedStyle} style={composedStyle}
data-list-surface=""
data-size={resolvedSize} data-size={resolvedSize}
data-variant={resolvedVariant} data-variant={resolvedVariant}
data-color={resolvedColor} data-color={resolvedColor}

@ -0,0 +1,30 @@
import { getContext, setContext } from 'svelte';
import type { Size } from './types';
/**
* Shared list-surface size context.
*
* Every floating list surface (action menus + selection popups) propagates its
* resolved `size` through this ONE context so nested / fly-out panels match the
* root surface's scale. Portaled panels (a `SubContent`, a Combobox listbox)
* leave the root Content's DOM subtree, so the size can't reach them by CSS
* inheritance — it rides the component tree instead. Replaces the per-component
* size contexts so all list surfaces share a single source.
*/
const LIST_SURFACE_SIZE_CONTEXT = Symbol('uix.eidos.list-surface.size');
export type ListSurfaceSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
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<ListSurfaceSizeContext | undefined>(LIST_SURFACE_SIZE_CONTEXT);
}

@ -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);
}

@ -19,7 +19,9 @@ export const contextMenuMorfo = {
focus: { focus: {
initial: 'first-focusable', 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', return: 'previous',
restore: true restore: true
}, },

@ -21,7 +21,10 @@ export const dropdownMenuMorfo = {
focus: { focus: {
initial: 'first-focusable', 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', return: 'trigger',
restore: true restore: true
}, },

@ -30,7 +30,7 @@
updatePositionStrategy = 'optimized', updatePositionStrategy = 'optimized',
strategy = 'fixed', strategy = 'fixed',
loop = false, loop = false,
trapFocus = true, trapFocus = false,
preventScroll = false, preventScroll = false,
onOpenAutoFocus = () => {}, onOpenAutoFocus = () => {},
onCloseAutoFocus = () => {}, onCloseAutoFocus = () => {},

@ -59,7 +59,8 @@ export type ContextMenuContentProps = WithChild<
/** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always'; /** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always';
/** @default 'fixed' */ strategy?: 'fixed' | 'absolute'; /** @default 'fixed' */ strategy?: 'fixed' | 'absolute';
/** Wrap keyboard navigation at boundaries. @default false */ loop?: boolean; /** 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; /** Body scroll lock. Context menus don't lock by default. @default false */ preventScroll?: boolean;
onOpenAutoFocus?: (e: Event) => void; onOpenAutoFocus?: (e: Event) => void;
onCloseAutoFocus?: (e: Event) => void; onCloseAutoFocus?: (e: Event) => void;

@ -32,8 +32,8 @@
updatePositionStrategy = 'optimized', updatePositionStrategy = 'optimized',
strategy = 'fixed', strategy = 'fixed',
loop = false, loop = false,
trapFocus = true, trapFocus = false,
preventScroll = true, preventScroll = false,
onOpenAutoFocus = () => {}, onOpenAutoFocus = () => {},
onCloseAutoFocus = () => {}, onCloseAutoFocus = () => {},
onEscapeKeydown = () => {}, onEscapeKeydown = () => {},
@ -43,6 +43,7 @@
onFocusOutside = () => {}, onFocusOutside = () => {},
dir, dir,
style = null, style = null,
customAnchor = null,
children, children,
child, child,
...restProps ...restProps
@ -81,7 +82,8 @@
dir: readableActive( dir: readableActive(
() => dir ?? parentMenu?.opts.dir.current ?? soma?.prefs.getDir() ?? 'ltr' () => 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)); const mergedProps = $derived(mergeProps(restProps, state.props));

@ -51,8 +51,9 @@ export type MenuContentProps = WithChild<
/** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always'; /** @default 'optimized' */ updatePositionStrategy?: 'optimized' | 'always';
/** @default 'fixed' */ strategy?: 'fixed' | 'absolute'; /** @default 'fixed' */ strategy?: 'fixed' | 'absolute';
/** Wrap keyboard navigation at boundaries. @default false */ loop?: boolean; /** 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
/** @default true */ preventScroll?: boolean; * 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; onOpenAutoFocus?: (e: Event) => void;
onCloseAutoFocus?: (e: Event) => void; onCloseAutoFocus?: (e: Event) => void;
onEscapeKeydown?: (e: KeyboardEvent) => void; onEscapeKeydown?: (e: KeyboardEvent) => void;
@ -62,6 +63,14 @@ export type MenuContentProps = WithChild<
onFocusOutside?: (e: FocusEvent) => void; onFocusOutside?: (e: FocusEvent) => void;
dir?: Direction; dir?: Direction;
style?: StyleProperties | string | null; 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 } { open: boolean }
> & > &

@ -20,7 +20,7 @@
let alignOffset = $state(0); let alignOffset = $state(0);
let avoidCollisions = $state(true); let avoidCollisions = $state(true);
let loop = $state(false); let loop = $state(false);
let trapFocus = $state(true); let trapFocus = $state(false);
let preventScroll = $state(false); let preventScroll = $state(false);
// Soma · Item-level // Soma · Item-level
let disableDelete = $state(false); let disableDelete = $state(false);

@ -21,8 +21,8 @@
let alignOffset = $state(0); let alignOffset = $state(0);
let avoidCollisions = $state(true); let avoidCollisions = $state(true);
let loop = $state(false); let loop = $state(false);
let trapFocus = $state(true); let trapFocus = $state(false);
let preventScroll = $state(true); let preventScroll = $state(false);
// Item-level // Item-level
let disableLogout = $state(false); let disableLogout = $state(false);
let closeOnSelect = $state(true); let closeOnSelect = $state(true);
@ -36,6 +36,7 @@
// consistent with the rest of the form-control surface. // consistent with the rest of the form-control surface.
let triggerVariant = $state<'surface' | 'outline' | 'ghost'>('surface'); let triggerVariant = $state<'surface' | 'outline' | 'ghost'>('surface');
let triggerSize = $state<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md'); 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'>( let triggerIntent = $state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'>(
'neutral' 'neutral'
); );
@ -174,7 +175,7 @@
dir !== 'ltr' && ` dir="${dir}"`, dir !== 'ltr' && ` dir="${dir}"`,
'>', '>',
` <DropdownMenu.Trigger${triggerVariant !== 'surface' ? ` variant="${triggerVariant}"` : ''}${triggerSize !== 'md' ? ` size="${triggerSize}"` : ''}${triggerIntent !== 'neutral' ? ` intent="${triggerIntent}"` : ''}>${triggerLabel}</DropdownMenu.Trigger>`, ` <DropdownMenu.Trigger${triggerVariant !== 'surface' ? ` variant="${triggerVariant}"` : ''}${triggerSize !== 'md' ? ` size="${triggerSize}"` : ''}${triggerIntent !== 'neutral' ? ` intent="${triggerIntent}"` : ''}>${triggerLabel}</DropdownMenu.Trigger>`,
` <DropdownMenu.Content${side !== 'bottom' ? ` side="${side}"` : ''}${align !== 'start' ? ` align="${align}"` : ''}>`, ` <DropdownMenu.Content${menuSize !== 'md' ? ` size="${menuSize}"` : ''}${side !== 'bottom' ? ` side="${side}"` : ''}${align !== 'start' ? ` align="${align}"` : ''}>`,
showGroupHeading && ' <DropdownMenu.Group>', showGroupHeading && ' <DropdownMenu.Group>',
showGroupHeading && ' <DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>', showGroupHeading && ' <DropdownMenu.GroupHeading>Account</DropdownMenu.GroupHeading>',
showGroupHeading && ' <DropdownMenu.Item onSelect={…}>Profile</DropdownMenu.Item>', showGroupHeading && ' <DropdownMenu.Item onSelect={…}>Profile</DropdownMenu.Item>',
@ -255,6 +256,7 @@
{triggerLabel} {triggerLabel}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
size={menuSize}
{side} {side}
{sideOffset} {sideOffset}
{align} {align}
@ -405,6 +407,22 @@
</span> </span>
</div> </div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>
size <span data-uix-control-hint>eidos · 1:1, flows to submenus</span>
</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button
data-uix-chip
data-active={menuSize === s}
onclick={() => (menuSize = s as typeof menuSize)}
>
{s}
</button>
{/each}
</span>
</label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>side</span> <span data-uix-control-label>side</span>
<span data-uix-chips role="radiogroup"> <span data-uix-chips role="radiogroup">
@ -709,6 +727,7 @@
<table data-uix-table> <table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> <thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody> <tbody>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'xs'…'xl'&gt;</td><td class="default">'md'</td><td>Typographic + density scale (1:1 with control sizes). Flows to items and, via context, to every fly-out submenu.</td></tr>
<tr><td class="name">forceMount</td><td class="type">boolean</td><td class="default">false</td><td>Keep in DOM when closed.</td></tr> <tr><td class="name">forceMount</td><td class="type">boolean</td><td class="default">false</td><td>Keep in DOM when closed.</td></tr>
<tr><td class="name">side</td><td class="type">'top' | 'right' | 'bottom' | 'left'</td><td class="default">'bottom'</td><td>Preferred side relative to the Trigger.</td></tr> <tr><td class="name">side</td><td class="type">'top' | 'right' | 'bottom' | 'left'</td><td class="default">'bottom'</td><td>Preferred side relative to the Trigger.</td></tr>
<tr><td class="name">sideOffset</td><td class="type">number</td><td class="default">0</td><td>Main-axis gap (pixels).</td></tr> <tr><td class="name">sideOffset</td><td class="type">number</td><td class="default">0</td><td>Main-axis gap (pixels).</td></tr>
@ -770,6 +789,7 @@
<tbody> <tbody>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Sub</td><td>Independent sub-menu open state.</td></tr> <tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Sub</td><td>Independent sub-menu open state.</td></tr>
<tr><td class="name">side</td><td class="type">Side</td><td class="default">'right'</td><td>SubContent</td><td>Default fly-out direction.</td></tr> <tr><td class="name">side</td><td class="type">Side</td><td class="default">'right'</td><td>SubContent</td><td>Default fly-out direction.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'xs'…'xl'&gt;</td><td class="default">inherited</td><td>SubContent</td><td>Matches the root menu's size by default (via context); set to override one submenu.</td></tr>
<tr><td class="name">align / alignOffset / sideOffset</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr> <tr><td class="name">align / alignOffset / sideOffset</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr>
<tr><td class="name">avoidCollisions / collisionBoundary / collisionPadding</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr> <tr><td class="name">avoidCollisions / collisionBoundary / collisionPadding</td><td class="type">like Content</td><td class="default">—</td><td>SubContent</td><td>—</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>SubContent</td><td>Wrap nav inside the fly-out.</td></tr> <tr><td class="name">loop</td><td class="type">boolean</td><td class="default">false</td><td>SubContent</td><td>Wrap nav inside the fly-out.</td></tr>

Loading…
Cancel
Save

Powered by TurnKey Linux.