Unifies the history/feed pattern (per-item status + evaluative intent) with the progress/stepper pattern (a root `active` index) in one component. Two independent color axes: status drives the marker treatment (filled / ring / hollow); intent overrides the hue via the canonical data-intent channel and feeds the `emerge-reveal` event in live feeds. - morfo: <ol>/<li> contract — data-status, data-intent, aria-current; one `emerge-reveal` event (fromProp intent); scope soma/sema/eidos. - soma: Provider + Item — status derivation (active|explicit), auto-index, live-feed emission gated to appends. 4 unit tests. - sema: emerge-reveal pack (soften gain via op:add + haptic tick). - eidos: rail · markers · connectors; vertical/horizontal + start/end/alternate; independent `size` (type) and `markerSize` (circle) axes; connector solid/dashed/dotted; live-feed reveal. Markers hold icons or numbers. - demo: web/routes/uix/components/timeline (6-tab interactive testbed) + nav. Connector runs behind the markers (continuous rail); horizontal entries stack via flex; horizontal-alternate pins fixed-height rows for a straight rail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>active-uix
parent
ec8428218f
commit
c1effca1b4
@ -0,0 +1,89 @@
|
||||
# Timeline (eidos)
|
||||
|
||||
The visual layer for `Timeline` — the rail, markers, connectors, orientation and
|
||||
alternating layout. Behavior (the status model, `active` derivation, the live
|
||||
feed + `emerge-reveal`) lives in soma: see
|
||||
[`soma/components/timeline/README.md`](../../../soma/components/timeline/README.md).
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Timeline } from '$uix/eidos/components/timeline';
|
||||
import { Rocket, CircleCheck } from '$uix/eidos/components/icon';
|
||||
let active = $state(2);
|
||||
const events = [/* { time, title, body, icon, intent? } */];
|
||||
</script>
|
||||
|
||||
<Timeline {active} color="primary">
|
||||
{#each events as ev, i}
|
||||
{@const Ico = ev.icon}
|
||||
<Timeline.Item index={i} intent={ev.intent}>
|
||||
{#snippet children({ status })}
|
||||
<Timeline.Marker><Ico /></Timeline.Marker>
|
||||
{#if i < events.length - 1}<Timeline.Connector />{/if}
|
||||
<Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>
|
||||
<Timeline.Content>
|
||||
<Timeline.Title>{ev.title}</Timeline.Title>
|
||||
<Timeline.Description>{ev.body}</Timeline.Description>
|
||||
</Timeline.Content>
|
||||
{/snippet}
|
||||
</Timeline.Item>
|
||||
{/each}
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Owner | Notes |
|
||||
| ------------- | -------- | ----- | ---------------------------------------------- |
|
||||
| `Timeline` | `<ol>` | soma | Root. Status model + live feed. |
|
||||
| `Item` | `<li>` | soma | Entry. Exposes `{ status }`. |
|
||||
| `Marker` | `<div>` | eidos | The dot. Holds an icon or number; tinted. |
|
||||
| `Connector` | `<div>` | eidos | The rail line. `variant` = solid/dashed/dotted. |
|
||||
| `Time` | `<time>` | eidos | Timestamp / meta opposite the content. |
|
||||
| `Content` | `<div>` | eidos | Entry body (Title + Description). |
|
||||
| `Title` | `<h3>` | eidos | Heading (`as` overrides the level). |
|
||||
| `Description` | `<p>` | eidos | Secondary prose. |
|
||||
|
||||
## Visual props (`<Timeline>`)
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ------------- | ------------------------------------- | ------------ | ----------------------------------------------------------- |
|
||||
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction (RTL handled via logical properties). |
|
||||
| `align` | `'start' \| 'end' \| 'alternate'` | `'start'` | Content side relative to the rail. `alternate` zig-zags. |
|
||||
| `size` | `ResponsiveProp<'sm' \| 'md' \| 'lg'>` | `'md'` | **Type + spacing** density. |
|
||||
| `markerSize` | `ResponsiveProp<'sm' \| 'md' \| 'lg'>` | `'md'` | **Circle diameter** — independent of `size`. |
|
||||
| `color` | `ProgressiveColorRole` | `'primary'` | Accent for done / current markers. |
|
||||
|
||||
`<Timeline.Connector variant="solid | dashed | dotted">` picks the line style
|
||||
(default `solid`).
|
||||
|
||||
## Two color axes
|
||||
|
||||
1. **Status** (`data-status`: done / current / upcoming) drives the marker
|
||||
**treatment** — done = solid fill, current = ring, upcoming = hollow. The hue
|
||||
comes from the root `color` accent.
|
||||
2. **Intent** (the item's `intent`) **overrides the hue** with the canonical
|
||||
evaluative palette (affirm = green, threat = red, …) via the standard
|
||||
`data-intent` channel, and also feeds the `emerge-reveal` event in live feeds.
|
||||
|
||||
The hue lives in `--_timeline-hue-*` on the item so the marker and the connector
|
||||
share it.
|
||||
|
||||
## Layout notes
|
||||
|
||||
- **Vertical**: a two-column grid (rail · body). The connector is an absolutely
|
||||
positioned logical border running from each marker's centre to the next, behind
|
||||
the opaque dot — one continuous rail. `align=end` mirrors; `align=alternate`
|
||||
centres the rail and zig-zags the entries by parity.
|
||||
- **Horizontal**: each entry is a flex column (marker · time · content) so the
|
||||
text stacks; the timeline scrolls instead of shrinking. `align=alternate`
|
||||
pins fixed-height content/time rows so every marker lands on a straight rail —
|
||||
keep horizontal-alternate content concise (title + one description line).
|
||||
- **Reduced motion**: the live-feed reveal is disabled under
|
||||
`prefers-reduced-motion`.
|
||||
|
||||
## Recipe
|
||||
|
||||
Tokens: `src/uix/eidos/lib/recipes/base.ts` (`timeline`). Selectors:
|
||||
[`timeline.css`](./timeline.css). Colors are referenced through the canonical
|
||||
`--color-{role}-*` tokens (no raw values).
|
||||
@ -0,0 +1,48 @@
|
||||
import TimelineComponent from './timeline.svelte';
|
||||
import Item from './timeline-item.svelte';
|
||||
import Marker from './timeline-marker.svelte';
|
||||
import Connector from './timeline-connector.svelte';
|
||||
import Time from './timeline-time.svelte';
|
||||
import Content from './timeline-content.svelte';
|
||||
import Title from './timeline-title.svelte';
|
||||
import Description from './timeline-description.svelte';
|
||||
|
||||
type TimelineNamespace = typeof TimelineComponent & {
|
||||
Item: typeof Item;
|
||||
Marker: typeof Marker;
|
||||
Connector: typeof Connector;
|
||||
Time: typeof Time;
|
||||
Content: typeof Content;
|
||||
Title: typeof Title;
|
||||
Description: typeof Description;
|
||||
};
|
||||
|
||||
const Timeline = TimelineComponent as TimelineNamespace;
|
||||
Timeline.Item = Item;
|
||||
Timeline.Marker = Marker;
|
||||
Timeline.Connector = Connector;
|
||||
Timeline.Time = Time;
|
||||
Timeline.Content = Content;
|
||||
Timeline.Title = Title;
|
||||
Timeline.Description = Description;
|
||||
|
||||
export { Timeline };
|
||||
|
||||
export default Timeline;
|
||||
|
||||
export type {
|
||||
TimelineStatus,
|
||||
TimelineProps,
|
||||
TimelineItemProps as ItemProps,
|
||||
TimelineMarkerProps as MarkerProps,
|
||||
TimelineConnectorProps as ConnectorProps,
|
||||
TimelineTimeProps as TimeProps,
|
||||
TimelineContentProps as ContentProps,
|
||||
TimelineTitleProps as TitleProps,
|
||||
TimelineDescriptionProps as DescriptionProps,
|
||||
TimelineSize,
|
||||
TimelineColor,
|
||||
TimelineOrientation,
|
||||
TimelineAlign,
|
||||
TimelineConnectorVariant
|
||||
} from './types';
|
||||
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Timeline.Connector>` — the rail line to the next entry.
|
||||
* Decorative; `variant` picks the line style.
|
||||
*/
|
||||
import type { TimelineConnectorProps } from './types';
|
||||
|
||||
let { variant, ...rest }: TimelineConnectorProps = $props();
|
||||
</script>
|
||||
|
||||
<div data-timeline-connector="" data-variant={variant} aria-hidden="true" {...rest}></div>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
/** Eidos `<Timeline.Content>` — the entry body (Title + Description). */
|
||||
import type { TimelineContentProps } from './types';
|
||||
|
||||
let { children, ...rest }: TimelineContentProps = $props();
|
||||
</script>
|
||||
|
||||
<div data-timeline-content="" {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
/** Eidos `<Timeline.Description>` — secondary prose under the Title. */
|
||||
import type { TimelineDescriptionProps } from './types';
|
||||
|
||||
let { children, ...rest }: TimelineDescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<p data-timeline-description="" {...rest}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Timeline from '$soma/components/timeline';
|
||||
import type { TimelineStatus, TimelineItemProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: TimelineItemProps = $props();
|
||||
</script>
|
||||
|
||||
<Timeline.Item {...rest}>
|
||||
{#snippet children(args: { status: TimelineStatus | undefined })}
|
||||
{@render bodyContent?.(args)}
|
||||
{/snippet}
|
||||
</Timeline.Item>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
/** Eidos `<Timeline.Marker>` — the decorative dot. Holds an icon or number. */
|
||||
import type { TimelineMarkerProps } from './types';
|
||||
|
||||
let { children, ...rest }: TimelineMarkerProps = $props();
|
||||
</script>
|
||||
|
||||
<div data-timeline-marker="" aria-hidden="true" {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
/** Eidos `<Timeline.Time>` — timestamp / meta opposite the content. */
|
||||
import type { TimelineTimeProps } from './types';
|
||||
|
||||
let { children, ...rest }: TimelineTimeProps = $props();
|
||||
</script>
|
||||
|
||||
<time data-timeline-time="" {...rest}>
|
||||
{@render children?.()}
|
||||
</time>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
/** Eidos `<Timeline.Title>` — entry heading. `<h3>` by default; override via `as`. */
|
||||
import type { TimelineTitleProps } from './types';
|
||||
|
||||
let { as = 'h3', children, ...rest }: TimelineTitleProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element this={as} data-timeline-title="" {...rest}>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@ -0,0 +1,479 @@
|
||||
/*
|
||||
* Timeline — a vertical/horizontal sequence of events.
|
||||
*
|
||||
* Two independent color axes:
|
||||
* - STATUS (data-status: done | current | upcoming) drives the marker
|
||||
* TREATMENT (filled / ring / hollow). The hue comes from the root accent
|
||||
* (data-color, default primary).
|
||||
* - INTENT (data-intent on the item) OVERRIDES the hue with the canonical
|
||||
* evaluative palette (affirm / threat / …). It wins over the accent.
|
||||
*
|
||||
* The hue lives in `--_timeline-hue-*` on the ITEM so both the marker and the
|
||||
* connector inherit it. The status treatments read it.
|
||||
*
|
||||
* Layout: logical properties throughout (RTL-safe). The connector is absolutely
|
||||
* positioned in the rail track so it bridges the gap to the next entry without a
|
||||
* wrapper element.
|
||||
*/
|
||||
|
||||
[data-timeline] {
|
||||
/* size-scaled (default md) */
|
||||
--_timeline-gap: var(--timeline-gap-md);
|
||||
--_timeline-item-gap: var(--timeline-item-gap-md);
|
||||
--_timeline-marker-size: var(--timeline-marker-size-md);
|
||||
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-md);
|
||||
--_timeline-font-size: var(--timeline-font-size-md);
|
||||
--_timeline-time-font-size: var(--timeline-time-font-size-md);
|
||||
|
||||
/* status accent — default primary; data-color overrides below */
|
||||
--_timeline-accent-solid: var(--color-primary-solid);
|
||||
--_timeline-accent-track: var(--color-primary-track);
|
||||
--_timeline-accent-border: var(--color-primary-border);
|
||||
--_timeline-accent-contrast: var(--color-primary-contrast);
|
||||
--_timeline-accent-text: var(--color-primary-text);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
min-inline-size: 0;
|
||||
font-family: var(--timeline-font-family);
|
||||
font-size: var(--_timeline-font-size);
|
||||
line-height: var(--timeline-line-height);
|
||||
color: var(--timeline-color);
|
||||
}
|
||||
|
||||
/* `size` is the TYPE / density axis (letters + spacing). The circle size is a
|
||||
* SEPARATE axis (`markerSize` → data-marker-size) below, so the dot and the
|
||||
* text scale independently — both default to md. */
|
||||
[data-timeline][data-size='sm'] {
|
||||
--_timeline-gap: var(--timeline-gap-sm);
|
||||
--_timeline-item-gap: var(--timeline-item-gap-sm);
|
||||
--_timeline-font-size: var(--timeline-font-size-sm);
|
||||
--_timeline-time-font-size: var(--timeline-time-font-size-sm);
|
||||
}
|
||||
|
||||
[data-timeline][data-size='lg'] {
|
||||
--_timeline-gap: var(--timeline-gap-lg);
|
||||
--_timeline-item-gap: var(--timeline-item-gap-lg);
|
||||
--_timeline-font-size: var(--timeline-font-size-lg);
|
||||
--_timeline-time-font-size: var(--timeline-time-font-size-lg);
|
||||
}
|
||||
|
||||
/* Circle (marker) size axis — independent of the type size. */
|
||||
[data-timeline][data-marker-size='sm'] {
|
||||
--_timeline-marker-size: var(--timeline-marker-size-sm);
|
||||
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-sm);
|
||||
}
|
||||
[data-timeline][data-marker-size='lg'] {
|
||||
--_timeline-marker-size: var(--timeline-marker-size-lg);
|
||||
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-lg);
|
||||
}
|
||||
|
||||
/* ── Status accent by data-color (root) ─────────────────────────────────── */
|
||||
|
||||
[data-timeline][data-color='secondary'] {
|
||||
--_timeline-accent-solid: var(--color-secondary-solid);
|
||||
--_timeline-accent-track: var(--color-secondary-track);
|
||||
--_timeline-accent-border: var(--color-secondary-border);
|
||||
--_timeline-accent-contrast: var(--color-secondary-contrast);
|
||||
--_timeline-accent-text: var(--color-secondary-text);
|
||||
}
|
||||
[data-timeline][data-color='neutral'] {
|
||||
--_timeline-accent-solid: var(--color-neutral-solid);
|
||||
--_timeline-accent-track: var(--color-neutral-track);
|
||||
--_timeline-accent-border: var(--color-neutral-border);
|
||||
--_timeline-accent-contrast: var(--color-neutral-contrast);
|
||||
--_timeline-accent-text: var(--color-neutral-text);
|
||||
}
|
||||
[data-timeline][data-color='affirm'] {
|
||||
--_timeline-accent-solid: var(--color-affirm-solid);
|
||||
--_timeline-accent-track: var(--color-affirm-track);
|
||||
--_timeline-accent-border: var(--color-affirm-border);
|
||||
--_timeline-accent-contrast: var(--color-affirm-contrast);
|
||||
--_timeline-accent-text: var(--color-affirm-text);
|
||||
}
|
||||
[data-timeline][data-color='fulfill'] {
|
||||
--_timeline-accent-solid: var(--color-fulfill-solid);
|
||||
--_timeline-accent-track: var(--color-fulfill-track);
|
||||
--_timeline-accent-border: var(--color-fulfill-border);
|
||||
--_timeline-accent-contrast: var(--color-fulfill-contrast);
|
||||
--_timeline-accent-text: var(--color-fulfill-text);
|
||||
}
|
||||
|
||||
/* ── Item: owns the hue (accent by default, intent overrides) ───────────── */
|
||||
|
||||
[data-timeline-item] {
|
||||
--_timeline-hue-solid: var(--_timeline-accent-solid);
|
||||
--_timeline-hue-track: var(--_timeline-accent-track);
|
||||
--_timeline-hue-border: var(--_timeline-accent-border);
|
||||
--_timeline-hue-contrast: var(--_timeline-accent-contrast);
|
||||
--_timeline-hue-text: var(--_timeline-accent-text);
|
||||
position: relative;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-timeline-item][data-intent='neutral'] {
|
||||
--_timeline-hue-solid: var(--color-neutral-solid);
|
||||
--_timeline-hue-track: var(--color-neutral-track);
|
||||
--_timeline-hue-border: var(--color-neutral-border);
|
||||
--_timeline-hue-contrast: var(--color-neutral-contrast);
|
||||
--_timeline-hue-text: var(--color-neutral-text);
|
||||
}
|
||||
[data-timeline-item][data-intent='affirm'] {
|
||||
--_timeline-hue-solid: var(--color-affirm-solid);
|
||||
--_timeline-hue-track: var(--color-affirm-track);
|
||||
--_timeline-hue-border: var(--color-affirm-border);
|
||||
--_timeline-hue-contrast: var(--color-affirm-contrast);
|
||||
--_timeline-hue-text: var(--color-affirm-text);
|
||||
}
|
||||
[data-timeline-item][data-intent='fulfill'] {
|
||||
--_timeline-hue-solid: var(--color-fulfill-solid);
|
||||
--_timeline-hue-track: var(--color-fulfill-track);
|
||||
--_timeline-hue-border: var(--color-fulfill-border);
|
||||
--_timeline-hue-contrast: var(--color-fulfill-contrast);
|
||||
--_timeline-hue-text: var(--color-fulfill-text);
|
||||
}
|
||||
[data-timeline-item][data-intent='risk'] {
|
||||
--_timeline-hue-solid: var(--color-risk-solid);
|
||||
--_timeline-hue-track: var(--color-risk-track);
|
||||
--_timeline-hue-border: var(--color-risk-border);
|
||||
--_timeline-hue-contrast: var(--color-risk-contrast);
|
||||
--_timeline-hue-text: var(--color-risk-text);
|
||||
}
|
||||
[data-timeline-item][data-intent='threat'] {
|
||||
--_timeline-hue-solid: var(--color-threat-solid);
|
||||
--_timeline-hue-track: var(--color-threat-track);
|
||||
--_timeline-hue-border: var(--color-threat-border);
|
||||
--_timeline-hue-contrast: var(--color-threat-contrast);
|
||||
--_timeline-hue-text: var(--color-threat-text);
|
||||
}
|
||||
[data-timeline-item][data-intent='loss'] {
|
||||
--_timeline-hue-solid: var(--color-loss-solid);
|
||||
--_timeline-hue-track: var(--color-loss-track);
|
||||
--_timeline-hue-border: var(--color-loss-border);
|
||||
--_timeline-hue-contrast: var(--color-loss-contrast);
|
||||
--_timeline-hue-text: var(--color-loss-text);
|
||||
}
|
||||
|
||||
/* ── Vertical layout (default) ──────────────────────────────────────────── */
|
||||
|
||||
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-item] {
|
||||
display: grid;
|
||||
grid-template-columns: var(--_timeline-marker-size) minmax(0, 1fr);
|
||||
column-gap: var(--_timeline-gap);
|
||||
align-items: start;
|
||||
padding-block-end: var(--_timeline-item-gap);
|
||||
}
|
||||
|
||||
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-marker] {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-time],
|
||||
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-content] {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* Connector: a line in the rail track, from below the marker to the item's
|
||||
* bottom edge (padding included) so it reaches the next entry. Rendered as a
|
||||
* logical border so `solid | dashed | dotted` come for free and RTL is handled
|
||||
* by the writing direction (no transform flip needed). */
|
||||
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-connector] {
|
||||
position: absolute;
|
||||
/* From this marker's CENTER down to the item's bottom edge (= the next
|
||||
* marker's top). The line runs BEHIND the opaque marker (z-index), so it
|
||||
* reads as one continuous rail through the dots. */
|
||||
inset-block: calc(var(--_timeline-marker-size) / 2) 0;
|
||||
inset-inline-start: calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
|
||||
inline-size: 0;
|
||||
border-inline-start: var(--timeline-connector-thickness) var(--_timeline-connector-style)
|
||||
var(--timeline-connector-color);
|
||||
min-block-size: var(--timeline-connector-min-size);
|
||||
}
|
||||
|
||||
/* align=end — rail on the inline-end side */
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-item] {
|
||||
grid-template-columns: minmax(0, 1fr) var(--_timeline-marker-size);
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-marker] {
|
||||
grid-column: 2;
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-time],
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-content] {
|
||||
grid-column: 1;
|
||||
text-align: end;
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-connector] {
|
||||
inset-inline: auto calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
|
||||
}
|
||||
|
||||
/* align=alternate — rail in the center, entries zig-zag by parity */
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-item] {
|
||||
grid-template-columns: minmax(0, 1fr) var(--_timeline-marker-size) minmax(0, 1fr);
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-marker] {
|
||||
grid-column: 2;
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-connector] {
|
||||
inset-inline-start: calc(50% - var(--timeline-connector-thickness) / 2);
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
|
||||
[data-timeline-item]:nth-child(odd)
|
||||
[data-timeline-content],
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
|
||||
[data-timeline-item]:nth-child(odd)
|
||||
[data-timeline-time] {
|
||||
grid-column: 3;
|
||||
text-align: start;
|
||||
}
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
|
||||
[data-timeline-item]:nth-child(even)
|
||||
[data-timeline-content],
|
||||
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
|
||||
[data-timeline-item]:nth-child(even)
|
||||
[data-timeline-time] {
|
||||
grid-column: 1;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* ── Horizontal layout ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-timeline][data-orientation='horizontal'] {
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-timeline][data-orientation='horizontal'] [data-timeline-item] {
|
||||
/* Each entry stacks vertically (marker · time · content); the absolute
|
||||
* connector overlays the marker row. Flex (not grid) so time and content
|
||||
* FLOW one below the other — two grid items in one cell would overlap. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: var(--_timeline-gap);
|
||||
padding-inline-end: var(--_timeline-item-gap);
|
||||
/* Don't shrink to fit the rail — keep a comfortable width and let the
|
||||
* timeline scroll, so the entry text never collapses to one word per line. */
|
||||
flex-shrink: 0;
|
||||
min-inline-size: var(--timeline-item-inline);
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'] [data-timeline-connector] {
|
||||
position: absolute;
|
||||
/* From this marker's CENTER across to the item's inline-end edge (= the next
|
||||
* marker's start), behind the dots. */
|
||||
inset-inline: calc(var(--_timeline-marker-size) / 2) 0;
|
||||
inset-block-start: calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
|
||||
block-size: 0;
|
||||
border-block-start: var(--timeline-connector-thickness) var(--_timeline-connector-style)
|
||||
var(--timeline-connector-color);
|
||||
min-inline-size: var(--timeline-connector-min-size);
|
||||
}
|
||||
/* horizontal align=end — content above the rail (marker drops to the bottom) */
|
||||
[data-timeline][data-orientation='horizontal'][data-align='end'] [data-timeline-item] {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='end'] [data-timeline-connector] {
|
||||
inset-block: auto calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
|
||||
}
|
||||
|
||||
/* horizontal align=alternate — rail centered, content zig-zags above/below.
|
||||
* The `1fr auto 1fr` rows keep the marker at the item's vertical centre for
|
||||
* every entry, so a single connector at 50% lines up across all of them
|
||||
* regardless of content height (no fixed-height assumption). */
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-item] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
/* even-content · even-time · marker · odd-time · odd-content. Rows are PINNED
|
||||
* (fixed heights) so every marker lands at the same offset — a straight rail
|
||||
* — and so time + content land in SEPARATE rows (two grid items in one cell
|
||||
* would overlap). The time always sits nearest the rail. */
|
||||
grid-template-rows:
|
||||
var(--timeline-alt-content-block)
|
||||
var(--timeline-alt-time-block)
|
||||
auto
|
||||
var(--timeline-alt-time-block)
|
||||
var(--timeline-alt-content-block);
|
||||
justify-items: start;
|
||||
align-items: center;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-marker] {
|
||||
grid-row: 3;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate']
|
||||
[data-timeline-item]:nth-child(odd)
|
||||
[data-timeline-time] {
|
||||
grid-row: 4;
|
||||
align-self: start;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate']
|
||||
[data-timeline-item]:nth-child(odd)
|
||||
[data-timeline-content] {
|
||||
grid-row: 5;
|
||||
align-self: start;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate']
|
||||
[data-timeline-item]:nth-child(even)
|
||||
[data-timeline-time] {
|
||||
grid-row: 2;
|
||||
align-self: end;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate']
|
||||
[data-timeline-item]:nth-child(even)
|
||||
[data-timeline-content] {
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
}
|
||||
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-connector] {
|
||||
inset-block: 50% auto;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Hide a stray connector on the last entry. */
|
||||
[data-timeline-item]:last-child [data-timeline-connector] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Marker ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-timeline-marker] {
|
||||
/* Scale any <Icon> inside the marker with the circle size (the Icon reads
|
||||
* `--icon-size`), and numbers/text via font-size. */
|
||||
--icon-size: var(--_timeline-marker-icon-size);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
inline-size: var(--_timeline-marker-size);
|
||||
block-size: var(--_timeline-marker-size);
|
||||
border: var(--timeline-marker-border-width) solid var(--timeline-marker-border);
|
||||
border-radius: var(--timeline-marker-radius);
|
||||
background: var(--timeline-marker-bg);
|
||||
color: var(--timeline-marker-color);
|
||||
font-weight: var(--timeline-marker-font-weight);
|
||||
font-size: var(--_timeline-marker-icon-size);
|
||||
line-height: 1;
|
||||
transition:
|
||||
background var(--timeline-transition-duration) var(--timeline-transition-ease),
|
||||
border-color var(--timeline-transition-duration) var(--timeline-transition-ease),
|
||||
color var(--timeline-transition-duration) var(--timeline-transition-ease),
|
||||
box-shadow var(--timeline-transition-duration) var(--timeline-transition-ease);
|
||||
}
|
||||
|
||||
[data-timeline-marker] :global(svg) {
|
||||
inline-size: var(--_timeline-marker-icon-size);
|
||||
block-size: var(--_timeline-marker-icon-size);
|
||||
}
|
||||
|
||||
/* Intent present (no status) → solid fill (the feed event's color). */
|
||||
[data-timeline-item][data-intent] [data-timeline-marker] {
|
||||
background: var(--_timeline-hue-solid);
|
||||
border-color: var(--_timeline-hue-solid);
|
||||
color: var(--_timeline-hue-contrast);
|
||||
}
|
||||
|
||||
/* Status treatments (read --_timeline-hue-*, set by accent or intent). Source
|
||||
* order after the intent-fill rule so done/current/upcoming win. */
|
||||
[data-timeline-item][data-status='done'] [data-timeline-marker] {
|
||||
background: var(--_timeline-hue-solid);
|
||||
border-color: var(--_timeline-hue-solid);
|
||||
color: var(--_timeline-hue-contrast);
|
||||
}
|
||||
[data-timeline-item][data-status='current'] [data-timeline-marker] {
|
||||
background: var(--_timeline-hue-track);
|
||||
border-color: var(--_timeline-hue-border);
|
||||
color: var(--_timeline-hue-text);
|
||||
box-shadow: 0 0 0 var(--timeline-marker-ring-width) var(--_timeline-hue-track);
|
||||
}
|
||||
[data-timeline-item][data-status='upcoming'] [data-timeline-marker] {
|
||||
background: var(--timeline-marker-bg);
|
||||
border-color: var(--timeline-marker-border);
|
||||
color: var(--timeline-marker-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Connector ──────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-timeline-connector] {
|
||||
--_timeline-connector-style: solid;
|
||||
}
|
||||
[data-timeline-connector][data-variant='dashed'] {
|
||||
--_timeline-connector-style: dashed;
|
||||
}
|
||||
[data-timeline-connector][data-variant='dotted'] {
|
||||
--_timeline-connector-style: dotted;
|
||||
}
|
||||
|
||||
/* A completed segment (or any tinted feed entry) colors the line. */
|
||||
[data-timeline-item][data-status='done'] [data-timeline-connector],
|
||||
[data-timeline-item][data-intent] [data-timeline-connector] {
|
||||
border-color: var(--_timeline-hue-border);
|
||||
}
|
||||
|
||||
/* ── Time / Content / Title / Description ───────────────────────────────── */
|
||||
|
||||
[data-timeline-time] {
|
||||
font-size: var(--_timeline-time-font-size);
|
||||
font-weight: var(--timeline-time-font-weight);
|
||||
color: var(--timeline-time-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-timeline-content] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--timeline-content-gap);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-timeline-title] {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: var(--timeline-title-font-weight);
|
||||
color: var(--timeline-title-color);
|
||||
line-height: var(--timeline-line-height);
|
||||
}
|
||||
|
||||
[data-timeline-description] {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
color: var(--timeline-description-color);
|
||||
line-height: var(--timeline-line-height);
|
||||
}
|
||||
|
||||
/* ── Live-feed reveal ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-timeline][data-live] [data-timeline-item] {
|
||||
animation: timeline-reveal var(--timeline-reveal-duration) var(--timeline-reveal-ease) both;
|
||||
}
|
||||
|
||||
@keyframes timeline-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--timeline-reveal-distance));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion ─────────────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-timeline-marker] {
|
||||
transition: none;
|
||||
}
|
||||
[data-timeline-item][data-status='current'] [data-timeline-marker] {
|
||||
animation: none;
|
||||
}
|
||||
[data-timeline][data-live] [data-timeline-item] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import './timeline.css';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as Timeline from '$soma/components/timeline';
|
||||
import type { TimelineProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
markerSize = 'md',
|
||||
color = 'primary',
|
||||
orientation = 'vertical',
|
||||
align = 'start',
|
||||
children: bodyContent,
|
||||
...rest
|
||||
}: TimelineProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const resolvedMarkerSize = $derived(eidos.resolve(markerSize, 'md'));
|
||||
</script>
|
||||
|
||||
<Timeline.Provider
|
||||
{...rest}
|
||||
data-size={resolvedSize}
|
||||
data-marker-size={resolvedMarkerSize}
|
||||
data-color={color}
|
||||
data-orientation={orientation}
|
||||
data-align={align}
|
||||
>
|
||||
{@render bodyContent?.()}
|
||||
</Timeline.Provider>
|
||||
@ -0,0 +1,58 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes, HTMLTimeAttributes } from 'svelte/elements';
|
||||
import type { ProgressiveColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
import type { ItemProps, ProviderProps, TimelineStatus } from '$soma/components/timeline';
|
||||
|
||||
/** Visual density. Timeline is a composite panel — `sm · md · lg`. */
|
||||
export type TimelineSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
/** Status accent (done / current). Progressive roles, like Stepper. */
|
||||
export type TimelineColor = ProgressiveColorRole;
|
||||
export type TimelineOrientation = 'vertical' | 'horizontal';
|
||||
/** Which side of the rail the content sits on. `alternate` zig-zags by parity. */
|
||||
export type TimelineAlign = 'start' | 'end' | 'alternate';
|
||||
/** Connector line style. */
|
||||
export type TimelineConnectorVariant = 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
export type TimelineProps = ProviderProps & {
|
||||
/** Type + spacing density (letters, gaps). Independent of the circle size. @default 'md' */
|
||||
size?: ResponsiveProp<TimelineSize>;
|
||||
/** Circle (marker) diameter. Independent of the type size. @default 'md' */
|
||||
markerSize?: ResponsiveProp<TimelineSize>;
|
||||
/** Accent palette for done / current markers. @default 'primary' */
|
||||
color?: TimelineColor;
|
||||
/** Layout direction of the sequence. @default 'vertical' */
|
||||
orientation?: TimelineOrientation;
|
||||
/** Content side relative to the rail. @default 'start' */
|
||||
align?: TimelineAlign;
|
||||
};
|
||||
|
||||
export type TimelineItemProps = ItemProps;
|
||||
|
||||
export type TimelineMarkerProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type TimelineConnectorProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
/** Line style. @default 'solid' */
|
||||
variant?: TimelineConnectorVariant;
|
||||
};
|
||||
|
||||
export type TimelineTimeProps = Omit<HTMLTimeAttributes, 'children'> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type TimelineContentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type TimelineTitleProps = Omit<HTMLAttributes<HTMLHeadingElement>, 'children'> & {
|
||||
/** Heading level override. @default 'h3' */
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type TimelineDescriptionProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type { TimelineStatus };
|
||||
@ -0,0 +1,15 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the timeline component. Merged under
|
||||
* `components.timeline.*` by `ActiveUix` (via the `componentLangs` barrel).
|
||||
*
|
||||
* Morfo references entries by absolute idlangref:
|
||||
* `'#?components.timeline.{key}|fallback'`.
|
||||
*/
|
||||
export const timelineLangs = {
|
||||
label: {
|
||||
es: 'Cronología',
|
||||
en: 'Timeline'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,190 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
import { INTENTS } from '../../intent';
|
||||
|
||||
/**
|
||||
* Timeline — a vertical/horizontal sequence of events.
|
||||
*
|
||||
* Unifies two patterns reference libraries keep apart:
|
||||
*
|
||||
* - **History / feed** — each item carries its own `status`
|
||||
* (done / current / upcoming) and an optional evaluative `intent`
|
||||
* (affirm / threat / …) that tints its marker via the canonical
|
||||
* `data-intent` color channel (the same surface Banner / Toast read).
|
||||
* - **Progress (stepper-like)** — a root-level `active` index derives
|
||||
* each item's status by position, so the same component renders a
|
||||
* read-only vertical stepper without re-declaring per-item status.
|
||||
*
|
||||
* Display-only by design: the `<ol>` / `<li>` structure carries the
|
||||
* semantics, `aria-current="step"` marks the active item, the marker and
|
||||
* connector are decorative (`aria-hidden`). No roving tabindex / keyboard —
|
||||
* interactive step-jumping stays with `Stepper`.
|
||||
*
|
||||
* Layer split: the morfo declares only the **behavioral / semantic**
|
||||
* surface — `data-status` (soma derives it from `active`/`status`),
|
||||
* `data-intent` (feeds the event), `aria-current`, `data-live`. The
|
||||
* **visual** axes (`orientation`, `align`, `size`, `color`, connector
|
||||
* `variant`) are eidos-only attrs stamped by the wrappers and reached on
|
||||
* the children through descendant selectors — exactly like `data-size` /
|
||||
* `data-variant` on the other recipes.
|
||||
*
|
||||
* One semantic event: `emerge-reveal` — fired by an Item when it mounts
|
||||
* into a `live` feed, so a real activity stream announces new entries
|
||||
* (intent comes from the item's `intent` prop). Static timelines stay
|
||||
* silent; the initial batch never fires (only later appends do).
|
||||
*/
|
||||
export const timelineMorfo = {
|
||||
name: 'Timeline',
|
||||
kebab: 'timeline',
|
||||
scope: ['soma', 'sema', 'eidos'],
|
||||
expression: 'pack',
|
||||
texts: {
|
||||
label: '#?components.timeline.label|Timeline'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
name: 'emerge-reveal',
|
||||
semantic: {
|
||||
family: 'emerge',
|
||||
verb: 'reveal',
|
||||
target: v.partRef('item'),
|
||||
// 'post' — the item is already in the DOM; the reveal signal
|
||||
// accompanies its settled appearance, it does not precede it.
|
||||
sequence: 'post',
|
||||
intent: {
|
||||
fromProp: 'intent',
|
||||
default: 'neutral',
|
||||
supported: [...INTENTS]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'ol',
|
||||
role: 'list',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-live',
|
||||
value: v.propRef('live'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'live' }
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.timeline.label|Timeline'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// No `archetype: 'item'` — that archetype is for selectable menu /
|
||||
// listbox / option rows (it ships cursor:pointer, user-select:none,
|
||||
// internal padding + a hover highlight). A timeline entry is a
|
||||
// display `<li>`, structurally unique, so it omits the archetype.
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'li',
|
||||
optional: false,
|
||||
states: ['done', 'current', 'upcoming'],
|
||||
data: [
|
||||
{
|
||||
attr: 'data-status',
|
||||
values: ['done', 'current', 'upcoming'],
|
||||
value: v.propRef('status'),
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Evaluative tint for the item's marker — the canonical UIX
|
||||
* color-role channel (same one Banner / Toast read). When set,
|
||||
* it also drives the `intent` of the `emerge-reveal` event.
|
||||
*/
|
||||
attr: 'data-intent',
|
||||
values: [...INTENTS],
|
||||
value: v.propRef('intent'),
|
||||
severity: 'optional'
|
||||
}
|
||||
],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-current',
|
||||
value: v.literal('step'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'current', value: 'true' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Marker',
|
||||
kebab: 'marker',
|
||||
archetype: 'indicator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
name: 'Connector',
|
||||
kebab: 'connector',
|
||||
archetype: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
name: 'Time',
|
||||
kebab: 'time',
|
||||
archetype: 'label',
|
||||
kind: 'public',
|
||||
defaultElement: 'time',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
kebab: 'content',
|
||||
archetype: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// Rendered as `<h3>` by default in eidos (the `as` prop overrides);
|
||||
// MorfoElement has no heading levels, so declare `div` + role heading.
|
||||
name: 'Title',
|
||||
kebab: 'title',
|
||||
archetype: 'title',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'heading',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Description',
|
||||
kebab: 'description',
|
||||
archetype: 'description',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,36 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { timelineMorfo } from '$uix/morfo/components/timeline';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* Timeline perceptual defaults — SOUND + HAPTIC ONLY.
|
||||
*
|
||||
* Visual concerns (the rail, marker tint by `data-status` / `data-intent`,
|
||||
* connector line style, the live-feed reveal animation) live in
|
||||
* `src/uix/eidos/components/timeline/timeline.css`.
|
||||
*
|
||||
* The single event is `emerge-reveal`, fired by an Item as it appends into a
|
||||
* live feed. Its `intent` comes from the item (affirm = a good event landed,
|
||||
* threat = a failure surfaced, …) so intent.deltas (capa 2) already shape the
|
||||
* valence. This pack only adds CHARACTER:
|
||||
*
|
||||
* - sound: soften the family emerge gain via `op: 'add'` so a feed entry is a
|
||||
* gentle cue rather than a popover-loud "puff". `op: 'add'` COMPOSES on top
|
||||
* of the intent deltas — never replace `pitch` / `gain` / `contour`, which
|
||||
* would flatten the affirm / threat / loss difference (per doctrine).
|
||||
* - haptic: a light tick — emerge carries no haptic by default.
|
||||
*/
|
||||
|
||||
const onItem = (matchers?: Parameters<typeof semaSelector<typeof timelineMorfo>>[2]) =>
|
||||
semaSelector(timelineMorfo, 'item', matchers);
|
||||
|
||||
export const timelineSema: Sema = {
|
||||
name: 'timeline',
|
||||
cascade: [
|
||||
{
|
||||
selector: onItem({ eventName: 'emerge-reveal' }),
|
||||
sound: { gain: { op: 'add', value: -0.08 } },
|
||||
haptic: { kind: 'tick' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,134 @@
|
||||
# Timeline
|
||||
|
||||
A vertical/horizontal sequence of events. Unifies the **history / feed** pattern
|
||||
(each item carries its own `status` + evaluative `intent`) with the **progress /
|
||||
stepper** pattern (a root `active` index derives status by position). Display-only:
|
||||
`<ol>` / `<li>` semantics, `aria-current="step"` on the active item, decorative
|
||||
markers and connectors. Interactive step-jumping stays with `Stepper`.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<Timeline active={1}>
|
||||
<Timeline.Item index={0} intent="affirm">
|
||||
<Timeline.Marker><Check /></Timeline.Marker>
|
||||
<Timeline.Connector />
|
||||
<Timeline.Time datetime="2026-06-23T09:30">09:30</Timeline.Time>
|
||||
<Timeline.Content>
|
||||
<Timeline.Title>Deploy ok</Timeline.Title>
|
||||
<Timeline.Description>v2.4.1 to production</Timeline.Description>
|
||||
</Timeline.Content>
|
||||
</Timeline.Item>
|
||||
|
||||
<Timeline.Item index={1}>
|
||||
<Timeline.Marker>2</Timeline.Marker>
|
||||
<Timeline.Content>
|
||||
<Timeline.Title>Running migrations</Timeline.Title>
|
||||
</Timeline.Content>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
```
|
||||
|
||||
> `Marker`, `Connector`, `Time`, `Content`, `Title` and `Description` are
|
||||
> **eidos-only** parts (pure structural display). The soma layer ships the two
|
||||
> parts that carry behaviour: `Provider` and `Item`.
|
||||
|
||||
## Parts (soma)
|
||||
|
||||
| Part | Element | Description |
|
||||
| ---------- | ------- | --------------------------------------------------------------------------- |
|
||||
| `Provider` | `<ol>` | Root list. Tracks `active`, auto-indexes items, gates the live feed. |
|
||||
| `Item` | `<li>` | One entry. Derives its `status`, exposes `{ status }`, announces in a feed. |
|
||||
|
||||
## Props
|
||||
|
||||
### Provider
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ----------- | ------------------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `active` | `number \| null` | `null` | Derives each item's status by position. Per-item `status` overrides. |
|
||||
| `live` | `boolean` | `false` | Feed mode — appended items fire `emerge-reveal` (initial batch silent). |
|
||||
| `ariaLabel` | `string` | — | Accessible name. Falls back to the translated "Timeline". |
|
||||
|
||||
### Item
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| -------- | ----------------------------------- | ------- | ---------------------------------------------------------------- |
|
||||
| `index` | `number` | auto | Position (0-based). Auto by order; pass it for dynamic lists. |
|
||||
| `status` | `'done' \| 'current' \| 'upcoming'` | derived | Explicit status — overrides the `active`-derived value. |
|
||||
| `intent` | `Intent` | — | Marker tint (`data-intent`) + `emerge-reveal` intent in feeds. |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| -------- | -------------- | --------------------------- |
|
||||
| Provider | `role` | `list` |
|
||||
| Provider | `aria-label` | translated "Timeline" |
|
||||
| Item | `aria-current` | `step` (when current) |
|
||||
| Marker | `aria-hidden` | `true` (decorative) |
|
||||
| Connector| `aria-hidden` | `true` (decorative) |
|
||||
|
||||
## Data attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| -------- | --------------------- | ----------------------------------- |
|
||||
| Provider | `data-timeline` | always present |
|
||||
| Provider | `data-live` | present in feed mode |
|
||||
| Item | `data-timeline-item` | always present |
|
||||
| Item | `data-status` | `done` \| `current` \| `upcoming` |
|
||||
| Item | `data-intent` | `neutral`…`loss` (when set) |
|
||||
|
||||
The visual axes — `data-orientation`, `data-align`, `data-size`, `data-color`
|
||||
(root) and the connector's `data-variant` — are **eidos-only** attrs stamped by
|
||||
the eidos wrappers; the children read them through descendant selectors.
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Two color axes
|
||||
|
||||
1. **Status** (`done` / `current` / `upcoming`) — the progress axis. Either set
|
||||
per-item `status` (feed) or let the root `active` derive it (stepper). The
|
||||
root `color` (eidos) is the accent for done/current.
|
||||
2. **Intent** (per item) — the semantic axis. `intent` tints the marker via the
|
||||
canonical `data-intent` channel (affirm = green, threat = red, …) and feeds
|
||||
the `emerge-reveal` event in live feeds.
|
||||
|
||||
### Live feed
|
||||
|
||||
With `live`, an item mounting **after** the initial render fires `emerge-reveal`
|
||||
(intent from the item). The initial batch never fires, so loading a feed of 20
|
||||
entries does not burst 20 signals — only genuinely new entries announce.
|
||||
|
||||
## Sema events
|
||||
|
||||
| Event | Family | Verb | Target | Intent | When |
|
||||
| --------------- | -------- | ------ | ------ | ---------------- | ------------------------------------- |
|
||||
| `emerge-reveal` | `emerge` | reveal | item | fromProp `intent`| an item appends into a `live` feed |
|
||||
|
||||
Pack: `src/uix/sema/components/timeline.ts` — `emerge.soft` tuning + haptic.
|
||||
|
||||
## Comparison with reference libraries
|
||||
|
||||
| Feature | MUI | Ant Design | PrimeReact | Vuetify | Mantine | Chakra v3 | Soma Timeline |
|
||||
| -------------------------------- | --- | ---------- | ---------- | ------- | ------- | --------- | ------------- |
|
||||
| Compositional API | ✅ | ✅+items | data-driven| ✅ | ✅ | ✅ | ✅ |
|
||||
| Vertical + Horizontal | V | V | ✅ | ✅ | V | V | ✅ |
|
||||
| Alternating sides / opposite | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Progress (status + active) | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ (unified) |
|
||||
| Semantic marker by **intent** | tint| tint | tint | tint | tint | tint | ✅ (canon) |
|
||||
| Connector dashed / dotted | ❌ | ❌ | partial | partial | ✅ | ✅ | ✅ |
|
||||
| Live feed (motion + sema) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| RTL (logical) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| `data-*` state per part | partial | ❌ | ❌ | ❌ | ❌ | partial | ✅ |
|
||||
|
||||
No reference library unifies progress + feed, ties the marker to a **canonical
|
||||
intent system**, or ships a live feed with motion + perceptual signals.
|
||||
|
||||
## Out of scope (v2 roadmap)
|
||||
|
||||
- **`pending` / loading item state** (Ant Design) — a `sustain`-family status with
|
||||
a spinner marker. Deferred: needs a `sustain` event + spinner composition. ~40 lines.
|
||||
- **Reverse order prop** (Ant Design) — the consumer reverses the array today; a
|
||||
`reverse` prop is sugar. Deferred. ~10 lines.
|
||||
- **Clickable step-jump** — `Stepper` already covers interactive wizards
|
||||
(tablist/tab + roving keyboard). Timeline stays display-only on purpose.
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { TimelineItemProvider } from '../timeline-provider.svelte';
|
||||
import type { TimelineItemProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'timeline-item'),
|
||||
index,
|
||||
status,
|
||||
intent,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TimelineItemProps = $props();
|
||||
|
||||
const state = TimelineItemProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
index: readableActive(() => index),
|
||||
status: readableActive(() => status),
|
||||
intent: readableActive(() => intent)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ status: state.effectiveStatus, props: mergedProps })}
|
||||
{:else}
|
||||
<li {...mergedProps}>
|
||||
{@render children?.({ status: state.effectiveStatus })}
|
||||
</li>
|
||||
{/if}
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { TimelineProvider } from '../timeline-provider.svelte';
|
||||
import type { TimelineProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'timeline'),
|
||||
active = null,
|
||||
live = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TimelineProps = $props();
|
||||
|
||||
const state = TimelineProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
active: readableActive(() => active),
|
||||
live: readableActive(() => live),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<ol {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</ol>
|
||||
{/if}
|
||||
@ -0,0 +1,8 @@
|
||||
export { default as Provider } from './components/timeline.svelte';
|
||||
export { default as Item } from './components/timeline-item.svelte';
|
||||
|
||||
export type {
|
||||
TimelineStatus,
|
||||
TimelineProps as ProviderProps,
|
||||
TimelineItemProps as ItemProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Imperative idlangref constants for the timeline provider. Component-owned
|
||||
* text is declared in `timelineMorfo.texts`; this file only holds the refs the
|
||||
* provider needs at runtime (the list's accessible-name fallback).
|
||||
*/
|
||||
export const TIMELINE_LANGS = {
|
||||
LABEL: '#?components.timeline.label|Timeline'
|
||||
} as const;
|
||||
@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { TimelineItemProvider, TimelineProvider } from './timeline-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: vi.fn(() => 'Timeline')
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(TimelineProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(TimelineItemProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function timelineOpts(active: number | null, live = false, root = document.createElement('ol')) {
|
||||
return {
|
||||
id: state('timeline-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
active: state<number | null>(active),
|
||||
live: state(live),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function itemOpts(index: number | undefined, opts?: { status?: 'done' | 'current' | 'upcoming' }) {
|
||||
return {
|
||||
id: state(`timeline-item-${index ?? 'auto'}`),
|
||||
ref: state<HTMLElement | null>(document.createElement('li')),
|
||||
index: state<number | undefined>(index),
|
||||
status: state<'done' | 'current' | 'upcoming' | undefined>(opts?.status),
|
||||
intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('derives item status from the active index and projects runtime attrs', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('ol');
|
||||
const firstEl = document.createElement('li');
|
||||
const currentEl = document.createElement('li');
|
||||
const lastEl = document.createElement('li');
|
||||
document.body.append(root, firstEl, currentEl, lastEl);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = TimelineProvider.create(timelineOpts(1, false, root));
|
||||
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
|
||||
|
||||
const first = TimelineItemProvider.create({ ...itemOpts(0), ref: state<HTMLElement | null>(firstEl) });
|
||||
const current = TimelineItemProvider.create({ ...itemOpts(1), ref: state<HTMLElement | null>(currentEl) });
|
||||
const last = TimelineItemProvider.create({ ...itemOpts(2), ref: state<HTMLElement | null>(lastEl) });
|
||||
return { provider, first, current, last };
|
||||
});
|
||||
|
||||
expect(result.first.effectiveStatus).toBe('done');
|
||||
expect(result.current.effectiveStatus).toBe('current');
|
||||
expect(result.last.effectiveStatus).toBe('upcoming');
|
||||
expect(result.current.isCurrent).toBe(true);
|
||||
|
||||
// renderProps resolves the morfo attrs into the props object the wrapper
|
||||
// spreads onto the element; assert the object (no wrapper in this harness).
|
||||
expect(result.provider.props).toMatchObject({
|
||||
id: 'timeline-root',
|
||||
'data-timeline': '',
|
||||
role: 'list',
|
||||
'aria-label': 'Timeline'
|
||||
});
|
||||
expect(result.first.props).toMatchObject({ 'data-timeline-item': '', 'data-status': 'done' });
|
||||
expect(result.current.props).toMatchObject({
|
||||
'data-status': 'current',
|
||||
'aria-current': 'step'
|
||||
});
|
||||
expect(result.last.props).toMatchObject({ 'data-status': 'upcoming' });
|
||||
expect(result.first.props['aria-current']).toBeUndefined();
|
||||
|
||||
await tick();
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('lets an explicit status override the derived value and tints with intent', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('ol');
|
||||
const el = document.createElement('li');
|
||||
document.body.append(root, el);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = TimelineProvider.create(timelineOpts(2, false, root));
|
||||
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
|
||||
// index 0 would derive 'done', but the explicit status wins.
|
||||
const item = TimelineItemProvider.create({
|
||||
...itemOpts(0, { status: 'current' }),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>('threat')
|
||||
});
|
||||
return { provider, item };
|
||||
});
|
||||
|
||||
expect(result.item.effectiveStatus).toBe('current');
|
||||
expect(result.item.props).toMatchObject({
|
||||
'data-status': 'current',
|
||||
'data-intent': 'threat',
|
||||
'aria-current': 'step'
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('auto-indexes items by registration order when no index is passed', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('ol');
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = TimelineProvider.create(timelineOpts(1, false, root));
|
||||
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
|
||||
const a = TimelineItemProvider.create(itemOpts(undefined));
|
||||
const b = TimelineItemProvider.create(itemOpts(undefined));
|
||||
const c = TimelineItemProvider.create(itemOpts(undefined));
|
||||
return { a, b, c };
|
||||
});
|
||||
|
||||
expect([result.a.index, result.b.index, result.c.index]).toEqual([0, 1, 2]);
|
||||
expect(result.a.effectiveStatus).toBe('done');
|
||||
expect(result.b.effectiveStatus).toBe('current');
|
||||
expect(result.c.effectiveStatus).toBe('upcoming');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('leaves status undefined when neither active nor explicit status is set', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = TimelineProvider.create(timelineOpts(null));
|
||||
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
|
||||
const item = TimelineItemProvider.create(itemOpts(0));
|
||||
return { item };
|
||||
});
|
||||
|
||||
expect(result.item.effectiveStatus).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,186 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { type ActiveProps } from '$libs/reactive';
|
||||
import type { Intent } from '$uix/intent';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { TIMELINE_LANGS } from './langs';
|
||||
|
||||
import { timelineMorfo } from '../../../morfo/components/timeline';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import type { TimelineStatus } from './types';
|
||||
|
||||
// ── Root ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
/** Active index — derives each item's status by position. `null` disables derivation. */
|
||||
active: number | null;
|
||||
/** Feed mode — items announce their arrival (`emerge-reveal`) as they are appended. */
|
||||
live: boolean;
|
||||
/** Accessible label for the list. Falls back to the translated "Timeline". */
|
||||
ariaLabel: string | undefined;
|
||||
}> {}
|
||||
|
||||
export class TimelineProvider {
|
||||
readonly opts: TimelineOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
static readonly ctx = context<TimelineProvider>('Timeline');
|
||||
static get(): TimelineProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as TimelineProvider | undefined;
|
||||
}
|
||||
static require(): TimelineProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: TimelineOpts) {
|
||||
return new TimelineProvider(opts);
|
||||
}
|
||||
|
||||
// Auto index handed to items that don't pass an explicit `index`.
|
||||
private _nextIndex = 0;
|
||||
// Flips true once the initial batch has mounted; later appends announce.
|
||||
private _ready = false;
|
||||
get ready(): boolean {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
private constructor(opts: TimelineOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(timelineMorfo, {
|
||||
props: { live: () => this.opts.live.current },
|
||||
translate: (key) => this.soma.langs.ts(key)
|
||||
});
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: TimelineProvider.ctx
|
||||
});
|
||||
// Parent effects run after children construct, so every item in the
|
||||
// initial render reads `ready === false` (no emerge); only items
|
||||
// appended afterwards see `ready === true`.
|
||||
$effect(() => {
|
||||
this._ready = true;
|
||||
});
|
||||
}
|
||||
|
||||
get live(): boolean {
|
||||
return this.opts.live.current;
|
||||
}
|
||||
|
||||
/** Hand out the next auto index in registration order. */
|
||||
registerItem(): number {
|
||||
return this._nextIndex++;
|
||||
}
|
||||
|
||||
/** Resolve an item's effective status — explicit prop wins, else derive from `active`. */
|
||||
resolveStatus(index: number, explicit: TimelineStatus | undefined): TimelineStatus | undefined {
|
||||
if (explicit) return explicit;
|
||||
const active = this.opts.active.current;
|
||||
if (active == null) return undefined;
|
||||
if (index < active) return 'done';
|
||||
if (index === active) return 'current';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
// data-timeline, role='list' and data-live are morfo-declared;
|
||||
// renderProps resolves them from the registered `live` source.
|
||||
...this.runtimePart.renderProps(),
|
||||
// Soma override — honour an explicit `ariaLabel`, falling back to the
|
||||
// morfo's translated default (same key).
|
||||
'aria-label': this.opts.ariaLabel.current || this.soma.langs.ts(TIMELINE_LANGS.LABEL)
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Item ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineItemOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
/** Position in the list. Auto-assigned by registration order when omitted. */
|
||||
index: number | undefined;
|
||||
/** Explicit status — overrides the value derived from the root `active`. */
|
||||
status: TimelineStatus | undefined;
|
||||
/** Evaluative tint for the marker; also drives the `emerge-reveal` intent. */
|
||||
intent: Intent | undefined;
|
||||
}> {}
|
||||
|
||||
export class TimelineItemProvider {
|
||||
readonly opts: TimelineItemOpts;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: TimelineProvider;
|
||||
readonly index: number;
|
||||
static readonly ctx = context<TimelineItemProvider>('TimelineItem');
|
||||
static get(): TimelineItemProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as TimelineItemProvider | undefined;
|
||||
}
|
||||
static require(): TimelineItemProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
|
||||
static create(opts: TimelineItemOpts) {
|
||||
return new TimelineItemProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: TimelineItemOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = TimelineProvider.require();
|
||||
this.index = opts.index.current ?? this.provider.registerItem();
|
||||
|
||||
// Freeze the emerge decision at construction: only an item mounting into
|
||||
// an already-live feed (an append after the initial batch) announces.
|
||||
const shouldEmerge = this.provider.live && this.provider.ready;
|
||||
|
||||
// Per-item runtime so the `emerge-reveal` event resolves THIS item's
|
||||
// `intent` (a shared runtime can't disambiguate one item from another).
|
||||
this.runtime = this.provider.soma.runtime(timelineMorfo, {
|
||||
props: {
|
||||
status: () => this.effectiveStatus,
|
||||
intent: () => this.opts.intent.current ?? undefined
|
||||
},
|
||||
translate: (key) => this.provider.soma.langs.ts(key)
|
||||
});
|
||||
this.runtimePart = this.runtime.part('item', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: TimelineItemProvider.ctx
|
||||
});
|
||||
|
||||
// Announce arrival once the element is attached (one-shot).
|
||||
let fired = false;
|
||||
$effect(() => {
|
||||
if (fired || !shouldEmerge) return;
|
||||
const el = opts.ref.current;
|
||||
if (!el) return;
|
||||
fired = true;
|
||||
untrack(() => void this.runtime.trigger('emerge-reveal', { fallbackTarget: el }));
|
||||
});
|
||||
}
|
||||
|
||||
readonly effectiveStatus = $derived.by(() =>
|
||||
this.provider.resolveStatus(this.index, this.opts.status.current)
|
||||
);
|
||||
readonly isCurrent = $derived.by(() => this.effectiveStatus === 'current');
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
// data-timeline-item, data-status and data-intent are morfo-declared;
|
||||
// renderProps resolves them from the registered status/intent sources.
|
||||
...this.runtimePart.renderProps(),
|
||||
// Soma override — aria-current reads the derived `current` status
|
||||
// (the morfo condition would need a registered state source).
|
||||
'aria-current': this.isCurrent ? ('step' as const) : undefined
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import type { WithChild, Without } from '../../types';
|
||||
import type { PrimitiveDivAttributes } from '../../types';
|
||||
import type { Intent } from '$uix/intent';
|
||||
|
||||
/** Progress status of a single timeline item. */
|
||||
export type TimelineStatus = 'done' | 'current' | 'upcoming';
|
||||
|
||||
export type TimelineProps = WithChild<{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* Active index (0-based) — derives each item's status by position
|
||||
* (`< active` → done, `=== active` → current, `> active` → upcoming).
|
||||
* Per-item `status` overrides the derived value. `null` disables
|
||||
* derivation (pure feed mode, where each item sets its own status).
|
||||
* @default null
|
||||
*/
|
||||
active?: number | null;
|
||||
/**
|
||||
* Feed mode. Items appended after the initial render announce their
|
||||
* arrival via the `emerge-reveal` event (the initial batch stays silent).
|
||||
* @default false
|
||||
*/
|
||||
live?: boolean;
|
||||
/** Accessible label for the list. Falls back to the translated "Timeline". */
|
||||
ariaLabel?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type TimelineItemProps = WithChild<
|
||||
{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* Position in the list (0-based). Auto-assigned by registration order
|
||||
* when omitted — pass it explicitly for dynamic / reorderable lists.
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Explicit status — overrides the value derived from the root `active`.
|
||||
* Use it for history / feed timelines where each entry owns its state.
|
||||
*/
|
||||
status?: TimelineStatus;
|
||||
/**
|
||||
* Evaluative tint for the item's marker (the canonical UIX color-role
|
||||
* channel). Also drives the `intent` of the `emerge-reveal` event in
|
||||
* live feeds.
|
||||
*/
|
||||
intent?: Intent;
|
||||
},
|
||||
{ status: TimelineStatus | undefined }
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
@ -0,0 +1,607 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Timeline,
|
||||
type TimelineAlign,
|
||||
type TimelineColor,
|
||||
type TimelineConnectorVariant,
|
||||
type TimelineOrientation,
|
||||
type TimelineSize
|
||||
} from '$uix/eidos/components/timeline';
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
GitCommitHorizontal,
|
||||
Rocket,
|
||||
RotateCcw,
|
||||
Wrench,
|
||||
Check
|
||||
} from '$uix/eidos/components/icon';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { timelineMorfo } from '@/uix/morfo/components/timeline';
|
||||
import type { Intent } from '$uix/intent';
|
||||
import type { Component } from 'svelte';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
import { page } from '$app/state';
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
|
||||
const uix = getActiveUix();
|
||||
const params = page.url.searchParams;
|
||||
|
||||
type Event = {
|
||||
time: string;
|
||||
iso: string;
|
||||
title: string;
|
||||
body: string;
|
||||
icon: Component;
|
||||
intent?: Intent;
|
||||
};
|
||||
|
||||
const baseEvents: Event[] = [
|
||||
{ time: '09:30', iso: '2026-06-23T09:30', title: 'Commit pushed', body: 'feat: timeline component', icon: GitCommitHorizontal },
|
||||
{ time: '09:32', iso: '2026-06-23T09:32', title: 'CI passed', body: 'All checks green', icon: CircleCheck, intent: 'affirm' },
|
||||
{ time: '09:40', iso: '2026-06-23T09:40', title: 'Deploy to staging', body: 'v2.4.1-rc', icon: Rocket },
|
||||
{ time: '09:55', iso: '2026-06-23T09:55', title: 'Smoke test failed', body: 'timeout on /api', icon: CircleX, intent: 'threat' },
|
||||
{ time: '10:10', iso: '2026-06-23T10:10', title: 'Rolled back', body: 'restored v2.4.0', icon: RotateCcw, intent: 'loss' }
|
||||
];
|
||||
const extraEvents: Event[] = [
|
||||
{ time: '10:20', iso: '2026-06-23T10:20', title: 'Hotfix merged', body: 'patch /api timeout', icon: Wrench, intent: 'affirm' },
|
||||
{ time: '10:34', iso: '2026-06-23T10:34', title: 'Re-deployed', body: 'v2.4.2 live', icon: Check, intent: 'fulfill' }
|
||||
];
|
||||
|
||||
let tab = $state<Tab>('live');
|
||||
let events = $state<Event[]>([...baseEvents]);
|
||||
let orientation = $state<TimelineOrientation>(
|
||||
params.get('perm.orientation') === 'horizontal' ? 'horizontal' : 'vertical'
|
||||
);
|
||||
let align = $state<TimelineAlign>('start');
|
||||
let active = $state(4);
|
||||
let live = $state(false);
|
||||
let size = $state<TimelineSize>('md');
|
||||
let markerSize = $state<TimelineSize>('md');
|
||||
let color = $state<TimelineColor>('primary');
|
||||
let connectorVariant = $state<TimelineConnectorVariant>('solid');
|
||||
let appendCount = $state(0);
|
||||
|
||||
function appendEvent() {
|
||||
const next = extraEvents[appendCount % extraEvents.length];
|
||||
appendCount += 1;
|
||||
const newIndex = events.length;
|
||||
events = [...events, { ...next, time: next.time }];
|
||||
// The freshly-arrived entry becomes the current highlight.
|
||||
active = newIndex;
|
||||
}
|
||||
function resetEvents() {
|
||||
events = [...baseEvents];
|
||||
appendCount = 0;
|
||||
active = 4;
|
||||
}
|
||||
|
||||
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
||||
let trace = $state<TraceEntry[]>([]);
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
|
||||
$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();
|
||||
});
|
||||
|
||||
const compiled = compileMorfo(timelineMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const eventsDecl = $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')}`;
|
||||
}
|
||||
|
||||
const somaSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import * as Timeline from '$soma/components/timeline';",
|
||||
'',
|
||||
' let active = $state(4);',
|
||||
' const events = [/* { time, title, body, intent? } */];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
`<Timeline.Provider {active}${live ? ' live' : ''}>`,
|
||||
' {#each events as ev, i}',
|
||||
' <Timeline.Item index={i} status={ev.status} intent={ev.intent}>',
|
||||
' {#snippet children({ status })}',
|
||||
' <!-- marker · connector · time · content -->',
|
||||
' {/snippet}',
|
||||
' </Timeline.Item>',
|
||||
' {/each}',
|
||||
'</Timeline.Provider>'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Timeline } from '$uix/eidos/components/timeline';",
|
||||
" import { Rocket } from '$uix/eidos/components/icon';",
|
||||
'',
|
||||
' let active = $state(4);',
|
||||
' const events = [/* { time, title, body, icon, intent? } */];',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<Timeline',
|
||||
orientation !== 'vertical' && ` orientation="${orientation}"`,
|
||||
align !== 'start' && ` align="${align}"`,
|
||||
` {active}`,
|
||||
live && ' live',
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
markerSize !== 'md' && ` markerSize="${markerSize}"`,
|
||||
color !== 'primary' && ` color="${color}"`,
|
||||
'>',
|
||||
' {#each events as ev, i}',
|
||||
' {@const Ico = ev.icon}',
|
||||
' <Timeline.Item index={i} intent={ev.intent}>',
|
||||
' {#snippet children({ status })}',
|
||||
' <Timeline.Marker><Ico /></Timeline.Marker>',
|
||||
' {#if i < events.length - 1}',
|
||||
connectorVariant !== 'solid'
|
||||
? ` <Timeline.Connector variant="${connectorVariant}" />`
|
||||
: ' <Timeline.Connector />',
|
||||
' {/if}',
|
||||
' <Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>',
|
||||
' <Timeline.Content>',
|
||||
' <Timeline.Title>{ev.title}</Timeline.Title>',
|
||||
' <Timeline.Description>{ev.body}</Timeline.Description>',
|
||||
' </Timeline.Content>',
|
||||
' {/snippet}',
|
||||
' </Timeline.Item>',
|
||||
' {/each}',
|
||||
'</Timeline>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="perm-axes" content="orientation" />
|
||||
</svelte:head>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Data · Timeline</div>
|
||||
<h1 data-uix-page-title>Timeline</h1>
|
||||
<p data-uix-page-lede>
|
||||
A sequence of events that unifies history/feed (per-item status + evaluative intent) with
|
||||
progress (a root <code>active</code> index, stepper-style). Soma owns the status model + the
|
||||
live-feed emission; Eidos owns the rail, markers, connectors, orientation and alternating
|
||||
layout.
|
||||
</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>{eventsDecl.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>entries</span>{events.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>active</span>{active}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
style="min-inline-size: min(100%, 44rem); padding-block: 0.5rem;"
|
||||
>
|
||||
<Timeline {orientation} {align} {active} {live} {size} {markerSize} {color}>
|
||||
{#each events as ev, i (ev.iso + i)}
|
||||
{@const Ico = ev.icon}
|
||||
<Timeline.Item index={i} intent={ev.intent}>
|
||||
{#snippet children({ status })}
|
||||
<Timeline.Marker><Ico /></Timeline.Marker>
|
||||
{#if i < events.length - 1}
|
||||
<Timeline.Connector variant={connectorVariant} />
|
||||
{/if}
|
||||
<Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>
|
||||
<Timeline.Content>
|
||||
<Timeline.Title>{ev.title}</Timeline.Title>
|
||||
<Timeline.Description>{ev.body}</Timeline.Description>
|
||||
</Timeline.Content>
|
||||
{/snippet}
|
||||
</Timeline.Item>
|
||||
{/each}
|
||||
</Timeline>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.length === 0}
|
||||
<span>turn on <code>live</code> and append an event to fire emerge-reveal</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>live</span>
|
||||
{live ? 'on' : 'off'}
|
||||
</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 · {eventsDecl.length}e</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>{eventsDecl.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>
|
||||
<span data-uix-layer-badge="soma">soma</span> owns the status model + live feed.
|
||||
<span data-uix-layer-badge="eidos">eidos</span> owns the rail, markers and layout. Items 2/4/5
|
||||
carry an <code>intent</code> so you can see the semantic axis next to the progress accent.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="soma">soma</span> props · status + feed
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>active <span data-uix-control-hint>{active}</span></span>
|
||||
<input type="range" bind:value={active} min="0" max={events.length} />
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>live</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={live} />
|
||||
<span data-uix-switch-label>{live ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>feed</span>
|
||||
<span data-uix-chips role="group">
|
||||
<button data-uix-chip onclick={appendEvent}>+ append event</button>
|
||||
<button data-uix-chip onclick={resetEvents}>reset</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · layout + visual
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>orientation</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['vertical', 'horizontal'] as o}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={orientation === o}
|
||||
onclick={() => (orientation = o as TimelineOrientation)}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>align</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['start', 'end', 'alternate'] as a}
|
||||
<button data-uix-chip data-active={align === a} onclick={() => (align = a as TimelineAlign)}>
|
||||
{a}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size (type)</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['sm', 'md', 'lg'] as s}
|
||||
<button data-uix-chip data-active={size === s} onclick={() => (size = s as TimelineSize)}>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>marker size (circle)</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['sm', 'md', 'lg'] as s}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={markerSize === s}
|
||||
onclick={() => (markerSize = s as TimelineSize)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color (accent)</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill'] as c}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={color === c}
|
||||
onclick={() => (color = c as TimelineColor)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>connector</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['solid', 'dashed', 'dotted'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={connectorVariant === v}
|
||||
onclick={() => (connectorVariant = v as TimelineConnectorVariant)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="soma">soma</span>
|
||||
<span>headless · status model + feed</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 · timeline recipe</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 comparison</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>MUI</th><th>AntD</th><th>Mantine</th><th>Chakra v3</th><th>UIX</th><th>Decision</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Compositional parts</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>Marker · Connector · Time · Content.</td></tr>
|
||||
<tr><td class="name">Vertical + horizontal</td><td>V</td><td>V</td><td>V</td><td>V</td><td>✅</td><td>Both, logical-RTL.</td></tr>
|
||||
<tr><td class="name">Alternating sides</td><td>✅</td><td>✅</td><td>❌</td><td>❌</td><td>✅</td><td><code>align="alternate"</code>.</td></tr>
|
||||
<tr><td class="name">Progress (status + active)</td><td>❌</td><td>❌</td><td>✅</td><td>❌</td><td>✅</td><td>Unified with Stepper.</td></tr>
|
||||
<tr><td class="name">Marker by intent (canon)</td><td>tint</td><td>tint</td><td>tint</td><td>tint</td><td>✅</td><td>Canonical intent palette + sema.</td></tr>
|
||||
<tr><td class="name">Connector dashed/dotted</td><td>❌</td><td>❌</td><td>✅</td><td>✅</td><td>✅</td><td>Per-connector <code>variant</code>.</td></tr>
|
||||
<tr><td class="name">Live feed (motion + sema)</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td><td>Reveal + <code>emerge-reveal</code>.</td></tr>
|
||||
<tr><td class="name">Pending / loading item</td><td>❌</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>Deferred to v2 (sustain status).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">active</td><td>soma</td><td class="default">null</td><td>Derives item status by position.</td></tr>
|
||||
<tr><td class="name">live</td><td>soma</td><td class="default">false</td><td>Appended items fire emerge-reveal.</td></tr>
|
||||
<tr><td class="name">status (item)</td><td>soma</td><td class="default">derived</td><td>done | current | upcoming; overrides active.</td></tr>
|
||||
<tr><td class="name">intent (item)</td><td>soma</td><td class="default">—</td><td>Marker tint + emerge intent.</td></tr>
|
||||
<tr><td class="name">orientation</td><td>eidos</td><td class="default">vertical</td><td>vertical | horizontal.</td></tr>
|
||||
<tr><td class="name">align</td><td>eidos</td><td class="default">start</td><td>start | end | alternate.</td></tr>
|
||||
<tr><td class="name">size</td><td>eidos</td><td class="default">md</td><td>Type + spacing density (sm | md | lg).</td></tr>
|
||||
<tr><td class="name">markerSize</td><td>eidos</td><td class="default">md</td><td>Circle diameter, independent of size.</td></tr>
|
||||
<tr><td class="name">color</td><td>eidos</td><td class="default">primary</td><td>Status accent (progressive roles).</td></tr>
|
||||
<tr><td class="name">variant (connector)</td><td>eidos</td><td class="default">solid</td><td>solid | dashed | dotted.</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>
|
||||
The morfo declares only the behavioral / semantic surface (status, intent, aria-current,
|
||||
live). The visual axes are eidos-only attrs reached on the children via descendant selectors.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</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.states.length ? part.states.join(' | ') : '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#each timelineMorfo.parts as rawPart}
|
||||
{@const partAny = rawPart as unknown as {
|
||||
kebab: string;
|
||||
aria?: ReadonlyArray<{ attr: string; value: { kind: 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>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Attribute</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}
|
||||
{#each ariaAttrs as a}
|
||||
<tr><td class="name">{a.attr}</td><td class="type">-</td><td>{a.value.kind}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div data-uix-subsection-head>Events</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target</th></tr></thead>
|
||||
<tbody>
|
||||
{#each eventsDecl as action}
|
||||
<tr>
|
||||
<td class="name">{action.name}</td>
|
||||
<td class="type">{action.semantic.family}</td>
|
||||
<td>{action.semantic.verb ?? '-'}</td>
|
||||
<td>{action.semantic.sequence ?? 'pre'}</td>
|
||||
<td>{typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'}</td>
|
||||
<td>{action.target}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Timeline emits <code>emerge-reveal</code> when an item appends into a live feed. Its intent
|
||||
comes from the item (affirm = a good event landed, threat = a failure surfaced). Play it on
|
||||
the last entry's marker.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Intent</th><th>Target</th><th>Play</th></tr></thead>
|
||||
<tbody>
|
||||
{#each eventsDecl as action}
|
||||
<tr>
|
||||
<td class="name">{action.name}</td>
|
||||
<td class="type">{action.semantic.family}</td>
|
||||
<td>{action.semantic.verb ?? '-'}</td>
|
||||
<td>{typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'}</td>
|
||||
<td>{action.target}</td>
|
||||
<td>
|
||||
<button
|
||||
data-uix-play
|
||||
onclick={() => {
|
||||
const items = stageRef?.querySelectorAll('[data-timeline-item]');
|
||||
const target = (items?.[items.length - 1] ?? stageRef) as HTMLElement | null;
|
||||
if (!target) return;
|
||||
void uix.events?.emit({
|
||||
name: action.name,
|
||||
family: action.semantic.family,
|
||||
intent: 'affirm',
|
||||
target
|
||||
});
|
||||
}}
|
||||
>
|
||||
▶ play
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Selectors live in <code>src/uix/eidos/components/timeline/timeline.css</code>. Status drives
|
||||
the marker treatment; intent overrides the hue; the connector renders as a logical border.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">[data-timeline-item][data-status]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-timeline-item][data-intent]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-timeline][data-live]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-timeline][data-orientation]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-timeline][data-align]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-timeline][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-timeline][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
|
||||
<tr><td class="name">[data-timeline-connector][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></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">Structure</td><td>An ordered <code><ol></code> with <code>role="list"</code>; each entry is an <code><li></code>.</td></tr>
|
||||
<tr><td class="name">Active item</td><td>The current item exposes <code>aria-current="step"</code>.</td></tr>
|
||||
<tr><td class="name">Marker / connector</td><td>Decorative — <code>aria-hidden="true"</code>. The list text carries meaning.</td></tr>
|
||||
<tr><td class="name">Label</td><td>The list has a translated <code>aria-label</code> (override via <code>ariaLabel</code>).</td></tr>
|
||||
<tr><td class="name">Reduced motion</td><td>The current-marker pulse and the live-feed reveal are disabled under <code>prefers-reduced-motion</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue