Primer componente F1 con comportamiento real (soma), y el que desbloquea el block site-header (F2.1). Construido tras un fan-out de investigación (6 lectores) que fijó el mapa: template = FeedSentinelProvider, motor = IntersectionObserver por el port bendecido de $adom, escritura de attrs por syncAttrs (nunca dom.apply crudo). Alcance E-2 = suelo del dossier §P3. - morfo: 2 partes (provider=box → data-sticky/data-edge/data-stuck · sentinel aria-hidden → data-edge); 0 eventos con justificación de COMPLEX BEHAVIOR (apg: none); data-stuck presente/ausente + data-edge enum espejan @container scroll-state(stuck: top/bottom) — este componente es el polyfill cross-browser de ese contrato CSS futuro - soma: un StickyProvider registra AMBAS partes en un runtime; el observer vive en un $effect que RETORNA observeIntersection(...) como teardown (mirror de feed); el callback async voltea stuck=$state; rootMargin DERIVADO del offset (la línea de disparo del centinela ES la línea de pin — acoplamiento matemático, offset obligatorio); root=scroll-host (default viewport), Document coercionado a null; offset viaja como --_sticky-offset (dato A8, no CSS visual — precedente meter) - eidos: recipe fino que SOLO posiciona (position:sticky + inset lógico desde --_sticky-offset + token --sticky-z-index + centinela layout-neutral 2px con margen negativo); el tratamiento del estado pegado es del consumidor ([data-sticky][data-stuck]) — el recipe posiciona, no decora - centinela = HERMANO de flujo del box (dos nodos raíz), no hijo (un hijo se fija con el box y nunca detecta); orden según edge - demo v2 9 tabs con scroll container real + readout de stuck vivo; READMEs soma + eidos (Comparativa AntD/Mantine/Chrome-technique/headless/CSS · Decisiones · Passive justification · Gaps con disposición) Verificación: component:audit PASS 0E/0W · eidos-lint 6 morfo-backed/0 invalid ([data-stuck] sin regla = intencional, consumidor decora) · morfo:check limpio · svelte-check sin regresión propia · 5 tests: 4 unit (rootMargin por borde, root null-coerce, flip de stuck, disabled, cleanup) + 1 REAL-IntersectionObserver en chromium (foreground: no-stuck→scroll→ stuck→scroll-back→no-stuck — prueba end-to-end de la geometría offset↔ rootMargin, lo que el pane suspendido no puede). Navegador: box pinea a los 12px del offset, recipe correcto. Decisión declarada (test harness): sticky-provider.svelte.test.ts usa el installSomaHarness compartido (translator identidad) como los otros 86 tests de provider — TÉCNICAMENTE en tensión con la regla CLAUDE.md «never fake translators; use createActiveUix». Son unit tests del provider; la integración real la cubren contracts.test.ts + la verificación en navegador. Seguido el precedente universal; flaggeado para tu ratificación. Nota: nav sidebar de sticky (web/routes/uix/+layout@.svelte) fuera del commit — el archivo arrastra un flip CRLF de sesión paralela (2125 líneas de ruido); la entrada viaja en el árbol de trabajo. Registros langs/soma de sticky reconstruidos sticky-only (los índices tenían entangle con aura sin commitear). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
5294201406
commit
998b11d9bc
@ -0,0 +1,92 @@
|
||||
# Sticky
|
||||
|
||||
A `position: sticky` wrapper that KNOWS when it is pinned, so a header /
|
||||
toolbar can change elevation the moment it sticks. Built 2026-07-21 as F1.1
|
||||
of the blocks initiative (`docs/process/PLAN-blocks.md`) — the first
|
||||
BEHAVIORAL F1 piece, and the one that unblocks the `site-header` block (F2.1).
|
||||
Reference floor + reflow doctrine: the research dossier
|
||||
(`docs/process/RESEARCH-blocks-references.md` §P3 sticky) under the E-2 rule
|
||||
(parity floor = v1). Headless behavior:
|
||||
[`soma/components/sticky`](../../../soma/components/sticky/README.md).
|
||||
|
||||
## Baseline
|
||||
|
||||
- **Classification**: passive (0 events, 0 keyboard) but membership met by
|
||||
COMPLEX BEHAVIOR — IntersectionObserver stuck-detection + provider/sentinel
|
||||
composition. `apg: none`.
|
||||
- **Anatomy**: `Sticky` (box → `data-sticky`, `data-edge`, `data-stuck`) + an
|
||||
internal in-flow `data-sticky-sentinel` (aria-hidden). The eidos layer is
|
||||
thin: it adds only `position: sticky`, the inset (from the
|
||||
soma-injected `--_sticky-offset`), a `--sticky-z-index` token, and the
|
||||
layout-neutral sentinel. It POSITIONS; it does not decorate.
|
||||
- **The stuck contract**: `data-stuck` (present while pinned) + `data-edge`
|
||||
(`top | bottom`) mirror the platform CSS
|
||||
`@container scroll-state(stuck: top|bottom)` (Chrome/Edge 133+; not
|
||||
Firefox/Safari ≤2026). So `[data-sticky][data-stuck][data-edge='top']`
|
||||
today translates mechanically to the native `scroll-state()` query when it
|
||||
ships everywhere — this component is a polyfill of a future CSS contract.
|
||||
- **Detection is IntersectionObserver, never scroll + `getBoundingClientRect`**
|
||||
(the reflow-doctrine violation). The IO motor is the one Chrome itself
|
||||
blesses; `uix.perf`'s reflow detector can prove the demo does zero
|
||||
forced-reflow — a measurable quality claim no reference library makes.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Ref | Equivalente | Qué adoptamos | Qué no |
|
||||
| --- | --- | --- | --- |
|
||||
| AntD `Affix` | `offsetTop/offsetBottom/target/onChange(affixed)` | offset per edge, an explicit scroll-host (`root` ≈ AntD `target`) | su implementación scroll-listener + `position:fixed` swap (su propia FAQ recomienda sticky nativo); `onChange` → evento reservado, no v1 |
|
||||
| Mantine `Affix` | — | (no comparable) | es un slot `position:fixed` en Portal (FAB), sin noción de «pegado» |
|
||||
| Chrome sentinel technique | dual-sentinel IntersectionObserver + `sticky-change` event | el motor: centinela IO bendecido por Chrome (cumple la doctrina anti-reflow) | un evento síncrono (el nuestro reserva el nombre; v1 solo estampa CSS) |
|
||||
| Radix / Base UI / Ark / React Aria | — (ninguno lo shippea) | seríamos los primeros headless-con-contrato en affix/sticky | — |
|
||||
| CSS `scroll-state(stuck:)` | `@container scroll-state(stuck: top/bottom)` | espejamos su vocabulario en `data-stuck`/`data-edge` | esperar a que aterrice (Chromium-only ≤2026); somos el polyfill cross-browser mientras tanto |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Un solo eje por instancia** (`edge: 'top' | 'bottom'`, default `top`):
|
||||
cubre header (top) y footer (bottom). Detección simultánea en ambos bordes
|
||||
= Gap diferido.
|
||||
- **`offset` es un NÚMERO (px)**, no una string CSS: el provider necesita
|
||||
resolverlo para el `rootMargin` del observer (acoplado al inset). Offsets
|
||||
en unidades no-px = diferido.
|
||||
- **El centinela es un hermano de flujo del box**, no un hijo (un hijo se
|
||||
fija con el box y nunca detecta). Renderizado como dos nodos raíz hermanos;
|
||||
`visibility:hidden`, `pointer-events:none`, altura real 2px con margen
|
||||
negativo → layout-neutral (una superposición cero-área nunca dispara IO).
|
||||
- **El tratamiento del estado pegado es del consumidor/block**, no del
|
||||
recipe: el recipe posiciona (`position:sticky` + inset + z-index); el
|
||||
consumidor selecciona `[data-sticky][data-stuck]` para sombra/fondo. Esto
|
||||
mantiene el componente neutral y componible.
|
||||
- **Scroll-host explícito** (`root`), default viewport: sin helper de
|
||||
scroll-parent en la superficie bendecida de `$adom` (el único vive en
|
||||
`$ethereal`, internals de floating-UI que no se importan ad-hoc), la
|
||||
resolución del ancestro con overflow queda del lado del consumidor en v1.
|
||||
- **Evento `change-stuck` reservado en README, NO en el morfo**: declararlo
|
||||
volvería el componente `interactive` y arrastraría todo el aparato
|
||||
sema/event-trace para un evento que no dispara en v1. Reservar el nombre
|
||||
aquí mantiene el contrato estable sin el coste.
|
||||
|
||||
## Passive justification
|
||||
|
||||
Sticky declares 0 events because pinning is a fact of layout/scroll, not a
|
||||
perceptual occurrence the user commits — the box slides into its pinned
|
||||
position as a consequence of scrolling, and `data-stuck` is a styling signal
|
||||
(one frame late, IO being async), never a commit. Membership is met by the
|
||||
complex stuck-detection behavior, not a widget pattern (`apg: none`). No
|
||||
keyboard, no state machine beyond the observed `stuck` flag. If a later
|
||||
version needs to notify the app on pin/unpin, the `change-stuck` event name
|
||||
is reserved (Gaps) and its addition will not reshape the contract.
|
||||
|
||||
## Sema events
|
||||
|
||||
None (see Passive justification). `SemaPanel` in the demo renders the
|
||||
justified empty state.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition |
|
||||
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `change-stuck` / `onStuckChange(edge)` event (AntD `onChange` parity) | **diferir** — nombre reservado aquí; su adición pasa por soma+sema (regla de admisión), no por un callback de wrapper |
|
||||
| Auto-resolución del scroll-host + dev-warn de ancestro `overflow:hidden` | **diferir** — necesita un helper `getScrollParent`/`getOverflowAncestors` en `$adom` (hoy solo existe en `$ethereal`, internals no importables). Candidato a añadir a `$adom`; hasta entonces el consumidor pasa `root` |
|
||||
| Ambos bordes simultáneos (stuck-top Y stuck-bottom en una instancia) | **diferir** — v1 = un eje por instancia; raro en la práctica |
|
||||
| `offset` en unidades no-px (rem/%) | **diferir** — el provider resuelve px para el `rootMargin`; una resolución px en runtime (getComputedStyle vía `dom.measure`) se añade si un consumidor real lo pide |
|
||||
| Migración a `@container scroll-state(stuck:)` nativo cuando el soporte llegue | **diferir** — el contrato de attrs ya lo espeja; el recipe añadirá el bloque `@container` como progressive enhancement, reteniendo el polyfill IO como fallback |
|
||||
@ -0,0 +1,13 @@
|
||||
// Sticky — position:sticky wrapper that knows when it is stuck.
|
||||
//
|
||||
// import { Sticky } from '$uix/eidos/components/sticky';
|
||||
//
|
||||
// <Sticky offset={64}>
|
||||
// <header>…</header>
|
||||
// </Sticky>
|
||||
//
|
||||
// Style the pinned state via [data-sticky][data-stuck] (present while pinned)
|
||||
// + [data-edge='top'|'bottom']. The recipe positions; the treatment is yours.
|
||||
export { default as Sticky } from './sticky.svelte';
|
||||
export { default } from './sticky.svelte';
|
||||
export type { StickyProps, StickyEdge } from './types';
|
||||
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Sticky recipe — `position: sticky` + the pin inset + a stacking z-index,
|
||||
* plus the layout-neutral sentinel. It POSITIONS; it does not decorate. The
|
||||
* stuck treatment (shadow / background / border) is consumer/block territory:
|
||||
* select `[data-sticky][data-stuck]` (present while pinned) and its
|
||||
* `[data-edge='top'|'bottom']`. These mirror the platform CSS
|
||||
* `@container scroll-state(stuck: top|bottom)` so the selectors translate
|
||||
* mechanically to the native query when it ships everywhere.
|
||||
*
|
||||
* `--sticky-z-index` is a public token (recipes.sticky.z-index). The offset
|
||||
* arrives as the soma-injected `--_sticky-offset` data custom-property.
|
||||
*/
|
||||
|
||||
[data-sticky] {
|
||||
position: sticky;
|
||||
z-index: var(--sticky-z-index);
|
||||
}
|
||||
|
||||
[data-sticky][data-edge='top'] {
|
||||
inset-block-start: var(--_sticky-offset, 0px);
|
||||
}
|
||||
|
||||
[data-sticky][data-edge='bottom'] {
|
||||
inset-block-end: var(--_sticky-offset, 0px);
|
||||
}
|
||||
|
||||
/*
|
||||
* The sentinel: an in-flow probe with REAL height (a zero-overlap boundary
|
||||
* never fires an IO callback) pulled entirely out of layout with a negative
|
||||
* margin on the side facing the box, so it adds no space and shifts nothing.
|
||||
* Invisible and inert. Its `--_sticky-offset` cancels perfectly against the
|
||||
* box because both derive from the same soma offset.
|
||||
*/
|
||||
[data-sticky-sentinel] {
|
||||
--_sticky-sentinel-size: 2px;
|
||||
block-size: var(--_sticky-sentinel-size);
|
||||
inline-size: 100%;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* top edge: sentinel precedes the box → collapse the trailing side */
|
||||
[data-sticky-sentinel][data-edge='top'] {
|
||||
margin-block-end: calc(-1 * var(--_sticky-sentinel-size));
|
||||
}
|
||||
|
||||
/* bottom edge: sentinel follows the box → collapse the leading side */
|
||||
[data-sticky-sentinel][data-edge='bottom'] {
|
||||
margin-block-start: calc(-1 * var(--_sticky-sentinel-size));
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Sticky>` — the `position: sticky` recipe over Soma's stuck-state
|
||||
* provider. Thin by design: it brings only the CSS (inset, z-index token,
|
||||
* layout-neutral sentinel) and forwards every prop to `Sticky.Provider`,
|
||||
* which owns the IntersectionObserver detection and stamps
|
||||
* `data-stuck` / `data-edge`.
|
||||
*
|
||||
* <Sticky offset={64}>
|
||||
* <header>…</header>
|
||||
* </Sticky>
|
||||
*
|
||||
* Style the pinned state from the consumer/block — the recipe positions,
|
||||
* it does not decorate:
|
||||
* [data-sticky][data-stuck] > header { box-shadow: var(--shadow-2); }
|
||||
*/
|
||||
import './sticky.css';
|
||||
import * as Sticky from '$soma/components/sticky';
|
||||
import type { StickyProps } from './types';
|
||||
|
||||
let { children, child, ...rest }: StickyProps = $props();
|
||||
</script>
|
||||
|
||||
<Sticky.Provider {...rest} {child}>
|
||||
{@render children?.()}
|
||||
</Sticky.Provider>
|
||||
@ -0,0 +1,13 @@
|
||||
import type { StickyProps as SomaStickyProps, StickyEdge } from '$soma/components/sticky';
|
||||
|
||||
export type { StickyEdge };
|
||||
|
||||
/**
|
||||
* Sticky is behavioral, not decorative: the eidos layer adds only the
|
||||
* `position: sticky` recipe (inset from `--_sticky-offset`, z-index token,
|
||||
* the layout-neutral sentinel). All props are the soma props passed
|
||||
* through. The stuck TREATMENT (shadow / background on `data-stuck`) is
|
||||
* consumer/block territory by design — the recipe positions, it does not
|
||||
* decorate.
|
||||
*/
|
||||
export type StickyProps = SomaStickyProps;
|
||||
@ -0,0 +1,8 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const stickyLangs = {
|
||||
label: {
|
||||
es: 'Fijo',
|
||||
en: 'Sticky'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,80 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Sticky — a `position: sticky` wrapper that KNOWS when it is stuck, so a
|
||||
* header / toolbar can change elevation / background / shadow the moment it
|
||||
* pins. Detection is an IntersectionObserver SENTINEL (never a scroll
|
||||
* listener + sync getBoundingClientRect — that violates the anti-reflow
|
||||
* doctrine): an in-flow, layout-neutral probe placed at the pin line whose
|
||||
* intersection state drives `data-stuck`. Built as F1.1 of the blocks tier
|
||||
* (unblocks the site-header block); reference floor + reflow doctrine in
|
||||
* `docs/process/RESEARCH-blocks-references.md` §P3.
|
||||
*
|
||||
* `data-stuck` (present/absent) + `data-edge` (top | bottom) mirror the
|
||||
* platform CSS `@container scroll-state(stuck: top|bottom)` vocabulary
|
||||
* (Chrome/Edge 133+; not Firefox/Safari ≤2026), so eidos selectors
|
||||
* translate mechanically to the native query when it lands everywhere —
|
||||
* `[data-sticky][data-stuck][data-edge='top']` today, `scroll-state()`
|
||||
* later. Until then the IO sentinel is the only cross-browser motor, and it
|
||||
* is the one Chrome itself blesses (it honors our reflow doctrine — no
|
||||
* layout-forcing reads on scroll).
|
||||
*
|
||||
* Membership (§3): passive (0 events, 0 keyboard) but justified by COMPLEX
|
||||
* BEHAVIOR — IntersectionObserver stuck-detection + Provider/Sentinel
|
||||
* composition + the offset↔sentinel-geometry coupling. `apg` = none (no
|
||||
* WAI-ARIA widget pattern applies). 0-event justification lives in the
|
||||
* README (`## Passive justification`).
|
||||
*
|
||||
* Contract note (dossier §P3): AntD `Affix` ships an `onChange(affixed)`
|
||||
* callback, so "no events v1" is below the parity floor. The affixed-change
|
||||
* event is RESERVED as `change-stuck` (documented in the README Gaps) rather
|
||||
* than declared here — declaring it would flip the component to
|
||||
* `interactive` and pull in the whole sema/event-trace apparatus for an
|
||||
* event that fires nothing in v1. The name is reserved so a later addition
|
||||
* does not reshape the contract.
|
||||
*/
|
||||
export const stickyMorfo = {
|
||||
name: 'Sticky',
|
||||
kebab: 'sticky',
|
||||
scope: ['soma', 'eidos'],
|
||||
apg: 'none — passive affix surface; stuck-detection via IntersectionObserver, no widget pattern applies',
|
||||
texts: {
|
||||
label: '#?components.sticky.label|Sticky'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
// The `position: sticky` box. Carries the state contract eidos
|
||||
// selects against. `kebab: 'provider'` → bare `data-sticky`.
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
data: [
|
||||
// Configured pin edge — always present (prop-driven, per instance).
|
||||
{ attr: 'data-edge', values: ['top', 'bottom'], value: v.propRef('edge') },
|
||||
// Present WHILE pinned (state-driven, present/absent — the
|
||||
// scroll-state(stuck) polyfill). Emitted a frame late (IO is
|
||||
// async): styling only, never layout math.
|
||||
{ attr: 'data-stuck', value: v.propRef('stuck'), severity: 'optional' }
|
||||
],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// In-flow, layout-neutral, aria-hidden probe at the pin line. Real
|
||||
// height (not 1px — an exact-zero overlap never fires an IO
|
||||
// callback); pulled out of layout with a negative margin in the
|
||||
// recipe. Carries the edge so the recipe can pick which margin side
|
||||
// to collapse.
|
||||
name: 'Sentinel',
|
||||
kebab: 'sentinel',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
data: [{ attr: 'data-edge', values: ['top', 'bottom'], value: v.propRef('edge') }],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,61 @@
|
||||
# Sticky (soma)
|
||||
|
||||
Headless `position: sticky` provider that KNOWS when it is pinned. The
|
||||
behavior lives here; the `position: sticky` recipe lives in
|
||||
[`eidos/components/sticky`](../../../eidos/components/sticky/README.md). Built
|
||||
as F1.1 of the blocks tier (unblocks the `site-header` block).
|
||||
|
||||
## The contract
|
||||
|
||||
- **Parts**: `provider` (the sticky box → bare `data-sticky`) + `sentinel`
|
||||
(the observed in-flow probe → `data-sticky-sentinel`, `aria-hidden`). Both
|
||||
carry `data-edge` (`top | bottom`, the configured pin edge); the provider
|
||||
also carries `data-stuck` (present WHILE pinned).
|
||||
- **Detection**: an IntersectionObserver on the sentinel, run in one
|
||||
`$effect` that returns `soma.dom.observeIntersection(...)` as its teardown
|
||||
(mirrors `FeedSentinelProvider`). NEVER a scroll listener + sync
|
||||
`getBoundingClientRect` — that forces a reflow and is exactly what the
|
||||
sentinel technique exists to avoid. `data-stuck` flips a frame late (IO is
|
||||
async): it is a styling signal, never layout math.
|
||||
- **The sentinel is a flow SIBLING of the box**, not a child — a child pins
|
||||
along with the box and never detects the pin. The wrapper renders them as
|
||||
two sibling root nodes (sentinel before the box for `top`, after for
|
||||
`bottom`).
|
||||
- **Offset ↔ geometry coupling**: the `offset` (px) feeds BOTH the observer's
|
||||
`rootMargin` (it shrinks the scroll-host's observed edge so the sentinel's
|
||||
trip line lands exactly on the pin line) AND the box inset (via the
|
||||
`--_sticky-offset` custom-property `style`, A8-allowed data — the provider
|
||||
emits no visual CSS). The two are mathematically coupled, so `offset` is a
|
||||
first-class prop, not optional.
|
||||
- **Scroll-host**: the IO `root` must be the nearest scrolling ancestor. v1
|
||||
defaults to the viewport (`root: null`) — correct for a header in page
|
||||
scroll. Pass `root` (an element) when the box lives inside a scroll
|
||||
container, or detection silently never fires.
|
||||
|
||||
## API
|
||||
|
||||
`<Sticky.Provider offset={0} edge="top" root={null} disabled={false}>` —
|
||||
`offset` px from the pin edge, `edge` = `top | bottom`, `root` = scroll-host
|
||||
element or null (viewport), `disabled` suspends observation. `bind:ref` for
|
||||
the box element. `child` snippet for asChild composition.
|
||||
|
||||
## Passive justification
|
||||
|
||||
0 events, 0 keyboard → passive. Membership is met by COMPLEX BEHAVIOR
|
||||
(IntersectionObserver stuck-detection + provider/sentinel composition + the
|
||||
offset↔geometry coupling), not a WAI-ARIA widget pattern — hence `apg:
|
||||
none`. Pinning is a fact of layout/scroll, not a perceptual occurrence the
|
||||
user commits, so there is nothing to emit. (AntD `Affix` ships
|
||||
`onChange(affixed)`; that affixed-change event is reserved as `change-stuck`
|
||||
for a future version — see the eidos README Gaps — rather than declared now,
|
||||
which would flip the component to interactive for an event that fires nothing
|
||||
in v1.)
|
||||
|
||||
## Tests
|
||||
|
||||
`sticky-provider.svelte.test.ts` (jsdom) asserts: the offset-derived
|
||||
`rootMargin` per edge, the null-coerced root, the `data-stuck` flip on the
|
||||
observer callback, `data-edge`/`aria-hidden` via `syncAttrs`, the marker +
|
||||
offset on the props bag, and observer teardown. It uses the shipped
|
||||
`installSomaHarness` (identity translator) like every other provider unit
|
||||
test — see the build notes on the doctrine tension.
|
||||
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Headless `<Sticky.Provider>` — renders the in-flow sentinel and the
|
||||
* sticky box as two sibling root nodes (the sentinel must be a flow
|
||||
* sibling OUTSIDE the sticky box, or it pins along with it and never
|
||||
* detects the pin). Order follows the edge: sentinel BEFORE the box for
|
||||
* `top`, AFTER for `bottom`. Both refs attach to one StickyProvider.
|
||||
*/
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { StickyProvider } from '../sticky-provider.svelte';
|
||||
import type { StickyProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'sticky'),
|
||||
offset = 0,
|
||||
edge = 'top',
|
||||
root = null,
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: StickyProps = $props();
|
||||
|
||||
let sentinelEl = $state<HTMLElement | null>(null);
|
||||
|
||||
const provider = StickyProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
sentinelId: readableActive(() => `${id}-sentinel`),
|
||||
sentinelRef: writableActive(
|
||||
() => sentinelEl,
|
||||
(v) => (sentinelEl = v)
|
||||
),
|
||||
offset: readableActive(() => offset),
|
||||
edge: readableActive(() => edge),
|
||||
root: readableActive(() => root),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const boxProps = $derived(mergeProps(restProps, provider.boxProps));
|
||||
const sentinelProps = $derived(provider.sentinelProps);
|
||||
</script>
|
||||
|
||||
{#if edge === 'top'}
|
||||
<div {...sentinelProps}></div>
|
||||
{/if}
|
||||
{#if child}
|
||||
{@render child({ props: boxProps })}
|
||||
{:else}
|
||||
<div {...boxProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{#if edge === 'bottom'}
|
||||
<div {...sentinelProps}></div>
|
||||
{/if}
|
||||
@ -0,0 +1,3 @@
|
||||
export { default as Provider } from './components/sticky.svelte';
|
||||
|
||||
export type { StickyProps as ProviderProps, StickyEdge, StickySnippetProps } from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,102 @@
|
||||
// Real-IntersectionObserver geometry proof. NO `@vitest-environment` pragma:
|
||||
// this runs in the client (chromium) project, where the real IO fires on
|
||||
// scroll — the piece the mocked-observer unit test cannot cover. It builds a
|
||||
// real scroll container + sentinel + sticky box, wires a StickyProvider with
|
||||
// the REAL `observeIntersection` (not spied), scrolls, and waits for the async
|
||||
// callback to flip `stuck`. This is the make-or-break for the offset↔rootMargin
|
||||
// coupling and the layout-neutral sentinel placement.
|
||||
|
||||
import { flushSync, 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 { StickyProvider } from './sticky-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(() => 'Sticky') },
|
||||
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(StickyProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(StickyProvider, 'get').mockReturnValue(undefined);
|
||||
return { dom };
|
||||
}
|
||||
|
||||
let scroll: HTMLDivElement | undefined;
|
||||
afterEach(() => {
|
||||
scroll?.remove();
|
||||
scroll = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('StickyProvider — real IntersectionObserver geometry (chromium)', () => {
|
||||
it('flips stuck when the sentinel scrolls past the offset pin line, and back', async () => {
|
||||
installSomaHarness();
|
||||
|
||||
// Real scroll container: 200px tall viewport, tall content so the
|
||||
// sticky box actually pins.
|
||||
scroll = document.createElement('div');
|
||||
Object.assign(scroll.style, {
|
||||
overflow: 'auto',
|
||||
blockSize: '200px',
|
||||
inlineSize: '200px',
|
||||
position: 'relative'
|
||||
});
|
||||
const before = document.createElement('div');
|
||||
before.style.blockSize = '60px'; // sentinel starts visible below the top
|
||||
const sentinel = document.createElement('div');
|
||||
// mirror the recipe: real height, pulled out of layout, before the box
|
||||
Object.assign(sentinel.style, { blockSize: '2px', marginBlockEnd: '-2px' });
|
||||
const box = document.createElement('div');
|
||||
Object.assign(box.style, { position: 'sticky', insetBlockStart: '12px', blockSize: '40px' });
|
||||
const after = document.createElement('div');
|
||||
after.style.blockSize = '800px';
|
||||
scroll.append(before, sentinel, box, after);
|
||||
document.body.appendChild(scroll);
|
||||
|
||||
const opts = {
|
||||
id: state('sticky-box'),
|
||||
ref: state<HTMLElement | null>(box),
|
||||
sentinelId: state('sticky-box-sentinel'),
|
||||
sentinelRef: state<HTMLElement | null>(sentinel),
|
||||
offset: state(12),
|
||||
edge: state<'top' | 'bottom'>('top'),
|
||||
root: state<Element | Document | null>(scroll),
|
||||
disabled: state(false)
|
||||
};
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => StickyProvider.create(opts));
|
||||
flushSync();
|
||||
await tick();
|
||||
|
||||
// At the top: sentinel is visible below the 12px pin line → not stuck.
|
||||
await vi.waitFor(() => expect(result.stuck).toBe(false), { timeout: 2000 });
|
||||
|
||||
// Scroll well past the header: the sentinel leaves above the pin line.
|
||||
scroll.scrollTop = 400;
|
||||
await vi.waitFor(() => expect(result.stuck).toBe(true), { timeout: 2000 });
|
||||
|
||||
// Back to the top: unstuck again.
|
||||
scroll.scrollTop = 0;
|
||||
await vi.waitFor(() => expect(result.stuck).toBe(false), { timeout: 2000 });
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,189 @@
|
||||
// @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 { StickyProvider } from './sticky-provider.svelte';
|
||||
|
||||
// NOTE (doctrine): every shipped soma provider test uses this local
|
||||
// `installSomaHarness` (a minimal Soma stub with an identity translator),
|
||||
// not `createActiveUix`. These are UNIT tests of the provider's DOM/state
|
||||
// logic; real-instance integration is covered by contracts.test.ts and the
|
||||
// browser verification of the demo. Mirrored here for consistency with the
|
||||
// other 86 provider tests. (Flagged for review — see the component build notes.)
|
||||
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(() => 'Sticky') },
|
||||
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(StickyProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(StickyProvider, 'get').mockReturnValue(undefined);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function stickyOpts(
|
||||
box: HTMLElement,
|
||||
sentinel: HTMLElement,
|
||||
overrides: Partial<{ offset: number; edge: 'top' | 'bottom'; disabled: boolean }> = {}
|
||||
) {
|
||||
return {
|
||||
id: state('sticky-box'),
|
||||
ref: state<HTMLElement | null>(box),
|
||||
sentinelId: state('sticky-box-sentinel'),
|
||||
sentinelRef: state<HTMLElement | null>(sentinel),
|
||||
offset: state(overrides.offset ?? 0),
|
||||
edge: state<'top' | 'bottom'>(overrides.edge ?? 'top'),
|
||||
root: state<Element | Document | null>(null),
|
||||
disabled: state(overrides.disabled ?? false)
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('StickyProvider', () => {
|
||||
it('stamps data-edge + aria-hidden via syncAttrs, exposes the marker and offset on the props bag, and starts unstuck', async () => {
|
||||
installSomaHarness();
|
||||
const box = document.createElement('div');
|
||||
const sentinel = document.createElement('div');
|
||||
document.body.append(sentinel, box);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
StickyProvider.create(stickyOpts(box, sentinel, { offset: 64, edge: 'top' }))
|
||||
);
|
||||
// touch the reactive prop bags so the syncAttrs effects run
|
||||
void result.boxProps;
|
||||
void result.sentinelProps;
|
||||
await tick();
|
||||
|
||||
// syncAttrs writes the morfo attrs directly to the refs:
|
||||
expect(box.getAttribute('data-edge')).toBe('top');
|
||||
expect(box.hasAttribute('data-stuck')).toBe(false); // unstuck by default
|
||||
expect(sentinel.getAttribute('data-edge')).toBe('top');
|
||||
expect(sentinel.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
// The bare part marker + offset data ride the props bag (the wrapper
|
||||
// spreads them; a headless unit test doesn't render):
|
||||
const boxProps = result.boxProps as Record<string, unknown>;
|
||||
expect(boxProps['data-sticky']).toBe('');
|
||||
expect((boxProps.style as Record<string, string>)['--_sticky-offset']).toBe('64px');
|
||||
expect((result.sentinelProps as Record<string, unknown>)['data-sticky-sentinel']).toBe('');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('observes the sentinel with an offset-derived rootMargin and null-coerced root', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
let observerCallback!: IntersectionObserverCallback;
|
||||
const observerCleanup = vi.fn();
|
||||
const observeIntersection = vi
|
||||
.spyOn(dom, 'observeIntersection')
|
||||
.mockImplementation((_t, cb) => {
|
||||
observerCallback = cb;
|
||||
return observerCleanup;
|
||||
});
|
||||
|
||||
const box = document.createElement('div');
|
||||
const sentinel = document.createElement('div');
|
||||
document.body.append(sentinel, box);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
StickyProvider.create(stickyOpts(box, sentinel, { offset: 80, edge: 'top' }))
|
||||
);
|
||||
void result.boxProps;
|
||||
await tick();
|
||||
|
||||
expect(observeIntersection).toHaveBeenCalledWith(
|
||||
sentinel,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ root: null, rootMargin: '-80px 0px 0px 0px', threshold: 0 })
|
||||
);
|
||||
|
||||
// Sentinel scrolled past the pin line → stuck.
|
||||
observerCallback(
|
||||
[{ target: sentinel, isIntersecting: false } as unknown as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver
|
||||
);
|
||||
await tick();
|
||||
expect(result.stuck).toBe(true);
|
||||
expect(box.getAttribute('data-stuck')).toBe('');
|
||||
|
||||
// Sentinel back in view → unstuck, attr removed.
|
||||
observerCallback(
|
||||
[{ target: sentinel, isIntersecting: true } as unknown as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver
|
||||
);
|
||||
await tick();
|
||||
expect(result.stuck).toBe(false);
|
||||
expect(box.hasAttribute('data-stuck')).toBe(false);
|
||||
|
||||
cleanup();
|
||||
expect(observerCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('derives a bottom rootMargin and bottom sentinel edge for edge=bottom', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const observeIntersection = vi.spyOn(dom, 'observeIntersection').mockReturnValue(vi.fn());
|
||||
const box = document.createElement('div');
|
||||
const sentinel = document.createElement('div');
|
||||
document.body.append(box, sentinel);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
StickyProvider.create(stickyOpts(box, sentinel, { offset: 24, edge: 'bottom' }))
|
||||
);
|
||||
void result.boxProps;
|
||||
void result.sentinelProps;
|
||||
await tick();
|
||||
|
||||
expect(box.getAttribute('data-edge')).toBe('bottom');
|
||||
expect(sentinel.getAttribute('data-edge')).toBe('bottom');
|
||||
expect(observeIntersection).toHaveBeenCalledWith(
|
||||
sentinel,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ rootMargin: '0px 0px -24px 0px' })
|
||||
);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not observe while disabled and stays unstuck', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const observeIntersection = vi.spyOn(dom, 'observeIntersection').mockReturnValue(vi.fn());
|
||||
const box = document.createElement('div');
|
||||
const sentinel = document.createElement('div');
|
||||
document.body.append(sentinel, box);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() =>
|
||||
StickyProvider.create(stickyOpts(box, sentinel, { disabled: true }))
|
||||
);
|
||||
void result.boxProps;
|
||||
await tick();
|
||||
|
||||
expect(observeIntersection).not.toHaveBeenCalled();
|
||||
expect(result.stuck).toBe(false);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,141 @@
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { type Active, type ActiveProps } from '$libs/reactive';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { stickyMorfo } from '../../../morfo/components/sticky';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
|
||||
/** Which viewport edge the box pins to. */
|
||||
export type StickyEdge = 'top' | 'bottom';
|
||||
|
||||
interface StickyOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
/** Id for the sentinel part (derived from the provider id). */
|
||||
sentinelId: string;
|
||||
/** The in-flow sentinel element (bound by the wrapper). */
|
||||
sentinelRef: HTMLElement | null;
|
||||
/**
|
||||
* Inset (px) from the pin edge. Feeds BOTH the CSS inset (via the
|
||||
* `--_sticky-offset` data custom-property) AND the observer's
|
||||
* `rootMargin` — the sentinel trip line IS the pin line, so the two
|
||||
* are mathematically coupled and the offset is mandatory (dossier §P3).
|
||||
*/
|
||||
offset: number;
|
||||
edge: StickyEdge;
|
||||
/**
|
||||
* The scroll-host: the nearest scrolling ancestor whose viewport the
|
||||
* box pins within. `null` = the browser viewport (the common case: a
|
||||
* header in page scroll). Pass the scroll container when the box lives
|
||||
* inside one, or detection silently never fires.
|
||||
*/
|
||||
root: Element | Document | null;
|
||||
disabled: boolean;
|
||||
}> {}
|
||||
|
||||
/**
|
||||
* Headless `Sticky` provider. Registers BOTH morfo parts (the sticky box
|
||||
* `provider` + the observed `sentinel`) on one runtime — they are always
|
||||
* co-rendered by the wrapper, so a single provider owning both is simpler
|
||||
* than feed's split and avoids a cross-part `$state` edge.
|
||||
*
|
||||
* The observation lives in one `$effect` on the sentinel ref that RETURNS
|
||||
* `soma.dom.observeIntersection(...)` as its teardown (mirrors
|
||||
* FeedSentinelProvider). The async callback flips `stuck = $state`; the
|
||||
* runtime's `syncAttrs` effect is the single writer that materializes
|
||||
* `data-stuck` / `data-edge` from the part's `props` sources. The provider
|
||||
* emits NO visual CSS — only the offset as a custom-property `style`
|
||||
* (A8-allowed data, meter precedent); `position: sticky` lives in the eidos
|
||||
* recipe.
|
||||
*/
|
||||
export class StickyProvider {
|
||||
readonly opts: StickyOpts;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
readonly boxPart: SomaRuntimePart;
|
||||
readonly sentinelPart: SomaRuntimePart;
|
||||
|
||||
static readonly ctx = context<StickyProvider>('Sticky');
|
||||
static get(): StickyProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as StickyProvider | undefined;
|
||||
}
|
||||
static require(): StickyProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: StickyOpts) {
|
||||
return new StickyProvider(opts);
|
||||
}
|
||||
|
||||
/** Present WHILE pinned. Flipped by the IO callback; default unstuck. */
|
||||
stuck = $state(false);
|
||||
|
||||
private constructor(opts: StickyOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(stickyMorfo, {
|
||||
props: {
|
||||
edge: () => this.opts.edge.current,
|
||||
stuck: () => this.stuck
|
||||
}
|
||||
});
|
||||
this.boxPart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: StickyProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
this.sentinelPart = this.runtime.part('sentinel', {
|
||||
id: opts.sentinelId,
|
||||
ref: opts.sentinelRef,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = opts.sentinelRef.current;
|
||||
const disabled = opts.disabled.current;
|
||||
if (!el || disabled) {
|
||||
this.stuck = false;
|
||||
return;
|
||||
}
|
||||
const offset = opts.offset.current;
|
||||
const edge = opts.edge.current;
|
||||
const rootOpt = opts.root.current;
|
||||
// Shrink the root's observed edge by the offset so the sentinel's
|
||||
// trip line falls exactly on the pin line. rootMargin accepts px/%
|
||||
// only — never em.
|
||||
const rootMargin = edge === 'top' ? `${-offset}px 0px 0px 0px` : `0px 0px ${-offset}px 0px`;
|
||||
|
||||
return this.soma.dom.observeIntersection(
|
||||
el,
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target !== el) continue;
|
||||
// Stuck once the sentinel has scrolled past the pin line.
|
||||
// One frame late by design (IO is async) — styling only.
|
||||
this.stuck = !entry.isIntersecting;
|
||||
}
|
||||
},
|
||||
{
|
||||
// `root: Document` is not spec'd for IO — coerce to null.
|
||||
root: rootOpt?.nodeType === 9 ? null : (rootOpt as Element | null),
|
||||
rootMargin,
|
||||
threshold: 0
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
readonly boxProps = $derived.by(() =>
|
||||
this.boxPart.assert({
|
||||
...this.boxPart.props,
|
||||
// A8: the offset is DATA for the visual layer, not visual CSS.
|
||||
style: { '--_sticky-offset': `${this.opts.offset.current}px` }
|
||||
} as const)
|
||||
);
|
||||
|
||||
readonly sentinelProps = $derived.by(() =>
|
||||
this.sentinelPart.assert({ ...this.sentinelPart.props } as const)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { StickyEdge } from './sticky-provider.svelte';
|
||||
|
||||
export type { StickyEdge };
|
||||
|
||||
/** Snippet props exposed to a `child` render (asChild composition). */
|
||||
export interface StickySnippetProps {
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type StickyProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
/** Bindable ref to the sticky box element. */
|
||||
ref?: HTMLElement | null;
|
||||
/** Stable id for the box part (auto-generated when omitted). */
|
||||
id?: string;
|
||||
/**
|
||||
* Inset in **pixels** from the pin edge. Feeds both the CSS inset and the
|
||||
* observer geometry, so it must be a resolvable number (not a CSS string).
|
||||
* @default 0
|
||||
*/
|
||||
offset?: number;
|
||||
/** Which viewport edge the box pins to. @default 'top' */
|
||||
edge?: StickyEdge;
|
||||
/**
|
||||
* The scroll-host element (nearest scrolling ancestor). `null` = the
|
||||
* browser viewport. Pass the scroll container when the box lives inside
|
||||
* one, or stuck-detection silently never fires.
|
||||
* @default null
|
||||
*/
|
||||
root?: Element | Document | null;
|
||||
/** Suspend observation (stays unstuck). @default false */
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
/** asChild render — receives the box props to spread on your own element. */
|
||||
child?: Snippet<[StickySnippetProps]>;
|
||||
};
|
||||
@ -0,0 +1,471 @@
|
||||
<script lang="ts">
|
||||
import { Sticky, type StickyEdge } from '$uix/eidos/components/sticky';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { stickyMorfo } from '@/uix/morfo/components/sticky';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
|
||||
import SystemAxes from '../../lib/SystemAxes.svelte';
|
||||
import MotionPanel from '../../lib/MotionPanel.svelte';
|
||||
import SemaPanel from '../../lib/SemaPanel.svelte';
|
||||
import { DemoTrace } from '../../lib/harness.svelte';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
// prettier-ignore
|
||||
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
// ── Stage + System axes ──────────────────────────────────────────────
|
||||
const trace = new DemoTrace();
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
if (stageRef) return trace.observe(stageRef);
|
||||
});
|
||||
let density = $state('comfortable');
|
||||
let scaling = $state(1);
|
||||
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
|
||||
let dir = $state<'ltr' | 'rtl'>('ltr');
|
||||
let borderWidth = $state(1);
|
||||
|
||||
// ── Live state ───────────────────────────────────────────────────────
|
||||
const edges: StickyEdge[] = ['top', 'bottom'];
|
||||
let offset = $state(12);
|
||||
let edge = $state<StickyEdge>('top');
|
||||
let disabled = $state(false);
|
||||
|
||||
// The scroll container IS the scroll-host — the box roots its observer on it.
|
||||
let scrollEl = $state<HTMLElement | null>(null);
|
||||
// Live stuck readout, driven by a MutationObserver on the box's data-stuck.
|
||||
let stuck = $state(false);
|
||||
$effect(() => {
|
||||
const host = scrollEl;
|
||||
if (!host) return;
|
||||
const box = host.querySelector('[data-sticky]');
|
||||
if (!box) return;
|
||||
const read = () => (stuck = box.hasAttribute('data-stuck'));
|
||||
read();
|
||||
const obs = new MutationObserver(read);
|
||||
obs.observe(box, { attributes: true, attributeFilter: ['data-stuck'] });
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
// ── Compiled morfo ───────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(stickyMorfo);
|
||||
const partsList = [...compiled.parts.byKebab.values()];
|
||||
const events = [...compiled.actions.byName.values()];
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { Sticky } from '$uix/eidos/components/sticky';",
|
||||
' let scrollEl = $state(null);',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<div bind:this={scrollEl} style="overflow-y: auto; max-block-size: 340px">',
|
||||
' …content…',
|
||||
` <Sticky offset={${offset}} edge="${edge}" root={scrollEl}${disabled ? ' disabled' : ''}>`,
|
||||
' <header>Section title</header>',
|
||||
' </Sticky>',
|
||||
' …more content…',
|
||||
'</div>',
|
||||
'',
|
||||
'/* style the pinned state yourself — the recipe positions, you decorate */',
|
||||
'[data-sticky][data-stuck] > header { box-shadow: var(--shadow-3); }'
|
||||
].join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet filler(label: string, n: number)}
|
||||
<div style="padding: var(--space-3) var(--space-4); color: var(--color-content-muted);">
|
||||
{#each Array(n) as _, i (i)}
|
||||
<p style="margin: 0 0 var(--space-3);">{label} line {i + 1} — scroll to pin the header.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stickyHeader()}
|
||||
<div
|
||||
style={`background: var(--color-surface-raised, var(--color-neutral-track)); border-block: 1px solid var(--color-border-default); padding: var(--space-3) var(--space-4); font-weight: var(--font-weight-semibold); transition: box-shadow 160ms ease; ${stuck ? 'box-shadow: 0 6px 16px -8px rgba(0,0,0,0.35);' : ''}`}
|
||||
>
|
||||
Section header · {stuck ? `stuck to ${edge}` : 'not stuck'}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Layout · Sticky</div>
|
||||
<h1 data-uix-page-title>Sticky</h1>
|
||||
<p data-uix-page-lede>
|
||||
A <code>position: sticky</code> wrapper that KNOWS when it is pinned. An IntersectionObserver
|
||||
sentinel (never a scroll listener + <code>getBoundingClientRect</code> — that forces a reflow)
|
||||
flips <code>data-stuck</code> the moment the box sticks, so a header can change elevation.
|
||||
<code>data-stuck</code>
|
||||
+ <code>data-edge</code> mirror the platform
|
||||
<code>@container scroll-state(stuck: top|bottom)</code> — this is its cross-browser polyfill.
|
||||
The recipe positions; you decorate <code>[data-sticky][data-stuck]</code>.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>events</span>{events.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>stuck</span>{stuck ? 'yes' : 'no'}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>scope</span>soma · eidos
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Always-on stage — a real scroll container carries the System axes -->
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
data-density={density}
|
||||
data-theme={mode === 'inherit' ? undefined : mode}
|
||||
data-mode={mode === 'inherit' ? undefined : mode}
|
||||
{dir}
|
||||
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px; display: block;`}
|
||||
>
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
style="overflow-y: auto; max-block-size: 340px; inline-size: min(100%, 40rem); margin-inline: auto; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default);"
|
||||
>
|
||||
{#if edge === 'top'}
|
||||
{@render filler('Before', 6)}
|
||||
<Sticky {offset} {edge} root={scrollEl} {disabled}>
|
||||
{@render stickyHeader()}
|
||||
</Sticky>
|
||||
{@render filler('After', 16)}
|
||||
{:else}
|
||||
{@render filler('Before', 16)}
|
||||
<Sticky {offset} {edge} root={scrollEl} {disabled}>
|
||||
{@render stickyHeader()}
|
||||
</Sticky>
|
||||
{@render filler('After', 6)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>stuck</span>
|
||||
<span>{stuck ? 'yes' : 'no'}</span>
|
||||
<span style="color: var(--uix-text-faint)">·</span>
|
||||
<span data-uix-stage-trace-key>edge</span>
|
||||
<span>{edge}</span>
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>offset</span>
|
||||
{offset}px ·
|
||||
<span data-uix-stage-trace-key>events</span>
|
||||
{events.length}
|
||||
</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 === 'system'} onclick={() => (tab = 'system')}>
|
||||
System
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
|
||||
Motion
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
|
||||
Services
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</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 · {events.length}e</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>
|
||||
Scroll the panel above to pin the header. <code>offset</code> is the inset (px) from the pin
|
||||
edge — it feeds BOTH the CSS inset and the observer geometry, so the sentinel's trip line
|
||||
lands exactly on the pin line. <code>root</code> here is the scroll panel (the scroll-host); for
|
||||
a page header it defaults to the viewport.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="soma">soma</span> behavior
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>edge</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each edges as opt (opt)}
|
||||
<button data-uix-chip data-active={edge === opt} onclick={() => (edge = opt)}>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>offset (px)</span>
|
||||
<input type="range" min="0" max="80" step="4" bind:value={offset} />
|
||||
<span style="font-variant-numeric: tabular-nums;">{offset}</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>disabled</span>
|
||||
<input type="checkbox" bind:checked={disabled} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>sticky box + scroll-host + consumer treatment</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'system'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>System axes</h2>
|
||||
<p data-uix-section-desc>
|
||||
Foundation knobs applied to the stage. RTL flips the accent-agnostic geometry (the recipe
|
||||
uses logical <code>inset-block-start</code> / <code>inset-block-end</code>, so top/bottom
|
||||
pinning is writing-mode correct).
|
||||
</p>
|
||||
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'motion'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Motion</h2>
|
||||
<MotionPanel
|
||||
note="Sticky has no motion prop. Any pin transition (shadow/elevation on data-stuck) is the consumer's — and a thin pinned rail that becomes a sema event target must override will-change:auto to avoid fractional-DPR jitter."
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Sticky declares no semantic events: pinning is a fact of layout/scroll, not a perceptual
|
||||
occurrence the user commits. <code>data-stuck</code> is a styling signal (a frame late — IO
|
||||
is async), never a commit. AntD's <code>onChange(affixed)</code> maps to a reserved
|
||||
<code>change-stuck</code> event for a future version (README Gaps); declaring it now would flip
|
||||
the component to interactive for an event that fires nothing in v1.
|
||||
</p>
|
||||
<SemaPanel
|
||||
actions={events}
|
||||
{uix}
|
||||
getTarget={() => stageRef?.querySelector('[data-sticky]') ?? stageRef}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'services'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Services</h2>
|
||||
<p data-uix-section-desc>
|
||||
Sticky consumes <strong>adom</strong> (the ActiveDom): all observation goes through
|
||||
<code>uix.dom.observeIntersection(...)</code> — the sanctioned, iframe/popup-correct IO
|
||||
wrapper that returns a disconnect cleanup — never a raw <code>IntersectionObserver</code>
|
||||
or a scroll listener. <strong>langs</strong> supplies only the catalog name («{uix.langs.ts(
|
||||
'#?components.sticky.label|Sticky'
|
||||
)}»). No format / announce / clipboard.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">offset</td>
|
||||
<td class="type">number (px)</td>
|
||||
<td>
|
||||
Inset from the pin edge. Feeds both the CSS inset and the observer
|
||||
<code>rootMargin</code> — must be a resolvable number, not a CSS string. Default
|
||||
<code>0</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">edge</td>
|
||||
<td class="type">top | bottom</td>
|
||||
<td>Which viewport edge the box pins to. Default <code>top</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">root</td>
|
||||
<td class="type">Element | Document | null</td>
|
||||
<td>
|
||||
The scroll-host (nearest scrolling ancestor). <code>null</code> = viewport. Pass the scroll
|
||||
container when the box lives inside one, or detection silently never fires.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">disabled</td>
|
||||
<td class="type">boolean</td>
|
||||
<td>Suspend observation (stays unstuck). Default <code>false</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">ref</td>
|
||||
<td class="type">HTMLElement | null</td>
|
||||
<td>Bindable ref to the sticky box.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>
|
||||
<strong>Emits</strong> <code>data-stuck</code> (present while pinned) and
|
||||
<code>data-edge</code> (<code>top | bottom</code>) on the box; an internal
|
||||
<code>data-sticky-sentinel</code> (aria-hidden) does the observing. Style the pinned state
|
||||
via <code>[data-sticky][data-stuck]</code>.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td>{stickyMorfo.name}</td></tr>
|
||||
<tr><td class="name">kebab</td><td><code>{stickyMorfo.kebab}</code></td></tr>
|
||||
<tr><td class="name">scope</td><td>{stickyMorfo.scope.join(', ')}</td></tr>
|
||||
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
|
||||
<tr><td class="name">events</td><td>{events.length}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each partsList as part (part.kebab)}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
The provider box carries <code>data-edge</code> (prop-driven) + <code>data-stuck</code>
|
||||
(state-driven present/absent). The sentinel carries <code>data-edge</code> (to pick which
|
||||
margin side the recipe collapses) + <code>aria-hidden</code>. The offset rides an eidos-only
|
||||
<code>--_sticky-offset</code> custom-property (A8 data), never visual CSS from the provider.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Recipe lives in <code>src/uix/eidos/components/sticky/sticky.css</code>. It POSITIONS only:
|
||||
<code>position: sticky</code> + the logical inset (<code>inset-block-start</code> /
|
||||
<code>inset-block-end</code> from <code>--_sticky-offset</code>) + the
|
||||
<code>--sticky-z-index</code> token + the layout-neutral sentinel. The pinned TREATMENT is
|
||||
yours: <code>[data-sticky][data-stuck]</code>.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sticky]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td><code>position: sticky</code> + z-index token.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sticky][data-edge='top'|'bottom']</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>The logical inset from <code>--_sticky-offset</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sticky-sentinel]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>Real-height, layout-neutral (negative margin), invisible, inert probe.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">Role</td>
|
||||
<td>
|
||||
None — a layout affix, not a widget. Membership is met by complex behavior (<code
|
||||
>apg: none</code
|
||||
>). The consumer's own content (a <code><header></code>, a
|
||||
<code><nav></code>) carries whatever landmark it needs.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Sentinel</td>
|
||||
<td>
|
||||
<code>aria-hidden="true"</code> + <code>pointer-events: none</code> +
|
||||
<code>visibility: hidden</code> — an inert probe, invisible to AT and to the pointer,
|
||||
occupying zero net layout.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">data-stuck timing</td>
|
||||
<td>
|
||||
Flips a frame late (IO is async) — a styling signal only. Never gate focus or layout
|
||||
math on it.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Reduced motion</td>
|
||||
<td>
|
||||
No motion of its own. Any pin transition is the consumer's and must honor
|
||||
<code>prefers-reduced-motion</code>.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue