diff --git a/src/uix/eidos/components/link-preview/index.ts b/src/uix/eidos/components/link-preview/index.ts new file mode 100644 index 000000000..bc280ac0a --- /dev/null +++ b/src/uix/eidos/components/link-preview/index.ts @@ -0,0 +1,53 @@ +// LinkPreview — eidos compound API. +// +// import { LinkPreview } from '$uix/eidos/components/link-preview'; +// +// +// +// hover for preview +// +// +// +// +//

Preview title

+//

Short description …

+//
+//
+//
+// +// Soma owns the hover-bridge SafePolygon, open/close timing +// (`openDelay` 700ms / `closeDelay` 300ms), floating-layer positioning, +// dismissal, and `data-state` / `data-side`. Eidos paints the anchor +// trigger and the floating content panel. +// +// Trigger is intentionally an `` (navigational) — for a button- +// triggered hover-card use `` with `openDelay` instead. +import LinkPreviewComponent from './link-preview.svelte'; +import Trigger from './link-preview-trigger.svelte'; +import Content from './link-preview-content.svelte'; +import Arrow from './link-preview-arrow.svelte'; +import { Portal } from '$soma/components/internal'; + +type LinkPreviewNamespace = typeof LinkPreviewComponent & { + Trigger: typeof Trigger; + Portal: typeof Portal; + Content: typeof Content; + Arrow: typeof Arrow; +}; + +const LinkPreview = LinkPreviewComponent as LinkPreviewNamespace; +LinkPreview.Trigger = Trigger; +LinkPreview.Portal = Portal; +LinkPreview.Content = Content; +LinkPreview.Arrow = Arrow; + +export { LinkPreview }; +export default LinkPreview; + +export type { + LinkPreviewProps, + LinkPreviewTriggerProps as TriggerProps, + LinkPreviewContentProps as ContentProps, + LinkPreviewArrowProps as ArrowProps, + LinkPreviewSize +} from './types'; diff --git a/src/uix/eidos/components/link-preview/link-preview-arrow.svelte b/src/uix/eidos/components/link-preview/link-preview-arrow.svelte new file mode 100644 index 000000000..ae606b666 --- /dev/null +++ b/src/uix/eidos/components/link-preview/link-preview-arrow.svelte @@ -0,0 +1,16 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/link-preview/link-preview-content.svelte b/src/uix/eidos/components/link-preview/link-preview-content.svelte new file mode 100644 index 000000000..0b3754277 --- /dev/null +++ b/src/uix/eidos/components/link-preview/link-preview-content.svelte @@ -0,0 +1,29 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/link-preview/link-preview-trigger.svelte b/src/uix/eidos/components/link-preview/link-preview-trigger.svelte new file mode 100644 index 000000000..789ac0ab0 --- /dev/null +++ b/src/uix/eidos/components/link-preview/link-preview-trigger.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/link-preview/link-preview.css b/src/uix/eidos/components/link-preview/link-preview.css new file mode 100644 index 000000000..9b4fc5d0b --- /dev/null +++ b/src/uix/eidos/components/link-preview/link-preview.css @@ -0,0 +1,184 @@ +/* + * LinkPreview recipe — Radix HoverCard equivalent. The trigger is an + * `` (navigational anchor); on hover (after `openDelay`, default + * 700ms) the Content panel reveals next to the anchor with a SafePolygon + * bridging trigger and content so the pointer can travel without + * dismissing. + * + * Soma owns the floating math, dismissal, open/close timing and + * `data-state` / `data-side`. Eidos paints: + * [data-link-preview-trigger] → anchor styling (underline + hover/open color) + * [data-link-preview-content] → floating panel chrome (border, bg, padding) + * [data-link-preview-arrow] → arrow fill matching the panel bg + * + * Unlike Popover/Dialog, the Trigger is intentionally NOT a Button — + * link previews are anchors, so the recipe styles the `` directly + * (underline + accent color, not bordered button chrome). + */ + +/* ── Trigger ─────────────────────────────────────────────────────────── */ +[data-link-preview-trigger] { + color: var(--color-primary-text, var(--color-content-primary)); + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); + text-decoration-skip-ink: auto; + border-radius: var(--radius-sm); + outline: none; + transition: + color var(--duration-fast) var(--ease-default), + text-decoration-color var(--duration-fast) var(--ease-default); +} + +[data-link-preview-trigger]:hover, +[data-link-preview-trigger][data-state='open'] { + color: var(--color-primary-solid, var(--color-content-primary)); + text-decoration-color: currentColor; +} + +[data-link-preview-trigger]:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +/* ── Content ─────────────────────────────────────────────────────────── */ +/* + * Size only affects PHYSICAL dimensions (padding, min-width, font-size). + * Typography family / weight / line-height stay constant across sizes — + * size and intrinsic type are independent concerns. + */ +[data-link-preview-content] { + --_link-preview-py: var(--space-3); + --_link-preview-px: var(--space-4); + --_link-preview-font-size: var(--font-size-sm); + --_link-preview-min-width: 14rem; + --_link-preview-max-width: min(28rem, calc(100vw - var(--space-4))); + + display: grid; + gap: var(--space-2); + min-inline-size: var(--_link-preview-min-width); + max-inline-size: var(--_link-preview-max-width); + padding: var(--_link-preview-py) var(--_link-preview-px); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-overlay); + color: var(--color-content-primary); + box-shadow: var(--shadow-floating); + font-family: var(--font-ui); + font-size: var(--_link-preview-font-size); + font-weight: 400; + line-height: var(--leading-body); + outline: none; +} + +[data-link-preview-content][data-size='xs'] { + --_link-preview-py: var(--space-2); + --_link-preview-px: var(--space-3); + --_link-preview-font-size: var(--font-size-xs); + --_link-preview-min-width: 11rem; +} + +[data-link-preview-content][data-size='sm'] { + --_link-preview-py: var(--space-2); + --_link-preview-px: var(--space-3); + --_link-preview-font-size: var(--font-size-sm); + --_link-preview-min-width: 12rem; +} + +[data-link-preview-content][data-size='md'] { + --_link-preview-py: var(--space-3); + --_link-preview-px: var(--space-4); + --_link-preview-font-size: var(--font-size-sm); + --_link-preview-min-width: 14rem; +} + +[data-link-preview-content][data-size='lg'] { + --_link-preview-py: var(--space-4); + --_link-preview-px: var(--space-5); + --_link-preview-font-size: var(--font-size-md); + --_link-preview-min-width: 18rem; +} + +[data-link-preview-content][data-size='xl'] { + --_link-preview-py: var(--space-5); + --_link-preview-px: var(--space-6); + --_link-preview-font-size: var(--font-size-md); + --_link-preview-min-width: 22rem; +} + +/* The floating wrapper is `position: fixed` but un-stacked. Lift it + * above the page chrome so the panel actually paints over content. */ +[data-floating-wrapper]:has(> [data-link-preview-content]) { + z-index: var(--popover-content-z); +} + +/* ── Arrow ──────────────────────────────────────────────────────────── */ +[data-link-preview-arrow] { + fill: var(--color-surface-overlay); +} + +/* ── State transitions per anchored side ────────────────────────────── */ +/* + * Soma's Floating layer writes `data-side` (top/right/bottom/left) on + * Content based on the resolved placement. The preview slides into + * place FROM the trigger edge. + */ +[data-link-preview-content][data-state='open'][data-side='top'] { + animation: link-preview-in-top var(--duration-normal) var(--ease-out) both; +} +[data-link-preview-content][data-state='closed'][data-side='top'] { + animation: link-preview-out-top var(--duration-fast) var(--ease-default) both; +} + +[data-link-preview-content][data-state='open'][data-side='bottom'] { + animation: link-preview-in-bottom var(--duration-normal) var(--ease-out) both; +} +[data-link-preview-content][data-state='closed'][data-side='bottom'] { + animation: link-preview-out-bottom var(--duration-fast) var(--ease-default) both; +} + +[data-link-preview-content][data-state='open'][data-side='right'] { + animation: link-preview-in-right var(--duration-normal) var(--ease-out) both; +} +[data-link-preview-content][data-state='closed'][data-side='right'] { + animation: link-preview-out-right var(--duration-fast) var(--ease-default) both; +} + +[data-link-preview-content][data-state='open'][data-side='left'] { + animation: link-preview-in-left var(--duration-normal) var(--ease-out) both; +} +[data-link-preview-content][data-state='closed'][data-side='left'] { + animation: link-preview-out-left var(--duration-fast) var(--ease-default) both; +} + +@keyframes link-preview-in-top { + from { opacity: 0; transform: translateY(4px) scale(0.985); } +} +@keyframes link-preview-out-top { + to { opacity: 0; transform: translateY(4px) scale(0.985); } +} +@keyframes link-preview-in-bottom { + from { opacity: 0; transform: translateY(-4px) scale(0.985); } +} +@keyframes link-preview-out-bottom { + to { opacity: 0; transform: translateY(-4px) scale(0.985); } +} +@keyframes link-preview-in-right { + from { opacity: 0; transform: translateX(-4px) scale(0.985); } +} +@keyframes link-preview-out-right { + to { opacity: 0; transform: translateX(-4px) scale(0.985); } +} +@keyframes link-preview-in-left { + from { opacity: 0; transform: translateX(4px) scale(0.985); } +} +@keyframes link-preview-out-left { + to { opacity: 0; transform: translateX(4px) scale(0.985); } +} + +@media (prefers-reduced-motion: reduce) { + [data-link-preview-content] { + animation: none !important; + } +} diff --git a/src/uix/eidos/components/link-preview/link-preview.svelte b/src/uix/eidos/components/link-preview/link-preview.svelte new file mode 100644 index 000000000..49cd1f30e --- /dev/null +++ b/src/uix/eidos/components/link-preview/link-preview.svelte @@ -0,0 +1,31 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/link-preview/types.ts b/src/uix/eidos/components/link-preview/types.ts new file mode 100644 index 000000000..748b11c1f --- /dev/null +++ b/src/uix/eidos/components/link-preview/types.ts @@ -0,0 +1,32 @@ +import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; +import type { + ProviderProps, + TriggerProps, + ContentProps, + ArrowProps +} from '$soma/components/link-preview'; + +/** + * LinkPreview visual size — five-step subset of the canonical `Size` + * scale. Maps to padding / panel width / font-size via the recipe. + * + * LinkPreview cards are content panels (richer than tooltips, less + * frame than dialog), so the typical size range is xs..xl. xxs and + * xxl are out of band. + */ +export type LinkPreviewSize = Extract; + +export type LinkPreviewProps = ProviderProps; +export type LinkPreviewTriggerProps = TriggerProps; +export type LinkPreviewArrowProps = ArrowProps; + +export type LinkPreviewContentProps = ContentProps & { + /** + * Visual size preset — `'xs' | 'sm' | 'md' | 'lg' | 'xl'`. Resolved + * reactively via `Eidos.resolve(size)`. Maps to padding / min-width / + * font-size tokens in the recipe. + * + * @default 'md' + */ + size?: ResponsiveProp; +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 261faf292..03c8d8587 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -105,6 +105,7 @@ @import './components/form/form.css'; @import './components/popover/popover.css'; @import './components/tooltip/tooltip.css'; +@import './components/link-preview/link-preview.css'; @import './components/progress/progress.css'; @import './components/meter/meter.css'; @import './components/number-field/number-field.css'; diff --git a/web/routes/uix/components/link-preview/+page.svelte b/web/routes/uix/components/link-preview/+page.svelte new file mode 100644 index 000000000..10d647e20 --- /dev/null +++ b/web/routes/uix/components/link-preview/+page.svelte @@ -0,0 +1,625 @@ + + +
+
+
Overlays · LinkPreview
+

