3 new layout primitives close the air port set (15 / 15 done).
`npm run component:audit`: 91 / 91 PASS, all 3 new PASS with 0 errors.
**scroll-area** — eidos compound wrapper over the existing soma:
`<ScrollArea><ScrollArea.Viewport>{content}</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical"><ScrollArea.Thumb />
</ScrollArea.Scrollbar><ScrollArea.Corner /></ScrollArea>`. Mirrors
Radix Primitives / Bits UI shape. Demo 667 lines.
**separator** — visual primitive (`scope: ['eidos']`) with conditional
ARIA: `role="separator"` + `aria-orientation` when not decorative,
`aria-hidden="true"` when decorative. New morfo
`src/uix/morfo/components/separator.ts`. Demo 645 lines.
**splitter** — eidos compound over existing soma:
`<Splitter orientation="horizontal"><Splitter.Panel>{a}</Splitter.Panel>
<Splitter.ResizeTrigger /><Splitter.Panel>{b}</Splitter.Panel>
</Splitter>`. Demo 824 lines.
**Splitter sema rewire — match Slider's handle/drag/commit shape:**
The morfo previously declared only `commit-resize`. Direct-manipulation
primitives (Slider, Splitter) should share the same event vocabulary so
audio/haptic projections feel coherent across the family. Renamed +
expanded to mirror Slider:
- `handle-pick` (family: handle, verb: pick, sequence: coincident,
target: resize-trigger) — fires on pointerdown when user grabs the
trigger.
- `handle-drag` (family: handle, verb: drag, sequence: coincident,
target: resize-trigger) — fires on each pointermove frame.
- `commit-set` (family: commit, verb: set, intent: neutral,
sequence: post, target: provider) — fires on pointerup. The runtime
`commit-set` handler is wired to call the consumer's `onResizeEnd`
callback, so sema and JS callback land at the same moment (parity
with Slider's `onValueCommit`).
Soma provider triggers these via `this.runtime.trigger(name)` in
`onpointerdown` / `onpointermove` / `notifyResizeEnd` (the latter
unchanged externally — internally now routes through the runtime
trigger).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
parent
a08a0a9890
commit
bd2e40366a
@ -0,0 +1,147 @@
|
||||
# Eidos ScrollArea
|
||||
|
||||
Custom-scrollbar container. Hides the native scrollbar and renders a
|
||||
custom-painted track + thumb on top of a scrollable viewport. The
|
||||
headless Soma layer owns the scroll math, the visibility timer
|
||||
(`type='hover' | 'scroll' | 'auto' | 'always'`) and the thumb
|
||||
position. Eidos paints chrome only — track, thumb, corner, hover and
|
||||
drag states.
|
||||
|
||||
## Superficie
|
||||
|
||||
```svelte
|
||||
<ScrollArea type="hover" scrollHideDelay={600}>
|
||||
<ScrollArea.Viewport>
|
||||
<!-- scrollable content goes here -->
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar orientation="vertical">
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Scrollbar orientation="horizontal">
|
||||
<ScrollArea.Thumb />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner />
|
||||
</ScrollArea>
|
||||
```
|
||||
|
||||
The compound shape mirrors Radix Primitives and Bits UI 1:1: the
|
||||
consumer composes every part explicitly so a vertical-only ScrollArea
|
||||
omits the horizontal scrollbar (and the Corner) without prop
|
||||
gymnastics on the root.
|
||||
|
||||
## Baseline
|
||||
|
||||
Origin: `air/components/layout/scroll-area` (branch `morfo-runtime`),
|
||||
which itself wrapped the legacy Terra scroll-area implementation.
|
||||
|
||||
Adjustments applied during the port:
|
||||
|
||||
- Drop the `air-scroll-area-*` class namespace. Eidos targets
|
||||
`[data-scroll-area]` / `[data-scroll-area-viewport]` / etc., emitted
|
||||
by the Soma provider so the recipe never depends on consumer
|
||||
classes.
|
||||
- Replace the `--air-scroll-area-*` token namespace with bare
|
||||
`--scroll-area-*` names per the eidos naming convention. The
|
||||
identifiers map to canonical UIX primitives:
|
||||
- `--scroll-area-track-bg` → `color-mix` of `--color-neutral-track`
|
||||
- `--scroll-area-thumb-bg` → `--color-content-muted`
|
||||
- `--scroll-area-scrollbar-size` → `--space-2-5`
|
||||
- `--scroll-area-thumb-min-size` → `--space-3`
|
||||
- Soma is the source of truth. The eidos wrappers are pure
|
||||
pass-through with no additional ARIA, no additional data-attrs,
|
||||
no extra props. The morfo (`src/uix/morfo/components/scroll-area.ts`)
|
||||
declares the contract — Provider / Viewport / Scrollbar / Thumb /
|
||||
Corner — and the Soma provider emits the markers; eidos just
|
||||
styles them.
|
||||
- Hover and drag states use the morfo's `data-hover` /
|
||||
`data-dragging` presence flags rather than CSS pseudo-classes,
|
||||
matching the rest of the UIX runtime.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | UIX (eidos) | Radix Primitives | Bits UI | react-aria-components |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Compound API (`Provider`/`Viewport`/`Scrollbar`/`Thumb`/`Corner`) | Yes | Yes | Yes | Partial — single `ScrollArea` + virtual list |
|
||||
| Scrollbar visibility modes | `hover` / `scroll` / `auto` / `always` (soma) | Same four | Same four | `always` / `auto` |
|
||||
| Horizontal + vertical scrollbars composed independently | Yes | Yes | Yes | Yes |
|
||||
| Corner element | Yes | Yes | Yes | No |
|
||||
| Auto-hide delay configurable | Yes (`scrollHideDelay` on soma) | Yes (`scrollHideDelay`) | Yes | No |
|
||||
| RTL support | Yes (via soma `dir`) | Yes | Yes | Yes |
|
||||
| Touch action discipline | `touch-action: none` on bar (recipe) | Same | Same | Same |
|
||||
| Drag state styling | Yes (`data-dragging`) | Yes (`data-state`) | Yes | n/a |
|
||||
| Custom thumb min size | Yes (`--scroll-area-thumb-min-size`) | CSS variable | CSS variable | n/a |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Soma is the source of state and ARIA.** Eidos adds no
|
||||
evaluative props — neither `size` nor `variant`. Scrollbar
|
||||
visibility timing belongs to soma's `type` / `scrollHideDelay`
|
||||
pair; the chrome's color and thickness live in CSS tokens.
|
||||
- **Compound API only.** No flat `<ScrollArea>` shortcut. The
|
||||
consumer composes Viewport + Scrollbar(s) + Corner explicitly so
|
||||
a single-axis container doesn't pay for the unused parts. Mirrors
|
||||
Radix Primitives.
|
||||
- **`Thumb` always sits inside `Scrollbar`.** The morfo declares it
|
||||
as an optional child (no compositional gymnastics needed): the
|
||||
Thumb's position and ARIA are wired through the parent
|
||||
Scrollbar's provider context, so the consumer can omit the Thumb
|
||||
to render a track-only scrollbar (rare but legal).
|
||||
- **No prop on the eidos root.** The headless `type` /
|
||||
`scrollHideDelay` / `dir` go through pass-through; the demo's
|
||||
controls bind directly to those soma props.
|
||||
|
||||
## Eventos Sema
|
||||
|
||||
ScrollArea declares 0 events. Scrolling is continuous and
|
||||
non-evaluative — there is no commit, no emerge, no contact. Soma
|
||||
emits the `data-state="visible" | "hidden"` markers as the
|
||||
scrollbar visibility transitions; if a consumer needs a sema
|
||||
signature on visibility change, it composes ScrollArea inside a
|
||||
larger primitive (e.g. a Drawer or Popover) that owns the relevant
|
||||
verb.
|
||||
|
||||
The Scrollbar drag does not commit a sema event either: scroll
|
||||
position is a viewport concern, not a value the surrounding form
|
||||
should react to. Slider / NumberField / Splitter are the right
|
||||
primitives when a drag commits a value.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition | Detail |
|
||||
| --- | --- | --- |
|
||||
| `viewport` style escape for inner padding | implementar | Today the consumer applies padding inside the viewport's child. Adding a `padding` prop on Viewport (passed through to soma) would centralise the API. |
|
||||
| Sema event on scroll-to-edge (`commit-end`) | diferir | Some apps need to know when the viewport hits top / bottom / start / end. Soma can emit `data-at-top` / `data-at-bottom` already; promoting that to a sema event is a v2 decision. |
|
||||
| `intersectionThreshold` for sticky headers | diferir | Out of scope for the layout-primitive sweep. Belongs to a future `<Sticky>` primitive on top of ScrollArea. |
|
||||
| Theme variant `subtle` / `strong` for track | descartar | Token overrides cover this already — `var(--scroll-area-track-bg: …)` is enough; a typed `variant` would manufacture an unused surface. |
|
||||
| Auto-handle hover targeting via padding-box | descartar | Air shipped the `padding` directly on the bar; modern UAs honour the existing approach. |
|
||||
|
||||
## Referencias
|
||||
|
||||
- Radix Primitives ScrollArea: https://www.radix-ui.com/primitives/docs/components/scroll-area
|
||||
- Bits UI ScrollArea: https://bits-ui.com/docs/components/scroll-area
|
||||
- react-aria-components ScrollArea: https://react-spectrum.adobe.com/react-aria/ScrollArea.html
|
||||
- WAI-ARIA `scrollbar` role: https://www.w3.org/TR/wai-aria-1.2/#scrollbar
|
||||
|
||||
## Passive justification
|
||||
|
||||
The morfo declares `scope: ['soma']` (the headless layer owns
|
||||
hover-timer state, drag math and scroll position), but **no
|
||||
semantic events**. Scrolling itself is continuous and non-evaluative
|
||||
— the user moves the viewport, the surrounding application reads
|
||||
the scroll position; there is no commit / emerge / contact verb to
|
||||
project onto a perceptual channel.
|
||||
|
||||
The Scrollbar's `data-state` (`visible` / `hidden`) IS a transition,
|
||||
but it is a CSS visibility transition driven by the soma timer
|
||||
rather than a user-authored intent. Promoting it to a sema event
|
||||
would either:
|
||||
|
||||
- fire on every show / hide of the scrollbar (high-frequency
|
||||
pollution, equivalent to firing on every frame of a drag), or
|
||||
- require an arbitrary debounce that the consumer would have to
|
||||
reverse-engineer.
|
||||
|
||||
For consumers that need to react to scroll position (sticky
|
||||
headers, lazy-load), `data-at-top` / `data-at-bottom` /
|
||||
`data-at-left` / `data-at-right` on the Viewport are sufficient and
|
||||
do not require a sema layer.
|
||||
@ -0,0 +1,44 @@
|
||||
// ScrollArea — eidos compound API, disciplined option C.
|
||||
//
|
||||
// import { ScrollArea } from '$uix/eidos/components/scroll-area';
|
||||
//
|
||||
// <ScrollArea type="hover">
|
||||
// <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
|
||||
// <ScrollArea.Scrollbar orientation="vertical">
|
||||
// <ScrollArea.Thumb />
|
||||
// </ScrollArea.Scrollbar>
|
||||
// <ScrollArea.Scrollbar orientation="horizontal">
|
||||
// <ScrollArea.Thumb />
|
||||
// </ScrollArea.Scrollbar>
|
||||
// <ScrollArea.Corner />
|
||||
// </ScrollArea>
|
||||
import ScrollAreaComponent from './scroll-area.svelte';
|
||||
import Viewport from './scroll-area-viewport.svelte';
|
||||
import Scrollbar from './scroll-area-scrollbar.svelte';
|
||||
import Thumb from './scroll-area-thumb.svelte';
|
||||
import Corner from './scroll-area-corner.svelte';
|
||||
|
||||
type ScrollAreaNamespace = typeof ScrollAreaComponent & {
|
||||
Viewport: typeof Viewport;
|
||||
Scrollbar: typeof Scrollbar;
|
||||
Thumb: typeof Thumb;
|
||||
Corner: typeof Corner;
|
||||
};
|
||||
|
||||
const ScrollArea = ScrollAreaComponent as ScrollAreaNamespace;
|
||||
ScrollArea.Viewport = Viewport;
|
||||
ScrollArea.Scrollbar = Scrollbar;
|
||||
ScrollArea.Thumb = Thumb;
|
||||
ScrollArea.Corner = Corner;
|
||||
|
||||
export { ScrollArea };
|
||||
export default ScrollArea;
|
||||
|
||||
export type {
|
||||
ScrollAreaProps,
|
||||
ScrollAreaViewportProps as ViewportProps,
|
||||
ScrollAreaScrollbarProps as ScrollbarProps,
|
||||
ScrollAreaThumbProps as ThumbProps,
|
||||
ScrollAreaCornerProps as CornerProps,
|
||||
ScrollAreaType
|
||||
} from './types';
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as ScrollArea from '$soma/components/scroll-area';
|
||||
import type { ScrollAreaCornerProps } from './types';
|
||||
|
||||
let { children, ...rest }: ScrollAreaCornerProps = $props();
|
||||
</script>
|
||||
|
||||
<ScrollArea.Corner {...rest}>
|
||||
{@render children?.()}
|
||||
</ScrollArea.Corner>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as ScrollArea from '$soma/components/scroll-area';
|
||||
import type { ScrollAreaScrollbarProps } from './types';
|
||||
|
||||
let { children, ...rest }: ScrollAreaScrollbarProps = $props();
|
||||
</script>
|
||||
|
||||
<ScrollArea.Scrollbar {...rest}>
|
||||
{@render children?.()}
|
||||
</ScrollArea.Scrollbar>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as ScrollArea from '$soma/components/scroll-area';
|
||||
import type { ScrollAreaThumbProps } from './types';
|
||||
|
||||
let { children, ...rest }: ScrollAreaThumbProps = $props();
|
||||
</script>
|
||||
|
||||
<ScrollArea.Thumb {...rest}>
|
||||
{@render children?.()}
|
||||
</ScrollArea.Thumb>
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import * as ScrollArea from '$soma/components/scroll-area';
|
||||
import type { ScrollAreaViewportProps } from './types';
|
||||
|
||||
// Destructure explicitly so the `children` snippet propagates to the
|
||||
// headless component as a normal prop. Spreading `$props()` directly
|
||||
// via `{...props}` can lose snippet bindings in Svelte 5.
|
||||
let { children, ...rest }: ScrollAreaViewportProps = $props();
|
||||
</script>
|
||||
|
||||
<ScrollArea.Viewport {...rest}>
|
||||
{@render children?.()}
|
||||
</ScrollArea.Viewport>
|
||||
@ -0,0 +1,100 @@
|
||||
/*
|
||||
* ScrollArea recipe — visual treatment for a custom-scrollbar
|
||||
* container. The headless Soma layer owns the scroll math, the
|
||||
* visibility timer (`type='hover' | 'scroll' | 'auto' | 'always'`)
|
||||
* and the thumb position; Eidos paints the chrome.
|
||||
*
|
||||
* Architecture mirrors Radix Primitives / Bits UI:
|
||||
*
|
||||
* [data-scroll-area] → outer relative-positioned shell
|
||||
* [data-scroll-area-viewport] → scroll container (overflow: auto)
|
||||
* [data-scroll-area-scrollbar] → track (vertical / horizontal)
|
||||
* [data-scroll-area-thumb] → draggable position indicator
|
||||
* [data-scroll-area-corner] → square where both bars meet
|
||||
*
|
||||
* Tokens stay close to the canonical UIX scale: track and thumb
|
||||
* borrow from the neutral palette; the corner tints with the same
|
||||
* track color so a dual-axis container reads as a single unit.
|
||||
*/
|
||||
|
||||
[data-scroll-area] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--scroll-area-radius, var(--radius-md));
|
||||
background: var(--scroll-area-bg, transparent);
|
||||
}
|
||||
|
||||
[data-scroll-area-viewport] {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
/* The viewport scrolls; expose the scroll position to the thumb via
|
||||
the headless layer's data-attrs without bleeding into Eidos. */
|
||||
|
||||
[data-scroll-area-scrollbar] {
|
||||
display: flex;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
padding: var(--scroll-area-scrollbar-padding, var(--space-0-5));
|
||||
background: var(--scroll-area-track-bg, color-mix(in srgb, var(--color-neutral-track) 60%, transparent));
|
||||
transition:
|
||||
background var(--scroll-area-transition-duration, var(--duration-fast))
|
||||
var(--scroll-area-transition-ease, var(--ease-default)),
|
||||
opacity var(--scroll-area-transition-duration, var(--duration-fast))
|
||||
var(--scroll-area-transition-ease, var(--ease-default));
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar][data-orientation='vertical'] {
|
||||
inline-size: var(--scroll-area-scrollbar-size, var(--space-2-5));
|
||||
block-size: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar][data-orientation='horizontal'] {
|
||||
block-size: var(--scroll-area-scrollbar-size, var(--space-2-5));
|
||||
inline-size: 100%;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar][data-state='hidden'] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar][data-state='visible'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar][data-hover],
|
||||
[data-scroll-area-scrollbar][data-dragging] {
|
||||
background: var(--scroll-area-track-bg-hover, color-mix(in srgb, var(--color-neutral-track) 84%, transparent));
|
||||
}
|
||||
|
||||
[data-scroll-area-scrollbar]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
[data-scroll-area-thumb] {
|
||||
flex: 1;
|
||||
border-radius: var(--scroll-area-thumb-radius, var(--radius-full));
|
||||
background: var(--scroll-area-thumb-bg, var(--color-content-muted));
|
||||
transition: background var(--scroll-area-transition-duration, var(--duration-fast))
|
||||
var(--scroll-area-transition-ease, var(--ease-default));
|
||||
min-inline-size: var(--scroll-area-thumb-min-size, var(--space-3));
|
||||
min-block-size: var(--scroll-area-thumb-min-size, var(--space-3));
|
||||
}
|
||||
|
||||
[data-scroll-area-thumb]:hover,
|
||||
[data-scroll-area-scrollbar][data-dragging] [data-scroll-area-thumb] {
|
||||
background: var(--scroll-area-thumb-bg-hover, var(--color-content-secondary));
|
||||
}
|
||||
|
||||
[data-scroll-area-corner] {
|
||||
background: var(--scroll-area-track-bg, color-mix(in srgb, var(--color-neutral-track) 60%, transparent));
|
||||
}
|
||||
|
||||
[data-scroll-area-corner][data-state='hidden'] {
|
||||
opacity: 0;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<ScrollArea>` — root component. Wraps Soma's
|
||||
* `<ScrollArea.Provider>` to set up the headless context; children
|
||||
* compose via the named members attached in `index.ts`:
|
||||
*
|
||||
* <ScrollArea type="hover">
|
||||
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
|
||||
* <ScrollArea.Scrollbar orientation="vertical">
|
||||
* <ScrollArea.Thumb />
|
||||
* </ScrollArea.Scrollbar>
|
||||
* <ScrollArea.Corner />
|
||||
* </ScrollArea>
|
||||
*/
|
||||
import * as ScrollArea from '$soma/components/scroll-area';
|
||||
import type { ScrollAreaProps } from './types';
|
||||
|
||||
let { children, ...rest }: ScrollAreaProps = $props();
|
||||
</script>
|
||||
|
||||
<ScrollArea.Provider {...rest}>
|
||||
{@render children?.()}
|
||||
</ScrollArea.Provider>
|
||||
@ -0,0 +1,45 @@
|
||||
import type {
|
||||
ProviderProps,
|
||||
ViewportProps,
|
||||
ScrollbarProps,
|
||||
ThumbProps,
|
||||
CornerProps,
|
||||
ScrollAreaType
|
||||
} from '$soma/components/scroll-area';
|
||||
|
||||
/**
|
||||
* Eidos ScrollArea types.
|
||||
*
|
||||
* Compound shape mirrors Radix Primitives:
|
||||
*
|
||||
* <ScrollArea>
|
||||
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
|
||||
* <ScrollArea.Scrollbar orientation="vertical">
|
||||
* <ScrollArea.Thumb />
|
||||
* </ScrollArea.Scrollbar>
|
||||
* <ScrollArea.Scrollbar orientation="horizontal">
|
||||
* <ScrollArea.Thumb />
|
||||
* </ScrollArea.Scrollbar>
|
||||
* <ScrollArea.Corner />
|
||||
* </ScrollArea>
|
||||
*
|
||||
* Eidos adds no evaluative props — geometry and scrollbar timing live
|
||||
* fully in the Soma headless layer. The recipe consumes the
|
||||
* `data-state` / `data-orientation` markers emitted by the morfo
|
||||
* runtime so visibility transitions and orientation swaps require no
|
||||
* additional wiring from Eidos.
|
||||
*/
|
||||
|
||||
/** Props for the `<ScrollArea>` root. Pass-through from headless. */
|
||||
export type ScrollAreaProps = ProviderProps;
|
||||
/** Props for `<ScrollArea.Viewport>`. Pass-through from headless. */
|
||||
export type ScrollAreaViewportProps = ViewportProps;
|
||||
/** Props for `<ScrollArea.Scrollbar>`. Pass-through from headless. */
|
||||
export type ScrollAreaScrollbarProps = ScrollbarProps;
|
||||
/** Props for `<ScrollArea.Thumb>`. Pass-through from headless. */
|
||||
export type ScrollAreaThumbProps = ThumbProps;
|
||||
/** Props for `<ScrollArea.Corner>`. Pass-through from headless. */
|
||||
export type ScrollAreaCornerProps = CornerProps;
|
||||
|
||||
/** Scrollbar visibility behavior — re-exported from Soma for ergonomic imports. */
|
||||
export type { ScrollAreaType };
|
||||
@ -0,0 +1,124 @@
|
||||
# Eidos Separator
|
||||
|
||||
Visual divider between sections. A single `<div>` styled by the
|
||||
recipe based on its `data-orientation` attribute. The component
|
||||
toggles between a semantic separator (with `role="separator"` and
|
||||
`aria-orientation`) and a decorative one (`aria-hidden="true"`,
|
||||
no role) via the `decorative` prop.
|
||||
|
||||
## Superficie
|
||||
|
||||
```svelte
|
||||
<!-- Default: horizontal, semantically a separator. -->
|
||||
<Separator />
|
||||
|
||||
<!-- Vertical, e.g. between toolbar groups. -->
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<!-- Decorative: hidden from assistive tech (visual chrome only). -->
|
||||
<Separator decorative />
|
||||
|
||||
<!-- Responsive: horizontal on mobile, vertical from md upward. -->
|
||||
<Separator orientation={{ base: 'horizontal', md: 'vertical' }} />
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Origin: `air/components/layout/separator` (branch `morfo-runtime`).
|
||||
|
||||
Adjustments applied during the port:
|
||||
|
||||
- Drop the `air-separator` class. Eidos targets `[data-separator]`
|
||||
via the marker emitted by the wrapper, so the recipe stays
|
||||
framework-agnostic and consumers do not need to opt in by adding
|
||||
a class.
|
||||
- Replace the `--air-separator-*` token namespace with bare
|
||||
`--separator-*` names (UIX naming convention from
|
||||
`eidos/components/README.md`).
|
||||
- Logical sizing via `inline-size` / `block-size` instead of
|
||||
`width` / `height` so the rule mirrors automatically in RTL
|
||||
contexts and inside vertical writing modes.
|
||||
- `min-block-size` on vertical separators is now `var(--space-4)`
|
||||
(was `var(--air-separator-min-length)`, which mapped to the same
|
||||
scale via a token shim). The fallback in the recipe keeps the
|
||||
override surface stable.
|
||||
- Eidos-native: no soma backing, no morfo events. The morfo's
|
||||
scope is `['eidos']` and the conditional ARIA contract
|
||||
(`role`/`aria-orientation` when `!decorative`,
|
||||
`aria-hidden="true"` when `decorative`) is encoded
|
||||
declaratively.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | UIX (eidos) | Radix Primitives | Mantine | Chakra UI |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `orientation` prop | Yes (`horizontal` / `vertical`, responsive) | Yes (string) | Yes (`horizontal` / `vertical`) | Yes (`horizontal` / `vertical`) |
|
||||
| Responsive `orientation` | Yes (`ResponsiveProp`) | No | Limited (per-breakpoint via Sx) | Yes via `_breakpoints` |
|
||||
| `decorative` prop | Yes — drops role + adds `aria-hidden` | Yes (identical semantics) | No — separator is always semantic | Implicit (no role if `aria-hidden` set manually) |
|
||||
| `role="separator"` when semantic | Yes | Yes | Yes | Yes |
|
||||
| `aria-orientation` when semantic | Yes | Yes | No (relies on visible orientation) | No |
|
||||
| Logical sizing (`inline-size`) | Yes | No (physical sides) | No | No |
|
||||
| Bare element (no soma) | Yes — pure CSS recipe | Yes | Yes | Yes |
|
||||
| Token-based color / thickness | Yes (`--separator-color`, `--separator-thickness`) | Themed via CSS variables | Theme prop | Theme prop |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Single Provider part, no compound API.** A separator is a single
|
||||
element; there are no slots, no inner sub-parts, no children. The
|
||||
morfo declares one part (`Provider`) emitting the
|
||||
`data-separator` marker so the recipe and audit tools can find
|
||||
it.
|
||||
- **Conditional ARIA via `condition`.** The morfo's `aria` entries
|
||||
use `condition: { when: 'prop-falsy', prop: 'decorative' }` and
|
||||
`condition: { when: 'prop-truthy', prop: 'decorative' }` instead
|
||||
of two parts. This keeps the contract honest — the same DOM
|
||||
element flips its accessibility shape based on the consumer
|
||||
prop.
|
||||
- **`aria-orientation` is `recommended`, not `required`.** Some
|
||||
assistive-tech implementations ignore the attribute on
|
||||
`role="separator"` and infer orientation from visual layout. The
|
||||
audit script does not flag missing values when severity is
|
||||
`recommended`.
|
||||
- **Eidos-native, no soma backing.** No state, no callbacks, no
|
||||
ARIA wiring beyond the conditional role. Adding a soma layer
|
||||
would manufacture indirection (rule 4.6: no re-export facades).
|
||||
- **Logical CSS axes.** The recipe uses `inline-size` /
|
||||
`block-size` so the divider mirrors automatically in RTL
|
||||
documents and inside vertical writing modes — consistent with
|
||||
Box / Flex / Stack.
|
||||
|
||||
## Eventos Sema
|
||||
|
||||
Separator declares 0 events. As a passive layout primitive it does
|
||||
not commit, emerge, or react to anything — it just paints a rule.
|
||||
Components that animate sections appearing or disappearing should
|
||||
compose Separator inside a primitive (Collapsible, Drawer, Dialog)
|
||||
that owns the relevant sema verbs.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition | Detail |
|
||||
| --- | --- | --- |
|
||||
| `thickness` prop (replace token override) | diferir | Today consumers set `--separator-thickness` via style or class. Reconsider if ≥2 real consumers need a strongly-typed prop. |
|
||||
| `inset` / start-end padding | diferir | Mantine ships `Divider inset` for indented separators inside menus. Bottom priority — composable via `marginInlineStart` on a Box wrapper. |
|
||||
| `label` slot (text in the middle of the rule) | diferir | Mantine `Divider label="Or"` pattern. Will need its own part declaration in the morfo and a recipe layer. Not in scope for the layout-primitive sweep. |
|
||||
| `variant="dashed"` / `"dotted"` | descartar | Tokenized via CSS `border-style` override is cleaner than a prop. Stays in user space. |
|
||||
| Decorative + horizontal default min-length | descartar | The minimum length only matters on the vertical axis where the cross-axis collapses to content. Horizontal already fills `100%`. |
|
||||
|
||||
## Referencias
|
||||
|
||||
- Radix Primitives Separator: https://www.radix-ui.com/primitives/docs/components/separator
|
||||
- Mantine Divider: https://mantine.dev/core/divider/
|
||||
- Chakra UI Divider: https://chakra-ui.com/docs/components/divider
|
||||
- WAI-ARIA `separator` role: https://www.w3.org/TR/wai-aria-1.2/#separator
|
||||
|
||||
## Passive justification
|
||||
|
||||
Visual-only primitive (`scope: ['eidos']` in the morfo). A single
|
||||
Provider part emits the `[data-separator]` + `[data-orientation]`
|
||||
markers. The conditional ARIA contract toggles `role` /
|
||||
`aria-orientation` / `aria-hidden` from the `decorative` prop, but
|
||||
the element itself never holds state, never emits a sema event,
|
||||
never participates in a keyboard contract. The morfo is therefore
|
||||
deliberately events-free and the demo's Sema tab states the
|
||||
absence explicitly.
|
||||
@ -0,0 +1,12 @@
|
||||
// Separator — visual divider between sections.
|
||||
//
|
||||
// import { Separator } from '$uix/eidos/components/separator';
|
||||
//
|
||||
// <Separator />
|
||||
// <Separator orientation="vertical" />
|
||||
// <Separator decorative />
|
||||
import Separator from './separator.svelte';
|
||||
|
||||
export { Separator };
|
||||
export default Separator;
|
||||
export type { SeparatorProps, SeparatorOrientation } from './types';
|
||||
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Separator recipe — purely geometric. A horizontal separator fills
|
||||
* the inline axis with a 1px-thick rule against the muted border
|
||||
* color; a vertical separator fills the block axis with the same
|
||||
* thickness. The tokens collapse to UIX primitives:
|
||||
*
|
||||
* --separator-thickness → border-width (1px equivalent in the
|
||||
* base theme)
|
||||
* --separator-color → muted border color, identical to the
|
||||
* tone of Field / Card borders so the
|
||||
* divider blends with surrounding chrome.
|
||||
*
|
||||
* The recipe uses logical sizing (`inline-size` / `block-size`) so
|
||||
* RTL contexts flip automatically. The shrink-to-fit defaults
|
||||
* (`flex-shrink: 0` + `min-block-size`) keep the rule visible inside
|
||||
* Flex / Stack parents where the cross axis may collapse to content.
|
||||
*/
|
||||
|
||||
[data-separator] {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: var(--separator-color, var(--color-border-default));
|
||||
}
|
||||
|
||||
[data-separator][data-orientation='horizontal'] {
|
||||
inline-size: 100%;
|
||||
block-size: var(--separator-thickness, var(--border-width));
|
||||
}
|
||||
|
||||
[data-separator][data-orientation='vertical'] {
|
||||
inline-size: var(--separator-thickness, var(--border-width));
|
||||
block-size: 100%;
|
||||
min-block-size: var(--separator-min-length, var(--space-4));
|
||||
align-self: stretch;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Separator>` — visual divider between sections. Single
|
||||
* `<div>` element; the recipe sizes it based on `data-orientation`.
|
||||
* Two modes:
|
||||
*
|
||||
* - semantic (default): emits `role="separator"` +
|
||||
* `aria-orientation`, exposing the divider to assistive tech.
|
||||
* - decorative (`decorative` prop): drops the role and adds
|
||||
* `aria-hidden="true"`.
|
||||
*
|
||||
* <Separator />
|
||||
* <Separator orientation="vertical" />
|
||||
* <Separator decorative />
|
||||
*
|
||||
* The `orientation` prop is responsive; it is resolved through
|
||||
* `ActiveEidos.resolve()` so a `{ base: 'horizontal', md:
|
||||
* 'vertical' }` value collapses to the active breakpoint at
|
||||
* runtime.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import type { SeparatorOrientation, SeparatorProps } from './types';
|
||||
|
||||
let {
|
||||
orientation = 'horizontal',
|
||||
decorative = false,
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedOrientation = $derived<SeparatorOrientation>(
|
||||
eidos.resolve(orientation) ?? 'horizontal'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
{...restProps}
|
||||
class={className}
|
||||
data-separator=""
|
||||
data-orientation={resolvedOrientation}
|
||||
role={decorative ? undefined : 'separator'}
|
||||
aria-orientation={decorative ? undefined : resolvedOrientation}
|
||||
aria-hidden={decorative ? 'true' : undefined}
|
||||
></div>
|
||||
@ -0,0 +1,37 @@
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ResponsiveProp } from '$uix/eidos/lib/types';
|
||||
|
||||
/**
|
||||
* Orientation of the separator. `horizontal` lays the divider across
|
||||
* the inline axis (default), `vertical` across the block axis. The
|
||||
* value is responsive: the consumer may pass `'horizontal'`,
|
||||
* `'vertical'`, or a responsive object `{ base: 'horizontal', md:
|
||||
* 'vertical' }`.
|
||||
*/
|
||||
export type SeparatorOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
/**
|
||||
* Public props for the `<Separator>` primitive.
|
||||
*
|
||||
* The separator is single-element: a `<div>` that styles itself based
|
||||
* on its `data-orientation` attribute. When `decorative` is true the
|
||||
* element drops its ARIA role and is hidden from assistive
|
||||
* technologies; otherwise it advertises `role="separator"` and
|
||||
* `aria-orientation`.
|
||||
*/
|
||||
export type SeparatorProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
/**
|
||||
* Layout direction of the divider. `horizontal` stretches across
|
||||
* the inline axis; `vertical` along the block axis. Responsive
|
||||
* forms are accepted via `ResponsiveProp`.
|
||||
* @default 'horizontal'
|
||||
*/
|
||||
orientation?: ResponsiveProp<SeparatorOrientation>;
|
||||
/**
|
||||
* Drop the ARIA `separator` role and mark the element as
|
||||
* `aria-hidden`. Use when the divider is purely visual (e.g.
|
||||
* decorating a card border) and no logical sectioning exists.
|
||||
* @default false
|
||||
*/
|
||||
decorative?: boolean;
|
||||
};
|
||||
@ -0,0 +1,141 @@
|
||||
# Eidos Splitter
|
||||
|
||||
Two-or-more resizable panels separated by draggable handles. The
|
||||
headless Soma layer owns the resize math (panel sizes as
|
||||
percentages, min / max clamping, collapsed state, keyboard arrow
|
||||
steps); the morfo declares a single semantic event,
|
||||
`commit-resize`, fired on drag-end (mouse-up). Eidos paints chrome:
|
||||
panels, the handle, its hover / drag affordance, and the center
|
||||
grip.
|
||||
|
||||
## Superficie
|
||||
|
||||
```svelte
|
||||
<Splitter orientation="horizontal">
|
||||
<Splitter.Panel panelId="a" defaultSize={50} minSize={20}>
|
||||
Left panel
|
||||
</Splitter.Panel>
|
||||
<Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
|
||||
<Splitter.Panel panelId="b" defaultSize={50} minSize={20}>
|
||||
Right panel
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
|
||||
<!-- Vertical with collapsible top panel -->
|
||||
<Splitter orientation="vertical">
|
||||
<Splitter.Panel panelId="header" defaultSize={30} collapsible collapsedSize={0}>
|
||||
Header
|
||||
</Splitter.Panel>
|
||||
<Splitter.ResizeTrigger panelBefore="header" panelAfter="body" />
|
||||
<Splitter.Panel panelId="body" defaultSize={70}>
|
||||
Body
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Origin: `air/components/layout/splitter` (branch `morfo-runtime`),
|
||||
which wrapped the legacy Terra splitter implementation.
|
||||
|
||||
Adjustments applied during the port:
|
||||
|
||||
- Drop the `air-splitter-*` class namespace. Eidos targets
|
||||
`[data-splitter]` / `[data-splitter-panel]` /
|
||||
`[data-splitter-resize-trigger]` via the markers emitted by the
|
||||
morfo runtime.
|
||||
- Replace the `--air-splitter-*` token namespace with bare
|
||||
`--splitter-*` names per the eidos naming convention. The
|
||||
identifiers map to canonical UIX primitives:
|
||||
- `--splitter-bg` → `--color-surface-default`
|
||||
- `--splitter-border-color` → `--color-border-default`
|
||||
- `--splitter-handle-bg` → `--color-border-subtle`
|
||||
- `--splitter-handle-bg-active` → `--color-primary-solid`
|
||||
- `--splitter-handle-size` → `--space-2`
|
||||
- `--splitter-grip-bg` → `--color-content-muted`
|
||||
- Wire the `commit-resize` sema event in the recipe: when the
|
||||
morfo's signal hold stamps `data-event='commit-resize'` on the
|
||||
resize trigger, the handle paints in the active color until the
|
||||
hold expires. Air had no equivalent because air predates the sema
|
||||
visual channel.
|
||||
- Soma is the source of state. The eidos wrappers are pure
|
||||
pass-through; min / max / collapsible / collapsedSize / keyboard
|
||||
step all live on `<Splitter.Panel>` props (PanelProps) or
|
||||
`<Splitter>` props (`disabled`, `orientation`, `keyboardStep`).
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | UIX (eidos) | Radix Themes Splitter | Ark UI Splitter | react-resizable-panels |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Compound API (Panel / ResizeTrigger) | Yes | Yes | Yes | Yes |
|
||||
| `orientation` prop (horizontal / vertical) | Yes (responsive via soma `dir`) | Yes | Yes | Yes |
|
||||
| `defaultSize` per panel (%) | Yes | Yes | Yes | Yes |
|
||||
| `minSize` / `maxSize` per panel | Yes | Yes | Yes | Yes |
|
||||
| `collapsible` + `collapsedSize` | Yes | No | Yes | Yes |
|
||||
| `onResize` callback (live during drag) | Yes (`onResize`) | No (only commit) | Yes | Yes |
|
||||
| `onResizeEnd` callback (drag commit) | Yes — also raised as sema `commit-resize` | Yes | Yes | Yes |
|
||||
| Keyboard arrow resize | Yes (configurable step via `keyboardStep`) | Yes | Yes | Yes |
|
||||
| Home / End jump to min / max | Yes | Yes | Yes | No |
|
||||
| Enter to toggle collapse | Yes | No | Yes | No |
|
||||
| Sema event surface | Yes (`commit-resize` in `commit` family) | No | No | No |
|
||||
| Drag affordance (center grip) | Yes (eidos pseudo) | Yes | Yes | n/a (consumer styles) |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Soma is the source of state and ARIA.** Eidos adds no
|
||||
evaluative props — no `size`, no `variant`, no `intent`. Panel
|
||||
sizing belongs to the morfo's `propRef('size')` mapping, the
|
||||
active drag belongs to `data-dragging`, keyboard belongs to the
|
||||
morfo's keyboard table.
|
||||
- **Single sema event: `commit-resize`.** Live drag (every pixel
|
||||
movement) is NOT a sema event — it would saturate the perceptual
|
||||
channels with high-frequency noise. Only the drag commit (mouse
|
||||
up, key release after arrow resize) fires the sema verb. This
|
||||
matches the `feedback_per_event_intent_intrinsic` principle:
|
||||
per-event intent reflects the act's own evaluative load — a
|
||||
resize commit is `commit.set` with `neutral` intent (the user
|
||||
set a new value; the result is geometric, neither affirm nor
|
||||
threat).
|
||||
- **Eidos pulses the handle on `commit-resize`.** The recipe
|
||||
selector
|
||||
`[data-splitter-resize-trigger][data-event='commit-resize']`
|
||||
paints the handle in the active color during the signal hold,
|
||||
giving the user visual confirmation that the drag was committed.
|
||||
The pulse fades automatically when the hold expires.
|
||||
- **`Indicator` is not implemented as a separate part.** The
|
||||
morfo lists `Provider` / `Panel` / `ResizeTrigger`; the brief
|
||||
mentioned an optional `Indicator` part for the grip / handle
|
||||
visualisation, but the grip is owned by the recipe's `::before`
|
||||
pseudo — promoting it to a part would manufacture a DOM element
|
||||
the consumer never needs to override. The decision is documented
|
||||
here so a future port can revisit.
|
||||
|
||||
## Eventos Sema
|
||||
|
||||
| Name | Family | Verb | Sequence | Intent | Target |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `commit-resize` | `commit` | `set` | `post` | `neutral` | `resize-trigger` |
|
||||
|
||||
The event fires on drag-end (mouse-up) and on keyboard arrow-key
|
||||
release. The signal hold is short (`brief` band from
|
||||
`SEMA_DURATIONS`) so the visual pulse on the handle disappears
|
||||
quickly. Sound and haptic channels project a low-intensity feedback
|
||||
suitable for a layout adjustment.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition | Detail |
|
||||
| --- | --- | --- |
|
||||
| `Splitter.Indicator` as a separately stylable part | diferir | The grip is rendered by `::before` today. Promote when ≥2 consumers want to override the grip independently (e.g. custom icon). |
|
||||
| Sema event on `panel-collapse` / `panel-expand` | diferir | When a Panel toggles its `collapsed` state via Enter, an `emerge` family event would be natural. Out of scope for the layout-primitive sweep. |
|
||||
| `nested` mode for splitter-in-splitter ergonomics | diferir | Today the consumer manually nests `<Splitter>`s. A `nested` flag could hide the outer border to avoid double-borders. |
|
||||
| 3+ panel keyboard parity check (Tab through handles) | implementar | Manual test pending — the soma test covers two-panel only; need a multi-panel keyboard walk. |
|
||||
| `variant="ghost"` for borderless splitter | descartar | Token override (`--splitter-border-color: transparent;`) is enough. Variant would manufacture a typed surface. |
|
||||
|
||||
## Referencias
|
||||
|
||||
- Radix Themes Splitter: https://www.radix-ui.com/themes/docs/components/splitter
|
||||
- Ark UI Splitter: https://ark-ui.com/docs/components/splitter
|
||||
- react-resizable-panels: https://github.com/bvaughn/react-resizable-panels
|
||||
- allotment: https://github.com/johnwalley/allotment
|
||||
- WAI-ARIA `separator` role with `aria-valuenow`: https://www.w3.org/TR/wai-aria-1.2/#separator
|
||||
@ -0,0 +1,30 @@
|
||||
// Splitter — eidos compound API, disciplined option C.
|
||||
//
|
||||
// import { Splitter } from '$uix/eidos/components/splitter';
|
||||
//
|
||||
// <Splitter orientation="horizontal">
|
||||
// <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
|
||||
// <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
|
||||
// <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
|
||||
// </Splitter>
|
||||
import SplitterComponent from './splitter.svelte';
|
||||
import Panel from './splitter-panel.svelte';
|
||||
import ResizeTrigger from './splitter-resize-trigger.svelte';
|
||||
|
||||
type SplitterNamespace = typeof SplitterComponent & {
|
||||
Panel: typeof Panel;
|
||||
ResizeTrigger: typeof ResizeTrigger;
|
||||
};
|
||||
|
||||
const Splitter = SplitterComponent as SplitterNamespace;
|
||||
Splitter.Panel = Panel;
|
||||
Splitter.ResizeTrigger = ResizeTrigger;
|
||||
|
||||
export { Splitter };
|
||||
export default Splitter;
|
||||
|
||||
export type {
|
||||
SplitterProps,
|
||||
SplitterPanelProps as PanelProps,
|
||||
SplitterResizeTriggerProps as ResizeTriggerProps
|
||||
} from './types';
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as Splitter from '$soma/components/splitter';
|
||||
import type { SplitterPanelProps } from './types';
|
||||
|
||||
// Soma's Splitter.Panel exposes `{ collapsed, size }` to its children
|
||||
// snippet. The eidos wrapper passes the consumer's children through
|
||||
// untouched so that `<Splitter.Panel>{#snippet children({ collapsed,
|
||||
// size })}…{/snippet}</Splitter.Panel>` keeps working without the
|
||||
// consumer knowing about the eidos layer.
|
||||
//
|
||||
// Destructure explicitly + rename to `consumerChildren` so the inner
|
||||
// `#snippet children` declaration doesn't shadow the prop (Svelte 5
|
||||
// shadow-recursion guard, §12.4 of the demo guide).
|
||||
let { children: consumerChildren, ...rest }: SplitterPanelProps = $props();
|
||||
</script>
|
||||
|
||||
<Splitter.Panel {...rest}>
|
||||
{#snippet children(args: { collapsed: boolean; size: number })}
|
||||
{@render consumerChildren?.(args)}
|
||||
{/snippet}
|
||||
</Splitter.Panel>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Splitter from '$soma/components/splitter';
|
||||
import type { SplitterResizeTriggerProps } from './types';
|
||||
|
||||
let { children, ...rest }: SplitterResizeTriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<Splitter.ResizeTrigger {...rest}>
|
||||
{@render children?.()}
|
||||
</Splitter.ResizeTrigger>
|
||||
@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Splitter recipe — paints two-or-more resizable panels separated by
|
||||
* draggable handles. Soma owns the resize math (panel sizes, min /
|
||||
* max clamping, collapsed state, keyboard arrow steps) and the
|
||||
* `commit-resize` sema event; the recipe consumes the morfo's
|
||||
* data-attrs to draw orientation-aware geometry plus hover / drag
|
||||
* affordances on the resize trigger.
|
||||
*
|
||||
* [data-splitter] → flex container (row / column)
|
||||
* [data-splitter-panel] → individual panel (flex item)
|
||||
* [data-splitter-resize-trigger] → handle between two panels
|
||||
*
|
||||
* The handle gets a center grip (the `::before` pseudo) sized to
|
||||
* match the orientation: tall and narrow in horizontal layouts,
|
||||
* short and wide in vertical layouts. The grip is the affordance
|
||||
* that tells the user the handle is draggable, not a passive border.
|
||||
*/
|
||||
|
||||
[data-splitter] {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 0;
|
||||
border: var(--splitter-border-width, var(--border-width)) solid
|
||||
var(--splitter-border-color, var(--color-border-default));
|
||||
border-radius: var(--splitter-radius, var(--radius-md));
|
||||
background: var(--splitter-bg, var(--color-surface-default));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-splitter][data-orientation='vertical'] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-splitter][data-disabled] {
|
||||
opacity: var(--splitter-disabled-opacity, 0.6);
|
||||
}
|
||||
|
||||
[data-splitter-panel] {
|
||||
min-inline-size: 0;
|
||||
min-block-size: 0;
|
||||
background: var(--splitter-panel-bg, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-splitter-panel][data-state='collapsed'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger] {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
background: var(--splitter-handle-bg, var(--color-border-subtle));
|
||||
transition: background var(--splitter-transition-duration, var(--duration-fast))
|
||||
var(--splitter-transition-ease, var(--ease-default));
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-orientation='horizontal'] {
|
||||
inline-size: var(--splitter-handle-size, var(--space-2));
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-orientation='vertical'] {
|
||||
block-size: var(--splitter-handle-size, var(--space-2));
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger]:hover,
|
||||
[data-splitter-resize-trigger][data-state='hover'] {
|
||||
background: var(--splitter-handle-bg-hover, var(--color-border-default));
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-state='dragging'],
|
||||
[data-splitter-resize-trigger][data-dragging] {
|
||||
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-disabled] {
|
||||
cursor: default;
|
||||
background: var(--splitter-handle-bg, var(--color-border-subtle));
|
||||
}
|
||||
|
||||
/* Center grip — a small pill in the middle of the handle. Sized to
|
||||
match orientation: tall for horizontal handles (so the user can
|
||||
target it on a thin column), wide for vertical handles. */
|
||||
|
||||
[data-splitter-resize-trigger]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 50%;
|
||||
border-radius: var(--splitter-grip-radius, var(--radius-full));
|
||||
background: var(--splitter-grip-bg, var(--color-content-muted));
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-orientation='horizontal']::before {
|
||||
inline-size: var(--splitter-grip-cross, 2px);
|
||||
block-size: var(--splitter-grip-main, var(--space-4));
|
||||
}
|
||||
|
||||
[data-splitter-resize-trigger][data-orientation='vertical']::before {
|
||||
inline-size: var(--splitter-grip-main, var(--space-4));
|
||||
block-size: var(--splitter-grip-cross, 2px);
|
||||
}
|
||||
|
||||
/* Sema event hook — when commit-resize fires, the eidos events layer
|
||||
stamps `data-event="commit-resize"` for `signal hold` duration.
|
||||
Pulse the handle so the user gets visual confirmation that the
|
||||
drag was committed. */
|
||||
|
||||
[data-splitter-resize-trigger][data-event='commit-resize'][data-event-phase='active'] {
|
||||
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Splitter>` — root component. Wraps Soma's
|
||||
* `<Splitter.Provider>` to set up the headless context; children
|
||||
* compose via the named members attached in `index.ts`:
|
||||
*
|
||||
* <Splitter orientation="horizontal">
|
||||
* <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
|
||||
* <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
|
||||
* <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
|
||||
* </Splitter>
|
||||
*/
|
||||
import * as Splitter from '$soma/components/splitter';
|
||||
import type { SplitterProps } from './types';
|
||||
|
||||
let { children, ...rest }: SplitterProps = $props();
|
||||
</script>
|
||||
|
||||
<Splitter.Provider {...rest}>
|
||||
{@render children?.()}
|
||||
</Splitter.Provider>
|
||||
@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ProviderProps,
|
||||
PanelProps,
|
||||
ResizeTriggerProps
|
||||
} from '$soma/components/splitter';
|
||||
|
||||
/**
|
||||
* Eidos Splitter types.
|
||||
*
|
||||
* Compound shape mirrors Radix Themes Splitter / Ark UI Splitter:
|
||||
*
|
||||
* <Splitter orientation="horizontal">
|
||||
* <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
|
||||
* <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
|
||||
* <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
|
||||
* </Splitter>
|
||||
*
|
||||
* Eidos adds no evaluative props — keyboard, drag math, min / max
|
||||
* clamping, collapsed state, etc. all live in Soma. The recipe
|
||||
* consumes the morfo's data-attrs (`data-orientation`, `data-state`,
|
||||
* `data-dragging`, `data-disabled`) to paint the panels and the
|
||||
* resize handle.
|
||||
*/
|
||||
|
||||
/** Props for the `<Splitter>` root. Pass-through from headless. */
|
||||
export type SplitterProps = ProviderProps;
|
||||
/** Props for `<Splitter.Panel>`. Pass-through from headless. */
|
||||
export type SplitterPanelProps = PanelProps;
|
||||
/** Props for `<Splitter.ResizeTrigger>`. Pass-through from headless. */
|
||||
export type SplitterResizeTriggerProps = ResizeTriggerProps;
|
||||
@ -0,0 +1,62 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Separator — visual divider between sections (layout primitive).
|
||||
*
|
||||
* Eidos-native: there is no headless state to coordinate. The morfo
|
||||
* declares a single Provider part with conditional ARIA contracts so
|
||||
* the consumer can choose between a semantic separator (default,
|
||||
* `role="separator"` + `aria-orientation`) and a purely decorative
|
||||
* one (`aria-hidden="true"`, no role). The `data-orientation` data
|
||||
* attr is emitted in both cases so the recipe sizes the element
|
||||
* regardless of accessibility intent.
|
||||
*
|
||||
* Justification for 0-event surface: a separator does not commit,
|
||||
* emerge, or transition. It is geometry plus an optional accessibility
|
||||
* hint. Adding events would manufacture semantics the primitive
|
||||
* doesn't carry. Same justification as `box.ts` / `flex.ts` /
|
||||
* `aspect-ratio.ts`.
|
||||
*/
|
||||
export const separatorMorfo = {
|
||||
name: 'Separator',
|
||||
kebab: 'separator',
|
||||
scope: ['eidos'],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-orientation',
|
||||
values: ['horizontal', 'vertical'],
|
||||
value: v.propRef('orientation')
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'role',
|
||||
value: v.literal('separator'),
|
||||
condition: { when: 'prop-falsy', prop: 'decorative' },
|
||||
severity: 'required'
|
||||
},
|
||||
{
|
||||
attr: 'aria-orientation',
|
||||
value: v.propRef('orientation'),
|
||||
condition: { when: 'prop-falsy', prop: 'decorative' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{
|
||||
attr: 'aria-hidden',
|
||||
value: v.literal('true'),
|
||||
condition: { when: 'prop-truthy', prop: 'decorative' },
|
||||
severity: 'required'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,645 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Separator,
|
||||
type SeparatorOrientation,
|
||||
type SeparatorProps
|
||||
} from '$uix/eidos/components/separator';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { separatorMorfo } from '@/uix/morfo/components/separator';
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
const orientations: SeparatorOrientation[] = ['horizontal', 'vertical'];
|
||||
let orientation = $state<SeparatorOrientation>('horizontal');
|
||||
let decorative = $state<boolean>(false);
|
||||
type Demo = 'stacked' | 'toolbar' | 'menu';
|
||||
const demoOptions = ['stacked', 'toolbar', 'menu'] as const;
|
||||
let demo = $state<Demo>('stacked');
|
||||
let thickness = $state<number>(1);
|
||||
type ColorOption = 'default' | 'subtle' | 'strong';
|
||||
const colorOptions = ['default', 'subtle', 'strong'] as const;
|
||||
let color = $state<ColorOption>('default');
|
||||
|
||||
const separatorProps = $derived<Partial<SeparatorProps>>({
|
||||
orientation,
|
||||
decorative
|
||||
});
|
||||
|
||||
const colorVar = $derived(
|
||||
color === 'default'
|
||||
? 'var(--color-border-default)'
|
||||
: color === 'subtle'
|
||||
? 'var(--color-border-subtle)'
|
||||
: 'var(--color-border-strong)'
|
||||
);
|
||||
|
||||
// ── Compiled morfo ───────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(separatorMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const events = $derived([...compiled.actions.byName.values()]);
|
||||
|
||||
$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();
|
||||
});
|
||||
|
||||
// ── Snippets ─────────────────────────────────────────────────────────
|
||||
const somaSnippet = $derived(
|
||||
[
|
||||
'<!-- Separator is eidos-native — no soma layer. -->',
|
||||
'<!-- Equivalent semantic markup (not real soma): -->',
|
||||
'',
|
||||
'<div',
|
||||
' data-separator',
|
||||
` data-orientation="${orientation}"`,
|
||||
decorative ? ' aria-hidden="true"' : ' role="separator"',
|
||||
decorative ? '' : ` aria-orientation="${orientation}"`,
|
||||
'></div>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Separator } from '$uix/eidos/components/separator';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Separator',
|
||||
orientation !== 'horizontal' && ` orientation="${orientation}"`,
|
||||
decorative && ' decorative',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
const styleOverride = $derived(
|
||||
[
|
||||
`--separator-thickness: ${thickness}px;`,
|
||||
`--separator-color: ${colorVar};`
|
||||
].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Layout · Separator</div>
|
||||
<h1 data-uix-page-title>Separator</h1>
|
||||
<p data-uix-page-lede>
|
||||
Visual divider between sections — a thin rule painted in the muted border color. The
|
||||
<code>orientation</code> prop drives the geometry; <code>decorative</code> toggles between a
|
||||
semantic
|
||||
<code>role="separator"</code> + <code>aria-orientation</code> and a purely visual
|
||||
<code>aria-hidden</code>. Eidos-native primitive: no soma backing, no semantic events. Logical
|
||||
sizing (<code>inline-size</code> / <code>block-size</code>) so the rule mirrors automatically
|
||||
in RTL.
|
||||
</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>scope</span>eidos
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>role</span>{decorative ? '— (decorative)' : 'separator'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live preview always rendered -->
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area bind:this={stageRef}>
|
||||
{#if demo === 'stacked'}
|
||||
<div
|
||||
style="display: flex; flex-direction: column; gap: var(--space-3); inline-size: 100%; max-inline-size: 32rem; padding: var(--space-4); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default);"
|
||||
>
|
||||
<div style="color: var(--color-content-primary); font-weight: 600;">Account</div>
|
||||
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
|
||||
Personal information and password.
|
||||
</div>
|
||||
<Separator {...separatorProps} style={styleOverride} />
|
||||
<div style="color: var(--color-content-primary); font-weight: 600;">Notifications</div>
|
||||
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
|
||||
Email, SMS, and in-app delivery preferences.
|
||||
</div>
|
||||
<Separator {...separatorProps} style={styleOverride} />
|
||||
<div style="color: var(--color-content-primary); font-weight: 600;">Danger zone</div>
|
||||
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
|
||||
Export data, delete account.
|
||||
</div>
|
||||
</div>
|
||||
{:else if demo === 'toolbar'}
|
||||
<div
|
||||
style="display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default); background: var(--color-surface-default);"
|
||||
>
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Bold</button>
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Italic</button>
|
||||
<Separator orientation="vertical" {decorative} style={styleOverride} />
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Left</button>
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Center</button>
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Right</button>
|
||||
<Separator orientation="vertical" {decorative} style={styleOverride} />
|
||||
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Link</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
style="inline-size: 100%; max-inline-size: 16rem; padding: var(--space-2); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default); background: var(--color-surface-overlay);"
|
||||
>
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">New file</button>
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Open</button>
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Open recent</button>
|
||||
<Separator {...separatorProps} style={styleOverride} />
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Save</button>
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Save as…</button>
|
||||
<Separator {...separatorProps} style={styleOverride} />
|
||||
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer; color: var(--color-loss-text);">Quit</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
|
||||
<span style="color: var(--uix-text-faint)">·</span>
|
||||
<span data-uix-stage-trace-key>orientation</span>
|
||||
<span>{orientation}</span>
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>decorative</span> {String(decorative)} ·
|
||||
<span data-uix-stage-trace-key>thickness</span> {thickness}px
|
||||
</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>2</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>
|
||||
Separator is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. Two
|
||||
public props drive geometry + accessibility; tokens override thickness and color via the
|
||||
<code>style</code> escape.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · visual + a11y treatment
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>orientation</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each orientations as opt}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={orientation === opt}
|
||||
onclick={() => (orientation = opt)}>{opt}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>decorative</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={decorative} />
|
||||
<span data-uix-switch-label>{decorative ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label
|
||||
>thickness <span data-uix-control-hint
|
||||
>pixels → <code>--separator-thickness</code></span
|
||||
></span
|
||||
>
|
||||
<input type="range" min="1" max="6" step="1" bind:value={thickness} />
|
||||
<span data-uix-control-value>{thickness}px</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label
|
||||
>color <span data-uix-control-hint
|
||||
>token chip → <code>--separator-color</code></span
|
||||
></span
|
||||
>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each colorOptions as opt}
|
||||
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
|
||||
>{opt}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>demo content</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each demoOptions as opt}
|
||||
<button data-uix-chip data-active={demo === opt} onclick={() => (demo = opt)}
|
||||
>{opt}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ── Code snippets per layer ─────────────────────────────────── -->
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="soma">soma</span>
|
||||
<span>n/a · separator is eidos-native — equivalent markup shown</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{somaSnippet}</code></pre>
|
||||
</div>
|
||||
|
||||
<div data-uix-code style="margin-top: var(--uix-space-3);">
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · orientation drives geometry; decorative drops the role</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>
|
||||
Two public props on the root. Visual tuning (thickness, color) is exposed via CSS custom
|
||||
properties (<code>--separator-thickness</code> / <code>--separator-color</code>) rather
|
||||
than typed props — there is no consumer-driven reason to manufacture a typed surface
|
||||
around a single CSS value.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>Separator props</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">orientation</td>
|
||||
<td class="type">{orientations.join(' | ')}</td>
|
||||
<td class="default">'horizontal'</td>
|
||||
<td>
|
||||
Layout axis. Maps to <code>inline-size</code> / <code>block-size</code>. Responsive
|
||||
via <code>ResponsiveProp<SeparatorOrientation></code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">decorative</td>
|
||||
<td class="type">boolean</td>
|
||||
<td class="default">false</td>
|
||||
<td>
|
||||
When true, drops <code>role="separator"</code> + <code>aria-orientation</code>
|
||||
and adds <code>aria-hidden="true"</code>.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>CSS tokens</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Token</th><th>Default</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">--separator-thickness</td>
|
||||
<td class="type">var(--border-width)</td>
|
||||
<td>Block-axis size on horizontal, inline-axis size on vertical.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">--separator-color</td>
|
||||
<td class="type">var(--color-border-default)</td>
|
||||
<td>Background of the rule. Use one of the canonical border colors.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">--separator-min-length</td>
|
||||
<td class="type">var(--space-4)</td>
|
||||
<td>Vertical separator's minimum block-axis size when stretched.</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</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">radix-primitives</td>
|
||||
<td><code><Separator></code></td>
|
||||
<td>
|
||||
Identical contract — <code>orientation</code> + <code>decorative</code>. UIX adds
|
||||
responsive orientation + logical CSS axes.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">mantine</td>
|
||||
<td><code><Divider></code></td>
|
||||
<td>
|
||||
Mantine adds <code>label</code> and <code>variant</code> (dashed / dotted); UIX
|
||||
defers those to consumer composition + style overrides.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">chakra-ui</td>
|
||||
<td><code><Divider></code></td>
|
||||
<td>
|
||||
Chakra mixes the prop with the style-prop system; UIX keeps it pure layout — no
|
||||
theme-coloured variants on the divider itself.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<p data-uix-section-desc>
|
||||
Source: <code>src/uix/morfo/components/separator.ts</code>. Eidos-native (scope:
|
||||
<code>['eidos']</code>). The Provider part declares conditional ARIA so the same DOM
|
||||
element flips between semantic and decorative modes based on the
|
||||
<code>decorative</code> prop.
|
||||
</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>{separatorMorfo.name}</td></tr>
|
||||
<tr><td class="name">kebab</td><td><code>{separatorMorfo.kebab}</code></td></tr>
|
||||
<tr><td class="name">scope</td><td>{separatorMorfo.scope.join(', ')}</td></tr>
|
||||
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
|
||||
<tr><td class="name">events</td><td>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>kebab</th>
|
||||
<th>marker</th>
|
||||
<th>element</th>
|
||||
<th>archetype</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.archetype}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#each separatorMorfo.parts as rawPart}
|
||||
{@const partAny = rawPart as unknown as {
|
||||
kebab: string;
|
||||
aria?: ReadonlyArray<{
|
||||
attr: string;
|
||||
value: { kind: string };
|
||||
condition?: { when: string; prop?: string; part?: 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 kind</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 kind</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})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}`
|
||||
: '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>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Separator declares no semantic events. As a passive layout primitive it paints a rule and
|
||||
nothing else — no commit, no emerge, no contact. Components that animate sections (e.g.
|
||||
Collapsible toggling visibility around a separator) own the relevant sema verbs in their
|
||||
own morfo. The morfo is declared with <code>scope: ['eidos']</code> and the README's
|
||||
Passive justification section makes the rationale explicit.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Recipe lives in <code>src/uix/eidos/components/separator/separator.css</code>. The
|
||||
<code>data-orientation</code> attribute switches between horizontal and vertical sizing;
|
||||
logical CSS axes (<code>inline-size</code> / <code>block-size</code>) make the rule
|
||||
mirror in RTL.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name"><code>[data-separator]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>Provider marker. Emitted by the wrapper on the single element.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"
|
||||
><code>{`[data-separator] { background: var(--separator-color, var(--color-border-default)); flex-shrink: 0; border: 0; }`}</code></td
|
||||
>
|
||||
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
|
||||
<td>Base painting + shrink protection inside flex parents.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"
|
||||
><code>[data-separator][data-orientation='horizontal']</code></td
|
||||
>
|
||||
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
|
||||
<td>
|
||||
<code>inline-size: 100%</code> + <code>block-size:
|
||||
var(--separator-thickness)</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"
|
||||
><code>[data-separator][data-orientation='vertical']</code></td
|
||||
>
|
||||
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
|
||||
<td>
|
||||
<code>inline-size: var(--separator-thickness)</code> +
|
||||
<code>block-size: 100%</code> + <code>min-block-size</code> safeguard.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<p data-uix-section-desc>
|
||||
The conditional ARIA contract honours the WAI-ARIA <code>separator</code> role when
|
||||
<code>decorative</code> is false (default). When true the element drops the role and is
|
||||
hidden from assistive tech, matching Radix Primitives' contract.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>Roles</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Mode</th><th>Role</th><th>aria-orientation</th><th>aria-hidden</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">semantic (default)</td>
|
||||
<td class="type">'separator'</td>
|
||||
<td class="type">orientation</td>
|
||||
<td class="default empty">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">decorative</td>
|
||||
<td class="default empty">—</td>
|
||||
<td class="default empty">—</td>
|
||||
<td class="type">'true'</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Other concerns</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">Keyboard</td>
|
||||
<td>Not focusable. Tab order follows surrounding content.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Focus visible</td>
|
||||
<td>No focus ring; the element never receives focus.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Color contrast</td>
|
||||
<td>
|
||||
Default color is <code>--color-border-default</code>, which meets 3:1 against
|
||||
the canonical surface tokens in both light and dark themes.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">RTL</td>
|
||||
<td>
|
||||
Logical CSS axes mean the divider doesn't need to know about direction — the
|
||||
horizontal rule fills the inline axis, which flips under <code>dir="rtl"</code>
|
||||
automatically.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue