Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/docs/components/Popover/examples.md
Original file line number Diff line number Diff line change
@@ -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 <kbd>Esc</kbd> and when an item is chosen. Positioning is native CSS anchor positioning.

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/menu/app.twig')"
:script="() => import('./stories/menu/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/menu/app.twig
<<< ./stories/menu/app.js

:::

</llm-only>

## 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/).

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/tooltip/app.twig')"
:script="() => import('./stories/tooltip/app.js?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/tooltip/app.twig
<<< ./stories/tooltip/app.js

:::

</llm-only>
98 changes: 98 additions & 0 deletions packages/docs/components/Popover/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
badges: [JS]
---

# Popover <Badges :texts="$frontmatter.badges" />

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), <kbd>Esc</kbd> 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
<!-- The trigger lives anywhere on the page. -->
<button
type="button"
data-component="Action"
data-on:click="Popover(#menu)->target.toggle()"
aria-haspopup="true"
aria-controls="menu">
Menu
</button>

<div
popover="manual"
id="menu"
data-component="Action Popover"
data-on:keydown="event.key === 'Escape' && Popover.close()">
<div
data-component="Transition"
data-option-enter-active="transition duration-200 ease-out"
data-option-enter-from="opacity-0"
data-option-leave-active="transition duration-150 ease-in"
data-option-leave-to="opacity-0"
data-option-leave-keep
class="p-2 rounded-lg bg-white shadow-xl opacity-0">
<a href="/profile" data-component="Action" data-on:click="Popover(#menu)->target.close()">Profile</a>
<a href="/settings" data-component="Action" data-on:click="Popover(#menu)->target.close()">Settings</a>
</div>
</div>
```

## 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()`.
- **<kbd>Esc</kbd>:** `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` (<kbd>Esc</kbd>, items closing on click). Nothing light-dismisses it behind your back.
- **`popover="auto"`:** the platform adds light-dismiss (outside click) and <kbd>Esc</kbd> for free, but a platform-driven close is **instant** — it skips the leave transition, exactly like the native <kbd>Esc</kbd> path of a `<dialog>`. 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.
50 changes: 50 additions & 0 deletions packages/docs/components/Popover/js-api.md
Original file line number Diff line number Diff line change
@@ -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<Transition | ViewTransition>`

A getter returning every [`Transition`](/components/Transition/) and [`ViewTransition`](/components/ViewTransition/) child the popover orchestrates.

## Methods

### `open`

- Returns `Promise<void>`

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<void>`

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<void>`

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 <kbd>Esc</kbd>).
6 changes: 6 additions & 0 deletions packages/docs/components/Popover/stories/menu/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { registerComponent } from '@studiometa/js-toolkit';
import { Action, Popover, Transition } from '@studiometa/ui';

registerComponent(Action);
registerComponent(Popover);
registerComponent(Transition);
47 changes: 47 additions & 0 deletions packages/docs/components/Popover/stories/menu/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<button
type="button"
id="menu-trigger"
data-component="Action"
data-on:click="Popover(#menu)->target.toggle()"
aria-haspopup="true"
aria-controls="menu"
class="px-4 py-2 rounded bg-emerald-600 text-white font-semibold">
Menu &#9662;
</button>

{# `popover="manual"` so the component owns show/hide and the enter/leave
transitions always play. Esc and the items close it, wired with Action. #}
<div
popover="manual"
id="menu"
data-component="Action Popover"
data-on:keydown="event.key === 'Escape' && Popover.close()">
<div
data-component="Transition"
data-option-enter-active="transition duration-200 ease-out"
data-option-enter-from="opacity-0"
data-option-leave-active="transition duration-150 ease-in"
data-option-leave-to="opacity-0"
data-option-leave-keep
class="min-w-48 p-2 rounded-lg bg-white text-black shadow-xl ring-1 ring-black/10 opacity-0">
<a href="#" data-component="Action" data-on:click="Popover(#menu)->target.close()" class="block px-3 py-2 rounded hover:bg-black/5">Profile</a>
<a href="#" data-component="Action" data-on:click="Popover(#menu)->target.close()" class="block px-3 py-2 rounded hover:bg-black/5">Settings</a>
<a href="#" data-component="Action" data-on:click="Popover(#menu)->target.close()" class="block px-3 py-2 rounded hover:bg-black/5">Sign out</a>
</div>
</div>

<style>
/* Headless popover host: drop the UA overlay styles, then position it
yourself — here with native CSS anchor positioning. */
#menu-trigger { anchor-name: --menu; }
#menu {
position: fixed;
inset: auto;
padding: 0;
border: 0;
background: transparent;
position-anchor: --menu;
position-area: bottom span-right;
margin: 0.5rem 0 0;
}
</style>
6 changes: 6 additions & 0 deletions packages/docs/components/Popover/stories/tooltip/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { registerComponent } from '@studiometa/js-toolkit';
import { Action, Popover, Transition } from '@studiometa/ui';

registerComponent(Action);
registerComponent(Popover);
registerComponent(Transition);
40 changes: 40 additions & 0 deletions packages/docs/components/Popover/stories/tooltip/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<button
type="button"
id="tip-trigger"
data-component="Action"
data-on:mouseenter="Popover(#tip)->target.open()"
data-on:mouseleave="Popover(#tip)->target.close()"
data-on:focus="Popover(#tip)->target.open()"
data-on:blur="Popover(#tip)->target.close()"
aria-describedby="tip"
class="px-4 py-2 rounded border border-current">
Hover / focus me
</button>

{# A tooltip is the same primitive, opened on hover/focus instead of click. #}
<div popover="manual" id="tip" role="tooltip" data-component="Popover">
<div
data-component="Transition"
data-option-enter-active="transition duration-150 ease-out"
data-option-enter-from="opacity-0"
data-option-leave-active="transition duration-100 ease-in"
data-option-leave-to="opacity-0"
data-option-leave-keep
class="px-2.5 py-1.5 rounded-md bg-gray-900 text-white text-sm shadow-lg opacity-0">
A headless tooltip, positioned with your own CSS.
</div>
</div>

<style>
#tip-trigger { anchor-name: --tip; }
#tip {
position: fixed;
inset: auto;
padding: 0;
border: 0;
background: transparent;
position-anchor: --tip;
position-area: top;
margin: 0 0 0.5rem;
}
</style>
Loading
Loading