|
| 1 | +# HoverCard Spec |
| 2 | + |
| 3 | +Status: accepted direction for `frappe-ui` v1. |
| 4 | + |
| 5 | +This document defines the exact public API for `HoverCard`. It is part of the |
| 6 | +overlay/floating stabilization workstream listed in |
| 7 | +[`v1-release/plan.md`](../v1-release/plan.md) and is the companion split-out of |
| 8 | +the deprecated `Popover` `trigger="hover"` mode (issue #773, P8). |
| 9 | + |
| 10 | +`HoverCard` is built directly on reka-ui's `HoverCard*` primitives and shares |
| 11 | +the floating-panel shell + motion machinery with `Popover` via the shared |
| 12 | +`PopoverPanel` shell and the `[data-selection]`-gated popover motion stylesheet. |
| 13 | + |
| 14 | +## Role |
| 15 | + |
| 16 | +`HoverCard` shows a non-interactive-to-open, sighted-only preview card when the |
| 17 | +pointer rests on a trigger — author cards, link previews, mention hovercards, |
| 18 | +metric explainers. It is **information on hover**, not an action surface. |
| 19 | + |
| 20 | +Reach for a different component when: |
| 21 | + |
| 22 | +- the surface holds focusable controls users must reach by keyboard, or must |
| 23 | + open on click/tap → use [`Popover`](../src/components/Popover/Popover.md). |
| 24 | +- the surface is a short text label → use |
| 25 | + [`Tooltip`](../src/components/Tooltip/Tooltip.md). |
| 26 | +- the surface is a menu of actions → use `Dropdown`. |
| 27 | + |
| 28 | +Because it only opens on pointer hover/focus, `HoverCard` content must be |
| 29 | +**supplementary** — never the only path to information or actions. Touch and |
| 30 | +keyboard-only users will not reliably open it. This is the same contract reka |
| 31 | +documents for HoverCard. |
| 32 | + |
| 33 | +## Relationship to `Popover` `trigger="hover"` |
| 34 | + |
| 35 | +`Popover` historically supported `trigger="hover"` with hand-rolled |
| 36 | +`hoverDelay` / `leaveDelay` timers (seconds; defaults `hoverDelay: 0`, |
| 37 | +`leaveDelay: 0.5`). In v1 that hand-rolled timer code is **deleted** and the |
| 38 | +hover affordance moves to this component. |
| 39 | + |
| 40 | +- `Popover` keeps `trigger="hover"` working through `v1.x` for back-compat, but |
| 41 | + emits a **one-time** dev-mode `warnDeprecated` pointing at `HoverCard`. |
| 42 | +- The deprecated path maps `hoverDelay` → `openDelay` and `leaveDelay` → |
| 43 | + `closeDelay` (both stay in seconds — see the mapping table below). No behavior |
| 44 | + change for existing callers in `v1.x`. |
| 45 | +- New code uses `<HoverCard>` directly. |
| 46 | + |
| 47 | +See the `Popover` spec ("Deprecations") for the full Popover back-compat table. |
| 48 | + |
| 49 | +## Decisions at a glance |
| 50 | + |
| 51 | +| Decision | Direction | |
| 52 | +|---|---| |
| 53 | +| Primitive | reka `HoverCardRoot` / `HoverCardTrigger` / `HoverCardPortal` / `HoverCardContent` | |
| 54 | +| Open trigger | Pointer hover + focus only — no click, no keyboard toggle (reka contract) | |
| 55 | +| Delay units | **Seconds** (`hoverDelay` / `leaveDelay`), consistent with `Tooltip`. Converted to reka's ms `openDelay` / `closeDelay` internally | |
| 56 | +| Visibility model | `v-model:open` (canonical) | |
| 57 | +| Positioning | `side` / `align` / `offset` / `collisionPadding` / `portalTo`, same vocabulary and defaults as `Popover` | |
| 58 | +| Shell | Shared `PopoverPanel` — owns `data-slot="content"` + rounded/elevated/ring visuals only, no behavior | |
| 59 | +| Motion | Shared popover motion (`[data-selection]` stylesheet + `usePopoverMotion`). Hover opens are pointer-driven → `animated` | |
| 60 | +| Styling | No class-injection props. Stable `data-slot` / `data-state` / `data-motion` hooks only | |
| 61 | +| Trigger slot | `#trigger` via reka `HoverCardTrigger as-child` — aria + hover/focus wiring is automatic | |
| 62 | + |
| 63 | +## Exact public API for v1 |
| 64 | + |
| 65 | +### Types |
| 66 | + |
| 67 | +```ts |
| 68 | +type PopoverSide = 'top' | 'right' | 'bottom' | 'left' |
| 69 | +type PopoverAlign = 'start' | 'center' | 'end' |
| 70 | + |
| 71 | +type HoverCardSlotProps = { |
| 72 | + /** Imperatively open the card. */ |
| 73 | + open: () => void |
| 74 | + /** Imperatively close the card. */ |
| 75 | + close: () => void |
| 76 | +} |
| 77 | + |
| 78 | +interface HoverCardProps { |
| 79 | + open?: boolean |
| 80 | + side?: PopoverSide |
| 81 | + align?: PopoverAlign |
| 82 | + offset?: number |
| 83 | + collisionPadding?: number |
| 84 | + portalTo?: string | HTMLElement |
| 85 | + /** Seconds from pointer-enter on the trigger until the card opens. */ |
| 86 | + hoverDelay?: number |
| 87 | + /** Seconds from pointer-leave (trigger or content) until the card closes. */ |
| 88 | + leaveDelay?: number |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +Defaults (aligned with `Popover` for positioning, `Tooltip` for delays): |
| 93 | + |
| 94 | +- `open = false` |
| 95 | +- `side = 'bottom'` |
| 96 | +- `align = 'start'` |
| 97 | +- `offset = 4` |
| 98 | +- `collisionPadding = 10` |
| 99 | +- `portalTo = 'body'` |
| 100 | +- `hoverDelay = 0.5` |
| 101 | +- `leaveDelay = 0.3` |
| 102 | + |
| 103 | +Notes: |
| 104 | + |
| 105 | +- `hoverDelay` / `leaveDelay` are in **seconds** and multiplied by `1000` for |
| 106 | + reka's `openDelay` / `closeDelay` (which are ms), matching how `Tooltip` |
| 107 | + converts `hoverDelay`. |
| 108 | +- `side` / `align` map to reka's `side` + `align` on `HoverCardContent`; |
| 109 | + `offset` maps to `side-offset`. Same split as `Popover` (and the |
| 110 | + `placement="bottom-start"` legacy mapping lives only on `Popover`). |
| 111 | +- There is **no** `dismissible` / `matchTriggerWidth` prop — hover cards are not |
| 112 | + dismissed by outside-click (they close on pointer-leave) and do not size to |
| 113 | + the trigger. Add later in `1.x` only with a concrete use case. |
| 114 | + |
| 115 | +### Emits |
| 116 | + |
| 117 | +```ts |
| 118 | +interface HoverCardEmits { |
| 119 | + 'update:open': [value: boolean] |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +Canonical visibility event is `update:open` (drives `v-model:open`). There are |
| 124 | +no behavior-named `@open` / `@close` emits — unlike `Popover`, no consumer binds |
| 125 | +keyboard shortcuts on a hover card's open/close, so the surface stays minimal. |
| 126 | + |
| 127 | +### Slots |
| 128 | + |
| 129 | +| Slot | Scope | Purpose | |
| 130 | +|---|---|---| |
| 131 | +| `#trigger` | `{ open, close }` | Rendered through reka `HoverCardTrigger as-child`. Hover/focus + `aria-describedby` wiring is automatic. The slot must render a single element root (as-child contract). | |
| 132 | +| `#default` | `{ open, close }` | Card content, rendered inside the shared `PopoverPanel` shell. | |
| 133 | + |
| 134 | +Slot rules: |
| 135 | + |
| 136 | +- `#trigger` is required; without it there is nothing to hover. |
| 137 | +- `#default` content should be read-only / supplementary. Focusable controls are |
| 138 | + technically renderable but discouraged (reka keeps the card open while the |
| 139 | + pointer is over it, but keyboard users cannot reliably reach it). |
| 140 | +- Both slots receive the same `{ open, close }` shape as `Popover` for symmetry, |
| 141 | + even though hover cards rarely need imperative control. |
| 142 | + |
| 143 | +### Exposed |
| 144 | + |
| 145 | +```ts |
| 146 | +defineExpose({ |
| 147 | + open: () => void, |
| 148 | + close: () => void, |
| 149 | +}) |
| 150 | +``` |
| 151 | + |
| 152 | +Mirrors `Popover`'s exposed surface. |
| 153 | + |
| 154 | +## Accessibility and semantics |
| 155 | + |
| 156 | +- Trigger and content use reka's HoverCard a11y: the content is associated with |
| 157 | + the trigger via `aria-describedby` (reka wires this on the trigger when the |
| 158 | + card is open). Do not hand-roll aria — let the primitive own it. |
| 159 | +- HoverCard is **sighted-pointer + focus** only. It is not keyboard-openable and |
| 160 | + is invisible to touch. Treat its content as progressive enhancement; never put |
| 161 | + primary information or the only copy of an action inside it. |
| 162 | +- The trigger remains a normal interactive element (link/button) for its own |
| 163 | + click semantics; the hover card layers a preview on top without intercepting |
| 164 | + that interaction. |
| 165 | +- Focusing the trigger via keyboard opens the card (reka behavior), so the |
| 166 | + preview is reachable for keyboard users tabbing through, but it cannot be |
| 167 | + toggled with Enter/Space. |
| 168 | + |
| 169 | +## Motion |
| 170 | + |
| 171 | +`HoverCard` reuses the shared popover motion machinery rather than a bespoke |
| 172 | +animation: |
| 173 | + |
| 174 | +- Content renders inside `PopoverPanel`, which carries `data-slot="content"` + |
| 175 | + `data-selection` and the inner `data-slot="content-body"` with |
| 176 | + `:data-motion="motion"` from `usePopoverMotion`. |
| 177 | +- Hover/focus opens are pointer-recency driven and therefore classify as |
| 178 | + `animated`: enter `180ms` / exit `140ms` with `cubic-bezier(0.23, 1, 0.32, 1)`, |
| 179 | + scaling in from the trigger via |
| 180 | + `transform-origin: var(--reka-hover-card-content-transform-origin)` on the |
| 181 | + content-body. (HoverCard owns its own transform-origin var, the same way |
| 182 | + Popover uses `--reka-popover-...` and Select uses `--reka-select-...`.) |
| 183 | +- The `instant` (~80ms opacity fade, no scale/translate) path still exists for |
| 184 | + any non-pointer open (e.g. a programmatic `v-model:open` flip), inherited from |
| 185 | + the shared stylesheet — no extra wiring. |
| 186 | +- `prefers-reduced-motion: reduce` disables the content animation (shared |
| 187 | + stylesheet forces `animation-duration: 0`). |
| 188 | + |
| 189 | +`PopoverPanel` owns the **shell only** (rounded-lg, `bg-surface-elevation-2`, |
| 190 | +shadow, ring + the `data-slot`/`data-state`/`data-motion` wiring). `HoverCard` |
| 191 | +renders its own `HoverCardContent` and its own contents inside `PopoverPanel`; |
| 192 | +it does not delegate behavior to the panel. This is the same DRY split applied |
| 193 | +to `Popover` / `Select` / `Combobox` / pickers. |
| 194 | + |
| 195 | +## Styling hooks |
| 196 | + |
| 197 | +No class-injection props (`popoverClass` and friends do not exist on this |
| 198 | +component). Stable hooks only: |
| 199 | + |
| 200 | +- `data-slot="trigger"` on the trigger element |
| 201 | +- `data-slot="content"` on the positioned content (from `PopoverPanel`) |
| 202 | +- `data-slot="content-body"` on the animated inner shell |
| 203 | +- `data-state="open" | "closed"` (supplied by reka `HoverCardContent`) |
| 204 | +- `data-motion="animated" | "instant"` on the content-body |
| 205 | + |
| 206 | +## Examples |
| 207 | + |
| 208 | +```vue |
| 209 | +<!-- Author card on hover --> |
| 210 | +<HoverCard :hover-delay="0.4" side="top" align="start"> |
| 211 | + <template #trigger> |
| 212 | + <a href="/u/jane" class="font-medium underline">Jane Doe</a> |
| 213 | + </template> |
| 214 | + <template #default> |
| 215 | + <div class="flex gap-3"> |
| 216 | + <Avatar :image="jane.image" :label="jane.name" size="lg" /> |
| 217 | + <div> |
| 218 | + <div class="text-base font-medium text-ink-gray-9">{{ jane.name }}</div> |
| 219 | + <div class="text-sm text-ink-gray-6">{{ jane.bio }}</div> |
| 220 | + </div> |
| 221 | + </div> |
| 222 | + </template> |
| 223 | +</HoverCard> |
| 224 | +``` |
| 225 | + |
| 226 | +```vue |
| 227 | +<!-- Programmatic control via exposed handle --> |
| 228 | +<HoverCard ref="card"> |
| 229 | + <template #trigger><Button label="Details" /></template> |
| 230 | + <template #default="{ close }"> |
| 231 | + <MetricExplainer @done="close" /> |
| 232 | + </template> |
| 233 | +</HoverCard> |
| 234 | +``` |
| 235 | + |
| 236 | +## Migration path |
| 237 | + |
| 238 | +### From `Popover` `trigger="hover"` |
| 239 | + |
| 240 | +```vue |
| 241 | +<!-- before — deprecated, warns once --> |
| 242 | +<Popover trigger="hover" :hover-delay="0.2" :leave-delay="0.5" placement="top-start"> |
| 243 | + <template #target><a href="/u/jane">Jane Doe</a></template> |
| 244 | + <template #body><AuthorCard :user="jane" /></template> |
| 245 | +</Popover> |
| 246 | +
|
| 247 | +<!-- after --> |
| 248 | +<HoverCard :hover-delay="0.2" :leave-delay="0.5" side="top" align="start"> |
| 249 | + <template #trigger><a href="/u/jane">Jane Doe</a></template> |
| 250 | + <template #default><AuthorCard :user="jane" /></template> |
| 251 | +</HoverCard> |
| 252 | +``` |
| 253 | + |
| 254 | +Mapping applied by the migration (and by `Popover`'s `v1.x` back-compat shim |
| 255 | +internally): |
| 256 | + |
| 257 | +| `Popover` (deprecated hover) | `HoverCard` | |
| 258 | +|---|---| |
| 259 | +| `trigger="hover"` | (implicit — HoverCard only opens on hover) | |
| 260 | +| `hoverDelay` (seconds) | `hoverDelay` (seconds) | |
| 261 | +| `leaveDelay` (seconds) | `leaveDelay` (seconds) | |
| 262 | +| `placement="top-start"` | `side="top"` + `align="start"` | |
| 263 | +| `#target` | `#trigger` | |
| 264 | +| `#body` / `#body-main` | `#default` | |
| 265 | +| `show` / `v-model:show` | `v-model:open` | |
| 266 | + |
| 267 | +## Out of scope for v1 |
| 268 | + |
| 269 | +Revisit in `1.x` only with a concrete use case: |
| 270 | + |
| 271 | +- `dismissible` / outside-click behavior (hover cards close on pointer-leave). |
| 272 | +- `matchTriggerWidth`. |
| 273 | +- An arrow element (reka `HoverCardArrow` is available; not exposed yet). |
| 274 | +- A shared open-delay group analogous to `TooltipProvider` / `TooltipGroup`. |
| 275 | +- Mobile/touch open affordance (HoverCard is intentionally pointer/focus only). |
0 commit comments