LinkPreview

+

+ Anchored content preview on hover or keyboard focus — Radix + HoverCard equivalent. Trigger is an <a>; + the floating panel reveals after openDelay (default + 700ms) with a SafePolygon bridging trigger and content so the + pointer can travel without dismissing. Non-modal, no-op on touch + (navigates the link directly). +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + sizes5 + + + apg + tooltip ↗ + +
+
+ + +
+
+

+ Read about + + + {triggerLabel} + + + + {#if showArrow}{/if} +

{previewTitle}

+

{previewBody}

+ + + + in the W3C reference. +

+
+
+ trace + {#if trace.length === 0} + hover the link to see events + {:else} + {#each trace.slice(0, 3) as entry (entry.at)} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + open {String(open)} + +
+
+ + +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Soma owns the hover-bridge polygon, open/close timing and dismissal. Eidos adds size on Content (xs..xl). Trigger is rendered as <a>; on touch the preview is a no-op and the link follows. +

+ + +
+ soma Provider props · open + delays + disabled +
+
+ + + + +
+ + +
+ soma Content props · floating placement +
+
+ + + + + +
+ + +
+ eidos Content props · visual size + + · padding / min-width / font-size + +
+
+ +
+ + +
Composition · toggle structural parts
+
+ +
+ + +
Demo content · literal text
+
+ + + + +
+ + +
+
+ soma + headless · hover-bridge + floating positioning + dismissal + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · adds size on Content + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ Three parts: Provider (root), Trigger (anchor), Content (panel), + plus optional Arrow inside Content. Portal wraps Content for body-level rendering. +

+ +
Provider
+
+ + + + + + + + + +
PropTypeDefaultDescription
open bindablebooleanfalseWhether the preview is open.
onOpenChange / onOpenChangeComplete(open: boolean) => void—Fire on every transition / after settle.
openDelaynumber700Hover-open delay (ms). Matches Radix HoverCard default.
closeDelaynumber300Hover-close delay (ms). Corridor for pointer to traverse to content.
disabledbooleanfalseHover/focus do not open the preview.
+
+ +
Trigger
+
+ + + + + + + +
PropTypeDefaultDescription
idstringauto—
…native anchor attrsPrimitiveAnchorAttributes—Renders an <a>; all HTML anchor attrs (href, target, rel) flow through.
child{`Snippet<[{ props }]>`}—Opt out of the default <a> and render your own element (e.g. button-triggered hover-card).
+
+ +
Content
+
+ + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
size eidosResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'>'md'Visual size — padding, min-width, font-size.
side'top' | 'right' | 'bottom' | 'left''top'Side of the trigger.
align'start' | 'center' | 'end''center'Alignment along the side.
sideOffsetnumber8Distance from trigger.
alignOffsetnumber0Offset along the alignment axis.
avoidCollisionsbooleantrueFlip / shift to avoid viewport edges.
collisionBoundaryBoundary | Boundary[][]Custom boundary element(s).
collisionPaddingnumber | Record<Side, number>0Padding from boundaries.
sticky'partial' | 'always''partial'Sticking behavior on scroll.
hideWhenDetachedbooleantrueHide when trigger leaves the viewport.
customAnchorstring | HTMLElement | Measurable | null—Anchor to a different element/rect than the trigger.
forceMountbooleanfalseKeep in DOM when closed (for external animation).
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

+ Source: src/uix/morfo/components/link-preview.ts. + Trigger emits the hover/focus events; Content stamps + data-state and data-side via the + Floating layer. +

+ +
+ + + + + + + + + +
FieldValue
name"{linkPreviewMorfo.name}"
kebab"{linkPreviewMorfo.kebab}"
scope[{linkPreviewMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{linkPreviewMorfo.parts.length}
events.length{events.length}
+
+ +
Parts
+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Events declaration
+
+ + + + {#each events as action (action.name)} + {@const sem = action.semantic} + {@const intentDecl = 'intent' in sem ? sem.intent : undefined} + {@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'} + + + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttargetprewritecommit
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{intentStr}{action.target}{action.prewrite.length ? action.prewrite.map((p) => `${p.attr}=${p.value}`).join(', ') : '—'}{action.commit ? `${action.commit.attr}=${action.commit.value}` : '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events + perceptual signature +

+

+ Hover / focus opens the preview; pointer-leave or escape closes + it. Click play to fire on the live preview. +

+ +
+ + + + {#each events as action (action.name)} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/link-preview/link-preview.css. + Trigger paints the <a> directly (underline + hover/open color shift) + — it does NOT render as <Button> like other triggers, because + link previews are navigational anchors rather than action controls. +

+
+ + + + + + + + + + +
SelectorSourceWhat it paints
[data-link-preview-trigger]morfoAnchor: underline + accent color.
[data-link-preview-trigger][data-state='open']morfoStronger color when preview is open.
[data-link-preview-content]morfoFloating panel: border, bg, shadow, padding.
[data-link-preview-content][data-size]eidosSize variant (xs..xl) — padding / min-width / font-size.
[data-link-preview-content][data-state][data-side]morfoOpen/close transition per side (slide+fade).
[data-link-preview-arrow]morfoArrow fill matches the panel bg.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ LinkPreview is decorative for assistive tech — the content + panel is sighted-user enhancement, not a primary information + channel. Screen readers should rely on the link's accessible + name (and surrounding context) to convey the destination. The + preview opens on keyboard focus too so sighted-keyboard users + get the affordance. +

+ +
Keyboard
+
+ + + + + + + +
KeyAction
TabFocus the trigger — preview opens after a short delay (skips on initial mount).
EscapeClose the preview, focus stays on trigger.
EnterFollow the link's href (native anchor behavior).
+
+ +
Pointer
+
+ + + + + + + + +
GestureBehavior
Hover triggerAfter openDelay (700ms default) the preview opens.
Leave trigger toward contentSafePolygon bridges the gap; preview stays open while pointer is in the triangle.
Leave bothAfter closeDelay (300ms default) the preview closes.
TouchNo preview — the link is followed directly. Avoids modal hover-trap on mobile.
+
+ +
ARIA contract
+
+ + + + + + + + +
PartAttributeValue
triggerdata-state"open" | "closed"
contentrole(none — sighted enhancement)
contentdata-state"open" | "closed"
contentdata-side"top" | "right" | "bottom" | "left"
+
+
+ {/if} +