diff --git a/packages/docs/components/Popover/examples.md b/packages/docs/components/Popover/examples.md new file mode 100644 index 00000000..964e4aa4 --- /dev/null +++ b/packages/docs/components/Popover/examples.md @@ -0,0 +1,47 @@ +--- +title: Popover examples +--- + +# Examples + +## Dropdown menu + +Click the button to toggle a dropdown wired entirely with [`Action`](/components/Action/). The panel is a [`Transition`](/components/Transition/) child the `Popover` orchestrates; it closes on Esc and when an item is chosen. Positioning is native CSS anchor positioning. + + + + + + +:::code-group + +<<< ./stories/menu/app.twig +<<< ./stories/menu/app.js + +::: + + + +## Tooltip + +The same primitive, opened on hover and focus instead of click. `open()`/`close()` are called from the trigger's pointer and focus events, with a short fade as the [`Transition`](/components/Transition/). + + + + + + +:::code-group + +<<< ./stories/tooltip/app.twig +<<< ./stories/tooltip/app.js + +::: + + diff --git a/packages/docs/components/Popover/index.md b/packages/docs/components/Popover/index.md new file mode 100644 index 00000000..bdf620f7 --- /dev/null +++ b/packages/docs/components/Popover/index.md @@ -0,0 +1,98 @@ +--- +badges: [JS] +--- + +# Popover + +The `Popover` component is a headless wrapper around the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) — the non-modal sibling of [`Dialog`](/components/Dialog/). It offloads to the platform what a floating panel needs: top-layer stacking (it always paints above the page, no `z-index` wars), Esc to close and, with `popover="auto"`, light-dismiss. It owns only the orchestration of your transitions. + +It ships no markup and no positioning: you author the HTML, wire the triggers with [`Action`](/components/Action/), animate with [`Transition`](/components/Transition/) or [`ViewTransition`](/components/ViewTransition/) children, and place the panel with your own CSS. A dropdown menu, a tooltip and a rich hover-card are all the same `Popover` — only the trigger and the positioning change. + +## Usage + +Register the component, along with the [`Action`](/components/Action/) and [`Transition`](/components/Transition/) components used by the authored HTML: + +```js +import { registerComponent } from '@studiometa/js-toolkit'; +import { Action, Popover, Transition } from '@studiometa/ui'; + +registerComponent(Action); +registerComponent(Popover); +registerComponent(Transition); +``` + +The `Popover` lives on the `[popover]` element; inside it sits a transition child — the visible, animated panel. Triggers live anywhere on the page and call the component through `Action`: + +```html + + + + +``` + +## Triggers are delegated to `Action` + +The `Popover` class has no refs and adds no event listeners of its own — every interaction is wired declaratively with [`Action`](/components/Action/), calling the component's methods: + +- **Dropdown (click):** `data-on:click="Popover(#id)->target.toggle()"` on the trigger, and `data-on:click="Popover(#id)->target.close()"` on each item. +- **Tooltip / hover-card (hover + focus):** `data-on:mouseenter` / `data-on:focus` call `open()`, `data-on:mouseleave` / `data-on:blur` call `close()`. +- **Esc:** `data-on:keydown="event.key === 'Escape' && Popover.close()"` on the popover itself. + +## `auto` vs `manual` + +The `popover` attribute value is yours to choose, and it decides who owns dismissal: + +- **`popover="manual"` (used in the examples):** the component owns show/hide, so the **leave transition always plays**. You re-add dismissal with `Action` (Esc, items closing on click). Nothing light-dismisses it behind your back. +- **`popover="auto"`:** the platform adds light-dismiss (outside click) and Esc for free, but a platform-driven close is **instant** — it skips the leave transition, exactly like the native Esc path of a ``. The component listens to the native `toggle` event and keeps its state (and the `close` event) in sync when this happens. + +## Positioning is yours + +`Popover` ships no positioning opinion. Neutralize the user-agent overlay styles on the host and place it however you like — `position: fixed`, or native [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) as the examples do: + +```css +#menu-trigger { anchor-name: --menu; } +#menu { + position: fixed; + inset: auto; + background: transparent; + position-anchor: --menu; + position-area: bottom span-right; + margin-top: 0.5rem; +} +``` + +## Transitions are fanned out + +Like `Dialog`, the `Popover` awaits `Promise.all` over the `enter()`/`leave()` of every [`Transition`](/components/Transition/) and [`ViewTransition`](/components/ViewTransition/) child: + +- `open()` shows the popover **then** runs `enter()`, so it is already in the top layer when its child animates in. +- `close()` runs `leave()` **before** hiding it, so the popover is still painted while its child animates out. + +A plain `Transition` (a CSS opacity/transform) is the right default for a small panel; reach for a `ViewTransition` only when a transform would otherwise judder — and if you add a backdrop, carry over the top-layer stacking guards from the [drawer example](/components/Dialog/#building-a-drawer). + +With no transition child, `Promise.all([])` resolves immediately and open/close are instant. + +See the [examples](./examples.md) for a live dropdown and tooltip, and the [JS API](./js-api.md) for the full list of methods and events. diff --git a/packages/docs/components/Popover/js-api.md b/packages/docs/components/Popover/js-api.md new file mode 100644 index 00000000..4d5c5458 --- /dev/null +++ b/packages/docs/components/Popover/js-api.md @@ -0,0 +1,50 @@ +--- +title: Popover JS API +outline: deep +--- + +# JS API + +## Properties + +### `popover` + +- Type: `HTMLElement` + +A getter returning the native `[popover]` element (`this.$el`). + +### `transitions` + +- Type: `Array` + +A getter returning every [`Transition`](/components/Transition/) and [`ViewTransition`](/components/ViewTransition/) child the popover orchestrates. + +## Methods + +### `open` + +- Returns `Promise` + +Open the popover: call [`showPopover()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/showPopover), emit `open`, then run every child's `enter()`. A no-op if the popover is already open. Resolves once the enter transitions have finished. + +### `close` + +- Returns `Promise` + +Close the popover: emit `close`, run every child's `leave()`, **then** call [`hidePopover()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/hidePopover) — so the popover is still painted while its children animate out. A no-op if the popover is already closed. Resolves once hidden. + +### `toggle` + +- Returns `Promise` + +Call `close()` if the popover is open, `open()` otherwise. + +## Events + +### `open` + +Emitted when the popover starts opening, before the enter transitions run. Also emitted when a `popover="auto"` element is opened by the platform. + +### `close` + +Emitted when the popover starts closing, before the leave transitions run. Also emitted when a `popover="auto"` element is light-dismissed by the platform (outside click or Esc). diff --git a/packages/docs/components/Popover/stories/menu/app.js b/packages/docs/components/Popover/stories/menu/app.js new file mode 100644 index 00000000..ab4e890d --- /dev/null +++ b/packages/docs/components/Popover/stories/menu/app.js @@ -0,0 +1,6 @@ +import { registerComponent } from '@studiometa/js-toolkit'; +import { Action, Popover, Transition } from '@studiometa/ui'; + +registerComponent(Action); +registerComponent(Popover); +registerComponent(Transition); diff --git a/packages/docs/components/Popover/stories/menu/app.twig b/packages/docs/components/Popover/stories/menu/app.twig new file mode 100644 index 00000000..4ad8d51d --- /dev/null +++ b/packages/docs/components/Popover/stories/menu/app.twig @@ -0,0 +1,47 @@ + + +{# `popover="manual"` so the component owns show/hide and the enter/leave + transitions always play. Esc and the items close it, wired with Action. #} + + + diff --git a/packages/docs/components/Popover/stories/tooltip/app.js b/packages/docs/components/Popover/stories/tooltip/app.js new file mode 100644 index 00000000..ab4e890d --- /dev/null +++ b/packages/docs/components/Popover/stories/tooltip/app.js @@ -0,0 +1,6 @@ +import { registerComponent } from '@studiometa/js-toolkit'; +import { Action, Popover, Transition } from '@studiometa/ui'; + +registerComponent(Action); +registerComponent(Popover); +registerComponent(Transition); diff --git a/packages/docs/components/Popover/stories/tooltip/app.twig b/packages/docs/components/Popover/stories/tooltip/app.twig new file mode 100644 index 00000000..feafed1c --- /dev/null +++ b/packages/docs/components/Popover/stories/tooltip/app.twig @@ -0,0 +1,40 @@ + + +{# A tooltip is the same primitive, opened on hover/focus instead of click. #} + + + diff --git a/packages/tests/Popover/Popover.spec.ts b/packages/tests/Popover/Popover.spec.ts new file mode 100644 index 00000000..ebf7adb8 --- /dev/null +++ b/packages/tests/Popover/Popover.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Popover } from '@studiometa/ui'; +import { h, mount } from '#test-utils'; + +/** + * happy-dom does not implement `HTMLElement.showPopover()`/`hidePopover()`, so + * we stub them as spies. The component tracks its own open state, so the stubs + * need no behavior of their own. + */ +function mockPopover(el: HTMLElement) { + el.showPopover = vi.fn(); + el.hidePopover = vi.fn(); + return el; +} + +function transitionChild() { + return h('div', { + dataComponent: 'Transition', + dataOptionEnterFrom: 'opacity-0', + dataOptionLeaveTo: 'opacity-0', + dataOptionLeaveKeep: '', + class: 'opacity-0', + }); +} + +async function createPopover({ children = 0 } = {}) { + const kids = Array.from({ length: children }, transitionChild); + const el = h('div', { dataComponent: 'Popover', popover: 'auto' }, kids); + mockPopover(el); + document.body.append(el); + const popover = new Popover(el); + await mount(popover); + return { popover, el }; +} + +/** + * Dispatch the native `toggle` event the platform fires on light-dismiss. + */ +function nativeToggle(el: HTMLElement, newState: 'open' | 'closed') { + const event = new Event('toggle'); + Object.assign(event, { newState }); + el.dispatchEvent(event); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('The Popover component', () => { + it('should be closed on instantiation', async () => { + const { popover, el } = await createPopover(); + expect(popover.__isOpen).toBe(false); + expect(el.showPopover).not.toHaveBeenCalled(); + }); + + it('should show the popover on open', async () => { + const { popover, el } = await createPopover(); + await popover.open(); + expect(el.showPopover).toHaveBeenCalledTimes(1); + expect(popover.__isOpen).toBe(true); + }); + + it('should hide the popover on close', async () => { + const { popover, el } = await createPopover(); + await popover.open(); + await popover.close(); + expect(el.hidePopover).toHaveBeenCalledTimes(1); + expect(popover.__isOpen).toBe(false); + }); + + it('should be a no-op to open an already open popover', async () => { + const { popover, el } = await createPopover(); + await popover.open(); + await popover.open(); + expect(el.showPopover).toHaveBeenCalledTimes(1); + }); + + it('should be a no-op to close an already closed popover', async () => { + const { popover, el } = await createPopover(); + await popover.close(); + expect(el.hidePopover).not.toHaveBeenCalled(); + }); + + it('should toggle between open and close', async () => { + const { popover, el } = await createPopover(); + await popover.toggle(); + expect(el.showPopover).toHaveBeenCalledTimes(1); + expect(popover.__isOpen).toBe(true); + await popover.toggle(); + expect(el.hidePopover).toHaveBeenCalledTimes(1); + expect(popover.__isOpen).toBe(false); + }); + + it('should emit `open` and `close` events', async () => { + const { popover } = await createPopover(); + const openFn = vi.fn(); + const closeFn = vi.fn(); + popover.$on('open', openFn); + popover.$on('close', closeFn); + + await popover.open(); + expect(openFn).toHaveBeenCalledTimes(1); + await popover.close(); + expect(closeFn).toHaveBeenCalledTimes(1); + }); + + it('should fan `enter()`/`leave()` out to every transition child', async () => { + const { popover } = await createPopover({ children: 2 }); + expect(popover.transitions).toHaveLength(2); + + const enters = popover.transitions.map((transition) => + vi.spyOn(transition, 'enter').mockResolvedValue(), + ); + const leaves = popover.transitions.map((transition) => + vi.spyOn(transition, 'leave').mockResolvedValue(), + ); + + await popover.open(); + for (const enter of enters) expect(enter).toHaveBeenCalledTimes(1); + for (const leave of leaves) expect(leave).not.toHaveBeenCalled(); + + await popover.close(); + for (const leave of leaves) expect(leave).toHaveBeenCalledTimes(1); + }); + + it('should leave transitions BEFORE hiding the popover', async () => { + const { popover, el } = await createPopover({ children: 1 }); + const [transition] = popover.transitions; + const order: string[] = []; + vi.spyOn(transition, 'leave').mockImplementation(async () => { + order.push('leave'); + }); + (el.hidePopover as ReturnType).mockImplementation(() => { + order.push('hide'); + }); + + await popover.open(); + await popover.close(); + expect(order).toEqual(['leave', 'hide']); + }); + + it('should sync state and emit `close` on a platform light-dismiss', async () => { + const { popover, el } = await createPopover(); + const closeFn = vi.fn(); + popover.$on('close', closeFn); + + await popover.open(); + // The platform closes it (Esc / outside click) without calling `close()`. + nativeToggle(el, 'closed'); + expect(popover.__isOpen).toBe(false); + expect(closeFn).toHaveBeenCalledTimes(1); + + // It can be reopened afterwards. + await popover.open(); + expect(el.showPopover).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 0f44e701..d565cb1b 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -49,6 +49,7 @@ test('components exports', () => { "Modal", "ModalWithTransition", "Panel", + "Popover", "PrefetchWhenOver", "PrefetchWhenVisible", "ScrollAnimation", diff --git a/packages/ui/Popover/Popover.ts b/packages/ui/Popover/Popover.ts new file mode 100644 index 00000000..388f2b12 --- /dev/null +++ b/packages/ui/Popover/Popover.ts @@ -0,0 +1,144 @@ +import { Base } from '@studiometa/js-toolkit'; +import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; +import { Transition } from '../Transition/index.js'; +import { ViewTransition } from '../ViewTransition/index.js'; + +/** + * A `toggle` event as dispatched by the native Popover API. Declared locally so + * the component does not depend on the DOM lib shipping `ToggleEvent` yet. + */ +type PopoverToggleEvent = Event & { newState: 'open' | 'closed' }; + +export interface PopoverProps extends BaseProps { + $el: HTMLElement; + $children: { + Transition: Transition[]; + ViewTransition: ViewTransition[]; + }; +} + +/** + * Popover class. + * + * A headless wrapper around the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API): + * the non-modal sibling of [`Dialog`](https://ui.studiometa.dev/components/Dialog/). + * The `[popover]` element gives top-layer stacking, Esc and — with + * `popover="auto"` — light-dismiss for free; this class only orchestrates the + * transitions, fanning `enter()`/`leave()` out to every `Transition` / + * `ViewTransition` child (a dropdown panel, a tooltip, …). + * + * It ships no markup and owns no triggers: wire opening and closing from your + * HTML with [`Action`](https://ui.studiometa.dev/components/Action/) — e.g. + * `data-on:click="Popover(#menu)->target.toggle()"` for a dropdown, or + * `data-on:mouseenter`/`data-on:mouseleave` for a tooltip. Positioning is + * entirely author-controlled (native CSS anchor positioning, `position: fixed`, + * …); the library ships no positioning opinion. + * + * @link https://ui.studiometa.dev/components/Popover/ + */ +export class Popover extends Base { + /** + * Config. + */ + static config: BaseConfig = { + name: 'Popover', + components: { Transition, ViewTransition }, + emits: ['open', 'close'], + }; + + /** + * Whether the popover is open, mirroring the platform state. + * @private + */ + __isOpen = false; + + /** + * Get the native `[popover]` element. + */ + get popover(): HTMLElement { + return this.$el; + } + + /** + * Get every transition child to orchestrate, `Transition` and + * `ViewTransition` alike. With none, open/close are instant. + */ + get transitions(): Array { + const { Transition: transitions = [], ViewTransition: viewTransitions = [] } = this.$children; + return [...transitions, ...viewTransitions] as Array; + } + + /** + * Keep the internal state in sync when the platform toggles the popover on + * its own — a light-dismiss (outside click) or Esc on a + * `popover="auto"` element closes it natively, without going through + * `close()`, so the leave transition is skipped (an instant close, like the + * native Esc path of `Dialog`). Our own `showPopover()` / + * `hidePopover()` calls are ignored here because they never disagree with the + * flag we just set. + * @private + */ + __onToggle = (event: Event) => { + const { newState } = event as PopoverToggleEvent; + if (newState === 'closed' && this.__isOpen) { + this.__isOpen = false; + this.$emit('close'); + } else if (newState === 'open' && !this.__isOpen) { + this.__isOpen = true; + this.$emit('open'); + } + }; + + /** + * Listen for platform-driven toggles. + */ + mounted() { + this.$el.addEventListener('toggle', this.__onToggle); + } + + /** + * Stop listening for platform-driven toggles. + */ + destroyed() { + this.$el.removeEventListener('toggle', this.__onToggle); + } + + /** + * Open the popover: show it in the top layer, emit `open`, then run every + * child's `enter()`. A no-op if already open. Resolves once the enter + * transitions have finished. + */ + async open(): Promise { + if (this.__isOpen) { + return; + } + + this.__isOpen = true; + this.popover.showPopover(); + this.$emit('open'); + await Promise.all(this.transitions.map((transition) => transition.enter())); + } + + /** + * Close the popover: emit `close`, run every child's `leave()`, **then** hide + * it — so the popover is still painted while its children animate out. A + * no-op if already closed. Resolves once hidden. + */ + async close(): Promise { + if (!this.__isOpen) { + return; + } + + this.__isOpen = false; + this.$emit('close'); + await Promise.all(this.transitions.map((transition) => transition.leave())); + this.popover.hidePopover(); + } + + /** + * Toggle the popover open or closed. + */ + toggle(): Promise { + return this.__isOpen ? this.close() : this.open(); + } +} diff --git a/packages/ui/Popover/index.ts b/packages/ui/Popover/index.ts new file mode 100644 index 00000000..29e4efc1 --- /dev/null +++ b/packages/ui/Popover/index.ts @@ -0,0 +1 @@ +export * from './Popover.js'; diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 5f471d02..037417d9 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -20,6 +20,7 @@ export * from './LazyInclude/index.js'; export * from './Menu/index.js'; export * from './Modal/index.js'; export * from './Panel/index.js'; +export * from './Popover/index.js'; export * from './Prefetch/index.js'; export * from './ScrollAnimation/index.js'; export * from './ScrollReveal/index.js';