Popover
A popover is a non-modal dialog that floats around a trigger. It is used to display contextual information to the user, and should be paired with a clickable trigger element.
Features
Install
Install the component from your command line.
Anatomy
Import all parts and piece them together.
<script setup lang="ts">import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId } from 'vue'
const [state, send] = useMachine(popover.machine({ id: useId() }))const api = computed(() => popover.connect(state.value, send, normalizeProps))</script>
<template> <button v-bind="api.getTriggerProps()"></button> <Teleport v-if="api.open" to="body"> <div v-bind="api.getPositionerProps()">
<div v-bind="api.getArrowProps()"> <div v-bind="api.getArrowTipProps()"></div> </div>
<div v-bind="api.getContentProps()">
<div v-bind="api.getTitleProps()"></div> <div v-bind="api.getDescriptionProps()"></div>
<button v-bind="api.getCloseTriggerProps()"/> </div>
</div> </Teleport></template>
import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/react'import { useId } from 'react'import { createPortal } from 'react-dom'
export default function Popover() { const [state, send] = useMachine(popover.machine({ id: useId(), }))
const api = popover.connect(state, send, normalizeProps)
return ( <> <button {...api.getTriggerProps()}></button>
{api.open && createPortal( <div {...api.getPositionerProps()}>
<div {...api.getArrowProps()}> <div {...api.getArrowTipProps()} /> </div>
<div {...api.getContentProps()}>
<div {...api.getTitleProps()}></div> <div {...api.getDescriptionProps()}></div>
<button {...api.getCloseTriggerProps()}/> </div>
</div>, document.body, )} </> )}
<script lang="ts"> import * as popover from '@destyler/popover' import { normalizeProps, useMachine,portal } from '@destyler/svelte'
const id = $props.id()
const [state, send] = useMachine(popover.machine({ id }))
const api = $derived(popover.connect(state, send, normalizeProps))</script>
<button {...api.getTriggerProps()}></button>
{#if api.open} <div use:portal> <div {...api.getPositionerProps()}>
<div {...api.getArrowProps()}> <div {...api.getArrowTipProps()}></div> </div>
<div {...api.getContentProps()}>
<div {...api.getTitleProps()}></div> <div {...api.getDescriptionProps()}></div>
<button {...api.getCloseTriggerProps()}></button> </div> </div> </div>
{/if}
import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/solid'import { createMemo, createUniqueId } from 'solid-js'import { Portal } from 'solid-js/web'
export default function Popover() { const [state, send] = useMachine(popover.machine({ id: createUniqueId(), }))
const api = createMemo(() => popover.connect(state, send, normalizeProps))
return ( <> <button {...api().getTriggerProps()}></button>
{api().open && ( <Portal mount={document.body}> <div {...api().getPositionerProps()}>
<div {...api().getArrowProps()}> <div {...api().getArrowTipProps()} /> </div>
<div {...api().getContentProps()}>
<div {...api().getTitleProps()}></div> <div {...api().getDescriptionProps()}></div>
<button {...api().getCloseTriggerProps()}/> </div> </div> </Portal> )} </> )}
Managing focus within popover
When the popover open, focus is automatically set to the first focusable element within the popover.
To customize the element that should get focus, set the initialFocusEl
property in the machine’s context.
<script setup lang="ts">import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId, ref } from 'vue'
const inputRef = ref(null)
const [state, send] = useMachine(popover.machine({ id: useId(), initialFocusEl: () => inputRef.value,}))const api = computed(() => popover.connect(state.value, send, normalizeProps))</script>
<template> <button v-bind="api.getTriggerProps()"></button> <Teleport v-if="api.open" to="body"> <div v-bind="api.getPositionerProps()"> <div v-bind="api.getContentProps()">
<input ref="inputRef"/>
</div> </div> </Teleport></template>
import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/react'import { useId, useRef } from 'react'import { createPortal } from 'react-dom'
export default function Popover() {
const inputRef = useRef(null)
const [state, send] = useMachine(popover.machine({ id: useId(), initialFocusEl: () => inputRef.current, }))
const api = popover.connect(state, send, normalizeProps)
return ( <> <button {...api.getTriggerProps()}></button>
{api.open && createPortal( <div {...api.getPositionerProps()}> <div {...api.getContentProps()}>
<input ref={inputRef} />
</div> </div>, document.body, )} </> )}
<script lang="ts"> import * as popover from '@destyler/popover' import { normalizeProps, useMachine,portal } from '@destyler/svelte'
let inputRef: HTMLInputElement | null = null
const id = $props.id()
const [state, send] = useMachine(popover.machine({ id, initialFocusEl: () => inputRef, }))
const api = $derived(popover.connect(state, send, normalizeProps))</script>
<button {...api.getTriggerProps()}></button>
{#if api.open} <div use:portal> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}>
<input bind:this={inputRef} />
</div> </div> </div>
{/if}
import * as popover from '@destyler/popover'import { normalizeProps, useMachine } from '@destyler/solid'import { createMemo, createUniqueId, createSignal } from 'solid-js'import { Portal } from 'solid-js/web'
export default function Popover() {
const [inputRef, setInputRef] = createSignal()
const [state, send] = useMachine(popover.machine({ id: createUniqueId(), initialFocusEl: inputRef, }))
const api = createMemo(() => popover.connect(state, send, normalizeProps))
return ( <> <button {...api().getTriggerProps()}></button>
{api().open && ( <Portal mount={document.body}> <div {...api().getPositionerProps()}> <div {...api().getContentProps()}>
<input ref={setInputRef} />
</div> </div> </Portal> )} </> )}
Changing the modality
In some cases, you might want the popover to be modal. This means that it’ll:
-
trap focus within its content
-
block scrolling on the
body
-
disable pointer interactions outside the popover
-
hide content behind the popover from screen readers
To make the popover modal, set the modal: true
property in the machine’s context.
When modal: true
, we set the portalled
attribute to true
as well.
const [state, send] = useMachine( popover.machine({ modal: true, }),)
Close behavior
The popover is designed to close on blur and when the esc
key is pressed.
To prevent it from closing on blur (clicking or focusing outside),
pass the closeOnInteractOutside
property and set it to false
.
const [state, send] = useMachine( popover.machine({ closeOnInteractOutside: true, }),)
To prevent it from closing when the esc
key is pressed, pass the closeOnEscape
property and set it to false
.
const [state, send] = useMachine( popover.machine({ closeOnEscape: true, }),)
Changing the placement
To change the placement of the popover, set the positioning.placement
property in the machine’s context.
const [state, send] = useMachine( popover.machine({ positioning: { placement: "top-start", }, }),)
Listening for open state changes
When the popover is opened or closed, the onOpenChange
callback is invoked.
const [state, send] = useMachine( popover.machine({ onOpenChange(details) { // details => { open: boolean } console.log("Popover", details.open) }, }),)
Styling Guide
Earlier, we mentioned that each collapse part has a
data-part
attribute added to them to select and style them in the DOM.
Open and closed state
When the popover is expanded, we add a data-state
and data-placement
attribute to the trigger.
[data-part="trigger"][data-state="open"] { /* styles for the expanded state */}
[data-part="content"][data-state="open"] { /* styles for the expanded state */}
[data-part="trigger"][data-placement="top-start"] { /* styles for computed placement */}
Position aware
When the popover is expanded, we add a data-state
and data-placement
attribute to the trigger.
[data-part="trigger"][data-placement=""] { /* styles for computed placement */}
[data-part="content"][data-placement="top-start"] { /* styles for computed placement */}
Arrow
The arrow element requires specific css variables to be set for it to show correctly.
[data-part="arrow"] { --arrow-background: white; --arrow-size: 16px;}
A common technique for adding a shadow to the arrow is to use set filter: drop-down(...)
css property on the content element.
Alternatively, you can use the --arrow-shadow-color
variable.
[data-part="arrow"] { --arrow-shadow-color: gray;}
Methods and Properties
Machine Context
The popover machine exposes the following context properties:
Partial<{ anchor: string; trigger: string; content: string; title: string; description: string; closeTrigger: string; positioner: string; arrow: string; }>
boolean
boolean
boolean
() => HTMLElement
boolean
boolean
(details: OpenChangeDetails) => void
PositioningOptions
boolean
boolean
string
() => Node | ShadowRoot | Document
"ltr" | "rtl"
(event: KeyboardEvent) => void
(event: PointerDownOutsideEvent) => void
(event: FocusOutsideEvent) => void
(event: InteractOutsideEvent) => void
(() => Element)[]
Machine API
The popover api
exposes the following methods:
boolean
boolean
(open: boolean) => void
(options?: Partial<PositioningOptions>) => void
Data Attributes
Trigger
data-scope
data-part
data-placement
data-state
Indicator
data-scope
data-part
data-state
Positioner
data-scope
data-part
data-state
Content
data-scope
data-part
data-state
data-expanded
data-placement
Title
data-scope
data-part
Description
data-scope
data-part
Close Trigger
data-scope
data-part
Accessibility
Keyboard Interaction
Space
Enter
Tab
Shift + Tab
Esc