From 0afcabee8baf7d2d4f738312b0129c352b41bcca Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 14:26:07 +0200 Subject: [PATCH] feat(eidos): port LinkPreview (Radix HoverCard equivalent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #128. Eidos wrapper + types + recipe + canonical-depth demo. Trigger renders as `` (navigational anchor); on hover/focus after `openDelay` (default 700ms) the Content panel reveals with SafePolygon bridging trigger and content so the pointer can travel without dismissing. Non-modal, no-op on touch (link follows directly). Reference parity: | Feature | Radix HoverCard | bits-ui LinkPreview | ark-ui HoverCard | this port | |---|---|---|---|---| | open with bind: | ✓ | ✓ | ✓ | ✓ | | openDelay (700ms) / closeDelay (300ms) | ✓ | ✓ | ✓ | ✓ | | disabled | ✗ | ✓ | ✓ | ✓ | | Trigger as `` | ✓ | ✓ | ✗ (` + + + + + + + + {#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} +