Skip to content

Commit ac8fab1

Browse files
committed
refactor(Popover): rebuild on reka-ui, add HoverCard and shared PopoverPanel
- Rebuild Popover on reka PopoverTrigger with v-model:open, side/align, dismissible, matchTriggerWidth; full back-compat for the v0 API - Add HoverCard component for hover-driven panels - Extract shared PopoverPanel shell; reuse across Select/Combobox/pickers - Move Popover migration notes into migration.md; add specs + docs - Fix Tooltip hoverDelay doc (seconds, not ms)
1 parent fa18b8a commit ac8fab1

38 files changed

Lines changed: 2617 additions & 606 deletions

components.d.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,6 @@ declare module 'vue' {
4848
Divider: typeof import('./src/components/Divider/Divider.vue')['default']
4949
DonutChart: typeof import('./src/components/Charts/DonutChart.vue')['default']
5050
Dropdown: typeof import('./src/components/Dropdown/Dropdown.vue')['default']
51-
DropdownMenuItemContent: typeof import('./src/components/Dropdown/DropdownMenuItemContent.vue')['default']
52-
DropdownMenuList: typeof import('./src/components/Dropdown/DropdownMenuList.vue')['default']
53-
DropdownRenderContent: typeof import('./src/components/Dropdown/DropdownRenderContent.vue')['default']
54-
DropdownRenderContentAsChild: typeof import('./src/components/Dropdown/DropdownRenderContentAsChild.vue')['default']
5551
Duration: typeof import('./src/components/Duration/Duration.vue')['default']
5652
ECharts: typeof import('./src/components/Charts/ECharts.vue')['default']
5753
EmojiList: typeof import('./src/components/TextEditor/extensions/emoji/EmojiList.vue')['default']
@@ -66,6 +62,7 @@ declare module 'vue' {
6662
FormLabel: typeof import('./src/components/FormLabel.vue')['default']
6763
FrappeUIProvider: typeof import('./src/components/Provider/FrappeUIProvider.vue')['default']
6864
FunnelChart: typeof import('./src/components/Charts/FunnelChart.vue')['default']
65+
HoverCard: typeof import('./src/components/HoverCard/HoverCard.vue')['default']
6966
Icon: typeof import('./src/components/Icon/Icon.vue')['default']
7067
IframeNodeView: typeof import('./src/components/TextEditor/extensions/iframe/IframeNodeView.vue')['default']
7168
ImageGroupNodeView: typeof import('./src/components/TextEditor/extensions/image-group/ImageGroupNodeView.vue')['default']
@@ -119,6 +116,8 @@ declare module 'vue' {
119116
PickerShell: typeof import('./src/components/shared/picker/PickerShell.vue')['default']
120117
Pill: typeof import('./src/components/Pill/Pill.vue')['default']
121118
Popover: typeof import('./src/components/Popover/Popover.vue')['default']
119+
PopoverPanel: typeof import('./src/components/shared/popover/PopoverPanel.vue')['default']
120+
PreviewWindow: typeof import('./src/components/ThemeSwitcher/previews/PreviewWindow.vue')['default']
122121
Progress: typeof import('./src/components/Progress/Progress.vue')['default']
123122
Rating: typeof import('./src/components/Rating/Rating.vue')['default']
124123
RequiredIndicator: typeof import('./src/components/InputLabeling/RequiredIndicator.vue')['default']

docs/content/docs/migration.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ Behavior changes that apply even if you don't touch your code:
9494
For the deprecated `Autocomplete`, see
9595
[Autocomplete (deprecated)](#autocomplete-deprecated).
9696

97+
## Popover / HoverCard
98+
99+
The v0 `Popover` API still works through v1.x — when only an old prop is bound
100+
it is mapped silently; binding both the old and new prop logs a one-time dev
101+
warning and the new prop wins.
102+
103+
| Before | After |
104+
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
105+
| `show` / `v-model:show` | `open` / `v-model:open` |
106+
| `placement="bottom-start"` | `side="bottom"` + `align="start"` (a bare side like `placement="bottom"` maps to `align="center"`) |
107+
| `hideOnBlur` | `dismissible` |
108+
| `matchTargetWidth` | `matchTriggerWidth` |
109+
| `trigger="hover"` (+ `hoverDelay` / `leaveDelay`) | the [`HoverCard`](./components/hovercard) component |
110+
| `popoverClass` | `data-slot` CSS hooks (no-op + warns) |
111+
| `transition="default"` | built-in motion (no-op) |
112+
| `#target` slot | `#trigger` (old `#target` contract preserved with manual wiring; `updatePosition` is now a no-op) |
113+
| `#body` / `#body-main` slots | `#default` |
114+
115+
Hover-driven panels move to the new [`HoverCard`](./components/hovercard)
116+
component, which keeps `hoverDelay` / `leaveDelay` in seconds.
117+
97118
## Inputs
98119

99120
Covers `TextInput`, `Textarea`, `Password`, `Checkbox`, `Switch`, `Rating`,

spec/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ If these disagree, update the lower-authority document or mark it historical.
2222
- [`editor.md`](./editor.md)
2323
- [`inputs.md`](./inputs.md)
2424
- [`date-picker.md`](./date-picker.md)
25+
- [`popover.md`](./popover.md)
26+
- [`hover-card.md`](./hover-card.md)
2527
- [`selection.md`](./selection.md)
2628
- [`item-list-row.md`](./item-list-row.md)
2729
- [`dropdown.md`](./dropdown.md)

spec/hover-card.md

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

Comments
 (0)