Ship the four action-cluster components. Per the audit the user requested, the hardcoded magic numbers in their CSS move to recipes (base.ts), so the values live in one place and pass the recipe-CSS contract: - fab: the off-ladder Material size / icon / lift scale → `fab` recipe (named tokens — the canonical control-height / icon-size ladders genuinely can't express 40/56/72 + 20/24/28; documented as the FAB's own scale). - onion-menu: the radial-chrome magics (sector ring widths, glyph bar, trail opacities) → `onion-menu` recipe; the centre trigger's lift + shadow REFERENCE `--fab-*` (it IS a FAB — the two can't drift apart). - button-group: the segmented-divider strength → `button-group` recipe. - split-button: the slim chevron tail's inline padding → `split-button` recipe; DropdownMenu gains `customAnchor` so the menu spans the whole split surface. All four compose the framework primitives (Group / Button / Icon / DropdownMenu), declare a morfo, ship langs + a demo, and pass the recipe-CSS contract test (the only flagged component left is palabras, an active dev track). check 0; onion-menu engine tests 31/31. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>active-uix
parent
a60abef4bf
commit
c00bac1345
@ -0,0 +1,156 @@
|
||||
# ButtonGroup
|
||||
|
||||
Presentational cluster of independent action **buttons**. `role="group"` +
|
||||
shared visual defaults that propagate to the child Buttons + segmented
|
||||
(attached) or spaced layout.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { ButtonGroup } from '$uix/eidos/components/button-group';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
</script>
|
||||
|
||||
<!-- Segmented (attached is ON by default) — children inherit variant/size/color -->
|
||||
<ButtonGroup aria-label="Text alignment" variant="surface">
|
||||
<Button>Left</Button>
|
||||
<Button>Center</Button>
|
||||
<Button>Right</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Spaced, vertical, full-width -->
|
||||
<ButtonGroup aria-label="Actions" attached={false} orientation="vertical" block>
|
||||
<Button variant="solid">Save</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</ButtonGroup>
|
||||
```
|
||||
|
||||
The children are real `<Button>` / `<IconButton>` / `<SplitButton>` — there is
|
||||
**no** `ButtonGroup.Item`. A child's explicit prop always wins over the group
|
||||
default.
|
||||
|
||||
## Baseline
|
||||
|
||||
No Air baseline (`air` had no ButtonGroup). The two precedents in the current
|
||||
codebase:
|
||||
|
||||
- **`<Group>`** (`$uix/eidos/components/group`) — the generic layout cluster.
|
||||
ButtonGroup **composes** it for all layout (attached corner-squaring, border
|
||||
overlap, direction, equal-width grow). The `attached` mechanism is Group's,
|
||||
not re-implemented here.
|
||||
- **`<ToggleGroup>`** — the structural precedent for "a group that propagates
|
||||
visual knobs to its children via an eidos context" (`context.ts`).
|
||||
ButtonGroup differs: ToggleGroup is a **selection control** (value, on/off,
|
||||
roving focus, `commit-toggle` event) with its own `ToggleGroup.Item`;
|
||||
ButtonGroup is **passive** and takes real standalone Buttons.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | Chakra v2 | MUI | Mantine | Bootstrap | Radix Themes | **UIX** |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `role="group"` + accessible name | no (styled Flex) | **yes** | no | **yes** (`aria-label` req.) | — (use Flex) | **yes** (declared in morfo) |
|
||||
| Propagate `variant`/`size`/`color` to children | **yes** (context) | **yes** (context) | no | no | — | **yes** (eidos context) |
|
||||
| Propagate `disabled` | yes | yes | no | no | — | yes |
|
||||
| `attached`/segmented | `isAttached` | default on | default on | default on | `SegmentedControl` (= selection) | **default on** (`attached`) |
|
||||
| Orientation H/V | yes | yes | yes | yes | — | yes (`aria-orientation`) |
|
||||
| `block` / fullWidth | flexProps | `fullWidth` | yes | yes | — | yes (`block`) |
|
||||
| Headless behaviour | no | no | no | no | no | **no** (0 events) |
|
||||
|
||||
No reference ships headless behaviour for a button group — Ark UI / React Aria
|
||||
have only a generic `Group` (role=group). This confirms ButtonGroup is
|
||||
**presentation + ARIA + prop propagation**, not a soma component.
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **scope: `['eidos']`** (no soma, no sema). It has no state/events/keyboard, so
|
||||
a soma provider would be empty. The ARIA contract is declared in the morfo and
|
||||
materialised by the wrapper — the Icon precedent for an eidos-only primitive
|
||||
with an a11y contract.
|
||||
- **Composes `<Group>`** for layout instead of re-implementing the attached
|
||||
overlap/corner logic (CG §4 — compose existing components). The only recipe
|
||||
addition is tuning Group's `--group-attached-overlap` to the Button's border
|
||||
and the focus/active z-index float so segmented buttons aren't clipped.
|
||||
- **Propagation via an eidos context** that `<Button>` opts into (a small,
|
||||
backward-compatible change: Button reads an optional group context for its
|
||||
variant/size/color/disabled defaults; an explicit prop always wins). A CSS
|
||||
descendant selector cannot reach the attrs Button's recipe reads on the button
|
||||
element, so the value must travel as a context (same reason ToggleGroup uses a
|
||||
context for its items). Parity with Chakra/MUI.
|
||||
- **`attached` ON by default** (segmented look) — matches MUI / Bootstrap /
|
||||
Mantine. Opt out with `attached={false}` for gap-separated buttons.
|
||||
- **`equal` (same button width), default-on when `attached`.** Pure CSS can only
|
||||
equalise on the **cross axis** of a flex line, so the two orientations differ
|
||||
by necessity: **vertical** stretches each button to the **widest** (the group
|
||||
stays content-sized — the case that motivated the prop); **horizontal** can't
|
||||
size the main axis to the widest in pure CSS, so it fills the container and
|
||||
splits it equally (the same result as `block`). Equalising a horizontal row
|
||||
to the widest *content* would need JS measurement — deliberately not done for
|
||||
a layout primitive. The segmented border / radius rules are independent of
|
||||
`equal`.
|
||||
- **Segmented seam = one border, no overlap.** Group's generic `attached` uses a
|
||||
negative-margin overlap that doubles to ~2px under fractional `--scaling`.
|
||||
ButtonGroup neutralises it (`--group-attached-overlap: 0`) and gives each seam
|
||||
exactly one 1px border: bordered variants (surface/outline) drop their leading
|
||||
border (the previous button's trailing border is the seam); border-less
|
||||
variants draw the `currentColor` divider.
|
||||
- **`role` fixed to `group`**. For toolbar semantics (roving focus, a single tab
|
||||
stop) use `<Toolbar>`; ButtonGroup keeps every button independently
|
||||
tab-focusable (WAI-ARIA: a group of independent actions).
|
||||
|
||||
## Subset
|
||||
|
||||
ButtonGroup forwards visual knobs to the child Buttons; it owns no `data-color`
|
||||
recipe of its own.
|
||||
|
||||
- **color** (hierarchy, propagated): `primary` (default) · `secondary` ·
|
||||
`neutral`. Evaluative intents (`affirm`/`fulfill`/`risk`/`threat`/`loss`) are
|
||||
**per-button**, set on the individual `<Button intent="...">` — the group does
|
||||
not propagate intent (a group can hold one destructive button; CANON §5
|
||||
per-event intent).
|
||||
- **variant** (propagated): the full `ButtonVariant` set (`solid` · `soft` ·
|
||||
`surface` · `outline` · `ghost` · `plain`). `attached` reads best with the
|
||||
bordered variants (solid/soft/surface/outline).
|
||||
- **size** (propagated): `ButtonSize` (`xs..xl`).
|
||||
|
||||
## Passive justification
|
||||
|
||||
0 semantic events. ButtonGroup is a structural frame: per CANON §5 the
|
||||
perceptual signal belongs to the act, not the container. Each composed
|
||||
`<Button>` fires its own `contact-activate`; the group emits nothing. The demo's
|
||||
trace strip shows the children's `contact-activate` events bubbling through the
|
||||
stage observer.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposición | Detalle |
|
||||
| --- | --- | --- |
|
||||
| `divider` slot between non-attached buttons | **diferir** | Same backlog as Group/Stack; compose a `<Separator>` for now. |
|
||||
| Propagate `rounded` / `shape` to children | **diferir** | Less commonly group-shared; in attached mode inner radii are squared anyway. Add if a consumer needs a shared pill group. |
|
||||
| Propagate `intent` | **descartar** | Per-button by doctrine (per-event intent). |
|
||||
|
||||
`equal` (every button = the widest) is **implemented** — it's the grid-based
|
||||
width-matching prop, default-on when `attached`. It supersedes the earlier
|
||||
`grow` gap.
|
||||
|
||||
## Sema events
|
||||
|
||||
0 events — passive. See **Passive justification** above. Perceptual feedback
|
||||
comes from the composed `<Button>` (`contact-activate`, family `contact`).
|
||||
|
||||
## Audit exceptions
|
||||
|
||||
`npm run component:audit --only button-group` reports two `error` rows that are
|
||||
**audit limitations for eidos-only (`scope: ['eidos']`) components**, not real
|
||||
defects — `icon` (the canonical eidos-only primitive) reports the identical two:
|
||||
|
||||
- **`E-2.2`** — "css not imported from eidos/index.css". ButtonGroup has its own
|
||||
`.svelte`, so it is **code-split**: `button-group.svelte` self-imports
|
||||
`./button-group.css` and the component ships its own chunk. `index.css`
|
||||
explicitly forbids re-adding code-split recipes there (it would double-load the
|
||||
CSS). The audit only checks `index.css` membership, so it can't see the
|
||||
self-import.
|
||||
- **`D-3.1`** — "Missing somaSnippet". ButtonGroup is eidos-only — there is **no
|
||||
soma layer**, so the demo ships only the `eidosSnippet` (same as the
|
||||
`icon` / `group` demos). A soma snippet for a component with no soma provider
|
||||
would be a lie.
|
||||
|
||||
Both are accepted by the eidos-only precedent (`icon`). All other rules pass.
|
||||
@ -0,0 +1,107 @@
|
||||
/*
|
||||
* ButtonGroup recipe.
|
||||
*
|
||||
* Composes <Group> for the flex layout + inner-radius squaring (the segmented
|
||||
* corners). ButtonGroup owns the rest of the segmented treatment because it is
|
||||
* VARIANT-AWARE in a way Group's generic cluster can't be:
|
||||
*
|
||||
* - Group's negative-margin overlap is neutralised (`--group-attached-overlap:
|
||||
* 0`). Under fractional `--scaling` two overlapping 1px borders render as a
|
||||
* ~2px doubled seam; instead every seam carries exactly ONE 1px border.
|
||||
* - bordered variants (surface / outline) drop their LEADING border, so the
|
||||
* previous button's trailing border is the single seam.
|
||||
* - border-less variants (solid / soft / ghost / plain) draw an explicit
|
||||
* subtle divider from the button's own foreground (`currentColor`) — it
|
||||
* adapts: a light hairline on solid colored fills, a dark hairline on
|
||||
* soft / ghost / plain light fills.
|
||||
*
|
||||
* [data-button-group] → the group (also [data-group][role=group])
|
||||
* [data-button-group][data-attached] > [data-button] → a segmented child button
|
||||
*/
|
||||
|
||||
/* Neutralise Group's overlap — one 1px border per seam (sub-pixel robust).
|
||||
* Four of ButtonGroup's own always-present attrs (specificity 0,4,0) win over
|
||||
* Group's `[data-box][data-flex][data-group]` default (0,3,0) without coupling
|
||||
* to Box/Flex internals or relying on stylesheet source order. */
|
||||
[data-button-group][data-attached][data-orientation][data-variant] {
|
||||
--group-attached-overlap: 0;
|
||||
}
|
||||
|
||||
[data-button-group][data-block] {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/* `equal` — every button the same width. Pure CSS can only equalise on the
|
||||
* CROSS axis of a flex line, so the two orientations differ (the wrapper
|
||||
* resolves `equal` to default-on when `attached`):
|
||||
* - vertical: cross axis IS the width → each button stretches to the WIDEST,
|
||||
* and the group stays content-sized.
|
||||
* - horizontal: the width is the MAIN axis (flex can't size it to the widest
|
||||
* in pure CSS), so `equal` fills the container inline-size and splits it
|
||||
* equally — the same result as `block`. */
|
||||
[data-button-group][data-equal][data-orientation='vertical'] {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-button-group][data-equal][data-orientation='vertical'] > [data-button] {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-button-group][data-equal]:not([data-orientation='vertical']) {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-button-group][data-equal]:not([data-orientation='vertical']) > [data-button] {
|
||||
flex: 1 1 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
/* Bordered variants (surface / outline): one border per seam — drop the
|
||||
* leading border on every non-first button so the previous button's trailing
|
||||
* border is the single seam. */
|
||||
[data-button-group][data-attached]:not([data-orientation='vertical'])
|
||||
> [data-button]:is([data-variant='surface'], [data-variant='outline']):not(:first-child) {
|
||||
border-inline-start-width: 0;
|
||||
}
|
||||
|
||||
[data-button-group][data-attached][data-orientation='vertical']
|
||||
> [data-button]:is([data-variant='surface'], [data-variant='outline']):not(:first-child) {
|
||||
border-block-start-width: 0;
|
||||
}
|
||||
|
||||
/* Border-less variants (solid / soft / ghost / plain): explicit subtle divider
|
||||
* on the leading edge. */
|
||||
[data-button-group][data-attached]:not([data-orientation='vertical'])
|
||||
> [data-button]:is(
|
||||
[data-variant='solid'],
|
||||
[data-variant='soft'],
|
||||
[data-variant='ghost'],
|
||||
[data-variant='plain']
|
||||
):not(:first-child) {
|
||||
border-inline-start: var(--button-border-width) solid
|
||||
color-mix(in srgb, currentColor var(--button-group-divider-strength), transparent);
|
||||
}
|
||||
|
||||
[data-button-group][data-attached][data-orientation='vertical']
|
||||
> [data-button]:is(
|
||||
[data-variant='solid'],
|
||||
[data-variant='soft'],
|
||||
[data-variant='ghost'],
|
||||
[data-variant='plain']
|
||||
):not(:first-child) {
|
||||
border-block-start: var(--button-border-width) solid
|
||||
color-mix(in srgb, currentColor var(--button-group-divider-strength), transparent);
|
||||
}
|
||||
|
||||
/* ── Float interactive button above neighbours so its ring isn't clipped ── */
|
||||
[data-button-group][data-attached] [data-button]:hover:not([data-disabled]) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[data-button-group][data-attached] [data-button]:active:not([data-disabled]) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[data-button-group][data-attached] [data-button]:focus-visible {
|
||||
z-index: 2;
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import './button-group.css';
|
||||
/**
|
||||
* Eidos <ButtonGroup> — presentational cluster of action <Button>s.
|
||||
*
|
||||
* Composes <Group> for the layout (attached/segmented corners + border
|
||||
* overlap, direction, equal-width stretch) instead of re-implementing
|
||||
* it, and publishes a context that child <Button>s read for their shared
|
||||
* visual defaults (variant / size / color / disabled).
|
||||
*
|
||||
* scope:['eidos'] — there is no behaviour. The ARIA contract
|
||||
* (`role="group"` + `aria-orientation` + accessible name) is DECLARED in
|
||||
* `buttonGroupMorfo`; this wrapper only materialises it. It never stamps
|
||||
* an attr the morfo doesn't declare.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import Group from '$uix/eidos/components/group';
|
||||
import type { ButtonGroupProps } from './types';
|
||||
import { setButtonGroupEidosCtx } from './context';
|
||||
|
||||
let {
|
||||
variant = 'solid',
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
disabled = false,
|
||||
orientation = 'horizontal',
|
||||
attached = true,
|
||||
block = false,
|
||||
equal,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonGroupProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
// `equal` (match every button to the widest) defaults to ON only for a
|
||||
// VERTICAL attached group — there it equalises WIDTH to the widest and the
|
||||
// group stays content-sized. A horizontal group's "equal" fills the
|
||||
// container (main-axis), which is rarely the wanted default, so it stays OFF
|
||||
// (natural content widths) unless set explicitly.
|
||||
const resolvedEqual = $derived(equal ?? (attached && orientation === 'vertical'));
|
||||
|
||||
// Shared visual defaults for child <Button>s. Getters keep it reactive:
|
||||
// when a group prop changes, every child re-reads on its next derivation.
|
||||
setButtonGroupEidosCtx({
|
||||
get variant() {
|
||||
return variant;
|
||||
},
|
||||
get size() {
|
||||
return resolvedSize;
|
||||
},
|
||||
get color() {
|
||||
return color;
|
||||
},
|
||||
get disabled() {
|
||||
return disabled || undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Group
|
||||
{...rest}
|
||||
role="group"
|
||||
direction={orientation === 'vertical' ? 'column' : 'row'}
|
||||
{attached}
|
||||
grow={block}
|
||||
inline={!block}
|
||||
gap={attached ? 0 : 2}
|
||||
aria-orientation={orientation}
|
||||
data-button-group=""
|
||||
data-orientation={orientation}
|
||||
data-variant={variant}
|
||||
data-size={resolvedSize}
|
||||
data-color={color}
|
||||
data-block={block ? '' : undefined}
|
||||
data-equal={resolvedEqual ? '' : undefined}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Group>
|
||||
@ -0,0 +1,39 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { ButtonGroupColor, ButtonGroupSize, ButtonGroupVariant } from './types';
|
||||
|
||||
/**
|
||||
* Eidos context published by <ButtonGroup> and read by child <Button>s
|
||||
* (and everything built on Button: IconButton, SplitButton, FAB).
|
||||
*
|
||||
* It carries the group's shared VISUAL defaults. A child uses a value ONLY
|
||||
* as a fallback — an explicit prop on the child always wins
|
||||
* (`child.variant ?? group?.variant ?? 'solid'`). When a Button is used
|
||||
* outside a group, `getButtonGroupEidosCtx()` returns undefined and the
|
||||
* child falls back to its own defaults — fully backward-compatible.
|
||||
*
|
||||
* Why a context and not CSS descendant selectors: Button's recipe reads
|
||||
* `data-variant` / `data-size` / `data-color` ON the button element, so a
|
||||
* descendant selector on the group can't reach them. The child must carry
|
||||
* those attrs itself — the context lets it inherit the group's values
|
||||
* while keeping every Button a real, standalone Button (no `Group.Item`).
|
||||
*
|
||||
* Property getters keep the bridge reactive: when the group's prop changes
|
||||
* every child observing the getter re-reads on its next derivation.
|
||||
*/
|
||||
|
||||
const KEY = Symbol('button-group-eidos-ctx');
|
||||
|
||||
export interface ButtonGroupEidosCtx {
|
||||
readonly variant: ButtonGroupVariant | undefined;
|
||||
readonly size: ButtonGroupSize | undefined;
|
||||
readonly color: ButtonGroupColor | undefined;
|
||||
readonly disabled: boolean | undefined;
|
||||
}
|
||||
|
||||
export function setButtonGroupEidosCtx(ctx: ButtonGroupEidosCtx): void {
|
||||
setContext(KEY, ctx);
|
||||
}
|
||||
|
||||
export function getButtonGroupEidosCtx(): ButtonGroupEidosCtx | undefined {
|
||||
return getContext<ButtonGroupEidosCtx | undefined>(KEY);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
// ButtonGroup — eidos presentational cluster of action buttons.
|
||||
//
|
||||
// import { ButtonGroup } from '$uix/eidos/components/button-group';
|
||||
// import { Button } from '$uix/eidos/components/button';
|
||||
//
|
||||
// <ButtonGroup aria-label="Text alignment" variant="surface">
|
||||
// <Button>Left</Button>
|
||||
// <Button>Center</Button>
|
||||
// <Button>Right</Button>
|
||||
// </ButtonGroup>
|
||||
//
|
||||
// Single-root component — the default IS the component. Children are real
|
||||
// <Button> / <IconButton> / <SplitButton> elements; there is no
|
||||
// `ButtonGroup.Item` (you drop real buttons in, and they inherit the
|
||||
// group's variant / size / color / disabled via context).
|
||||
import ButtonGroup from './button-group.svelte';
|
||||
|
||||
export { ButtonGroup };
|
||||
export default ButtonGroup;
|
||||
export type {
|
||||
ButtonGroupProps,
|
||||
ButtonGroupVariant,
|
||||
ButtonGroupSize,
|
||||
ButtonGroupColor,
|
||||
ButtonGroupOrientation
|
||||
} from './types';
|
||||
@ -0,0 +1,89 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { GroupProps } from '$uix/eidos/components/group';
|
||||
import type { ButtonSize, ButtonVariant } from '$uix/eidos/components/button';
|
||||
import type { ResponsiveProp } from '$uix/eidos/lib/types';
|
||||
|
||||
/** Visual variant propagated to child Buttons. Mirrors {@link ButtonVariant}. */
|
||||
export type ButtonGroupVariant = ButtonVariant;
|
||||
|
||||
/** Sizing scale propagated to child Buttons. Mirrors {@link ButtonSize}. */
|
||||
export type ButtonGroupSize = ButtonSize;
|
||||
|
||||
/** Hierarchical color propagated to child Buttons (non-evaluative). */
|
||||
export type ButtonGroupColor = 'primary' | 'secondary' | 'neutral';
|
||||
|
||||
/** Layout axis of the group. */
|
||||
export type ButtonGroupOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
type ButtonGroupOwnProps = {
|
||||
/**
|
||||
* Visual variant propagated as the DEFAULT to every child Button. An
|
||||
* explicit `variant` on a child Button overrides it. @default 'solid'
|
||||
*/
|
||||
variant?: ButtonGroupVariant;
|
||||
/**
|
||||
* Sizing scale propagated as the DEFAULT to every child Button. @default 'md'
|
||||
*/
|
||||
size?: ResponsiveProp<ButtonGroupSize>;
|
||||
/**
|
||||
* Hierarchical color propagated as the DEFAULT to every child Button.
|
||||
* @default 'primary'
|
||||
*/
|
||||
color?: ButtonGroupColor;
|
||||
/** Disable every child Button at once. @default false */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Layout axis. Maps to `aria-orientation` + `data-orientation`.
|
||||
* @default 'horizontal'
|
||||
*/
|
||||
orientation?: ButtonGroupOrientation;
|
||||
/**
|
||||
* Render as a single segmented unit — children share edges, inner
|
||||
* corners squared, doubled borders collapsed. @default true
|
||||
*/
|
||||
attached?: boolean;
|
||||
/**
|
||||
* Stretch to fill the container inline-size; children share width
|
||||
* equally. @default false
|
||||
*/
|
||||
block?: boolean;
|
||||
/**
|
||||
* Make every button the width of the **widest** one (content-sized, not
|
||||
* container-filling — that's `block`). Defaults to ON when `attached`, so
|
||||
* a segmented control reads as one unit; set explicitly to force it on or
|
||||
* off in either layout. @default attached
|
||||
*/
|
||||
equal?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the eidos `<ButtonGroup>`.
|
||||
*
|
||||
* A presentational cluster of independent action buttons — `role="group"`
|
||||
* plus shared visual defaults that propagate to the child Buttons. The
|
||||
* children are real `<Button>` / `<IconButton>` / `<SplitButton>` elements
|
||||
* (there is no `ButtonGroup.Item`). Provide an accessible name via the
|
||||
* inherited `aria-label` / `aria-labelledby` — `role="group"` needs one.
|
||||
*
|
||||
* The accessible name + grouping (`role`, `aria-orientation`, `aria-label`,
|
||||
* `aria-labelledby`) are declared in `buttonGroupMorfo`; this prop surface
|
||||
* adds the visual treatment. The component composes `<Group>` for layout, so
|
||||
* the passthrough surface mirrors `GroupProps` (class / style / id /
|
||||
* `aria-*` / event handlers …) minus the axes ButtonGroup owns. `role` and
|
||||
* `aria-orientation` are fixed by the morfo, so they are not passthrough.
|
||||
*/
|
||||
export type ButtonGroupProps = ButtonGroupOwnProps &
|
||||
Omit<
|
||||
GroupProps,
|
||||
| keyof ButtonGroupOwnProps
|
||||
| 'grow'
|
||||
| 'direction'
|
||||
| 'inline'
|
||||
| 'gap'
|
||||
| 'role'
|
||||
| 'aria-orientation'
|
||||
| 'children'
|
||||
> & {
|
||||
/** The buttons. */
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Fab (Floating Action Button) recipe.
|
||||
*
|
||||
* The composed <Button> paints the chrome (variant fill, intent colour, focus
|
||||
* ring, icon slot). This recipe owns only the FAB-specific treatment, layered
|
||||
* on the same `<button>` (it carries `data-button` + `data-fab`):
|
||||
*
|
||||
* 1. prominent elevation, lifting on hover;
|
||||
* 2. the FAB size scale (its own sm/md/lg — larger than a control);
|
||||
* 3. the circular icon form vs the extended pill (`data-extended`);
|
||||
* 4. floating placement, fixed to the viewport (`data-placement`).
|
||||
*
|
||||
* The dimensional rules out-specify Button's `[data-button][data-size]` (0,2,0)
|
||||
* by also matching `[data-fab]` (≥ 0,3,0), so they win regardless of which
|
||||
* code-split chunk loads first.
|
||||
*
|
||||
* Elevation drives Button's OWN `--button-shadow` token (its `[data-button]`
|
||||
* rule already renders + transitions `box-shadow`), so the FAB shadow composes
|
||||
* with Button's `outline` focus ring instead of fighting its box-shadow rule on
|
||||
* a specificity tie. The `[data-button][data-fab]` prefix (0,2,0) wins over any
|
||||
* `[data-button]` setter regardless of code-split load order.
|
||||
*
|
||||
* The hover lift uses the `translate` property, NOT `transform` — Button's
|
||||
* active press-squeeze animates `transform: scale(...)`, so a transform here
|
||||
* would clobber it; `translate` composes cleanly alongside it. Button transitions
|
||||
* `transform` but not `translate`, so we re-declare its transition list + add
|
||||
* `translate` for the lift to animate.
|
||||
*/
|
||||
|
||||
/* The FAB's size / icon / offset / elevation / lift scale (`--fab-*`) is declared
|
||||
* in the recipe (`base.ts > fab`) and generated to `:root` — this file only
|
||||
* CONSUMES it (the values are NOT literals here). The scale is deliberately off
|
||||
* the control-height ladder (Material 40 / 56 / 72); the shadow tops out at
|
||||
* `--shadow-overlay`, so the hover affordance is the `--fab-lift` translate. */
|
||||
|
||||
[data-button][data-fab] {
|
||||
--button-shadow: var(--fab-shadow);
|
||||
transition:
|
||||
background var(--button-transition-duration) var(--button-transition-ease),
|
||||
border-color var(--button-transition-duration) var(--button-transition-ease),
|
||||
color var(--button-transition-duration) var(--button-transition-ease),
|
||||
box-shadow var(--button-transition-duration) var(--button-transition-ease),
|
||||
transform var(--button-transition-duration) var(--button-transition-ease),
|
||||
translate var(--button-transition-duration) var(--button-transition-ease);
|
||||
}
|
||||
|
||||
[data-button][data-fab]:hover:not([data-disabled]):not(:active) {
|
||||
translate: 0 calc(-1 * var(--fab-lift));
|
||||
}
|
||||
|
||||
[data-button][data-fab][data-disabled] {
|
||||
--button-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Size (diameter + glyph) ─────────────────────────────────────────── */
|
||||
|
||||
[data-button][data-fab][data-fab-size='sm'] {
|
||||
block-size: var(--fab-size-sm);
|
||||
font-size: var(--fab-icon-sm);
|
||||
}
|
||||
[data-button][data-fab][data-fab-size='md'] {
|
||||
block-size: var(--fab-size-md);
|
||||
font-size: var(--fab-icon-md);
|
||||
}
|
||||
[data-button][data-fab][data-fab-size='lg'] {
|
||||
block-size: var(--fab-size-lg);
|
||||
font-size: var(--fab-icon-lg);
|
||||
}
|
||||
|
||||
/* Circular icon-only form: width = height. Overrides Button's iconOnly
|
||||
* `inline-size: var(--_button-height)` (which would size to the control
|
||||
* height, not the FAB diameter). */
|
||||
[data-button][data-fab][data-fab-size='sm']:not([data-extended]) {
|
||||
inline-size: var(--fab-size-sm);
|
||||
}
|
||||
[data-button][data-fab][data-fab-size='md']:not([data-extended]) {
|
||||
inline-size: var(--fab-size-md);
|
||||
}
|
||||
[data-button][data-fab][data-fab-size='lg']:not([data-extended]) {
|
||||
inline-size: var(--fab-size-lg);
|
||||
}
|
||||
|
||||
/* Extended pill: height = diameter, width hugs the content with comfortable
|
||||
* inline padding + a gap between the icon and the label. */
|
||||
[data-button][data-fab][data-extended] {
|
||||
inline-size: auto;
|
||||
padding-inline: var(--space-5);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* The visible glyph follows the FAB's font-size (1em icon). */
|
||||
[data-fab] [data-button-icon] {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* ── Floating placement (fixed to the viewport) ──────────────────────── */
|
||||
/*
|
||||
* `static` = no positioning (the consumer places the FAB). The four corners
|
||||
* pin to their edges, padded by `--fab-offset` + the matching safe-area inset
|
||||
* so the FAB clears notches / home indicators.
|
||||
*/
|
||||
[data-fab][data-placement='bottom-end'],
|
||||
[data-fab][data-placement='bottom-start'],
|
||||
[data-fab][data-placement='top-end'],
|
||||
[data-fab][data-placement='top-start'] {
|
||||
position: fixed;
|
||||
z-index: var(--fab-z);
|
||||
}
|
||||
|
||||
[data-fab][data-placement='bottom-end'] {
|
||||
inset-block-end: calc(var(--fab-offset) + env(safe-area-inset-bottom, 0px));
|
||||
inset-inline-end: calc(var(--fab-offset) + env(safe-area-inset-right, 0px));
|
||||
}
|
||||
[data-fab][data-placement='bottom-start'] {
|
||||
inset-block-end: calc(var(--fab-offset) + env(safe-area-inset-bottom, 0px));
|
||||
inset-inline-start: calc(var(--fab-offset) + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
[data-fab][data-placement='top-end'] {
|
||||
inset-block-start: calc(var(--fab-offset) + env(safe-area-inset-top, 0px));
|
||||
inset-inline-end: calc(var(--fab-offset) + env(safe-area-inset-right, 0px));
|
||||
}
|
||||
[data-fab][data-placement='top-start'] {
|
||||
inset-block-start: calc(var(--fab-offset) + env(safe-area-inset-top, 0px));
|
||||
inset-inline-start: calc(var(--fab-offset) + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-button][data-fab]:hover:not([data-disabled]):not(:active) {
|
||||
translate: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import './fab.css';
|
||||
/**
|
||||
* Eidos `<Fab>` — Floating Action Button.
|
||||
*
|
||||
* Composes the framework `<Button>` and layers on the FAB treatment. The
|
||||
* Button owns the chrome (variant fill, `intent`/`color`, focus ring, the
|
||||
* icon slot, the native `<button>` + `onclick`); the FAB recipe owns the
|
||||
* elevation (rest → hover lift), the circular / extended shape, the FAB size
|
||||
* scale, and the floating placement.
|
||||
*
|
||||
* scope:['eidos'] — no runtime. The morfo (`fabMorfo`) declares the action
|
||||
* role + accessible name + the `data-fab` identity; this wrapper materialises
|
||||
* them on the composed Button and stamps the eidos-only visual knobs
|
||||
* (`data-fab-size` / `data-extended` / `data-placement`). It never stamps an
|
||||
* attr the morfo doesn't promise as the FAB's own (role / disabled come from
|
||||
* Button).
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { composeInlineStyle } from '$uix/eidos/lib/style';
|
||||
import type { FabProps } from './types';
|
||||
|
||||
let {
|
||||
extended = false,
|
||||
size = 'md',
|
||||
placement = 'bottom-end',
|
||||
offset,
|
||||
variant = 'solid',
|
||||
icon,
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: FabProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const resolvedPlacement = $derived(eidos.resolve(placement, 'bottom-end'));
|
||||
const composedStyle = $derived(
|
||||
composeInlineStyle(offset ? `--fab-offset: ${offset};` : undefined, style)
|
||||
);
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
{variant}
|
||||
{icon}
|
||||
rounded="full"
|
||||
iconOnly={!extended}
|
||||
style={composedStyle}
|
||||
data-fab=""
|
||||
data-fab-size={resolvedSize}
|
||||
data-extended={extended ? '' : undefined}
|
||||
data-placement={resolvedPlacement}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
@ -0,0 +1,23 @@
|
||||
// Fab — eidos Floating Action Button.
|
||||
//
|
||||
// import { Fab } from '$uix/eidos/components/fab';
|
||||
//
|
||||
// <Fab aria-label="New" placement="bottom-end" onclick={create}>
|
||||
// {#snippet icon()}<SvgPlus />{/snippet}
|
||||
// </Fab>
|
||||
//
|
||||
// <!-- extended: pill with a visible label -->
|
||||
// <Fab extended aria-label="Compose" onclick={compose}>
|
||||
// {#snippet icon()}<SvgPlus />{/snippet}
|
||||
// Compose
|
||||
// </Fab>
|
||||
//
|
||||
// Composes <Button> (chrome / intent / focus / icon slot / native button) and
|
||||
// adds the FAB elevation, the circular / extended shape, the FAB size scale,
|
||||
// and the floating placement. scope:['eidos'] — see `fabMorfo`.
|
||||
import Fab from './fab.svelte';
|
||||
|
||||
export { Fab };
|
||||
export default Fab;
|
||||
|
||||
export type { FabProps, FabSize, FabPlacement } from './types';
|
||||
@ -0,0 +1,58 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ButtonProps } from '$uix/eidos/components/button';
|
||||
import type { ResponsiveProp } from '$uix/eidos/lib/types';
|
||||
|
||||
/**
|
||||
* FAB size scale. Deliberately its OWN 3-step scale (not Button's `xs…xl`): a
|
||||
* FAB is a prominent target, larger than an inline control — `sm` ≈ 40px, `md`
|
||||
* ≈ 56px (the canonical FAB), `lg` ≈ 72px.
|
||||
*/
|
||||
export type FabSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Floating placement around the viewport. `static` opts out of the fixed
|
||||
* positioning entirely — the FAB becomes a prominent, elevated button the
|
||||
* consumer positions itself (in a toolbar, a card corner, …).
|
||||
*/
|
||||
export type FabPlacement = 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static';
|
||||
|
||||
/**
|
||||
* Props for the eidos `<Fab>` — Floating Action Button.
|
||||
*
|
||||
* Composes `<Button>` for the chrome (variant fill, `intent`/`color`, focus
|
||||
* ring, the icon slot, the native `<button>` + `onclick`) and layers on the
|
||||
* FAB treatment: prominent elevation (rest → hover lift), the circular (or
|
||||
* extended-pill) shape, the FAB size scale, and the floating placement.
|
||||
*
|
||||
* The accessible name + action role + structural identity (`data-fab`) are
|
||||
* declared in `fabMorfo`; this prop surface adds the visual + placement knobs.
|
||||
* Button's own `size` / `iconOnly` / `rounded` / `block` are owned by the FAB
|
||||
* (size → FAB scale, iconOnly → derived from `extended`, shape is fixed), so
|
||||
* they are omitted from the passthrough.
|
||||
*/
|
||||
export type FabProps = Omit<
|
||||
ButtonProps,
|
||||
'size' | 'iconOnly' | 'rounded' | 'block' | 'children'
|
||||
> & {
|
||||
/** The label. Visible in the extended pill; sr-only (named via `aria-label`) on the icon-only FAB. */
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Extended FAB — a pill that shows the label next to the icon. Off → a
|
||||
* circular icon-only FAB (the label stays in the accessibility tree).
|
||||
* @default false
|
||||
*/
|
||||
extended?: boolean;
|
||||
/** FAB size scale. @default 'md' */
|
||||
size?: ResponsiveProp<FabSize>;
|
||||
/**
|
||||
* Where the FAB floats, fixed to the viewport (with safe-area insets).
|
||||
* `static` opts out — no fixed positioning, the consumer places it.
|
||||
* @default 'bottom-end'
|
||||
*/
|
||||
placement?: ResponsiveProp<FabPlacement>;
|
||||
/**
|
||||
* Distance from the viewport edges when floating (any CSS length). Ignored
|
||||
* for `placement="static"`. @default var(--fab-offset) (≈ space-5)
|
||||
*/
|
||||
offset?: string;
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ONION_SCALES,
|
||||
onionBaseColor,
|
||||
deriveOnionShade,
|
||||
deriveOnionMuted,
|
||||
deriveOnionTrigger
|
||||
} from './color';
|
||||
|
||||
// The colour engine is theme-driven: it emits CSS colour STRINGS that reference
|
||||
// the theme's `--scale-*` / `--primitive-*` tokens (resolved live by the cascade).
|
||||
// These tests assert the token wiring, not concrete hex values.
|
||||
|
||||
const ON_SOLID = 'var(--color-content-on-solid)';
|
||||
/** The `pct` of the second colour in `color-mix(in oklch, A, B pct%)`, or 0 if no mix. */
|
||||
const mixPct = (s: string): number => {
|
||||
const m = s.match(/\s(\d+(?:\.\d+)?)%\)\s*$/);
|
||||
return m ? Number(m[1]) : 0;
|
||||
};
|
||||
|
||||
describe('onionBaseColor — distinct theme scale per top option', () => {
|
||||
it('cycles the default scales by index', () => {
|
||||
expect(onionBaseColor(0)).toBe(DEFAULT_ONION_SCALES[0]);
|
||||
expect(onionBaseColor(1)).toBe(DEFAULT_ONION_SCALES[1]);
|
||||
expect(onionBaseColor(DEFAULT_ONION_SCALES.length)).toBe(DEFAULT_ONION_SCALES[0]);
|
||||
});
|
||||
it('the default scales are distinct canonical scale names', () => {
|
||||
expect(new Set(DEFAULT_ONION_SCALES).size).toBe(DEFAULT_ONION_SCALES.length);
|
||||
});
|
||||
it('honours an explicit scale / role name', () => {
|
||||
expect(onionBaseColor(0, 'indigo')).toBe('indigo');
|
||||
expect(onionBaseColor(3, 'primary')).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveOnionShade — derive deeper rings from the branch scale', () => {
|
||||
it('references the branch scale tokens (no hardcoded colours)', () => {
|
||||
const s = deriveOnionShade('teal', 2);
|
||||
expect(s.fill).toContain('var(--scale-teal-9)');
|
||||
expect(s.fill).toContain('var(--scale-teal-3)');
|
||||
expect(s.fill.startsWith('color-mix(in oklch')).toBe(true);
|
||||
});
|
||||
it('depth 1 is the solid step (no recession)', () => {
|
||||
expect(deriveOnionShade('teal', 1).fill).toBe('var(--scale-teal-9)');
|
||||
});
|
||||
it('recedes further toward the subtle step with depth', () => {
|
||||
const p1 = mixPct(deriveOnionShade('teal', 1).fill);
|
||||
const p2 = mixPct(deriveOnionShade('teal', 2).fill);
|
||||
const p3 = mixPct(deriveOnionShade('teal', 3).fill);
|
||||
expect(p2).toBeGreaterThan(p1);
|
||||
expect(p3).toBeGreaterThan(p2);
|
||||
});
|
||||
it('maps a role name to the primitive token', () => {
|
||||
expect(deriveOnionShade('primary', 2).fill).toContain('var(--primitive-primary-9)');
|
||||
});
|
||||
it('text is on-solid over the solid steps and the scale text step over the receded tints', () => {
|
||||
expect(deriveOnionShade('teal', 1).text).toBe(ON_SOLID);
|
||||
expect(deriveOnionShade('teal', 6).text).toBe('var(--scale-teal-12)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveOnionMuted', () => {
|
||||
it('is a heavily receded tint of the scale with high-contrast text', () => {
|
||||
const m = deriveOnionMuted('orange');
|
||||
expect(m.fill).toContain('var(--scale-orange-9)');
|
||||
expect(m.fill).toContain('var(--scale-orange-3)');
|
||||
expect(mixPct(m.fill)).toBeGreaterThan(mixPct(deriveOnionShade('orange', 3).fill));
|
||||
expect(m.text).toBe('var(--scale-orange-12)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveOnionTrigger', () => {
|
||||
it('is the neutral solid at root and the branch solid when drilled', () => {
|
||||
expect(deriveOnionTrigger()).toBe('var(--primitive-neutral-9)');
|
||||
expect(deriveOnionTrigger('indigo')).toBe('var(--scale-indigo-9)');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,96 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { OnionDisposition, OnionDisplay } from './geometry';
|
||||
import type { OnionColor } from './types';
|
||||
|
||||
/**
|
||||
* OnionMenu registration tree. The composed `<OnionLayer>` / `<OnionOption>`
|
||||
* components don't paint — they REGISTER into this reactive tree (via the
|
||||
* contexts below); the root `<OnionMenu>` reads the tree and renders the SVG
|
||||
* centrally. Parts declare structure, the root draws. Both node types are
|
||||
* reactive ($state) so changing a part's props re-paints the menu.
|
||||
*/
|
||||
|
||||
export class OnionOptionNode {
|
||||
label = $state<string | undefined>(undefined);
|
||||
icon = $state<Snippet | undefined>(undefined);
|
||||
color = $state<OnionColor | undefined>(undefined);
|
||||
display = $state<OnionDisplay | undefined>(undefined);
|
||||
disabled = $state(false);
|
||||
onSelect = $state<(() => void) | undefined>(undefined);
|
||||
// `$state.raw`: the child layer is a class instance — keep it UN-proxied so its
|
||||
// identity is stable (otherwise it gets deep-proxied and indexOf / === break).
|
||||
// Reactivity is reassignment-level, which is exactly the structural granularity.
|
||||
/** Set by a nested `<OnionLayer>` that composes inside this option. */
|
||||
childLayer = $state.raw<OnionLayerNode | null>(null);
|
||||
|
||||
get hasChildren(): boolean {
|
||||
return !!this.childLayer && this.childLayer.options.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class OnionLayerNode {
|
||||
textDisposition = $state<OnionDisposition | undefined>(undefined);
|
||||
color = $state<OnionColor | undefined>(undefined);
|
||||
display = $state<OnionDisplay | undefined>(undefined);
|
||||
ringWidth = $state<number | undefined>(undefined);
|
||||
gap = $state<number | undefined>(undefined);
|
||||
// `$state.raw` + reassign-on-change: option nodes stay UN-proxied (stable
|
||||
// identity for indexOf / keyed rendering); the array reference changes drive
|
||||
// the structural reactivity. Each node carries its own field-level $state.
|
||||
/** In declaration order. */
|
||||
options = $state.raw<OnionOptionNode[]>([]);
|
||||
private order: number[] = [];
|
||||
|
||||
/** Insert an option at its declaration index (uses the DOM-order seq the layer assigns). */
|
||||
insert(node: OnionOptionNode, seq: number): void {
|
||||
// keep `options` ordered by registration sequence so markup order is preserved
|
||||
let at = this.order.findIndex((s) => s > seq);
|
||||
if (at === -1) at = this.order.length;
|
||||
this.order.splice(at, 0, seq);
|
||||
this.options = [...this.options.slice(0, at), node, ...this.options.slice(at)];
|
||||
}
|
||||
remove(node: OnionOptionNode): void {
|
||||
const i = this.options.indexOf(node);
|
||||
if (i === -1) return;
|
||||
this.order.splice(i, 1);
|
||||
this.options = [...this.options.slice(0, i), ...this.options.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contexts ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MENU = Symbol('uix.onion-menu');
|
||||
const LAYER = Symbol('uix.onion-menu.layer');
|
||||
const OPTION = Symbol('uix.onion-menu.option');
|
||||
|
||||
export interface OnionMenuRegistry {
|
||||
/** The top `<OnionLayer>` registers itself here (null on unmount). */
|
||||
setRootLayer(node: OnionLayerNode | null): void;
|
||||
/** Monotonic sequence for declaration-order registration. */
|
||||
nextSeq(): number;
|
||||
}
|
||||
export interface OnionLayerRegistry {
|
||||
/** Each `<OnionOption>` registers here in declaration order. */
|
||||
registerOption(node: OnionOptionNode, seq: number): void;
|
||||
unregisterOption(node: OnionOptionNode): void;
|
||||
}
|
||||
export interface OnionOptionRegistry {
|
||||
/** A nested `<OnionLayer>` registers as this option's children. */
|
||||
setChildLayer(node: OnionLayerNode | null): void;
|
||||
}
|
||||
|
||||
export const setOnionMenuRegistry = (r: OnionMenuRegistry): void => {
|
||||
setContext(MENU, r);
|
||||
};
|
||||
export const getOnionMenuRegistry = (): OnionMenuRegistry | undefined => getContext(MENU);
|
||||
|
||||
export const setOnionLayerRegistry = (r: OnionLayerRegistry): void => {
|
||||
setContext(LAYER, r);
|
||||
};
|
||||
export const getOnionLayerRegistry = (): OnionLayerRegistry | undefined => getContext(LAYER);
|
||||
|
||||
export const setOnionOptionRegistry = (r: OnionOptionRegistry): void => {
|
||||
setContext(OPTION, r);
|
||||
};
|
||||
export const getOnionOptionRegistry = (): OnionOptionRegistry | undefined => getContext(OPTION);
|
||||
@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
onionArc,
|
||||
polar,
|
||||
gapPadDeg,
|
||||
computeOnionGeometry,
|
||||
ICON_SIZE,
|
||||
type OnionGeometryInput
|
||||
} from './geometry';
|
||||
|
||||
describe('onionArc — placement drives span + orientation', () => {
|
||||
it('centre is a full circle', () => {
|
||||
expect(onionArc('center')).toEqual({ start: 0, span: 360 });
|
||||
});
|
||||
it('edges are half circles opening away from the edge', () => {
|
||||
expect(onionArc('bottom-center')).toEqual({ start: 270, span: 180 });
|
||||
expect(onionArc('top-center')).toEqual({ start: 90, span: 180 });
|
||||
expect(onionArc('left-center')).toEqual({ start: 0, span: 180 });
|
||||
expect(onionArc('right-center')).toEqual({ start: 180, span: 180 });
|
||||
});
|
||||
it('corners are quarter circles into the diagonal free quadrant', () => {
|
||||
for (const p of ['bottom-end', 'bottom-start', 'top-end', 'top-start'] as const) {
|
||||
expect(onionArc(p).span).toBe(90);
|
||||
}
|
||||
expect(onionArc('bottom-end').start).toBe(270); // up-left
|
||||
expect(onionArc('top-start').start).toBe(90); // down-right
|
||||
});
|
||||
});
|
||||
|
||||
describe('polar', () => {
|
||||
it('0° is straight up from the centre', () => {
|
||||
const [x, y] = polar(100, 100, 50, 0);
|
||||
expect(x).toBeCloseTo(100, 6);
|
||||
expect(y).toBeCloseTo(50, 6);
|
||||
});
|
||||
it('90° is to the right, 180° is down', () => {
|
||||
expect(polar(0, 0, 10, 90)[0]).toBeCloseTo(10, 6);
|
||||
expect(polar(0, 0, 10, 180)[1]).toBeCloseTo(10, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gapPadDeg — constant pixel gap', () => {
|
||||
it('scales as 1/r so the px gap is constant across radii', () => {
|
||||
const inner = gapPadDeg(50, 6);
|
||||
const outer = gapPadDeg(100, 6);
|
||||
// double the radius → half the angular pad → same arc length in px
|
||||
expect(outer).toBeCloseTo(inner / 2, 6);
|
||||
// arc length (= angle_rad * r) is identical at both radii
|
||||
expect((inner / (180 / Math.PI)) * 50).toBeCloseTo((outer / (180 / Math.PI)) * 100, 6);
|
||||
});
|
||||
});
|
||||
|
||||
const baseInput = (over: Partial<OnionGeometryInput> = {}): OnionGeometryInput => ({
|
||||
placement: 'center',
|
||||
center: [200, 200],
|
||||
innerRadius: 40,
|
||||
rings: [
|
||||
{
|
||||
options: [
|
||||
{ hasIcon: true, display: 'both' },
|
||||
{ hasIcon: true, display: 'both' },
|
||||
{ hasIcon: false, display: 'both' },
|
||||
{ hasIcon: true, display: 'both' }
|
||||
],
|
||||
width: 60,
|
||||
gap: 5,
|
||||
disposition: 'angular'
|
||||
}
|
||||
],
|
||||
...over
|
||||
});
|
||||
|
||||
describe('computeOnionGeometry', () => {
|
||||
it('emits one sector per option and partitions the arc evenly', () => {
|
||||
const g = computeOnionGeometry(baseInput());
|
||||
expect(g.sectors).toHaveLength(4);
|
||||
expect(g.span).toBe(360);
|
||||
// each sector spans 90° and they tile [0,360)
|
||||
g.sectors.forEach((s, i) => {
|
||||
expect(s.a0).toBeCloseTo(90 * i, 6);
|
||||
expect(s.a1).toBeCloseTo(90 * (i + 1), 6);
|
||||
expect(s.mid).toBeCloseTo(90 * i + 45, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('places rings outward with the configured width; outerRadius accounts for all', () => {
|
||||
const g = computeOnionGeometry(
|
||||
baseInput({
|
||||
rings: [
|
||||
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 4, disposition: 'angular' },
|
||||
{ options: [{ hasIcon: false, display: 'text' }], width: 50, gap: 4, disposition: 'angular' }
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(g.outerRadius).toBe(40 + 60 + 50);
|
||||
const [r0, r1] = g.sectors;
|
||||
expect(r0.ring).toBe(0);
|
||||
expect(r1.ring).toBe(1);
|
||||
expect(r1.rIn).toBeGreaterThan(r0.rOut); // outer ring is further out
|
||||
});
|
||||
|
||||
it('every sector has a non-empty path and a sane radial band', () => {
|
||||
for (const s of computeOnionGeometry(baseInput()).sectors) {
|
||||
expect(s.path.startsWith('M')).toBe(true);
|
||||
expect(s.path).toContain('A');
|
||||
expect(s.rOut).toBeGreaterThan(s.rIn);
|
||||
expect(s.midR).toBeCloseTo((s.rIn + s.rOut) / 2, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('display "both" + hasIcon pairs an icon with a label; "icon"-only option still gets a label fallback', () => {
|
||||
const g = computeOnionGeometry(baseInput());
|
||||
const withIcon = g.sectors[0]; // hasIcon: true
|
||||
expect(withIcon.icon).toBeDefined();
|
||||
expect(withIcon.icon?.size).toBe(ICON_SIZE);
|
||||
expect(withIcon.label).toBeDefined(); // both → icon AND text
|
||||
|
||||
const noIcon = g.sectors[2]; // hasIcon: false, display 'both' → text only
|
||||
expect(noIcon.icon).toBeUndefined();
|
||||
expect(noIcon.label).toBeDefined();
|
||||
});
|
||||
|
||||
it('disposition selects the label geometry fields', () => {
|
||||
const ang = computeOnionGeometry(baseInput()).sectors[0].label!;
|
||||
expect(ang.disposition).toBe('angular');
|
||||
expect(ang.arcPath).toBeDefined();
|
||||
expect(typeof ang.flip).toBe('boolean');
|
||||
|
||||
const horiz = computeOnionGeometry(
|
||||
baseInput({
|
||||
rings: [
|
||||
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 5, disposition: 'horizontal' }
|
||||
]
|
||||
})
|
||||
).sectors[0].label!;
|
||||
expect(horiz.disposition).toBe('horizontal');
|
||||
expect(horiz.x).toBeDefined();
|
||||
expect(horiz.arcPath).toBeUndefined();
|
||||
|
||||
const vert = computeOnionGeometry(
|
||||
baseInput({
|
||||
rings: [
|
||||
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 5, disposition: 'vertical' }
|
||||
]
|
||||
})
|
||||
).sectors[0].label!;
|
||||
expect(vert.disposition).toBe('vertical');
|
||||
expect(typeof vert.rotate).toBe('number');
|
||||
expect(vert.availablePx).toBeCloseTo(60 - 5, 1); // radial band ≈ width - gap
|
||||
});
|
||||
|
||||
it('display "icon" hides the label when the option has an icon', () => {
|
||||
const g = computeOnionGeometry(
|
||||
baseInput({
|
||||
rings: [
|
||||
{ options: [{ hasIcon: true, display: 'icon' }], width: 60, gap: 5, disposition: 'angular' }
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(g.sectors[0].icon).toBeDefined();
|
||||
expect(g.sectors[0].label).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* OnionMenu geometry engine — pure, DOM-free, deterministic.
|
||||
*
|
||||
* Computes the concentric-ring ("onion") radial layout: given the anchor
|
||||
* placement, the visible levels (root + the active drill path) and their
|
||||
* per-level config, it returns the annular-sector path for every option plus
|
||||
* its icon position and label slot. The Svelte layer feeds counts + flags here
|
||||
* and renders the result; it never does trig itself.
|
||||
*
|
||||
* Two invariants from the design:
|
||||
* - The arc SPAN is a function of placement — 360° at the centre, 180° on an
|
||||
* edge, 90° in a corner — and the START orients the fan into the free space.
|
||||
* - The separation between segments is a CONSTANT PIXEL width at every radius:
|
||||
* the angular padding is derived per-radius (`pad = (gap/2)/r`), so the gap
|
||||
* never widens outward, and a matching radial gap separates the rings.
|
||||
*/
|
||||
|
||||
export type OnionPlacement =
|
||||
| 'bottom-end'
|
||||
| 'bottom-start'
|
||||
| 'top-end'
|
||||
| 'top-start'
|
||||
| 'bottom-center'
|
||||
| 'top-center'
|
||||
| 'left-center'
|
||||
| 'right-center'
|
||||
| 'center';
|
||||
|
||||
/** How an option's label is laid out inside its sector. */
|
||||
export type OnionDisposition = 'angular' | 'horizontal' | 'vertical';
|
||||
|
||||
/** What an option renders. */
|
||||
export type OnionDisplay = 'icon' | 'text' | 'both';
|
||||
|
||||
/** Icon glyph box size + how far it sits off the band centre when paired with text. */
|
||||
export const ICON_SIZE = 22;
|
||||
export const ICON_OFFSET = 11;
|
||||
|
||||
const DEG = 180 / Math.PI;
|
||||
const round = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
/**
|
||||
* Placement → arc. `span` is the total degrees the ring covers; `start` is the
|
||||
* opening angle (0° = up, clockwise) chosen so the fan points into the free
|
||||
* space. Degrees are in the same frame as {@link polar} (0 at top).
|
||||
*/
|
||||
export function onionArc(placement: OnionPlacement): { start: number; span: number } {
|
||||
switch (placement) {
|
||||
case 'center':
|
||||
return { start: 0, span: 360 };
|
||||
case 'bottom-center':
|
||||
return { start: 270, span: 180 }; // opens up
|
||||
case 'top-center':
|
||||
return { start: 90, span: 180 }; // opens down
|
||||
case 'left-center':
|
||||
return { start: 0, span: 180 }; // opens right
|
||||
case 'right-center':
|
||||
return { start: 180, span: 180 }; // opens left
|
||||
case 'bottom-end':
|
||||
return { start: 270, span: 90 }; // up-left
|
||||
case 'bottom-start':
|
||||
return { start: 0, span: 90 }; // up-right
|
||||
case 'top-end':
|
||||
return { start: 180, span: 90 }; // down-left
|
||||
case 'top-start':
|
||||
return { start: 90, span: 90 }; // down-right
|
||||
}
|
||||
}
|
||||
|
||||
/** Point on a circle. `deg` measured from the top (0°), clockwise. */
|
||||
export function polar(cx: number, cy: number, r: number, deg: number): [number, number] {
|
||||
const a = (deg - 90) / DEG;
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular padding (degrees) that yields a `gap`/2 ARC of constant pixel length
|
||||
* at radius `r`. Because it scales as 1/r, the gap stays the same width in px at
|
||||
* the inner and outer radii (the signature of a fixed-pixel gap, not angular).
|
||||
*/
|
||||
export function gapPadDeg(r: number, gap: number): number {
|
||||
return ((gap / 2) / r) * DEG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annular-sector ("donut slice") path between `rIn..rOut` over `a0..a1`, with a
|
||||
* constant-pixel `gap` carved out of both angular sides. The per-radius padding
|
||||
* makes the side edges slightly converge so the gap is the same width in px at
|
||||
* the inner and outer arcs.
|
||||
*/
|
||||
export function sectorPath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
rIn: number,
|
||||
rOut: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
gap: number
|
||||
): string {
|
||||
const pIn = gapPadDeg(rIn, gap);
|
||||
const pOut = gapPadDeg(rOut, gap);
|
||||
let ai0 = a0 + pIn;
|
||||
let ai1 = a1 - pIn;
|
||||
const ao0 = a0 + pOut;
|
||||
const ao1 = a1 - pOut;
|
||||
if (ai1 <= ai0) {
|
||||
const m = (a0 + a1) / 2;
|
||||
ai0 = m - 0.05;
|
||||
ai1 = m + 0.05;
|
||||
}
|
||||
const [xo0, yo0] = polar(cx, cy, rOut, ao0);
|
||||
const [xo1, yo1] = polar(cx, cy, rOut, ao1);
|
||||
const [xi1, yi1] = polar(cx, cy, rIn, ai1);
|
||||
const [xi0, yi0] = polar(cx, cy, rIn, ai0);
|
||||
const big = ao1 - ao0 > 180 ? 1 : 0;
|
||||
return (
|
||||
`M${round(xo0)} ${round(yo0)} A${rOut} ${rOut} 0 ${big} 1 ${round(xo1)} ${round(yo1)} ` +
|
||||
`L${round(xi1)} ${round(yi1)} A${rIn} ${rIn} 0 ${big} 0 ${round(xi0)} ${round(yi0)} Z`
|
||||
);
|
||||
}
|
||||
|
||||
/** Baseline arc for an `angular` label. Reversed (flipped) on the lower half so text stays upright. */
|
||||
export function labelArcPath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
rad: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
flip: boolean
|
||||
): string {
|
||||
const A0 = flip ? a1 : a0;
|
||||
const A1 = flip ? a0 : a1;
|
||||
const sweep = flip ? 0 : 1;
|
||||
const [x0, y0] = polar(cx, cy, rad, A0);
|
||||
const [x1, y1] = polar(cx, cy, rad, A1);
|
||||
const big = Math.abs(A1 - A0) > 180 ? 1 : 0;
|
||||
return `M${round(x0)} ${round(y0)} A${rad} ${rad} 0 ${big} ${sweep} ${round(x1)} ${round(y1)}`;
|
||||
}
|
||||
|
||||
export interface OnionOptionInput {
|
||||
/** Whether this option has an icon to place (drives the label slot radius). */
|
||||
readonly hasIcon: boolean;
|
||||
/** What this option renders. Display is per-OPTION; disposition is per-ring. */
|
||||
readonly display: OnionDisplay;
|
||||
}
|
||||
|
||||
export interface OnionRingInput {
|
||||
readonly options: readonly OnionOptionInput[];
|
||||
readonly width: number;
|
||||
readonly gap: number;
|
||||
readonly disposition: OnionDisposition;
|
||||
}
|
||||
|
||||
export interface OnionGeometryInput {
|
||||
readonly placement: OnionPlacement;
|
||||
readonly center: readonly [number, number];
|
||||
/** Inner radius where the first ring begins (just outside the trigger). */
|
||||
readonly innerRadius: number;
|
||||
/** One entry per visible level: root first, then each drilled level outward. */
|
||||
readonly rings: readonly OnionRingInput[];
|
||||
}
|
||||
|
||||
/** Resolved label slot — exactly one disposition's fields are populated. */
|
||||
export interface OnionLabel {
|
||||
readonly disposition: OnionDisposition;
|
||||
/** Length (px) available to the text; the renderer fits/truncates to it. */
|
||||
readonly availablePx: number;
|
||||
/** angular */
|
||||
readonly arcPath?: string;
|
||||
readonly flip?: boolean;
|
||||
/** horizontal / vertical */
|
||||
readonly x?: number;
|
||||
readonly y?: number;
|
||||
readonly rotate?: number;
|
||||
}
|
||||
|
||||
export interface OnionSector {
|
||||
readonly ring: number;
|
||||
readonly index: number;
|
||||
readonly a0: number;
|
||||
readonly a1: number;
|
||||
readonly mid: number;
|
||||
readonly rIn: number;
|
||||
readonly rOut: number;
|
||||
readonly midR: number;
|
||||
readonly path: string;
|
||||
/** Icon box centre + size, when the option shows an icon for this display mode. */
|
||||
readonly icon?: { readonly x: number; readonly y: number; readonly size: number };
|
||||
/** Label slot, when the option shows text for this display mode. */
|
||||
readonly label?: OnionLabel;
|
||||
}
|
||||
|
||||
export interface OnionGeometry {
|
||||
readonly start: number;
|
||||
readonly span: number;
|
||||
readonly outerRadius: number;
|
||||
readonly sectors: readonly OnionSector[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the full onion layout. Pure: same input → same output, no DOM.
|
||||
*/
|
||||
export function computeOnionGeometry(input: OnionGeometryInput): OnionGeometry {
|
||||
const [cx, cy] = input.center;
|
||||
const { start, span } = onionArc(input.placement);
|
||||
const sectors: OnionSector[] = [];
|
||||
|
||||
let ringStart = input.innerRadius;
|
||||
for (let r = 0; r < input.rings.length; r++) {
|
||||
const ring = input.rings[r];
|
||||
const count = ring.options.length;
|
||||
const rIn = ringStart + ring.gap / 2;
|
||||
const rOut = ringStart + ring.width - ring.gap / 2;
|
||||
const midR = (rIn + rOut) / 2;
|
||||
ringStart += ring.width;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a0 = start + (span * i) / count;
|
||||
const a1 = start + (span * (i + 1)) / count;
|
||||
const mid = (a0 + a1) / 2;
|
||||
const opt = ring.options[i];
|
||||
const path = sectorPath(cx, cy, rIn, rOut, a0, a1, ring.gap);
|
||||
|
||||
const showIcon = opt.display !== 'text' && opt.hasIcon;
|
||||
const showText =
|
||||
opt.display === 'text' || opt.display === 'both' || (opt.display === 'icon' && !opt.hasIcon);
|
||||
const paired = showIcon && showText;
|
||||
// Text on the WIDER outer arc (more arc length to read), icon on the
|
||||
// NARROWER inner arc (a compact glyph fits the shorter inner edge).
|
||||
const textR = paired ? midR + ICON_OFFSET : midR;
|
||||
const iconR = paired ? midR - ICON_OFFSET : midR;
|
||||
|
||||
let icon: OnionSector['icon'];
|
||||
if (showIcon) {
|
||||
const [ix, iy] = polar(cx, cy, iconR, mid);
|
||||
icon = { x: round(ix), y: round(iy), size: ICON_SIZE };
|
||||
}
|
||||
|
||||
let label: OnionLabel | undefined;
|
||||
if (showText) {
|
||||
label = computeLabel(cx, cy, ring.disposition, textR, rIn, rOut, a0, a1, mid, ring.gap);
|
||||
}
|
||||
|
||||
sectors.push({ ring: r, index: i, a0, a1, mid, rIn, rOut, midR, path, icon, label });
|
||||
}
|
||||
}
|
||||
|
||||
return { start, span, outerRadius: ringStart, sectors };
|
||||
}
|
||||
|
||||
function computeLabel(
|
||||
cx: number,
|
||||
cy: number,
|
||||
disposition: OnionDisposition,
|
||||
textR: number,
|
||||
rIn: number,
|
||||
rOut: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
mid: number,
|
||||
gap: number
|
||||
): OnionLabel {
|
||||
const padMid = gapPadDeg(textR, gap) + 1.6;
|
||||
if (disposition === 'angular') {
|
||||
const flip = mid > 90 && mid < 270;
|
||||
const availablePx = (Math.max(0, a1 - a0 - 2 * padMid) * Math.PI) / 180 * textR;
|
||||
return {
|
||||
disposition,
|
||||
availablePx,
|
||||
flip,
|
||||
arcPath: labelArcPath(cx, cy, textR, a0 + padMid, a1 - padMid, flip)
|
||||
};
|
||||
}
|
||||
const [x, y] = polar(cx, cy, textR, mid);
|
||||
if (disposition === 'horizontal') {
|
||||
const chord = 2 * textR * Math.sin((Math.max(0, a1 - a0 - 2 * padMid) * Math.PI) / 360);
|
||||
return { disposition, availablePx: chord, x: round(x), y: round(y) };
|
||||
}
|
||||
// vertical — text runs along the radius; rotate to stay upright on the lower half
|
||||
let rotate = mid - 90;
|
||||
if (rotate > 90 || rotate < -90) rotate += 180;
|
||||
return { disposition, availablePx: rOut - rIn, x: round(x), y: round(y), rotate: round(rotate) };
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
// OnionMenu — eidos radial ("onion") menu. Compositional, trigger-agnostic.
|
||||
//
|
||||
// import { OnionMenu, OnionLayer, OnionOption } from '$uix/eidos/components/onion-menu';
|
||||
//
|
||||
// <OnionMenu placement="bottom-end">
|
||||
// {#snippet trigger()}<Button>Menu</Button>{/snippet}
|
||||
// <OnionLayer textDisposition="angular" color="primary">
|
||||
// <OnionOption label="Create" icon={createIcon}>
|
||||
// <OnionLayer textDisposition="vertical" color="teal"> <!-- Create's children -->
|
||||
// <OnionOption label="Doc" onSelect={…} />
|
||||
// </OnionLayer>
|
||||
// </OnionOption>
|
||||
// <OnionOption label="Share" icon={shareIcon} onSelect={…} />
|
||||
// </OnionLayer>
|
||||
// </OnionMenu>
|
||||
//
|
||||
// The parts REGISTER into a reactive tree (via context); the root paints the SVG
|
||||
// rings centrally from the pure geometry + colour engines. An option's children
|
||||
// are a nested <OnionLayer> composed inside it (like DropdownMenu.Sub).
|
||||
import OnionMenu from './onion-menu.svelte';
|
||||
import OnionLayer from './onion-layer.svelte';
|
||||
import OnionOption from './onion-option.svelte';
|
||||
|
||||
export { OnionMenu, OnionLayer, OnionOption };
|
||||
|
||||
export type {
|
||||
OnionMenuProps,
|
||||
OnionLayerProps,
|
||||
OnionOptionProps,
|
||||
OnionColor,
|
||||
OnionPlacement,
|
||||
OnionDisposition,
|
||||
OnionDisplay
|
||||
} from './types';
|
||||
|
||||
// Pure engines — exposed for advanced consumers + tests.
|
||||
export { computeOnionGeometry, onionArc, ICON_SIZE } from './geometry';
|
||||
export { onionBaseColor, deriveOnionShade, deriveOnionMuted, deriveOnionTrigger } from './color';
|
||||
@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<OnionLayer>` — one concentric ring / level. It does NOT paint; it registers
|
||||
* an {@link OnionLayerNode} either as the menu's ROOT layer (when composed
|
||||
* directly in `<OnionMenu>`) or as an option's CHILDREN (when composed inside an
|
||||
* `<OnionOption>` — it reads the option registry). It exposes a layer registry
|
||||
* so its `<OnionOption>`s register in order. The root `<OnionMenu>` paints.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
OnionLayerNode,
|
||||
getOnionOptionRegistry,
|
||||
getOnionMenuRegistry,
|
||||
setOnionLayerRegistry
|
||||
} from './context.svelte';
|
||||
import type { OnionLayerProps } from './types';
|
||||
|
||||
let { textDisposition, color, display, ringWidth, gap, children }: OnionLayerProps = $props();
|
||||
|
||||
const option = getOnionOptionRegistry();
|
||||
const menu = getOnionMenuRegistry();
|
||||
const node = new OnionLayerNode();
|
||||
|
||||
$effect(() => void (node.textDisposition = textDisposition));
|
||||
$effect(() => void (node.color = color));
|
||||
$effect(() => void (node.display = display));
|
||||
$effect(() => void (node.ringWidth = ringWidth));
|
||||
$effect(() => void (node.gap = gap));
|
||||
|
||||
// Register: composed inside an option → that option's children; else → root layer.
|
||||
// `untrack` the writes (symmetry with OnionOption; keeps the effect single-run).
|
||||
$effect(() => {
|
||||
if (option) {
|
||||
untrack(() => option.setChildLayer(node));
|
||||
return () => untrack(() => option.setChildLayer(null));
|
||||
}
|
||||
untrack(() => menu?.setRootLayer(node));
|
||||
return () => untrack(() => menu?.setRootLayer(null));
|
||||
});
|
||||
|
||||
// Provide the layer registry for this level's options.
|
||||
setOnionLayerRegistry({
|
||||
registerOption: (n, seq) => node.insert(n, seq),
|
||||
unregisterOption: (n) => node.remove(n)
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,515 @@
|
||||
<script lang="ts">
|
||||
import './onion-menu.css';
|
||||
/**
|
||||
* `<OnionMenu>` — the compositional radial ("onion") menu root. It provides the
|
||||
* registration context that the composed `<OnionLayer>` / `<OnionOption>` parts
|
||||
* build a reactive tree into, OWNS the open + drill state, and paints the SVG
|
||||
* rings CENTRALLY from that tree via the pure geometry + colour engines. Parts
|
||||
* declare structure; the root draws.
|
||||
*
|
||||
* Behaviour comes from the soma layer (`$soma/components/onion-menu`): the
|
||||
* runtime emits the morfo's `open` / `close` / `commit-select` perceptual
|
||||
* signals and dispatches the keyboard contract; the pure `onionNav*` helpers
|
||||
* answer radial focus moves. This root drives focus, drill and dismiss against
|
||||
* them — radial keyboard nav (Arrow / Enter / Backspace / Escape), focus into
|
||||
* the ring on open + restore to the trigger on close, and outside-click / Tab
|
||||
* dismissal (non-modal, per `onionMenuMorfo.focus`).
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import {
|
||||
createOnionMenuRuntime,
|
||||
onionNavNext,
|
||||
onionNavPrev,
|
||||
onionNavFirst
|
||||
} from '$soma/components/onion-menu';
|
||||
import { computeOnionGeometry } from './geometry';
|
||||
import { onionBaseColor, deriveOnionShade, deriveOnionMuted, deriveOnionTrigger } from './color';
|
||||
import {
|
||||
setOnionMenuRegistry,
|
||||
type OnionLayerNode,
|
||||
type OnionOptionNode
|
||||
} from './context.svelte';
|
||||
import type { OnionMenuProps } from './types';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
drillPath = $bindable([]),
|
||||
selected = $bindable(null),
|
||||
placement = 'bottom-end',
|
||||
innerRadius = 82,
|
||||
ringWidth = 66,
|
||||
gap = 5,
|
||||
fontSize = 13,
|
||||
width = 680,
|
||||
height = 620,
|
||||
trigger,
|
||||
onItemClick,
|
||||
onTriggerClick,
|
||||
children
|
||||
}: OnionMenuProps = $props();
|
||||
|
||||
const M = 70;
|
||||
const TRIGGER_R = 30;
|
||||
|
||||
// ── Registration: the composed parts build this reactive tree ──────────
|
||||
// `$state.raw`: the layer node is a class instance carrying its own
|
||||
// field-level reactivity — keep it un-proxied so the whole node tree
|
||||
// (options, childLayer) keeps stable identity (no proxy-equality breakage).
|
||||
let rootLayer = $state.raw<OnionLayerNode | null>(null);
|
||||
let seq = 0;
|
||||
setOnionMenuRegistry({
|
||||
setRootLayer: (n) => (rootLayer = n),
|
||||
nextSeq: () => ++seq
|
||||
});
|
||||
|
||||
// ── Soma behaviour bridge: perceptual emission + keyboard dispatch ──────
|
||||
const eidos = ActiveEidos.require();
|
||||
const runtime = createOnionMenuRuntime({
|
||||
dom: eidos.dom,
|
||||
eventEngine: eidos.uix?.events,
|
||||
announce: eidos.uix ? (m, p) => eidos.uix!.announce(m, p) : undefined,
|
||||
actions: {
|
||||
open: (e) => {
|
||||
e.preventDefault();
|
||||
openMenu();
|
||||
},
|
||||
close: (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
},
|
||||
'next-item': (e) => {
|
||||
e.preventDefault();
|
||||
moveFocus(1);
|
||||
},
|
||||
'prev-item': (e) => {
|
||||
e.preventDefault();
|
||||
moveFocus(-1);
|
||||
},
|
||||
activate: (e) => {
|
||||
e.preventDefault();
|
||||
activateFocused();
|
||||
},
|
||||
'drill-up': (e) => {
|
||||
e.preventDefault();
|
||||
drillUp();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DOM refs (focus management + the perceptual signal target).
|
||||
let frameEl = $state<HTMLDivElement | null>(null);
|
||||
let surfaceEl = $state<SVGSVGElement | null>(null);
|
||||
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
// Resolve a sector element by (level, index) by querying the live DOM — the
|
||||
// sectors render in (ring, index) order, so the index-th item at `data-depth`
|
||||
// is the one we want. Avoids `bind:this` into a computed key (Svelte 5 warns)
|
||||
// and any ref-map timing race: the DOM is current when an effect reads it.
|
||||
function sectorEl(level: number, index: number): SVGPathElement | null {
|
||||
const ring = surfaceEl?.querySelectorAll<SVGPathElement>(
|
||||
`[data-onion-menu-item][data-depth="${level + 1}"]`
|
||||
);
|
||||
return ring?.[index] ?? null;
|
||||
}
|
||||
|
||||
// Roving focus within the deepest (current) ring.
|
||||
let focusedIndex = $state(0);
|
||||
let pendingFocus = $state<number | null>(null);
|
||||
|
||||
// The perceptual signal target is any Element (the engine just stamps attrs);
|
||||
// our refs are SVG, so narrow to the runtime's HTMLElement slot at the boundary.
|
||||
const asTarget = (el: Element | null | undefined): HTMLElement | undefined =>
|
||||
(el ?? undefined) as HTMLElement | undefined;
|
||||
|
||||
function placeCenter(): [number, number] {
|
||||
switch (placement) {
|
||||
case 'center':
|
||||
return [width / 2, height / 2];
|
||||
case 'bottom-end':
|
||||
return [width - M, height - M];
|
||||
case 'bottom-start':
|
||||
return [M, height - M];
|
||||
case 'top-end':
|
||||
return [width - M, M];
|
||||
case 'top-start':
|
||||
return [M, M];
|
||||
case 'bottom-center':
|
||||
return [width / 2, height - M];
|
||||
case 'top-center':
|
||||
return [width / 2, M];
|
||||
case 'left-center':
|
||||
return [M, height / 2];
|
||||
case 'right-center':
|
||||
return [width - M, height / 2];
|
||||
}
|
||||
}
|
||||
const center = $derived(placeCenter());
|
||||
|
||||
// Visible levels = root, then one ring per drilled option (its children layer).
|
||||
const visible = $derived.by(() => {
|
||||
const levels: { layer: OnionLayerNode; options: OnionOptionNode[] }[] = [];
|
||||
if (!rootLayer) return levels;
|
||||
levels.push({ layer: rootLayer, options: rootLayer.options });
|
||||
let cur = rootLayer;
|
||||
for (let k = 0; k < drillPath.length; k++) {
|
||||
const opt = cur.options[drillPath[k]];
|
||||
const child = opt?.childLayer;
|
||||
if (child && child.options.length) {
|
||||
levels.push({ layer: child, options: child.options });
|
||||
cur = child;
|
||||
} else break;
|
||||
}
|
||||
return levels;
|
||||
});
|
||||
|
||||
const branchBase = $derived(
|
||||
drillPath.length && rootLayer
|
||||
? onionBaseColor(drillPath[0], rootLayer.options[drillPath[0]]?.color)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const geometry = $derived(
|
||||
computeOnionGeometry({
|
||||
placement,
|
||||
center,
|
||||
innerRadius,
|
||||
rings: visible.map(({ layer, options }) => ({
|
||||
options: options.map((o) => ({
|
||||
hasIcon: !!o.icon,
|
||||
display: o.display ?? layer.display ?? 'both'
|
||||
})),
|
||||
width: layer.ringWidth ?? ringWidth,
|
||||
gap: layer.gap ?? gap,
|
||||
disposition: layer.textDisposition ?? 'angular'
|
||||
}))
|
||||
})
|
||||
);
|
||||
|
||||
type SectorView = {
|
||||
path: string;
|
||||
opt: OnionOptionNode;
|
||||
ring: number;
|
||||
index: number;
|
||||
active: boolean;
|
||||
isSelected: boolean;
|
||||
muted: boolean;
|
||||
highlight: boolean;
|
||||
hasChildren: boolean;
|
||||
fill: string;
|
||||
text: string;
|
||||
icon?: { x: number; y: number; size: number };
|
||||
label?: { text: string; geom: NonNullable<(typeof geometry.sectors)[number]['label']> };
|
||||
};
|
||||
|
||||
function fit(label: string | undefined, availablePx: number): string {
|
||||
if (!label) return '';
|
||||
const max = Math.floor(availablePx / (fontSize * 0.55));
|
||||
if (label.length <= max) return label;
|
||||
return max >= 2 ? label.slice(0, max - 1) + '…' : '';
|
||||
}
|
||||
|
||||
const sectors = $derived.by<SectorView[]>(() =>
|
||||
geometry.sectors.map((s) => {
|
||||
const opt = visible[s.ring].options[s.index];
|
||||
const depth = s.ring + 1;
|
||||
const isCurrent = s.ring === visible.length - 1;
|
||||
const active = drillPath[s.ring] === s.index;
|
||||
const isSelected =
|
||||
selected != null && selected.length === s.ring + 1 && selected[s.ring] === s.index;
|
||||
const muted = !isCurrent && !active;
|
||||
const base =
|
||||
opt.color !== undefined
|
||||
? onionBaseColor(s.index, opt.color)
|
||||
: s.ring === 0
|
||||
? onionBaseColor(s.index)
|
||||
: branchBase!;
|
||||
const shade = muted ? deriveOnionMuted(base) : deriveOnionShade(base, depth);
|
||||
return {
|
||||
path: s.path,
|
||||
opt,
|
||||
ring: s.ring,
|
||||
index: s.index,
|
||||
active,
|
||||
isSelected,
|
||||
muted,
|
||||
highlight: active || isSelected,
|
||||
hasChildren: opt.hasChildren,
|
||||
fill: shade.fill,
|
||||
text: shade.text,
|
||||
icon: s.icon,
|
||||
label: s.label ? { text: fit(opt.label, s.label.availablePx), geom: s.label } : undefined
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const triggerFill = $derived(deriveOnionTrigger(branchBase));
|
||||
const sid = (v: SectorView) => `om-${v.ring}-${v.index}`;
|
||||
|
||||
// ── Interaction (the menu owns its drill state) ────────────────────────
|
||||
// The deepest visible ring is the one keyboard focus + activation act on.
|
||||
const currentLevel = $derived(Math.max(0, visible.length - 1));
|
||||
function currentRingNav() {
|
||||
const opts = visible[currentLevel]?.options ?? [];
|
||||
const disabled: number[] = [];
|
||||
opts.forEach((o, i) => o.disabled && disabled.push(i));
|
||||
return { count: opts.length, disabled };
|
||||
}
|
||||
function focusAt(i: number) {
|
||||
focusedIndex = i;
|
||||
sectorEl(currentLevel, i)?.focus();
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (open) return;
|
||||
open = true;
|
||||
runtime.trigger('open', { fallbackTarget: asTarget(surfaceEl) });
|
||||
pendingFocus = onionNavFirst(currentRingNav());
|
||||
onTriggerClick?.();
|
||||
}
|
||||
function closeMenu(restoreFocus = true) {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
drillPath = [];
|
||||
selected = null;
|
||||
focusedIndex = 0;
|
||||
pendingFocus = null;
|
||||
runtime.trigger('close', { fallbackTarget: asTarget(surfaceEl) });
|
||||
if (restoreFocus) triggerEl?.focus();
|
||||
}
|
||||
|
||||
function clickItem(ring: number, index: number) {
|
||||
const opt = visible[ring]?.options[index];
|
||||
if (!opt || opt.disabled) return;
|
||||
if (opt.hasChildren) {
|
||||
drillPath = [...drillPath.slice(0, ring), index];
|
||||
selected = null;
|
||||
pendingFocus = onionNavFirst(currentRingNav());
|
||||
} else {
|
||||
const dp = drillPath.slice(0, ring);
|
||||
drillPath = dp;
|
||||
selected = [...dp, index];
|
||||
opt.onSelect?.();
|
||||
runtime.trigger('commit-select', {
|
||||
fallbackTarget: asTarget(sectorEl(ring, index) ?? surfaceEl)
|
||||
});
|
||||
}
|
||||
onItemClick?.(ring, index);
|
||||
}
|
||||
function clickTrigger() {
|
||||
if (open) closeMenu();
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
// Keyboard actions (dispatched by the soma runtime against the morfo contract).
|
||||
function moveFocus(dir: 1 | -1) {
|
||||
const nav = currentRingNav();
|
||||
const next = dir > 0 ? onionNavNext(focusedIndex, nav) : onionNavPrev(focusedIndex, nav);
|
||||
if (next >= 0) focusAt(next);
|
||||
}
|
||||
function activateFocused() {
|
||||
clickItem(currentLevel, focusedIndex);
|
||||
}
|
||||
function drillUp() {
|
||||
if (drillPath.length === 0) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
const parentIdx = drillPath[drillPath.length - 1];
|
||||
drillPath = drillPath.slice(0, -1);
|
||||
selected = null;
|
||||
pendingFocus = parentIdx;
|
||||
}
|
||||
|
||||
// The trigger is an HTML button (its own keydown → trigger contract); the SVG
|
||||
// surface routes sector keys. Enter/Space on the button: the `open` action
|
||||
// preventDefaults, so the native click doesn't also fire (no double-toggle).
|
||||
function onSurfaceKeydown(e: KeyboardEvent) {
|
||||
runtime.keydown('surface', e);
|
||||
}
|
||||
function onTriggerKeydown(e: KeyboardEvent) {
|
||||
runtime.keydown('trigger', e);
|
||||
}
|
||||
function onFocusout(e: FocusEvent) {
|
||||
// Non-modal (morfo `focus.trap: false`): focus leaving the frame (Tab out)
|
||||
// dismisses. Ignore null relatedTarget (transient blur) and intra-frame moves.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (open && next && frameEl && !frameEl.contains(next)) closeMenu(false);
|
||||
}
|
||||
|
||||
// Move focus into the ring on a controlled open TRANSITION too (not initial
|
||||
// mount — that would steal focus from the page when `open` defaults true).
|
||||
// `openMenu` already queues focus for trigger-driven opens; this covers a
|
||||
// consumer that flips `open` programmatically.
|
||||
let hasMounted = false;
|
||||
let prevOpen = false;
|
||||
$effect(() => {
|
||||
const isOpen = open;
|
||||
if (hasMounted && isOpen && !prevOpen && pendingFocus == null) {
|
||||
pendingFocus = onionNavFirst(currentRingNav());
|
||||
}
|
||||
prevOpen = isOpen;
|
||||
});
|
||||
$effect(() => {
|
||||
hasMounted = true;
|
||||
});
|
||||
|
||||
// Focus into the current ring after it (re)renders — open, drill, drill-up.
|
||||
$effect(() => {
|
||||
void sectors;
|
||||
if (pendingFocus == null) return;
|
||||
const el = sectorEl(currentLevel, pendingFocus);
|
||||
if (el) {
|
||||
el.focus();
|
||||
focusedIndex = pendingFocus;
|
||||
pendingFocus = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Outside-click dismissal while open (non-modal).
|
||||
$effect(() => {
|
||||
if (!open || !frameEl) return;
|
||||
const doc = eidos.dom.getDocument(frameEl);
|
||||
const onDown = (e: Event) => {
|
||||
const t = e.target as Node | null;
|
||||
if (frameEl && t && !frameEl.contains(t)) closeMenu(false);
|
||||
};
|
||||
doc.addEventListener('pointerdown', onDown, true);
|
||||
return () => doc.removeEventListener('pointerdown', onDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Registration mount: the composed parts run here (no paint). The SVG below
|
||||
reads the registered tree and draws. -->
|
||||
<div data-onion-menu data-placement={placement} style="display:contents">
|
||||
<div style="display:none" aria-hidden="true">{@render children?.()}</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div data-onion-menu-frame bind:this={frameEl} onfocusout={onFocusout}>
|
||||
<!-- The SVG paints only the rings; the trigger is an HTML button overlaid at
|
||||
the centre (so it uses the canonical depth/halo elevation + a reliable
|
||||
hover lift). Sector keydown bubbles here → the surface contract. -->
|
||||
<svg
|
||||
bind:this={surfaceEl}
|
||||
{width}
|
||||
viewBox="0 0 {width} {height}"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
data-onion-menu-surface
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
aria-label="Onion menu"
|
||||
onkeydown={onSurfaceKeydown}
|
||||
>
|
||||
<defs>
|
||||
{#each sectors as v (sid(v))}
|
||||
{#if v.label && v.label.text && v.label.geom.disposition === 'angular'}
|
||||
<path id="{sid(v)}-lp" d={v.label.geom.arcPath} fill="none" />
|
||||
{/if}
|
||||
{/each}
|
||||
</defs>
|
||||
|
||||
{#if open}
|
||||
{#each sectors as v (sid(v))}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- Keyboard activation is owned by the soma layer; the sector is a
|
||||
passive click target with role/aria for AT here. The <g> wrapper
|
||||
scopes the hover affordance (brighten the fill + un-dim the
|
||||
label / icon) to this one sector. -->
|
||||
<g
|
||||
class="onion-menu-sector-group"
|
||||
class:is-muted={v.muted}
|
||||
class:is-highlight={v.highlight}
|
||||
class:is-disabled={v.opt.disabled}
|
||||
>
|
||||
<!-- Fill is a theme-token CSS colour (color-mix of `--scale-*`), so it
|
||||
goes via `style` — SVG presentation attrs reject CSS colour funcs.
|
||||
The highlight / hover rings live in the recipe, keyed on the group
|
||||
class, and use `--color-content-on-solid` (no raw colours here). -->
|
||||
<path
|
||||
d={v.path}
|
||||
style="fill: {v.fill};"
|
||||
data-onion-menu-item
|
||||
data-depth={v.ring + 1}
|
||||
data-active={v.active ? '' : undefined}
|
||||
data-muted={v.muted ? '' : undefined}
|
||||
data-disabled={v.opt.disabled ? '' : undefined}
|
||||
role="menuitem"
|
||||
aria-haspopup={v.hasChildren ? 'menu' : undefined}
|
||||
aria-expanded={v.hasChildren ? (v.active ? 'true' : 'false') : undefined}
|
||||
aria-disabled={v.opt.disabled ? 'true' : undefined}
|
||||
aria-label={v.opt.label}
|
||||
tabindex="-1"
|
||||
class="onion-menu-sector"
|
||||
onclick={() => clickItem(v.ring, v.index)}
|
||||
/>
|
||||
|
||||
{#if v.label && v.label.text}
|
||||
{@const g = v.label.geom}
|
||||
{#if g.disposition === 'angular'}
|
||||
<text
|
||||
class="onion-menu-label"
|
||||
dy={fontSize * 0.34}
|
||||
style="font-size:{fontSize}px; fill:{v.text};"
|
||||
>
|
||||
<textPath href="#{sid(v)}-lp" startOffset="50%" style="text-anchor:middle"
|
||||
>{v.label.text}</textPath
|
||||
>
|
||||
</text>
|
||||
{:else}
|
||||
<text
|
||||
class="onion-menu-label"
|
||||
x={g.x}
|
||||
y={g.y}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
transform={g.rotate ? `rotate(${g.rotate} ${g.x} ${g.y})` : undefined}
|
||||
style="font-size:{fontSize}px; fill:{v.text};"
|
||||
>{v.label.text}</text
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if v.icon}
|
||||
<!-- pointer-events none so a click on the icon passes through to the
|
||||
sector path beneath it (otherwise the icon swallows the click). -->
|
||||
<foreignObject
|
||||
x={v.icon.x - v.icon.size / 2}
|
||||
y={v.icon.y - v.icon.size / 2}
|
||||
width={v.icon.size}
|
||||
height={v.icon.size}
|
||||
pointer-events="none"
|
||||
>
|
||||
<div class="onion-menu-icon" style="color:{v.text};" xmlns="http://www.w3.org/1999/xhtml">
|
||||
{@render v.opt.icon?.()}
|
||||
</div>
|
||||
</foreignObject>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</svg>
|
||||
|
||||
<!-- Center trigger — a real HTML button overlaid at the SVG centre. It uses
|
||||
the canonical depth/halo elevation (reads on light AND dark) + a reliable
|
||||
hover lift, which an SVG element can't do (no box-shadow / inset). The
|
||||
custom `trigger` snippet renders as its content; else the +→× glyph. -->
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
class="onion-menu-trigger"
|
||||
data-onion-menu-trigger
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
style="left:{(center[0] / width) * 100}%; top:{(center[1] / height) * 100}%; inline-size:{((TRIGGER_R * 2) / width) * 100}%; --_onion-trigger-fill:{triggerFill};"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onclick={clickTrigger}
|
||||
onkeydown={onTriggerKeydown}
|
||||
>
|
||||
{#if trigger}
|
||||
{@render trigger()}
|
||||
{:else}
|
||||
<span class="onion-menu-trigger-glyph" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* `<OnionOption>` — one option (sector). It does NOT paint; it registers an
|
||||
* {@link OnionOptionNode} into the enclosing `<OnionLayer>` (in declaration
|
||||
* order via a monotonic seq), and exposes an option registry so a nested
|
||||
* `<OnionLayer>` composed inside it becomes this option's children. The root
|
||||
* `<OnionMenu>` paints the sector from the registered node.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
OnionOptionNode,
|
||||
getOnionLayerRegistry,
|
||||
getOnionMenuRegistry,
|
||||
setOnionOptionRegistry
|
||||
} from './context.svelte';
|
||||
import type { OnionOptionProps } from './types';
|
||||
|
||||
let {
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
display,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
children
|
||||
}: OnionOptionProps = $props();
|
||||
|
||||
const layer = getOnionLayerRegistry();
|
||||
const menu = getOnionMenuRegistry();
|
||||
const node = new OnionOptionNode();
|
||||
// seq captured at init = document order (the layer sorts by it).
|
||||
const seq = menu ? menu.nextSeq() : 0;
|
||||
|
||||
// Keep the node reactive to live props.
|
||||
$effect(() => void (node.label = label));
|
||||
$effect(() => void (node.icon = icon));
|
||||
$effect(() => void (node.color = color));
|
||||
$effect(() => void (node.display = display));
|
||||
$effect(() => void (node.disabled = disabled));
|
||||
$effect(() => void (node.onSelect = onSelect));
|
||||
|
||||
// Register into the layer (insert-by-seq keeps markup order); clean up on unmount.
|
||||
// `untrack`: insert/remove READ the layer's `options` (slice / indexOf) to build
|
||||
// the next array — without untrack this effect would subscribe to `options` and
|
||||
// re-run on its own write (register → write → invalidate → unregister → register…),
|
||||
// an infinite loop. Deps (layer / node / seq) are const, so it runs exactly once.
|
||||
$effect(() => {
|
||||
untrack(() => layer?.registerOption(node, seq));
|
||||
return () => untrack(() => layer?.unregisterOption(node));
|
||||
});
|
||||
|
||||
// A nested <OnionLayer> registers as this option's children.
|
||||
setOnionOptionRegistry({ setChildLayer: (l) => (node.childLayer = l) });
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,32 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { ButtonSize } from '$uix/eidos/components/button';
|
||||
|
||||
const SPLIT_BUTTON_EIDOS_CONTEXT = Symbol('uix.eidos.split-button');
|
||||
|
||||
export interface SplitButtonEidosContext {
|
||||
/**
|
||||
* The segmented group element (`[data-split-button]`). `<SplitButton.Menu>`
|
||||
* reads it to use as the floating anchor, so the popup aligns to the WHOLE
|
||||
* split surface — same leading edge, and `--floating-anchor-width` reports
|
||||
* the group's width so the menu can match it — instead of anchoring to the
|
||||
* bare ▾ trigger. Null until the group mounts.
|
||||
*/
|
||||
getAnchorEl(): HTMLElement | null;
|
||||
/**
|
||||
* The resolved split `size`. The menu reads it to scale its own typography /
|
||||
* density and to pick the canonical `--space-*` gap step, so the float tracks
|
||||
* the parent instead of staying frozen at the DropdownMenu default.
|
||||
*/
|
||||
getSize(): ButtonSize;
|
||||
}
|
||||
|
||||
export function setSplitButtonEidosContext(
|
||||
ctx: SplitButtonEidosContext
|
||||
): SplitButtonEidosContext {
|
||||
setContext(SPLIT_BUTTON_EIDOS_CONTEXT, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function getSplitButtonEidosContext(): SplitButtonEidosContext | undefined {
|
||||
return getContext<SplitButtonEidosContext | undefined>(SPLIT_BUTTON_EIDOS_CONTEXT);
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
// SplitButton — eidos compound API.
|
||||
//
|
||||
// import { SplitButton } from '$uix/eidos/components/split-button';
|
||||
//
|
||||
// <SplitButton bind:open variant="solid" aria-label="Save options">
|
||||
// <SplitButton.Primary onclick={save}>Save</SplitButton.Primary>
|
||||
// <SplitButton.Menu>
|
||||
// <SplitButton.Item onSelect={saveAs}>Save as…</SplitButton.Item>
|
||||
// <SplitButton.Item onSelect={saveCopy}>Save a copy</SplitButton.Item>
|
||||
// <SplitButton.Separator />
|
||||
// <SplitButton.Item intent="threat" onSelect={discard}>Discard</SplitButton.Item>
|
||||
// </SplitButton.Menu>
|
||||
// </SplitButton>
|
||||
//
|
||||
// The ▾ trigger is auto-rendered (a chevron that rotates while the menu is
|
||||
// open); override its glyph with the `icon` prop. The menu surface
|
||||
// (`Menu`/`Item`/`Separator`/`Group`/checkbox/radio/sub…) is re-exported
|
||||
// from `<DropdownMenu>` — the menu IS a DropdownMenu.
|
||||
import SplitButtonComponent from './split-button.svelte';
|
||||
import Primary from './split-button-primary.svelte';
|
||||
import Menu from './split-button-menu.svelte';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
|
||||
type SplitButtonNamespace = typeof SplitButtonComponent & {
|
||||
Primary: typeof Primary;
|
||||
Menu: typeof Menu;
|
||||
Item: typeof DropdownMenu.Item;
|
||||
Separator: typeof DropdownMenu.Separator;
|
||||
Group: typeof DropdownMenu.Group;
|
||||
GroupHeading: typeof DropdownMenu.GroupHeading;
|
||||
CheckboxItem: typeof DropdownMenu.CheckboxItem;
|
||||
CheckboxGroup: typeof DropdownMenu.CheckboxGroup;
|
||||
RadioGroup: typeof DropdownMenu.RadioGroup;
|
||||
RadioItem: typeof DropdownMenu.RadioItem;
|
||||
Sub: typeof DropdownMenu.Sub;
|
||||
SubTrigger: typeof DropdownMenu.SubTrigger;
|
||||
SubContent: typeof DropdownMenu.SubContent;
|
||||
Arrow: typeof DropdownMenu.Arrow;
|
||||
};
|
||||
|
||||
const SplitButton = SplitButtonComponent as SplitButtonNamespace;
|
||||
SplitButton.Primary = Primary;
|
||||
SplitButton.Menu = Menu;
|
||||
SplitButton.Item = DropdownMenu.Item;
|
||||
SplitButton.Separator = DropdownMenu.Separator;
|
||||
SplitButton.Group = DropdownMenu.Group;
|
||||
SplitButton.GroupHeading = DropdownMenu.GroupHeading;
|
||||
SplitButton.CheckboxItem = DropdownMenu.CheckboxItem;
|
||||
SplitButton.CheckboxGroup = DropdownMenu.CheckboxGroup;
|
||||
SplitButton.RadioGroup = DropdownMenu.RadioGroup;
|
||||
SplitButton.RadioItem = DropdownMenu.RadioItem;
|
||||
SplitButton.Sub = DropdownMenu.Sub;
|
||||
SplitButton.SubTrigger = DropdownMenu.SubTrigger;
|
||||
SplitButton.SubContent = DropdownMenu.SubContent;
|
||||
SplitButton.Arrow = DropdownMenu.Arrow;
|
||||
|
||||
export { SplitButton };
|
||||
export default SplitButton;
|
||||
|
||||
export type {
|
||||
SplitButtonProps,
|
||||
SplitButtonVariant,
|
||||
SplitButtonSize,
|
||||
SplitButtonColor,
|
||||
SplitButtonPrimaryProps,
|
||||
SplitButtonMenuProps,
|
||||
SplitButtonItemProps
|
||||
} from './types';
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos <SplitButton.Primary> — the primary action. It IS a `<Button>`;
|
||||
* it inherits variant / size / color / disabled from the enclosing
|
||||
* ButtonGroup context, so the consumer passes only behaviour
|
||||
* (`onclick`, `type`, `loading`, `intent`, …) and the label as children.
|
||||
* The `data-split-button-primary` marker lands on the same `<button>`
|
||||
* Button renders.
|
||||
*/
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import type { SplitButtonPrimaryProps } from './types';
|
||||
|
||||
let props: SplitButtonPrimaryProps = $props();
|
||||
</script>
|
||||
|
||||
<Button {...props} data-split-button-primary="" />
|
||||
@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import './split-button.css';
|
||||
/**
|
||||
* Eidos <SplitButton> — primary action + ▾ menu trigger.
|
||||
*
|
||||
* Pure composition (scope:['eidos']): a `<DropdownMenu>` context wraps a
|
||||
* segmented `<ButtonGroup attached>` holding the consumer's
|
||||
* `<SplitButton.Primary>` plus an auto-rendered ▾ `<DropdownMenu.Trigger>`.
|
||||
* The trigger is rendered via the Trigger's `child` snippet as a plain
|
||||
* `<Button iconOnly>` WITHOUT an explicit variant, so it inherits the
|
||||
* group's variant / size / color from the ButtonGroup context — matching
|
||||
* the primary automatically. The menu (`<SplitButton.Menu>` =
|
||||
* `<DropdownMenu.Content>`) portals out of the group.
|
||||
*
|
||||
* The split-specific ARIA + the open/closed state (which rotates the
|
||||
* chevron) are declared in `splitButtonMorfo`; this wrapper materialises
|
||||
* them. role="group" comes from ButtonGroup; the trigger's
|
||||
* aria-haspopup/aria-expanded + the menu's role come from DropdownMenu.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { state, attachRef } from '$libs/reactive';
|
||||
import { ButtonGroup } from '$uix/eidos/components/button-group';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
|
||||
import { SvgChevron } from '$uix/eidos/components/svg';
|
||||
import { setSplitButtonEidosContext } from './context';
|
||||
import type { SplitButtonProps } from './types';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
variant = 'solid',
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
disabled = false,
|
||||
icon,
|
||||
menuLabel,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
children,
|
||||
...menuRest
|
||||
}: SplitButtonProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const triggerLabel = $derived(
|
||||
menuLabel ?? eidos.langs.ts('#?components.split-button.menu-label|More actions')
|
||||
);
|
||||
|
||||
// Capture the segmented group element so `<SplitButton.Menu>` can anchor the
|
||||
// floating menu to the WHOLE split surface (matching width + a gap below it),
|
||||
// not the bare ▾ trigger. The attachment forwards through ButtonGroup's
|
||||
// prop-spread onto its `[data-split-button]` element. The resolved size rides
|
||||
// along so the menu scales with the split instead of the DropdownMenu default.
|
||||
const groupRef = state<HTMLElement | null>(null);
|
||||
setSplitButtonEidosContext({
|
||||
getAnchorEl: () => groupRef.current,
|
||||
getSize: () => resolvedSize
|
||||
});
|
||||
</script>
|
||||
|
||||
<DropdownMenu bind:open {...menuRest}>
|
||||
<ButtonGroup
|
||||
{...attachRef(groupRef)}
|
||||
{variant}
|
||||
{size}
|
||||
{color}
|
||||
{disabled}
|
||||
attached
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
data-split-button=""
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
>
|
||||
{@render children?.()}
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
iconOnly
|
||||
icon={triggerIcon}
|
||||
aria-label={triggerLabel}
|
||||
data-split-button-trigger=""
|
||||
/>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
</ButtonGroup>
|
||||
</DropdownMenu>
|
||||
|
||||
{#snippet triggerIcon()}
|
||||
{#if icon}
|
||||
{@render icon()}
|
||||
{:else}
|
||||
<SvgChevron direction="down" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
@ -0,0 +1,93 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ButtonProps, ButtonVariant, ButtonSize } from '$uix/eidos/components/button';
|
||||
import type { ButtonGroupColor } from '$uix/eidos/components/button-group';
|
||||
import type {
|
||||
DropdownMenuProps,
|
||||
ContentProps as DropdownMenuContentProps
|
||||
} from '$uix/eidos/components/dropdown-menu';
|
||||
import type { ResponsiveProp } from '$uix/eidos/lib/types';
|
||||
|
||||
/** Visual variant propagated to the primary + ▾ trigger. Mirrors {@link ButtonVariant}. */
|
||||
export type SplitButtonVariant = ButtonVariant;
|
||||
/** Sizing scale propagated to both buttons. Mirrors {@link ButtonSize}. */
|
||||
export type SplitButtonSize = ButtonSize;
|
||||
/** Hierarchical color propagated to both buttons. */
|
||||
export type SplitButtonColor = ButtonGroupColor;
|
||||
|
||||
/**
|
||||
* Props for the eidos `<SplitButton>`.
|
||||
*
|
||||
* A primary action joined to a ▾ menu trigger (a segmented `<ButtonGroup>`
|
||||
* of `<Button>` + `<DropdownMenu.Trigger>`). The visual props propagate to
|
||||
* BOTH buttons via the ButtonGroup context; the ▾ trigger and the menu come
|
||||
* from the composed `<DropdownMenu>`, so DropdownMenu provider props
|
||||
* (`open`, `modal`, `onOpenChange`, …) pass through.
|
||||
*
|
||||
* Compose the menu with the re-exported parts:
|
||||
* `<SplitButton.Primary>` + `<SplitButton.Menu>` + `<SplitButton.Item>`…
|
||||
*/
|
||||
export type SplitButtonProps = Omit<DropdownMenuProps, 'children'> & {
|
||||
/** Visual variant propagated to both buttons. @default 'solid' */
|
||||
variant?: SplitButtonVariant;
|
||||
/** Sizing scale propagated to both buttons. @default 'md' */
|
||||
size?: ResponsiveProp<SplitButtonSize>;
|
||||
/** Hierarchical color propagated to both buttons. @default 'primary' */
|
||||
color?: SplitButtonColor;
|
||||
/** Disable both buttons at once. @default false */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Override the ▾ trigger glyph. Defaults to a down chevron that rotates
|
||||
* 180° while the menu is open.
|
||||
*/
|
||||
icon?: Snippet;
|
||||
/**
|
||||
* Accessible name for the ▾ menu trigger. Defaults to a localised
|
||||
* "More actions".
|
||||
*/
|
||||
menuLabel?: string;
|
||||
/** Accessible name for the split group. Recommended. */
|
||||
'aria-label'?: string;
|
||||
/** Accessible name for the split group by reference. */
|
||||
'aria-labelledby'?: string;
|
||||
/** `<SplitButton.Primary>` + `<SplitButton.Menu>`. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for `<SplitButton.Primary>` — the primary action. It IS a `<Button>`
|
||||
* (inherits variant/size/color from the group context); pass behaviour props
|
||||
* (`onclick`, `type`, `loading`, `intent`, …) and, if needed, explicit visual
|
||||
* overrides.
|
||||
*/
|
||||
export type SplitButtonPrimaryProps = ButtonProps;
|
||||
|
||||
/**
|
||||
* Props for `<SplitButton.Menu>` — the floating panel. It IS a
|
||||
* `<DropdownMenu.Content>` with split-button-aware defaults: it anchors to the
|
||||
* whole segmented group (not the bare ▾ trigger) and sits a small gap below it
|
||||
* (`sideOffset` defaults to 6). Every Content positioning prop still passes
|
||||
* through and overrides those defaults.
|
||||
*/
|
||||
export type SplitButtonMenuProps = DropdownMenuContentProps & {
|
||||
/**
|
||||
* Grow the menu to at least the width of the split surface (the group),
|
||||
* so the popup never reads narrower than the button it belongs to. Set
|
||||
* false to let the menu size purely to its content. @default true
|
||||
*/
|
||||
matchAnchorWidth?: ResponsiveProp<boolean>;
|
||||
};
|
||||
|
||||
// Menu surface re-exported from DropdownMenu (the menu IS a DropdownMenu).
|
||||
export type {
|
||||
ItemProps as SplitButtonItemProps,
|
||||
GroupProps as SplitButtonGroupProps,
|
||||
GroupHeadingProps as SplitButtonGroupHeadingProps,
|
||||
SeparatorProps as SplitButtonSeparatorProps,
|
||||
CheckboxItemProps as SplitButtonCheckboxItemProps,
|
||||
CheckboxGroupProps as SplitButtonCheckboxGroupProps,
|
||||
RadioGroupProps as SplitButtonRadioGroupProps,
|
||||
RadioItemProps as SplitButtonRadioItemProps,
|
||||
SubProps as SplitButtonSubProps,
|
||||
SubTriggerProps as SplitButtonSubTriggerProps,
|
||||
SubContentProps as SplitButtonSubContentProps
|
||||
} from '$uix/eidos/components/dropdown-menu';
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const buttonGroupLangs = {
|
||||
label: {
|
||||
es: 'Grupo de botones',
|
||||
en: 'Button group'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const fabLangs = {
|
||||
label: {
|
||||
es: 'Botón de acción flotante',
|
||||
en: 'Floating action button'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const onionMenuLangs = {
|
||||
label: {
|
||||
es: 'Menú cebolla',
|
||||
en: 'Onion menu'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,12 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const splitButtonLangs = {
|
||||
label: {
|
||||
es: 'Botón dividido',
|
||||
en: 'Split button'
|
||||
},
|
||||
'menu-label': {
|
||||
es: 'Más acciones',
|
||||
en: 'More actions'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,71 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* ButtonGroup — presentational cluster of independent action buttons.
|
||||
*
|
||||
* NOT a selection control: it owns no value, no pressed state, no roving
|
||||
* focus. Each child <Button> stays independently Tab-focusable and fires
|
||||
* its OWN `contact-activate`. The group carries no perceptual signal — per
|
||||
* the book (CANON §5), a structural frame does not absorb the act's signal;
|
||||
* the signal lives in each composed Button. Hence 0 events.
|
||||
*
|
||||
* The group's only contract is the ACCESSIBILITY grouping: `role="group"`
|
||||
* plus an accessible name (`aria-label` / `aria-labelledby`) and the
|
||||
* orientation hint. Everything else — variant / size / color propagation,
|
||||
* the attached/segmented layout, block stretch — is VISUAL and owned by
|
||||
* eidos (the recipe + the composed <Group>).
|
||||
*
|
||||
* scope: ['eidos'] — like Icon, there is no behaviour / state / keyboard,
|
||||
* so no soma runtime. The eidos wrapper materialises the ARIA + data-attrs
|
||||
* declared HERE directly; the morfo remains the single source of truth for
|
||||
* the contract and the wrapper never stamps an attr this file doesn't
|
||||
* declare.
|
||||
*/
|
||||
export const buttonGroupMorfo = {
|
||||
name: 'ButtonGroup',
|
||||
kebab: 'button-group',
|
||||
scope: ['eidos'],
|
||||
texts: {
|
||||
label: '#?components.button-group.label|Button group'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-orientation',
|
||||
values: ['horizontal', 'vertical'],
|
||||
value: v.propRef('orientation')
|
||||
},
|
||||
{
|
||||
attr: 'data-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-orientation', value: v.propRef('orientation') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.propRef('aria-label'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-label' }
|
||||
},
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.propRef('aria-labelledby'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-labelledby' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,62 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* FAB — Floating Action Button. A prominent, elevated button for a screen's
|
||||
* primary action: a circular icon by default, or an extended pill with a
|
||||
* label. It floats over content (own `placement`, fixed) and lifts on hover.
|
||||
*
|
||||
* scope: ['eidos'] — the FAB composes the framework `<Button>` (chrome, intent
|
||||
* colour, focus ring, icon slot, the native `<button>` + onclick) and adds only
|
||||
* the FAB-specific treatment: the prominent elevation, the circular / extended
|
||||
* shape, the FAB size scale, and the floating placement. This morfo declares
|
||||
* the FAB's own contract — the structural identity `data-fab`, the action role,
|
||||
* the accessible name — and REFERENCES, never re-stamps, Button's own ARIA
|
||||
* (`role="button"`, `data-disabled`, the native disabled state).
|
||||
*
|
||||
* A FAB that fans out to several actions (speed-dial / FAB menu) is a separate,
|
||||
* deferred feature (see README → Gaps); this morfo covers the single primary
|
||||
* action only.
|
||||
*/
|
||||
export const fabMorfo = {
|
||||
name: 'Fab',
|
||||
kebab: 'fab',
|
||||
scope: ['eidos'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
|
||||
texts: {
|
||||
label: '#?components.fab.label|Floating action button'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
// The FAB itself. Structurally a <Button>: the same <button> carries
|
||||
// `data-button` + `data-fab`. role / disabled come from Button; this
|
||||
// morfo declares the FAB identity + the accessible name.
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'action',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
// Recommended on every FAB and REQUIRED for the icon-only form
|
||||
// (no visible label). The extended form also keeps it as the
|
||||
// authoritative name when the visible label is terse.
|
||||
attr: 'aria-label',
|
||||
value: v.propRef('aria-label'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-label' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,167 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* OnionMenu — a trigger-agnostic radial ("onion") menu. Any trigger opens a
|
||||
* ring of options around it; an option with children drills into a new
|
||||
* concentric ring (the active branch derives its colour, the inner rings mute
|
||||
* to a trail). It is a Menu Button by APG — the visual is radial, the semantics
|
||||
* are a `role="menu"` with `menuitem`s opened by a `aria-haspopup` trigger.
|
||||
*
|
||||
* scope: ['soma','sema','eidos'] — soma owns the open + drill state, the radial
|
||||
* keyboard navigation and focus/dismiss; eidos renders the SVG rings from the
|
||||
* geometry + colour engines; sema picks up open/close from the `emerge` family
|
||||
* base and commit-select from `commit` (expression: 'family-default', no pack).
|
||||
*/
|
||||
export const onionMenuMorfo = {
|
||||
name: 'OnionMenu',
|
||||
kebab: 'onion-menu',
|
||||
scope: ['soma', 'sema', 'eidos'],
|
||||
expression: 'family-default',
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
|
||||
texts: {
|
||||
label: '#?components.onion-menu.label|Onion menu'
|
||||
},
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
// Non-modal: arrow keys navigate the ring, but Tab / outside-click exit
|
||||
// and close (dismissal owns that). The trigger gets focus back on close.
|
||||
trap: false,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
events: [
|
||||
{
|
||||
name: 'open',
|
||||
semantic: { family: 'emerge', verb: 'open', target: v.partRef('surface'), sequence: 'pre' },
|
||||
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'open' }
|
||||
},
|
||||
{
|
||||
name: 'close',
|
||||
semantic: { family: 'emerge', verb: 'close', target: v.partRef('surface'), sequence: 'pre' },
|
||||
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'closed' }
|
||||
},
|
||||
{
|
||||
name: 'commit-select',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'select',
|
||||
target: v.partRef('item'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'virtual',
|
||||
defaultElement: 'none',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// The trigger is consumer-supplied (any element); the soma stamps this
|
||||
// contract onto it. role / haspopup / expanded come from here.
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
|
||||
aria: [
|
||||
{ attr: 'aria-haspopup', value: v.literal('menu') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('surface'),
|
||||
condition: { when: 'part-present', part: 'surface' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Enter', action: 'open' },
|
||||
{ key: ' ', action: 'open' },
|
||||
{ key: 'ArrowDown', action: 'open' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// The radial surface (an SVG menu). Portals over content; the rings are
|
||||
// painted inside it.
|
||||
name: 'Surface',
|
||||
kebab: 'surface',
|
||||
archetype: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menu',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('trigger'),
|
||||
condition: { when: 'part-present', part: 'trigger' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'ArrowRight', action: 'next-item' },
|
||||
{ key: 'ArrowLeft', action: 'prev-item' },
|
||||
{ key: 'Enter', action: 'activate' },
|
||||
{ key: ' ', action: 'activate' },
|
||||
{ key: 'Backspace', action: 'drill-up' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// One option = one annular sector. `data-depth` is the ring level,
|
||||
// `data-active` the followed option in a drilled ring, `data-muted` an
|
||||
// already-passed option. Branch options expose `aria-haspopup` +
|
||||
// `aria-expanded`; the rest are plain `menuitem`s.
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'menuitem',
|
||||
optional: false,
|
||||
states: ['expanded', 'collapsed'],
|
||||
data: [
|
||||
{ attr: 'data-depth', severity: 'optional' },
|
||||
{ attr: 'data-active', severity: 'optional' },
|
||||
{ attr: 'data-muted', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-haspopup',
|
||||
value: v.literal('menu'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'hasChildren' }
|
||||
},
|
||||
{
|
||||
attr: 'aria-expanded',
|
||||
value: v.stateRef('expanded'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'hasChildren' }
|
||||
},
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.literal('true'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,99 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* SplitButton — a primary action button joined to a menu trigger that
|
||||
* opens secondary actions (e.g. "Save | ▾" → Save as…, Save a copy…).
|
||||
*
|
||||
* The primary button runs its own `onclick`; ONLY the ▾ trigger opens the
|
||||
* menu (a single button whose whole surface opens a menu is a *menu
|
||||
* button*, covered by `<DropdownMenu.Trigger endIcon={chevron}>`, not a
|
||||
* split button).
|
||||
*
|
||||
* scope: ['eidos'] — SplitButton composes existing components and adds no
|
||||
* runtime of its own: the group + segmented chrome + variant/size/color
|
||||
* propagation come from <ButtonGroup attached>, the primary from <Button>,
|
||||
* the ▾ + menu from <DropdownMenu>. This morfo declares the SPLIT-specific
|
||||
* contract (the role="group" container, the open/closed state that drives
|
||||
* the chevron rotation, and the two button parts as structural identities).
|
||||
* The composed components keep their own ARIA (role="button",
|
||||
* aria-haspopup/aria-expanded, aria-pressed, the menu's role="menu"); this
|
||||
* morfo references — never re-stamps — those.
|
||||
*/
|
||||
export const splitButtonMorfo = {
|
||||
name: 'SplitButton',
|
||||
kebab: 'split-button',
|
||||
scope: ['eidos'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
|
||||
texts: {
|
||||
label: '#?components.split-button.label|Split button'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
// Reflects the menu open/closed state at the split level — drives the
|
||||
// ▾ chevron rotation (and any group-level open affordance). Stamped by
|
||||
// the wrapper from the bound `open` (no soma runtime for scope:['eidos']).
|
||||
attr: 'data-state',
|
||||
values: ['open', 'closed']
|
||||
},
|
||||
{
|
||||
attr: 'data-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.propRef('aria-label'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-label' }
|
||||
},
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.propRef('aria-labelledby'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'aria-labelledby' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// The primary action. Structurally a <Button>: the same <button> carries
|
||||
// `data-button` + `data-split-button-primary`. role/aria come from Button.
|
||||
name: 'Primary',
|
||||
kebab: 'primary',
|
||||
archetype: 'action',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// The ▾ menu trigger. Structurally a <DropdownMenu.Trigger> (a <Button>):
|
||||
// the same <button> carries `data-button` + `data-dropdown-menu-trigger` +
|
||||
// `data-split-button-trigger`. `aria-expanded` + `data-state` come from the
|
||||
// DropdownMenu soma; this morfo declares only the menu-button affordance.
|
||||
name: 'Trigger',
|
||||
kebab: 'trigger',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-haspopup', value: v.literal('menu') }]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,9 @@
|
||||
// OnionMenu soma layer — the headless behaviour bridge for the radial menu.
|
||||
//
|
||||
// import { createOnionMenuRuntime, onionNavNext } from '$soma/components/onion-menu';
|
||||
//
|
||||
// The eidos root owns the open + drill state and the SVG render; this layer owns
|
||||
// the perceptual emission + keyboard-action dispatch (`createOnionMenuRuntime`)
|
||||
// and the pure radial focus maths (`onionNav*`).
|
||||
export { createOnionMenuRuntime, onionMenuMorfo } from './onion-menu.svelte';
|
||||
export { onionNavNext, onionNavPrev, onionNavFirst, type OnionRingNav } from './onion-menu-nav';
|
||||
@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { onionNavNext, onionNavPrev, onionNavFirst } from './onion-menu-nav';
|
||||
|
||||
describe('onion radial nav — wraps around the ring', () => {
|
||||
const ring = { count: 4 };
|
||||
it('next advances and wraps past the last back to the first', () => {
|
||||
expect(onionNavNext(0, ring)).toBe(1);
|
||||
expect(onionNavNext(2, ring)).toBe(3);
|
||||
expect(onionNavNext(3, ring)).toBe(0);
|
||||
});
|
||||
it('prev retreats and wraps past the first back to the last', () => {
|
||||
expect(onionNavPrev(2, ring)).toBe(1);
|
||||
expect(onionNavPrev(0, ring)).toBe(3);
|
||||
});
|
||||
it('first is index 0', () => {
|
||||
expect(onionNavFirst(ring)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onion radial nav — skips disabled options', () => {
|
||||
const ring = { count: 4, disabled: [1, 2] };
|
||||
it('next jumps over the disabled run', () => {
|
||||
expect(onionNavNext(0, ring)).toBe(3);
|
||||
expect(onionNavNext(3, ring)).toBe(0);
|
||||
});
|
||||
it('prev jumps over the disabled run', () => {
|
||||
expect(onionNavPrev(3, ring)).toBe(0);
|
||||
expect(onionNavPrev(0, ring)).toBe(3);
|
||||
});
|
||||
it('first skips a disabled leading index', () => {
|
||||
expect(onionNavFirst({ count: 3, disabled: [0] })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onion radial nav — degenerate rings', () => {
|
||||
it('returns -1 when there are no options', () => {
|
||||
expect(onionNavNext(0, { count: 0 })).toBe(-1);
|
||||
expect(onionNavFirst({ count: 0 })).toBe(-1);
|
||||
});
|
||||
it('returns -1 when every option is disabled', () => {
|
||||
expect(onionNavFirst({ count: 2, disabled: [0, 1] })).toBe(-1);
|
||||
expect(onionNavNext(0, { count: 2, disabled: [0, 1] })).toBe(-1);
|
||||
});
|
||||
it('a single enabled option is its own neighbour', () => {
|
||||
expect(onionNavNext(0, { count: 1 })).toBe(0);
|
||||
expect(onionNavPrev(0, { count: 1 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* OnionMenu radial keyboard navigation — pure index transitions within the
|
||||
* focused ring. A radial menu has no ends, so `next` / `prev` wrap around, and
|
||||
* disabled options are skipped. The eidos root owns the drill path + the SVG
|
||||
* render and the per-ring option data; these helpers only answer "where does
|
||||
* focus go next?" so the movement rule is testable in isolation.
|
||||
*/
|
||||
|
||||
export interface OnionRingNav {
|
||||
/** Number of options in the focused ring. */
|
||||
readonly count: number;
|
||||
/** Indices that are disabled (skipped by keyboard nav). */
|
||||
readonly disabled?: readonly number[];
|
||||
}
|
||||
|
||||
/** Walk `dir` steps from `from`, wrapping around the ring, skipping disabled. -1 if none focusable. */
|
||||
function step(from: number, dir: 1 | -1, ring: OnionRingNav): number {
|
||||
const { count } = ring;
|
||||
if (count <= 0) return -1;
|
||||
const disabled = new Set(ring.disabled ?? []);
|
||||
if (disabled.size >= count) return -1;
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const idx = (((from + dir * i) % count) + count) % count;
|
||||
if (!disabled.has(idx)) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The next focusable index after `from` (wraps; skips disabled). -1 if none. */
|
||||
export function onionNavNext(from: number, ring: OnionRingNav): number {
|
||||
return step(from, 1, ring);
|
||||
}
|
||||
|
||||
/** The previous focusable index before `from` (wraps; skips disabled). -1 if none. */
|
||||
export function onionNavPrev(from: number, ring: OnionRingNav): number {
|
||||
return step(from, -1, ring);
|
||||
}
|
||||
|
||||
/** The first focusable index in the ring (skips leading disabled). -1 if none. */
|
||||
export function onionNavFirst(ring: OnionRingNav): number {
|
||||
return step(-1, 1, ring);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
import { onionMenuMorfo } from '$uix/morfo/components/onion-menu';
|
||||
|
||||
/**
|
||||
* The OnionMenu soma runtime. Unlike a compound provider, the OnionMenu's render
|
||||
* is a single SVG root that already owns the open + drill state, so soma here is
|
||||
* the headless BEHAVIOUR bridge rather than a parts tree:
|
||||
*
|
||||
* - `keydown('trigger' | 'surface', event)` dispatches against the morfo's
|
||||
* keyboard contract (Enter/Space/ArrowDown open; Arrow/Enter/Backspace/Escape
|
||||
* navigate) → the eidos-supplied `actions` handlers;
|
||||
* - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` emits the
|
||||
* perceptual signal (sound / haptic) + drives the a11y live region.
|
||||
*
|
||||
* The eidos root creates this with `dom` + `eventEngine` (`uix.events`) +
|
||||
* `announce` + its `actions`, and drives focus / drill / dismiss itself. Pure
|
||||
* radial-nav index maths live in {@link './onion-menu-nav'} (testable apart).
|
||||
*/
|
||||
export function createOnionMenuRuntime(sources: SomaRuntimeSources): SomaRuntime {
|
||||
return createSomaRuntime(onionMenuMorfo, sources);
|
||||
}
|
||||
|
||||
export { onionMenuMorfo };
|
||||
@ -0,0 +1,496 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ButtonGroup,
|
||||
type ButtonGroupVariant,
|
||||
type ButtonGroupSize,
|
||||
type ButtonGroupColor,
|
||||
type ButtonGroupOrientation
|
||||
} from '$uix/eidos/components/button-group';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { buttonGroupMorfo } from '@/uix/morfo/components/button-group';
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
||||
let tab = $state<Tab>('live');
|
||||
let trace = $state<TraceEntry[]>([]);
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// ── Live state — every public prop is a control ────────────────────────
|
||||
let variant = $state<ButtonGroupVariant>('surface');
|
||||
let size = $state<ButtonGroupSize>('md');
|
||||
let color = $state<ButtonGroupColor>('primary');
|
||||
let orientation = $state<ButtonGroupOrientation>('horizontal');
|
||||
let attached = $state(true);
|
||||
let block = $state(false);
|
||||
let equal = $state<'auto' | 'on' | 'off'>('auto');
|
||||
let disabled = $state(false);
|
||||
let ariaLabel = $state('Text alignment');
|
||||
let overrideMiddle = $state(false);
|
||||
|
||||
// `equal` prop: 'auto' (undefined → follows attached) · 'on' · 'off'.
|
||||
const equalProp = $derived(equal === 'auto' ? undefined : equal === 'on');
|
||||
|
||||
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
|
||||
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
|
||||
const colors = ['primary', 'secondary', 'neutral'] as const;
|
||||
const orientationOptions = ['horizontal', 'vertical'] as const;
|
||||
|
||||
// ── Compiled morfo ─────────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(buttonGroupMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const events = $derived([...compiled.actions.byName.values()]);
|
||||
|
||||
function fmtTime(at: number): string {
|
||||
const d = new Date(at);
|
||||
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = stageRef;
|
||||
if (!el) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.attributeName !== 'data-event') continue;
|
||||
const target = m.target as Element;
|
||||
const ev = target.getAttribute('data-event');
|
||||
if (!ev) continue;
|
||||
trace = [
|
||||
{
|
||||
event: ev,
|
||||
family: target.getAttribute('data-event-family') ?? '—',
|
||||
intent: target.getAttribute('data-event-intent') ?? undefined,
|
||||
at: Date.now()
|
||||
},
|
||||
...trace
|
||||
].slice(0, 6);
|
||||
}
|
||||
});
|
||||
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
// ── Code snippet (eidos-native — ButtonGroup has no soma layer) ────────
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { ButtonGroup } from '$uix/eidos/components/button-group';",
|
||||
" import { Button } from '$uix/eidos/components/button';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<ButtonGroup',
|
||||
` aria-label="${ariaLabel || 'Actions'}"`,
|
||||
variant !== 'solid' && ` variant="${variant}"`,
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
color !== 'primary' && ` color="${color}"`,
|
||||
orientation !== 'horizontal' && ` orientation="${orientation}"`,
|
||||
!attached && ' attached={false}',
|
||||
block && ' block',
|
||||
equal === 'on' && ' equal',
|
||||
equal === 'off' && ' equal={false}',
|
||||
disabled && ' disabled',
|
||||
'>',
|
||||
' <Button>Left</Button>',
|
||||
overrideMiddle ? ' <Button variant="solid">Center</Button>' : ' <Button>Center</Button>',
|
||||
' <Button>Right</Button>',
|
||||
'</ButtonGroup>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Controls · ButtonGroup</div>
|
||||
<h1 data-uix-page-title>ButtonGroup</h1>
|
||||
<p data-uix-page-lede>
|
||||
Presentational cluster of independent action buttons — <code>role="group"</code> plus shared
|
||||
visual defaults (<code>variant</code> / <code>size</code> / <code>color</code> /
|
||||
<code>disabled</code>) that propagate to the child <a href="/uix/components/button"
|
||||
><Button></a
|
||||
>s via context. Composes <a href="/uix/components/group"><Group></a> for the
|
||||
segmented (<code>attached</code>) layout. Passive — 0 semantic events; each button fires its
|
||||
own <code>contact-activate</code>.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>events</span>0
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>composes</span>Group · Button
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>scope</span>eidos
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live preview always rendered -->
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area bind:this={stageRef}>
|
||||
<ButtonGroup
|
||||
{variant}
|
||||
{size}
|
||||
{color}
|
||||
{orientation}
|
||||
{attached}
|
||||
{block}
|
||||
equal={equalProp}
|
||||
{disabled}
|
||||
aria-label={ariaLabel || undefined}
|
||||
>
|
||||
<Button>Left</Button>
|
||||
<Button variant={overrideMiddle ? 'solid' : undefined}>Center</Button>
|
||||
<Button>Right</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.length === 0}
|
||||
<span>click a button to see its contact-activate</span>
|
||||
{:else}
|
||||
{#each trace.slice(0, 3) as entry}
|
||||
<span
|
||||
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
|
||||
? ' · ' + entry.intent
|
||||
: ''}</span
|
||||
>
|
||||
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>attached</span>
|
||||
{String(attached)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
|
||||
API <span data-uix-tab-count>10</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · 0e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
|
||||
>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
ButtonGroup is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The
|
||||
visual props propagate as defaults to the child Buttons; an explicit prop on a child
|
||||
always wins (toggle <code>override middle</code> to see it).
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment + layout
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>variant <span data-uix-control-hint>propagated</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each variants as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={variant === v}
|
||||
onclick={() => (variant = v)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size <span data-uix-control-hint>propagated</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each sizes as s}
|
||||
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color <span data-uix-control-hint>hierarchy, propagated</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each colors as c}
|
||||
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>orientation</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each orientationOptions as o}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={orientation === o}
|
||||
onclick={() => (orientation = o)}>{o}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>attached <span data-uix-control-hint>segmented</span></span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={attached} />
|
||||
<span data-uix-switch-label>{attached ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>block <span data-uix-control-hint>full width</span></span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={block} />
|
||||
<span data-uix-switch-label>{block ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>equal <span data-uix-control-hint>same width · auto when attached</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['auto', 'on', 'off'] as e}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={equal === e}
|
||||
onclick={() => (equal = e as 'auto' | 'on' | 'off')}>{e}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>disabled <span data-uix-control-hint>propagated</span></span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={disabled} />
|
||||
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>override middle <span data-uix-control-hint>child variant="solid" wins</span></span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={overrideMiddle} />
|
||||
<span data-uix-switch-label>{overrideMiddle ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>aria-label</span>
|
||||
<input type="text" bind:value={ariaLabel} placeholder="(role=group needs a name)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · composes Group + Button, propagates defaults</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<p data-uix-section-desc>
|
||||
Single-root component — <code><ButtonGroup></code> takes real
|
||||
<code><Button></code> children (no <code>ButtonGroup.Item</code>). The visual props
|
||||
propagate as defaults to those children via an eidos context.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">variant</td><td class="type">'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain'</td><td class="default">'solid'</td><td>Propagated to child Buttons as their default variant.</td></tr>
|
||||
<tr><td class="name">size</td><td class="type">ResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'></td><td class="default">'md'</td><td>Propagated to child Buttons.</td></tr>
|
||||
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral'</td><td class="default">'primary'</td><td>Hierarchical color propagated to child Buttons. Per-button intent is set on the Button itself.</td></tr>
|
||||
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disables every child Button at once.</td></tr>
|
||||
<tr><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td class="default">'horizontal'</td><td>Layout axis → <code>aria-orientation</code> + <code>data-orientation</code>.</td></tr>
|
||||
<tr><td class="name">attached</td><td class="type">boolean</td><td class="default">true</td><td>Segmented: children share edges, inner corners squared, doubled borders collapsed.</td></tr>
|
||||
<tr><td class="name">block</td><td class="type">boolean</td><td class="default">false</td><td>Stretch the group to fill the container inline-size; children fill it equally.</td></tr>
|
||||
<tr><td class="name">equal</td><td class="type">boolean</td><td class="default">attached</td><td>Same button width. Vertical: each = the widest (content-sized). Horizontal: fills the container, split equally. Defaults ON when attached.</td></tr>
|
||||
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. Recommended — <code>role="group"</code> needs a name.</td></tr>
|
||||
<tr><td class="name">aria-labelledby</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name by reference (id of a visible label).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Reference comparison</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference vs UIX</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">chakra-ui</td><td><code><ButtonGroup></code></td><td>Same shape — propagates size/variant/colorScheme + isAttached. Chakra doesn't set <code>role="group"</code>; UIX does (declared in morfo).</td></tr>
|
||||
<tr><td class="name">mui</td><td><code><ButtonGroup></code></td><td>MUI sets <code>role="group"</code> + propagates via context, attached by default — same model. UIX adds <code>orientation</code> → <code>aria-orientation</code>.</td></tr>
|
||||
<tr><td class="name">radix-themes</td><td>none (use <code><Flex></code>)</td><td>Radix has no ButtonGroup; <code>SegmentedControl</code> is a selection control. UIX keeps actions independent + groups them semantically.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
ButtonGroup is scope-eidos — there is no soma runtime. The morfo locks the ARIA grouping
|
||||
contract (<code>role="group"</code> + <code>aria-orientation</code> + accessible name) and
|
||||
the <code>data-orientation</code> / <code>data-disabled</code> attrs. The wrapper
|
||||
materialises exactly what the morfo declares — nothing more. Source:
|
||||
<code>src/uix/morfo/components/button-group.ts</code>.
|
||||
</p>
|
||||
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td class="type">"{buttonGroupMorfo.name}"</td></tr>
|
||||
<tr><td class="name">kebab</td><td class="type">"{buttonGroupMorfo.kebab}"</td></tr>
|
||||
<tr><td class="name">scope</td><td class="type">[{buttonGroupMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
|
||||
<tr><td class="name">parts.length</td><td class="default">{buttonGroupMorfo.parts.length}</td></tr>
|
||||
<tr><td class="name">events.length</td><td class="default">0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#each buttonGroupMorfo.parts as rawPart}
|
||||
{@const partAny = rawPart as unknown as {
|
||||
kebab: string;
|
||||
aria?: ReadonlyArray<{
|
||||
attr: string;
|
||||
value: { kind: string };
|
||||
condition?: { when: string; prop?: string };
|
||||
severity?: string;
|
||||
}>;
|
||||
}}
|
||||
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
|
||||
{@const ariaAttrs = partAny.aria ?? []}
|
||||
{#if dataAttrs.length || ariaAttrs.length}
|
||||
<div data-uix-subsection-head>{partAny.kebab}</div>
|
||||
{#if dataAttrs.length}
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>data-attr</th><th>Values</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
{#each dataAttrs as attr}
|
||||
<tr>
|
||||
<td class="name">{attr.attr}</td>
|
||||
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
|
||||
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{#if ariaAttrs.length}
|
||||
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>aria-attr</th><th>Source</th><th>Condition</th><th>Severity</th></tr></thead>
|
||||
<tbody>
|
||||
{#each ariaAttrs as a}
|
||||
<tr>
|
||||
<td class="name">{a.attr}</td>
|
||||
<td class="type">{a.value.kind}</td>
|
||||
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}</td>
|
||||
<td class="default">{a.severity ?? 'required'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section id="sema">
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
ButtonGroup declares no semantic events. As a structural frame it carries no perceptual
|
||||
signal — per CANON §5 the signal belongs to the act, not the container. Each composed
|
||||
<a href="/uix/components/button"><Button></a> fires its own
|
||||
<code>contact-activate</code> (family <code>contact</code>); click a button in the stage
|
||||
and watch the trace strip.
|
||||
</p>
|
||||
<div data-uix-empty-state>No Sema events declared.</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Layout (flex, gap, attached corners + border overlap, grow) is owned by the composed
|
||||
<a href="/uix/components/group"><Group></a> (<code>[data-group]</code>). ButtonGroup's
|
||||
own CSS at <code>src/uix/eidos/components/button-group/button-group.css</code> adds only the
|
||||
button-specific concerns.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name"><code>[data-button-group]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Tunes Group's <code>--group-attached-overlap</code> to the Button border.</td></tr>
|
||||
<tr><td class="name"><code>[data-button-group][data-orientation]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Layout axis (also drives Group's <code>data-direction</code>).</td></tr>
|
||||
<tr><td class="name"><code>[data-button-group][data-disabled]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Group-level disabled (propagated to each Button).</td></tr>
|
||||
<tr><td class="name"><code>[data-button-group][data-attached] [data-button]:focus-visible</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Float the focused/active button above neighbours so the ring isn't clipped.</td></tr>
|
||||
<tr><td class="name"><code>[data-button-group][data-block]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Full-width stretch.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Role</td><td><code>role="group"</code> — declared in the morfo, fixed. A cluster of independent actions. For toolbar semantics (roving focus, single tab stop) use <a href="/uix/components/toolbar"><Toolbar></a> instead.</td></tr>
|
||||
<tr><td class="name">Label</td><td>Provide <code>aria-label</code> or <code>aria-labelledby</code> — a <code>role="group"</code> without a name is announced as an unnamed group.</td></tr>
|
||||
<tr><td class="name">Orientation</td><td><code>aria-orientation</code> mirrors the <code>orientation</code> prop (horizontal / vertical).</td></tr>
|
||||
<tr><td class="name">Keyboard</td><td>Each button stays independently <span data-uix-kbd>Tab</span>-focusable — no roving. The group itself is not focusable; activation is the native button keyboard (<span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span>) on each child.</td></tr>
|
||||
<tr><td class="name">Disabled</td><td>The <code>disabled</code> prop propagates to every child Button (each gets <code>aria-disabled</code>) and the group sets <code>data-disabled</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { Fab, type FabSize, type FabPlacement } from '$uix/eidos/components/fab';
|
||||
import { SvgPlus } from '$uix/eidos/components/svg';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { fabMorfo } from '@/uix/morfo/components/fab';
|
||||
import type { ButtonVariant } from '$uix/eidos/components/button';
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
||||
let tab = $state<Tab>('live');
|
||||
let trace = $state<TraceEntry[]>([]);
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// ── Live state — every public prop is a control ────────────────────────
|
||||
let extended = $state(false);
|
||||
let size = $state<FabSize>('md');
|
||||
let placement = $state<FabPlacement>('static');
|
||||
let variant = $state<ButtonVariant>('solid');
|
||||
let intent = $state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'>('neutral');
|
||||
let color = $state<'primary' | 'secondary' | 'neutral'>('primary');
|
||||
let disabled = $state(false);
|
||||
let label = $state('Compose');
|
||||
let count = $state(0);
|
||||
|
||||
const sizes = ['sm', 'md', 'lg'] as const;
|
||||
const placements = ['static', 'bottom-end', 'bottom-start', 'top-end', 'top-start'] as const;
|
||||
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
|
||||
const intents = ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
|
||||
const colors = ['primary', 'secondary', 'neutral'] as const;
|
||||
|
||||
// ── Compiled morfo ─────────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(fabMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const events = $derived([...compiled.actions.byName.values()]);
|
||||
|
||||
function fmtTime(at: number): string {
|
||||
const d = new Date(at);
|
||||
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = stageRef;
|
||||
if (!el) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.attributeName !== 'data-event') continue;
|
||||
const target = m.target as Element;
|
||||
const ev = target.getAttribute('data-event');
|
||||
if (!ev) continue;
|
||||
trace = [
|
||||
{
|
||||
event: ev,
|
||||
family: target.getAttribute('data-event-family') ?? '—',
|
||||
intent: target.getAttribute('data-event-intent') ?? undefined,
|
||||
at: Date.now()
|
||||
},
|
||||
...trace
|
||||
].slice(0, 6);
|
||||
}
|
||||
});
|
||||
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
// ── Code snippet (eidos-native — no soma layer) ────────────────────────
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Fab } from '$uix/eidos/components/fab';",
|
||||
" import { SvgPlus } from '$uix/eidos/components/svg';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Fab',
|
||||
extended && ' extended',
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
placement !== 'bottom-end' && ` placement="${placement}"`,
|
||||
variant !== 'solid' && ` variant="${variant}"`,
|
||||
intent !== 'neutral' && ` intent="${intent}"`,
|
||||
color !== 'primary' && intent === 'neutral' && ` color="${color}"`,
|
||||
disabled && ' disabled',
|
||||
` aria-label="${extended ? label : 'Compose'}"`,
|
||||
' onclick={compose}',
|
||||
'>',
|
||||
' {#snippet icon()}<SvgPlus />{/snippet}',
|
||||
extended && ` ${label}`,
|
||||
'</Fab>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Controls · Fab</div>
|
||||
<h1 data-uix-page-title>Fab</h1>
|
||||
<p data-uix-page-lede>
|
||||
Floating Action Button — a prominent, elevated button for a screen's primary action. A
|
||||
circular icon by default, or an <code>extended</code> pill with a label. It floats over
|
||||
content (own <code>placement</code>, fixed) and lifts on hover. Pure composition over
|
||||
<a href="/uix/components/button"><Button></a> — 0 own events, interactive by composition.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>events</span>0
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>composes</span>Button
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>scope</span>eidos
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live preview always rendered. The "screen" box uses `contain: layout` so a
|
||||
floating FAB pins to ITS corners (it's the containing block for the fixed
|
||||
FAB), instead of the whole viewport — otherwise the demo FAB would jump
|
||||
over the page chrome. -->
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
style="position: relative; contain: layout; min-block-size: 300px; border-radius: var(--uix-radius-lg); overflow: hidden;"
|
||||
>
|
||||
<div style="padding: var(--uix-space-4); color: var(--uix-text-faint); font-size: 0.85em; max-inline-size: 30ch;">
|
||||
A page. The FAB floats in {placement === 'static' ? 'flow (static)' : `the ${placement} corner`} of this box.
|
||||
</div>
|
||||
<Fab
|
||||
{extended}
|
||||
{size}
|
||||
{placement}
|
||||
{variant}
|
||||
{intent}
|
||||
{color}
|
||||
{disabled}
|
||||
aria-label={extended ? label : 'Compose'}
|
||||
onclick={() => (count += 1)}
|
||||
>
|
||||
{#snippet icon()}<SvgPlus />{/snippet}
|
||||
{#if extended}{label}{/if}
|
||||
</Fab>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.length === 0}
|
||||
<span>click the FAB to fire its action</span>
|
||||
{:else}
|
||||
{#each trace.slice(0, 3) as entry}
|
||||
<span
|
||||
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
|
||||
? ' · ' + entry.intent
|
||||
: ''}</span
|
||||
>
|
||||
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>clicks</span>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
|
||||
API <span data-uix-tab-count>8</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · 0e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
|
||||
>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
Fab is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The visual props
|
||||
forward to the composed <a href="/uix/components/button"><Button></a>; the FAB owns
|
||||
size, shape, elevation and placement.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · FAB treatment
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>extended <span data-uix-control-hint>pill + label</span></span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={extended} />
|
||||
<span data-uix-switch-label>{extended ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each sizes as s}
|
||||
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>placement <span data-uix-control-hint>fixed; static = in-flow</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each placements as p}
|
||||
<button data-uix-chip data-active={placement === p} onclick={() => (placement = p)}>{p}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>disabled</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={disabled} />
|
||||
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · forwarded to Button
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>variant</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each variants as v}
|
||||
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>intent</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each intents as i}
|
||||
<button data-uix-chip data-active={intent === i} onclick={() => (intent = i)}>{i}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color <span data-uix-control-hint>under intent=neutral</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each colors as c}
|
||||
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>label <span data-uix-control-hint>extended only</span></span>
|
||||
<input type="text" bind:value={label} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · composes Button</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<p data-uix-section-desc>
|
||||
FAB-specific props below; everything else (<code>intent</code>, <code>color</code>,
|
||||
<code>shape</code>, <code>loading</code>, <code>onclick</code>, <code>aria-label</code>…)
|
||||
forwards to <a href="/uix/components/button">Button</a>. Button's <code>size</code> /
|
||||
<code>iconOnly</code> / <code>rounded</code> / <code>block</code> are owned by the FAB.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">extended</td><td class="type">boolean</td><td class="default">false</td><td>Pill with a visible label vs circular icon-only.</td></tr>
|
||||
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>FAB scale (≈ 40 / 56 / 72px) — its own, larger than a control.</td></tr>
|
||||
<tr><td class="name">placement</td><td class="type">'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static'</td><td class="default">'bottom-end'</td><td>Where it floats (fixed + safe-area). <code>static</code> = consumer positions it.</td></tr>
|
||||
<tr><td class="name">offset</td><td class="type">string</td><td class="default">var(--fab-offset)</td><td>Distance from the edges when floating.</td></tr>
|
||||
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">—</td><td>The glyph (visible in both forms).</td></tr>
|
||||
<tr><td class="name">variant</td><td class="type">ButtonVariant</td><td class="default">'solid'</td><td>Forwarded to Button (FAB defaults to the prominent solid).</td></tr>
|
||||
<tr><td class="name">intent / color</td><td class="type">like Button</td><td class="default">neutral / primary</td><td>Forwarded — the screen's primary action.</td></tr>
|
||||
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. Required for the icon-only form.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Reference comparison</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference vs UIX</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">material-web</td><td><code><md-fab></code></td><td>Same model (regular + extended, sm/md/lg). UIX composes its own Button for chrome + intent palette.</td></tr>
|
||||
<tr><td class="name">mui</td><td><code><Fab></code> + <code><SpeedDial></code></td><td>UIX ships the FAB; speed-dial (fan-out menu) is a deferred follow-up.</td></tr>
|
||||
<tr><td class="name">ant-design</td><td><code><FloatButton></code></td><td>Both own placement. UIX drops badge/BackTop (compose Badge / out of scope).</td></tr>
|
||||
<tr><td class="name">radix / ark / chakra</td><td>— (none)</td><td>Headless libs have no FAB — you compose a button + positioning, which is exactly what UIX packages here.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
scope-eidos — no soma runtime. The morfo declares the FAB's own contract: the action role,
|
||||
the accessible name, and the <code>data-fab</code> structural identity (over the composed
|
||||
Button). role / disabled come from Button — referenced, never re-stamped. Source:
|
||||
<code>src/uix/morfo/components/fab.ts</code>.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td class="type">"{fabMorfo.name}"</td></tr>
|
||||
<tr><td class="name">kebab</td><td class="type">"{fabMorfo.kebab}"</td></tr>
|
||||
<tr><td class="name">scope</td><td class="type">[{fabMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
|
||||
<tr><td class="name">apg</td><td><a href={fabMorfo.apg} target="_blank" rel="noreferrer">button ↗</a></td></tr>
|
||||
<tr><td class="name">parts.length</td><td class="default">{fabMorfo.parts.length}</td></tr>
|
||||
<tr><td class="name">events.length</td><td class="default">0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section id="sema">
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Fab declares no events of its own — it is interactive <strong>by composition</strong>. The
|
||||
press fires <code>contact-activate</code> on the composed Button. Click the FAB and watch
|
||||
the trace strip.
|
||||
</p>
|
||||
<div data-uix-empty-state>No Sema events declared.</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
The composed <a href="/uix/components/button"><Button></a> paints the chrome;
|
||||
<code>src/uix/eidos/components/fab/fab.css</code> owns the FAB treatment — elevation,
|
||||
size scale, circular/extended shape, and floating placement.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name"><code>[data-fab]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Elevation tokens + rest shadow + transition.</td></tr>
|
||||
<tr><td class="name"><code>[data-fab]:hover:not([data-disabled])</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Hover lift via the <code>translate</code> property (composes with Button's press-squeeze).</td></tr>
|
||||
<tr><td class="name"><code>[data-button][data-fab][data-fab-size='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>FAB diameter + glyph size (out-specifies Button).</td></tr>
|
||||
<tr><td class="name"><code>[data-fab][data-extended]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Pill: auto width + label padding + gap.</td></tr>
|
||||
<tr><td class="name"><code>[data-fab][data-placement='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Fixed positioning + safe-area inset.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Role</td><td>A native <code><button></code> (from Button) — <code>role="button"</code>, focusable, Enter/Space activate.</td></tr>
|
||||
<tr><td class="name">Accessible name</td><td>Icon-only FAB has no visible text, so <code>aria-label</code> is required. Extended FAB names itself from the visible label (keep <code>aria-label</code> when the label is terse).</td></tr>
|
||||
<tr><td class="name">Disabled</td><td><code>disabled</code> (from Button) removes it from the tab order + drops the elevation.</td></tr>
|
||||
<tr><td class="name">Motion</td><td>The hover lift is suppressed under <span data-uix-kbd>prefers-reduced-motion</span>.</td></tr>
|
||||
<tr><td class="name">Placement</td><td>Floating corners clear notches / home indicators via <code>env(safe-area-inset-*)</code>. A fixed FAB shouldn't obscure focusable content — keep it clear of interactive bottom bars.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue