From 1bd2e5df9c501e0913e1aa04b55bb1ca1d4bf8b7 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 18 May 2026 16:14:47 +0530 Subject: [PATCH] feat(ui): native-HTML rewrite of stateful primitives + shadcn-API sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight registry components now lean on native HTML primitives instead of hand-rolled JS. Three drop their custom elements entirely; five keep a thin custom-element wrapper around the platform feature. Tier-2 -> Tier-1 (custom element removed; native HTML + class helpers): - popover -> - * - * - * - * + * Cancel + * Delete * * * * - * Behavior is identical to except role="alertdialog" and Escape / - * overlay-click are disabled (user MUST choose Cancel or Action). - * * Design tokens used: --background, --border, --muted-foreground. */ import { cn, Base, defineElement } from '../lib/utils.ts'; @@ -41,8 +44,6 @@ import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts'; export const alertDialogContentClass = (): string => 'group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadcn-lg shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg'; -export const alertDialogOverlayClass = (): string => 'fixed inset-0 z-50 bg-black/50'; - export const alertDialogHeaderClass = (): string => 'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left'; @@ -54,9 +55,23 @@ export const alertDialogTitleClass = (): string => 'text-lg font-semibold'; export const alertDialogDescriptionClass = (): string => 'text-sm text-muted-foreground'; const STYLES = ` -ui-alert-dialog:not([open]) ui-alert-dialog-content, -ui-alert-dialog:not([open]) ui-alert-dialog-overlay { display: none !important; } +ui-alert-dialog:not([open]) ui-alert-dialog-content { display: none !important; } ui-alert-dialog-content { display: grid; } +ui-alert-dialog dialog[data-slot="alert-dialog-native"] { + border: 0; + background: transparent; + padding: 0; + margin: 0; + width: 0; + height: 0; + max-width: none; + max-height: none; + overflow: visible; + color: inherit; +} +ui-alert-dialog dialog[data-slot="alert-dialog-native"]::backdrop { + background: rgba(0, 0, 0, 0.5); +} `; function installStyles(): void { @@ -75,7 +90,7 @@ function lockScroll(): void { if (scrollLockCount === 0) { // Reserve the gutter the OS scrollbar was occupying so the body doesn't // visibly widen when `overflow: hidden` removes it. See dialog.ts for - // the full rationale — kept in lockstep here because alert-dialog + // the full rationale. Kept in lockstep here because alert-dialog // intentionally re-implements the lock (rather than importing from // dialog.ts) so users can `webjs ui add alert-dialog` without pulling // in the full dialog component. @@ -95,24 +110,42 @@ function unlockScroll(): void { } } +// -------------------------------------------------------------------------- +// +// -------------------------------------------------------------------------- + export class UiAlertDialog extends Base { static get observedAttributes(): string[] { return ['open']; } - private _previouslyFocused: HTMLElement | null = null; + + private _native: HTMLDialogElement | null = null; + + // Cancel the native Escape-to-close. The browser fires a `cancel` event + // when the user presses Escape on an open dialog; preventDefault stops + // the subsequent close. No click-to-close on the backdrop either (intentional + // omission — alert dialogs require an explicit Cancel/Action choice). + private _onNativeCancel = (e: Event): void => e.preventDefault(); + private _onNativeClose = (): void => { + if (this.isOpen) this.removeAttribute('open'); + }; connectedCallback(): void { installStyles(); this.setAttribute('data-slot', 'alert-dialog'); - if (!this.querySelector(':scope > ui-alert-dialog-overlay')) { - const overlay = document.createElement('ui-alert-dialog-overlay'); - this.insertBefore(overlay, this.firstChild); - } + this._wrap(); this._reflect(); + if (this.isOpen) this._setup(); } + disconnectedCallback(): void { - if (this.hasAttribute('open')) this._teardown(); + if (this.isOpen) this._teardown(); + if (this._native) { + this._native.removeEventListener('cancel', this._onNativeCancel); + this._native.removeEventListener('close', this._onNativeClose); + } } + attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void { if (name === 'open' && oldVal !== newVal) { this._reflect(); @@ -120,34 +153,58 @@ export class UiAlertDialog extends Base { else this._teardown(); } } + show(): void { this.setAttribute('open', ''); } + hide(): void { this.removeAttribute('open'); } + + private get isOpen(): boolean { + return this.hasAttribute('open'); + } + + private _wrap(): void { + const content = this.querySelector(':scope > ui-alert-dialog-content'); + if (!content) return; + if (content.parentElement?.tagName === 'DIALOG') { + this._native = content.parentElement as HTMLDialogElement; + } else { + const dlg = document.createElement('dialog'); + dlg.setAttribute('data-slot', 'alert-dialog-native'); + content.replaceWith(dlg); + dlg.appendChild(content); + this._native = dlg; + } + // The legacy is no longer needed; ::backdrop covers it. + this.querySelector(':scope > ui-alert-dialog-overlay')?.remove(); + this._native.addEventListener('cancel', this._onNativeCancel); + this._native.addEventListener('close', this._onNativeClose); + // No click-to-close on backdrop. Alert dialogs require explicit choice. + } + private _reflect(): void { - const open = this.hasAttribute('open'); + const open = this.isOpen; this.setAttribute('data-state', open ? 'open' : 'closed'); - const content = this.querySelector(':scope > ui-alert-dialog-content'); + const content = this.querySelector('ui-alert-dialog-content'); if (content) { content.setAttribute('data-state', open ? 'open' : 'closed'); content.setAttribute('role', 'alertdialog'); content.setAttribute('aria-modal', 'true'); } } + private _setup(): void { - this._previouslyFocused = document.activeElement as HTMLElement | null; + if (!this._native) return; lockScroll(); - queueMicrotask(() => { - const action = this.querySelector('ui-alert-dialog-action button, ui-alert-dialog-action'); - action?.focus({ preventScroll: true }); - }); + if (!this._native.open) this._native.showModal(); } + private _teardown(): void { unlockScroll(); - this._previouslyFocused?.focus({ preventScroll: true }); - this._previouslyFocused = null; + if (this._native?.open) this._native.close(); } } defineElement('ui-alert-dialog', UiAlertDialog); @@ -176,17 +233,6 @@ export class UiAlertDialogContent extends Base { } defineElement('ui-alert-dialog-content', UiAlertDialogContent); -export class UiAlertDialogOverlay extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'alert-dialog-overlay'); - this.setAttribute('aria-hidden', 'true'); - const userClass = this.getAttribute('class') ?? ''; - this.className = cn(alertDialogOverlayClass(), userClass); - // No click-to-close — alert dialogs require explicit Cancel/Action. - } -} -defineElement('ui-alert-dialog-overlay', UiAlertDialogOverlay); - // shadcn's and ARE button-styled // elements with forwarded `variant` and `size` props from the Button // component. We mirror that: the host element itself receives @@ -196,7 +242,7 @@ defineElement('ui-alert-dialog-overlay', UiAlertDialogOverlay); // to variant="outline" (matches shadcn); Action defaults to "default". // // Back-compat: if the consumer provided a child - * - * - *

Hidden by default. Revealed on trigger click.

- *
- * + *
+ * + * Show details + * + * + *
+ * Hidden until is clicked, Enter/Space pressed, or the + *
element's `open` property is set via JS. + *
+ *
+ * + * Initial state: add `open` attribute on
to render expanded on + * first paint. Programmatic toggling: `el.open = true` / `el.open = false`. * - * Attributes: `open` (boolean reflected). - * Events: `ui-open-change`. + * Migrated from the prior custom element. Class helpers + * keep the same shadcn-style visuals; the trigger class hides the native + * disclosure marker so callers can render their own chevron if desired. */ -import { Base, defineElement } from '../lib/utils.ts'; - -const STYLES = ` -ui-collapsible:not([open]) ui-collapsible-content { display: none !important; } -ui-collapsible-content { display: block; } -`; -function installStyles(): void { - if (typeof document === 'undefined') return; - if (document.getElementById('ui-collapsible-styles')) return; - const style = document.createElement('style'); - style.id = 'ui-collapsible-styles'; - style.textContent = STYLES; - document.head.appendChild(style); -} - -export class UiCollapsible extends Base { - static get observedAttributes(): string[] { - return ['open']; - } - connectedCallback(): void { - installStyles(); - this.setAttribute('data-slot', 'collapsible'); - this._reflect(); - } - attributeChangedCallback(): void { - this._reflect(); - this.dispatchEvent( - new CustomEvent('ui-open-change', { detail: { open: this.hasAttribute('open') }, bubbles: true }), - ); - } - show(): void { - this.setAttribute('open', ''); - } - hide(): void { - this.removeAttribute('open'); - } - toggle(): void { - if (this.hasAttribute('open')) this.hide(); - else this.show(); - } - private _reflect(): void { - const open = this.hasAttribute('open'); - this.setAttribute('data-state', open ? 'open' : 'closed'); - const trigger = this.querySelector(':scope > ui-collapsible-trigger'); - trigger?.setAttribute('aria-expanded', String(open)); - const content = this.querySelector(':scope > ui-collapsible-content'); - content?.setAttribute('data-state', open ? 'open' : 'closed'); - } -} -defineElement('ui-collapsible', UiCollapsible); +/** + * Root: marks the disclosure widget as a `group` so descendants can use + * Tailwind's `group-open:` variant to react to the `[open]` attribute + * (which `
` sets natively). No visual styling of its own. + */ +export const collapsibleClass = (): string => 'group'; -export class UiCollapsibleTrigger extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'collapsible-trigger'); - this.addEventListener('click', this._onClick); - } - disconnectedCallback(): void { - this.removeEventListener('click', this._onClick); - } - private _onClick = (): void => (this.closest('ui-collapsible') as UiCollapsible | null)?.toggle(); -} -defineElement('ui-collapsible-trigger', UiCollapsibleTrigger); +/** + * Trigger: hides the native ::marker (and the WebKit -details-marker shim) + * so the disclosure triangle does not appear; callers wrap their own + * chevron icon and rotate it on open via `group-open:rotate-180`. + * + * `disabled: true` returns the visual disabled state. Native
+ * has no `disabled` attribute, so for full keyboard prevention add the + * standard `inert` attribute on the
element. shadcn's React + * `disabled` prop combines both visual and behavior; we split them. + */ +export const collapsibleTriggerClass = (opts: { disabled?: boolean } = {}): string => { + const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden'; + if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`; + return base; +}; -export class UiCollapsibleContent extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'collapsible-content'); - } -} -defineElement('ui-collapsible-content', UiCollapsibleContent); +/** + * Content:
already hides children other than when + * not [open], so this is purely typographic spacing. No display rules. + */ +export const collapsibleContentClass = (): string => 'text-sm'; diff --git a/packages/ui/packages/registry/components/dialog.ts b/packages/ui/packages/registry/components/dialog.ts index dabd009a..c85bd119 100644 --- a/packages/ui/packages/registry/components/dialog.ts +++ b/packages/ui/packages/registry/components/dialog.ts @@ -1,7 +1,24 @@ /** - * Dialog — modal dialog with focus trap, Escape-to-close, and overlay click. + * Dialog — modal dialog built on the native element. * - * APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ + * The custom element is a thin decorator. All the heavy lifting comes + * from HTMLDialogElement.showModal(): + * - Top-layer rendering (no z-index wars) + * - ::backdrop pseudo-element for the overlay + * - Native focus management: initial focus on first tabbable, Tab + * trapped inside the dialog, focus restored on close + * - Escape-to-close via the cancel event + * - Background made inert (clicks pass through to nothing) + * + * On connection the component reparents inside a + * programmatically-created so we can call showModal() on it. + * The is transparent and zero-sized; the visible panel is + * with its `position: fixed; top: 50%; left: 50%` + * classes, which is what gets the shadow and rounded border. + * + * The previous version's focus trap, Tab cycling, Escape listener, + * `` element, and document-level keydown handler are + * all gone — the platform owns them now. * * shadcn parity: * @@ -33,64 +50,66 @@ * * * Attributes on : - * `open` — boolean (reflected). Presence ⇒ dialog is shown. - * - * Events fired on : - * `ui-open-change` — `{ detail: { open: boolean } }` — fires after the - * element transitions between open / closed states. + * `open` — boolean (reflected). Presence shows the dialog. * - * Keyboard: - * Escape — close - * Tab / Shift-Tab — cycle focusable elements within the dialog content + * Events on : + * `ui-open-change` — { detail: { open: boolean } }, fires after the + * element transitions between open and closed. * - * Programmatic API on : - * .show() .hide() .toggle() + * Programmatic API: .show() .hide() .toggle() * - * Design tokens used: --background, --border, --muted-foreground, --foreground. + * Design tokens used: --background, --border, --muted-foreground. */ import { cn, Base, defineElement } from '../lib/utils.ts'; import { buttonClass } from './button.ts'; // -------------------------------------------------------------------------- -// Class helpers for static subparts. Compose with plain elements. +// Class helpers for subparts. Unchanged from the prior version. // -------------------------------------------------------------------------- -/** Dialog header — flex column for title + description, stacks on mobile. */ export const dialogHeaderClass = (): string => 'flex flex-col gap-2 text-center sm:text-left'; -/** Dialog title — large semibold heading. */ export const dialogTitleClass = (): string => 'text-lg leading-none font-semibold'; -/** Dialog description — subdued caption below the title. */ export const dialogDescriptionClass = (): string => 'text-sm text-muted-foreground'; -/** Dialog footer — right-aligned actions on desktop, reverse-stacked on mobile. */ export const dialogFooterClass = (): string => 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end'; -/** Dialog content panel — centered, fixed-position box with shadow + border. */ export const dialogContentClass = (): string => 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none sm:max-w-lg'; -/** Dialog backdrop — fixed full-viewport translucent overlay. */ -export const dialogOverlayClass = (): string => 'fixed inset-0 z-50 bg-black/50'; - // -------------------------------------------------------------------------- -// Visibility CSS — installed once. Hides content + overlay when the host -// doesn't have the `open` attribute. This works at SSR time too: -// because SSR includes no `open` attribute by default, content is invisible -// until JS hydrates and the user opens the dialog. +// Visibility CSS. The pre-hydration SSR pass renders +// with the host having no [open] attribute, so it must stay hidden until +// JS upgrades. Once upgraded the native takes over: it is +// `display: none` while closed and `display: block` when showModal() +// puts it in the top layer. // -------------------------------------------------------------------------- const STYLES = ` -ui-dialog:not([open]) ui-dialog-content, -ui-dialog:not([open]) ui-dialog-overlay { display: none !important; } +ui-dialog:not([open]) ui-dialog-content { display: none !important; } ui-dialog[open] { display: contents; } ui-dialog-content { display: grid; } +ui-dialog dialog[data-slot="dialog-native"] { + border: 0; + background: transparent; + padding: 0; + margin: 0; + width: 0; + height: 0; + max-width: none; + max-height: none; + overflow: visible; + color: inherit; +} +ui-dialog dialog[data-slot="dialog-native"]::backdrop { + background: rgba(0, 0, 0, 0.5); +} `; function installStyles(): void { @@ -103,7 +122,9 @@ function installStyles(): void { } // -------------------------------------------------------------------------- -// Body scroll lock — refcounted so multiple open dialogs nest correctly. +// Body scroll lock. Refcounted so nested dialogs unlock in order. Native +// does not lock body scroll, only inert-ifies the background; +// preserved behavior parity with the previous version. // -------------------------------------------------------------------------- let scrollLockCount = 0; @@ -112,14 +133,6 @@ let savedPaddingRight = ''; function lockScroll(): void { if (scrollLockCount === 0) { - // Measure the vertical scrollbar's width BEFORE hiding it. When the body - // switches to `overflow: hidden` the OS-painted scrollbar disappears and - // the viewport gains back its width — without compensation, every - // element constrained to `100vw` (or the body itself, which fills the - // viewport) reflows ~15px to the right and the page visibly jumps. - // Reserving the same width as inline padding-right keeps the content - // pinned in place. Restored on unlock so apps that author their own - // body padding aren't permanently shifted. const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; savedOverflow = document.body.style.overflow; savedPaddingRight = document.body.style.paddingRight; @@ -138,27 +151,7 @@ function unlockScroll(): void { } // -------------------------------------------------------------------------- -// Focus management — find focusables, trap Tab, restore focus on close. -// -------------------------------------------------------------------------- - -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'textarea:not([disabled])', - 'select:not([disabled])', - '[tabindex]:not([tabindex="-1"])', - '[contenteditable="true"]', -].join(','); - -function getFocusables(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null, - ); -} - -// -------------------------------------------------------------------------- -// — owns open state, focus trap, escape, scroll lock. +// // -------------------------------------------------------------------------- export class UiDialog extends Base { @@ -166,24 +159,27 @@ export class UiDialog extends Base { return ['open']; } - private _previouslyFocused: HTMLElement | null = null; - private _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e); + private _native: HTMLDialogElement | null = null; + private _onNativeClose = (): void => { + if (this.isOpen) this.removeAttribute('open'); + }; + private _onNativeClick = (e: MouseEvent): void => { + if (e.target === this._native) this.hide(); + }; connectedCallback(): void { installStyles(); this.setAttribute('data-slot', 'dialog'); - // Auto-ensure an overlay element exists inside the dialog so styles work - // even if the author forgot to write . - if (!this.querySelector(':scope > ui-dialog-overlay')) { - const overlay = document.createElement('ui-dialog-overlay'); - this.insertBefore(overlay, this.firstChild); - } + this._wrap(); this._reflect(); + if (this.isOpen) this._setup(); } disconnectedCallback(): void { - if (this.isOpen) { - this._teardown(); + if (this.isOpen) this._teardown(); + if (this._native) { + this._native.removeEventListener('close', this._onNativeClose); + this._native.removeEventListener('click', this._onNativeClick as EventListener); } } @@ -219,9 +215,28 @@ export class UiDialog extends Base { this.isOpen = !this.isOpen; } + private _wrap(): void { + const content = this.querySelector(':scope > ui-dialog-content'); + if (!content) return; + // Already wrapped (HMR re-attach or repeated connectedCallback). + if (content.parentElement?.tagName === 'DIALOG') { + this._native = content.parentElement as HTMLDialogElement; + } else { + const dlg = document.createElement('dialog'); + dlg.setAttribute('data-slot', 'dialog-native'); + content.replaceWith(dlg); + dlg.appendChild(content); + this._native = dlg; + } + // The legacy is no longer needed; ::backdrop covers it. + this.querySelector(':scope > ui-dialog-overlay')?.remove(); + this._native.addEventListener('close', this._onNativeClose); + this._native.addEventListener('click', this._onNativeClick as EventListener); + } + private _reflect(): void { this.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); - const content = this.querySelector(':scope > ui-dialog-content'); + const content = this.querySelector('ui-dialog-content'); if (content) { content.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); content.setAttribute('role', 'dialog'); @@ -230,61 +245,20 @@ export class UiDialog extends Base { } private _setup(): void { - this._previouslyFocused = document.activeElement as HTMLElement | null; + if (!this._native) return; lockScroll(); - document.addEventListener('keydown', this._keyHandler); - // Focus first focusable inside the content after a microtask so DOM is settled. - queueMicrotask(() => { - const content = this.querySelector(':scope > ui-dialog-content'); - if (!content) return; - const focusables = getFocusables(content); - (focusables[0] ?? content).focus({ preventScroll: true }); - }); + if (!this._native.open) this._native.showModal(); } private _teardown(): void { unlockScroll(); - document.removeEventListener('keydown', this._keyHandler); - if (this._previouslyFocused) { - this._previouslyFocused.focus({ preventScroll: true }); - this._previouslyFocused = null; - } - } - - private _onKeyDown(e: KeyboardEvent): void { - if (!this.isOpen) return; - if (e.key === 'Escape') { - e.preventDefault(); - this.hide(); - return; - } - if (e.key === 'Tab') { - const content = this.querySelector(':scope > ui-dialog-content'); - if (!content) return; - const focusables = getFocusables(content); - if (focusables.length === 0) { - e.preventDefault(); - content.focus(); - return; - } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const active = document.activeElement as HTMLElement | null; - if (e.shiftKey && active === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && active === last) { - e.preventDefault(); - first.focus(); - } - } + if (this._native?.open) this._native.close(); } } defineElement('ui-dialog', UiDialog); // -------------------------------------------------------------------------- -// — clicks on this (or any element inside) open the -// enclosing . Decorator only — does not render anything. +// // -------------------------------------------------------------------------- export class UiDialogTrigger extends Base { @@ -296,27 +270,18 @@ export class UiDialogTrigger extends Base { this.removeEventListener('click', this._onClick); } private _onClick = (): void => { - const dialog = this.closest('ui-dialog') as UiDialog | null; - dialog?.show(); + (this.closest('ui-dialog') as UiDialog | null)?.show(); }; } defineElement('ui-dialog-trigger', UiDialogTrigger); // -------------------------------------------------------------------------- -// — the centered panel. Applies the visual classes from -// dialogContentClass() to its host, merging with any user-provided class. +// // -------------------------------------------------------------------------- -// Class helper for the auto-injected close button. shadcn ships an X -// pinned to the top-right of every DialogContent; we mirror that so the -// `` toggle has a default visual -// to inject. Class includes focus/hover states + the data-state-keyed -// fade matched to shadcn's DialogClose. export const dialogCloseButtonClass = (): string => "absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"; -// Inline X (lucide-style) auto-appended into when -// show-close-button is enabled (the default — matches shadcn). const DIALOG_CLOSE_X_SVG = ''; @@ -326,17 +291,6 @@ export class UiDialogContent extends Base { this.setAttribute('tabindex', '-1'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(dialogContentClass(), userClass); - // shadcn auto-injects an X in the top-right corner of every - // DialogContent, opt-out via `showCloseButton={false}`. Mirror the - // same default. The injected element is a real - // (defined below) so clicking it goes through the existing - // close-on-click path and ARIA wiring stays consistent. - // - // Skip if the user explicitly disabled it OR if they've already - // authored their own as a direct child (legacy - // hand-authored close-X pattern). A nested - // deeper (e.g. inside the footer as a Cancel button) does NOT - // suppress the X — it's a different role. const showCloseButton = this.getAttribute('show-close-button') !== 'false'; if (showCloseButton && !this.querySelector(':scope > ui-dialog-close')) { const closeEl = document.createElement('ui-dialog-close'); @@ -350,31 +304,7 @@ export class UiDialogContent extends Base { defineElement('ui-dialog-content', UiDialogContent); // -------------------------------------------------------------------------- -// — the translucent backdrop. Clicks here close the -// enclosing dialog (matches shadcn's modal-close-on-overlay behavior). -// -------------------------------------------------------------------------- - -export class UiDialogOverlay extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'dialog-overlay'); - this.setAttribute('aria-hidden', 'true'); - const userClass = this.getAttribute('class') ?? ''; - this.className = cn(dialogOverlayClass(), userClass); - this.addEventListener('click', this._onClick); - } - disconnectedCallback(): void { - this.removeEventListener('click', this._onClick); - } - private _onClick = (): void => { - const dialog = this.closest('ui-dialog') as UiDialog | null; - dialog?.hide(); - }; -} -defineElement('ui-dialog-overlay', UiDialogOverlay); - -// -------------------------------------------------------------------------- -// — clicks on this (or any element inside) close the -// enclosing dialog. +// // -------------------------------------------------------------------------- export class UiDialogClose extends Base { @@ -386,18 +316,13 @@ export class UiDialogClose extends Base { this.removeEventListener('click', this._onClick); } private _onClick = (): void => { - const dialog = this.closest('ui-dialog') as UiDialog | null; - dialog?.hide(); + (this.closest('ui-dialog') as UiDialog | null)?.hide(); }; } defineElement('ui-dialog-close', UiDialogClose); // -------------------------------------------------------------------------- -// — applies dialogFooterClass + optionally auto-injects -// a "Close" button via show-close-button="true". Shadcn ships -// showCloseButton on DialogFooter across all 15 styles (default false) -// so users can opt into a stock Close action without authoring the -// markup themselves. Mirrors the auto-X injection on . +// // -------------------------------------------------------------------------- export class UiDialogFooter extends Base { @@ -405,10 +330,6 @@ export class UiDialogFooter extends Base { this.setAttribute('data-slot', 'dialog-footer'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(dialogFooterClass(), userClass); - // Auto-inject a "Close" button when show-close-button="true" (or just - // the bare boolean attribute). Skip if the user already authored - // their own as a direct child — back-compat with - // hand-authored footers. const showClose = this.hasAttribute('show-close-button') && this.getAttribute('show-close-button') !== 'false'; if (showClose && !this.querySelector(':scope > ui-dialog-close')) { diff --git a/packages/ui/packages/registry/components/dropdown-menu.ts b/packages/ui/packages/registry/components/dropdown-menu.ts index e9f26b70..dfe06fb2 100644 --- a/packages/ui/packages/registry/components/dropdown-menu.ts +++ b/packages/ui/packages/registry/components/dropdown-menu.ts @@ -122,11 +122,18 @@ export const dropdownMenuSubTriggerClass = (): string => export const dropdownMenuSubContentClass = (): string => 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg'; +// Content panels opt into the native Popover API in manual mode so they +// render in the top layer (no z-index wars) while keyboard navigation, +// outside-click dismissal, and submenu logic stay JS-driven. Visibility +// is governed by `[popover]:not(:popover-open) { display: none }` (UA +// default), backed by our explicit show/hide-popover calls. The legacy +// `:not([open])` selectors are kept as a paint fallback for the moment +// before the JS runs. const STYLES = ` ui-dropdown-menu:not([open]) ui-dropdown-menu-content { display: none !important; } -ui-dropdown-menu-content { display: block; position: fixed; } +ui-dropdown-menu-content[popover] { display: block; position: fixed; margin: 0; padding: 0.25rem; border: 0; background: revert; color: revert; overflow: revert; } ui-dropdown-menu-sub:not([open]) ui-dropdown-menu-sub-content { display: none !important; } -ui-dropdown-menu-sub-content { display: block; position: fixed; } +ui-dropdown-menu-sub-content[popover] { display: block; position: fixed; margin: 0; padding: 0.25rem; border: 0; background: revert; color: revert; overflow: revert; } `; function installStyles(): void { @@ -183,13 +190,25 @@ export class UiDropdownMenu extends Base { side: (content.getAttribute('side') ?? 'bottom') as PopoverSide, align: (content.getAttribute('align') ?? 'start') as PopoverAlign, sideOffset: Number(content.getAttribute('side-offset') ?? 4), + alignOffset: Number(content.getAttribute('align-offset') ?? 0), }); } private _reflect(): void { const open = this.hasAttribute('open'); this.setAttribute('data-state', open ? 'open' : 'closed'); const content = this.querySelector(':scope > ui-dropdown-menu-content'); - content?.setAttribute('data-state', open ? 'open' : 'closed'); + if (!content) return; + content.setAttribute('data-state', open ? 'open' : 'closed'); + // Delegate visibility + top-layer placement to the native Popover API. + // `popover="manual"` is set in UiDropdownMenuContent.connectedCallback. + if (typeof (content as HTMLElement & { showPopover?: () => void }).showPopover === 'function') { + const isPopoverOpen = (content as HTMLElement & { matches: (s: string) => boolean }).matches(':popover-open'); + if (open && !isPopoverOpen) { + (content as HTMLElement & { showPopover: () => void }).showPopover(); + } else if (!open && isPopoverOpen) { + (content as HTMLElement & { hidePopover: () => void }).hidePopover(); + } + } } private _setup(): void { queueMicrotask(() => { @@ -207,7 +226,7 @@ export class UiDropdownMenu extends Base { document.removeEventListener('keydown', this._keyHandler); window.removeEventListener('resize', this._resizeHandler); window.removeEventListener('scroll', this._resizeHandler, true); - // Close any open submenus when the root closes — otherwise on re-open + // Close any open submenus when the root closes; otherwise on re-open // they'd still carry their `[open]` attribute and re-appear. this.querySelectorAll(':scope ui-dropdown-menu-sub[open]').forEach( (sub) => sub.hide(), @@ -294,6 +313,32 @@ export class UiDropdownMenu extends Base { sub?.hide(); trigger?.focus(); } + } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { + // Typeahead. Accumulate printable characters into a buffer (cleared + // after 500ms of inactivity) and focus the first item whose + // text-value or textContent starts with the buffer. Matches + // shadcn/Radix's behavior. + this._typeahead(e, items); + } + } + + private _typeBuffer = ''; + private _typeBufferTimer: number | undefined; + private _typeahead(e: KeyboardEvent, items: HTMLElement[]): void { + this._typeBuffer = (this._typeBuffer + e.key).toLowerCase(); + clearTimeout(this._typeBufferTimer); + this._typeBufferTimer = window.setTimeout(() => { this._typeBuffer = ''; }, 500); + const buffer = this._typeBuffer; + // textValue attribute overrides textContent so consumers can supply a + // typeahead string different from the visible label (e.g. an icon-only + // row labeled "Print" → text-value="print"). + const match = items.find((it) => { + const text = (it.getAttribute('text-value') ?? it.textContent ?? '').trim().toLowerCase(); + return text.startsWith(buffer); + }); + if (match) { + e.preventDefault(); + match.focus(); } } } @@ -315,6 +360,11 @@ export class UiDropdownMenuContent extends Base { connectedCallback(): void { this.setAttribute('data-slot', 'dropdown-menu-content'); this.setAttribute('role', 'menu'); + // Opt into the native top-layer via the Popover API. Manual mode (vs + // auto) keeps the existing outside-click handler + Escape handler + // authoritative, so nested submenus don't accidentally close their + // parent when the auto light-dismiss fires. + if (!this.hasAttribute('popover')) this.setAttribute('popover', 'manual'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(dropdownMenuContentClass(), userClass); } @@ -492,7 +542,18 @@ export class UiDropdownMenuSub extends Base { const trigger = this.querySelector(':scope > ui-dropdown-menu-sub-trigger'); const content = this.querySelector(':scope > ui-dropdown-menu-sub-content'); trigger?.setAttribute('data-state', open ? 'open' : 'closed'); - content?.setAttribute('data-state', open ? 'open' : 'closed'); + if (!content) return; + content.setAttribute('data-state', open ? 'open' : 'closed'); + // Same top-layer wiring as the root content; manual mode so the + // parent menu's existing handlers stay authoritative. + if (typeof (content as HTMLElement & { showPopover?: () => void }).showPopover === 'function') { + const isPopoverOpen = (content as HTMLElement & { matches: (s: string) => boolean }).matches(':popover-open'); + if (open && !isPopoverOpen) { + (content as HTMLElement & { showPopover: () => void }).showPopover(); + } else if (!open && isPopoverOpen) { + (content as HTMLElement & { hidePopover: () => void }).hidePopover(); + } + } } _position(): void { @@ -510,6 +571,7 @@ export class UiDropdownMenuSub extends Base { side: (content.getAttribute('side') ?? 'right') as PopoverSide, align: (content.getAttribute('align') ?? 'start') as PopoverAlign, sideOffset: Number(content.getAttribute('side-offset') ?? -4), + alignOffset: Number(content.getAttribute('align-offset') ?? 0), }); }); } @@ -560,6 +622,7 @@ export class UiDropdownMenuSubContent extends Base { connectedCallback(): void { this.setAttribute('data-slot', 'dropdown-menu-sub-content'); this.setAttribute('role', 'menu'); + if (!this.hasAttribute('popover')) this.setAttribute('popover', 'manual'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(dropdownMenuSubContentClass(), userClass); } diff --git a/packages/ui/packages/registry/components/hover-card.ts b/packages/ui/packages/registry/components/hover-card.ts index 1fc00cbc..df790db9 100644 --- a/packages/ui/packages/registry/components/hover-card.ts +++ b/packages/ui/packages/registry/components/hover-card.ts @@ -1,5 +1,8 @@ /** - * HoverCard — popover-like panel triggered by hover with configurable delays. + * HoverCard — popover-like panel triggered by hover with configurable + * open/close delays. The content uses the native Popover API in + * `popover="manual"` mode for top-layer rendering. The hover-with-linger + * state machine remains JS. * * shadcn parity: HoverCard, HoverCardTrigger, HoverCardContent. * open-delay, close-delay (ms). @@ -11,7 +14,7 @@ * * *
- * + * *
*

@vivek

*

Building webjs.

@@ -29,8 +32,10 @@ export const hoverCardContentClass = (): string => 'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden'; const STYLES = ` -ui-hover-card:not([open]) ui-hover-card-content { display: none !important; } -ui-hover-card-content { display: block; position: fixed; } +ui-hover-card-content[popover] { + position: fixed; + margin: 0; +} `; function installStyles(): void { @@ -76,12 +81,19 @@ export class UiHoverCard extends Base { side: (content.getAttribute('side') ?? 'bottom') as PopoverSide, align: (content.getAttribute('align') ?? 'center') as PopoverAlign, sideOffset: Number(content.getAttribute('side-offset') ?? 4), + alignOffset: Number(content.getAttribute('align-offset') ?? 0), }); } private _reflect(): void { - this.setAttribute('data-state', this.hasAttribute('open') ? 'open' : 'closed'); - const c = this.querySelector(':scope > ui-hover-card-content'); - c?.setAttribute('data-state', this.hasAttribute('open') ? 'open' : 'closed'); + const open = this.hasAttribute('open'); + this.setAttribute('data-state', open ? 'open' : 'closed'); + const content = this.querySelector(':scope > ui-hover-card-content'); + if (!content) return; + content.setAttribute('data-state', open ? 'open' : 'closed'); + if (typeof (content as HTMLElement & { showPopover?: () => void }).showPopover === 'function') { + if (open) (content as HTMLElement & { showPopover: () => void }).showPopover(); + else (content as HTMLElement & { hidePopover: () => void }).hidePopover(); + } } } defineElement('ui-hover-card', UiHoverCard); @@ -109,6 +121,10 @@ export class UiHoverCardContent extends Base { connectedCallback(): void { this.setAttribute('data-slot', 'hover-card-content'); this.setAttribute('role', 'dialog'); + // Opt into the native top-layer via the Popover API in manual mode. + // Manual (rather than auto) avoids the native light-dismiss closing + // the card when the cursor is briefly off the trigger. + if (!this.hasAttribute('popover')) this.setAttribute('popover', 'manual'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(hoverCardContentClass(), userClass); // Keep open while pointer is over the content itself. diff --git a/packages/ui/packages/registry/components/popover.ts b/packages/ui/packages/registry/components/popover.ts index 5e60aae7..92b8690a 100644 --- a/packages/ui/packages/registry/components/popover.ts +++ b/packages/ui/packages/registry/components/popover.ts @@ -1,68 +1,196 @@ /** - * Popover — floating panel anchored to a trigger. Hand-rolled positioning - * (no @floating-ui/dom). Auto-flips if there's not enough room. + * Popover — floating panel anchored to a trigger button, built on the + * native HTML Popover API (`popover` attribute + `popovertarget`). + * + * Tier-1 component (no custom element). The browser handles: + * - Open/close state via the `popover` attribute + showPopover()/hidePopover() + * - Top-layer rendering (no z-index wars) + * - Light-dismiss on outside click (with popover="auto") + * - Escape-to-close + * - Focus restoration to the invoking + *
+ *
+ *

Filter posts

+ *

By tag and status.

+ *
+ *
* - * Usage: - * - * - * - * - * - *
- *

Filter posts

- *

By tag and status.

- *
- * … - *
- *
+ * When you DO need explicit anchor binding (multiple invokers for the + * same popover, or anchoring to a different element than the invoker), + * add the `anchor-name` / `position-anchor` pair via inline style: * - * Events on ``: - * `ui-open-change` — { detail: { open: boolean } }. + * @vivek + * + *
* - * Keyboard: Escape closes; outside-click closes; Tab cycles content focus. + * CSS Anchor Positioning ships in Chrome 125+, Safari 26+, Firefox 134+. * - * Design tokens used: --popover, --popover-foreground, --border, - * --muted-foreground. + * For consumers that need imperative positioning right now, the + * `positionFloating` helper is still exported and is what the tier-2 + * tooltip / hover-card / dropdown-menu components use internally. + * + * Migrated from the prior / / + * custom elements. */ -import { cn, Base, defineElement } from '../lib/utils.ts'; // -------------------------------------------------------------------------- // Class helpers // -------------------------------------------------------------------------- -export const popoverContentClass = (): string => - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden'; +/** + * Popover content options — mirror shadcn's `` props. + * `side` and `align` map to CSS Anchor Positioning's `position-area`; + * `sideOffset` maps to a directional margin so the popover sits a few + * pixels off the anchor's edge. + * + * `sideOffset` is restricted to a discrete set so Tailwind 4's static + * scanner sees each emitted class literal. For arbitrary values, + * override the margin via inline style on the popover element. + */ +export type PopoverSideOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24; +export type PopoverAlignOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24; -export const popoverHeaderClass = (): string => 'flex flex-col gap-1 text-sm'; -export const popoverTitleClass = (): string => 'font-medium'; -export const popoverDescriptionClass = (): string => 'text-muted-foreground'; +export interface PopoverContentOptions { + /** Which side of the trigger the popover appears on. Default 'bottom'. */ + side?: PopoverSide; + /** Alignment along the chosen side. Default 'center'. */ + align?: PopoverAlign; + /** Pixels between the trigger and the popover. Default 4 (shadcn default). */ + sideOffset?: PopoverSideOffset; + /** + * Pixels offset along the align axis. Positive values push away from + * the aligned edge toward the opposite edge. No-op for align='center'. + * Default 0 (matches shadcn). + */ + alignOffset?: PopoverAlignOffset; +} -// -------------------------------------------------------------------------- -// Visibility CSS -// -------------------------------------------------------------------------- +// position-area combinations baked as literal class strings so Tailwind 4's +// scanner generates CSS for every (side, align) pair the helper can return. +// Underscores become spaces in arbitrary CSS values. +const POSITION_AREA_CLASS: Record = { + 'top-start': '[position-area:top_span-right]', + 'top-center': '[position-area:top]', + 'top-end': '[position-area:top_span-left]', + 'bottom-start': '[position-area:bottom_span-right]', + 'bottom-center': '[position-area:bottom]', + 'bottom-end': '[position-area:bottom_span-left]', + 'left-start': '[position-area:left_span-bottom]', + 'left-center': '[position-area:left]', + 'left-end': '[position-area:left_span-top]', + 'right-start': '[position-area:right_span-bottom]', + 'right-center': '[position-area:right]', + 'right-end': '[position-area:right_span-top]', +}; -const STYLES = ` -ui-popover:not([open]) ui-popover-content { display: none !important; } -ui-popover-content { display: block; position: fixed; } -`; - -function installStyles(): void { - if (typeof document === 'undefined') return; - if (document.getElementById('ui-popover-styles')) return; - const style = document.createElement('style'); - style.id = 'ui-popover-styles'; - style.textContent = STYLES; - document.head.appendChild(style); +// Align-axis translate classes. For align='start', positive offset moves +// the popover AWAY from the start edge (toward the end edge); align='end' +// reverses. align='center' is a no-op. The axis is perpendicular to the +// side: top/bottom sides translate X; left/right sides translate Y. +// All 36 (4 axis-direction combos × 9 offset values) appear literally so +// Tailwind 4 generates each class. +const ALIGN_OFFSET_CLASS: Record> = { + 'horizontal-start': { + 0: 'translate-x-[0px]', 2: 'translate-x-[2px]', 4: 'translate-x-[4px]', + 6: 'translate-x-[6px]', 8: 'translate-x-[8px]', 12: 'translate-x-[12px]', + 16: 'translate-x-[16px]', 20: 'translate-x-[20px]', 24: 'translate-x-[24px]', + }, + 'horizontal-end': { + 0: 'translate-x-[0px]', 2: 'translate-x-[-2px]', 4: 'translate-x-[-4px]', + 6: 'translate-x-[-6px]', 8: 'translate-x-[-8px]', 12: 'translate-x-[-12px]', + 16: 'translate-x-[-16px]', 20: 'translate-x-[-20px]', 24: 'translate-x-[-24px]', + }, + 'vertical-start': { + 0: 'translate-y-[0px]', 2: 'translate-y-[2px]', 4: 'translate-y-[4px]', + 6: 'translate-y-[6px]', 8: 'translate-y-[8px]', 12: 'translate-y-[12px]', + 16: 'translate-y-[16px]', 20: 'translate-y-[20px]', 24: 'translate-y-[24px]', + }, + 'vertical-end': { + 0: 'translate-y-[0px]', 2: 'translate-y-[-2px]', 4: 'translate-y-[-4px]', + 6: 'translate-y-[-6px]', 8: 'translate-y-[-8px]', 12: 'translate-y-[-12px]', + 16: 'translate-y-[-16px]', 20: 'translate-y-[-20px]', 24: 'translate-y-[-24px]', + }, +}; + +// Per-side offset margin classes. Side 'bottom' wants margin-top, etc. +// Each value of PopoverSideOffset appears literally for Tailwind's scanner. +const MARGIN_OFFSET_CLASS: Record> = { + top: { + 0: '[margin-bottom:0px]', 2: '[margin-bottom:2px]', 4: '[margin-bottom:4px]', + 6: '[margin-bottom:6px]', 8: '[margin-bottom:8px]', 12: '[margin-bottom:12px]', + 16: '[margin-bottom:16px]', 20: '[margin-bottom:20px]', 24: '[margin-bottom:24px]', + }, + bottom: { + 0: '[margin-top:0px]', 2: '[margin-top:2px]', 4: '[margin-top:4px]', + 6: '[margin-top:6px]', 8: '[margin-top:8px]', 12: '[margin-top:12px]', + 16: '[margin-top:16px]', 20: '[margin-top:20px]', 24: '[margin-top:24px]', + }, + left: { + 0: '[margin-right:0px]', 2: '[margin-right:2px]', 4: '[margin-right:4px]', + 6: '[margin-right:6px]', 8: '[margin-right:8px]', 12: '[margin-right:12px]', + 16: '[margin-right:16px]', 20: '[margin-right:20px]', 24: '[margin-right:24px]', + }, + right: { + 0: '[margin-left:0px]', 2: '[margin-left:2px]', 4: '[margin-left:4px]', + 6: '[margin-left:6px]', 8: '[margin-left:8px]', 12: '[margin-left:12px]', + 16: '[margin-left:16px]', 20: '[margin-left:20px]', 24: '[margin-left:24px]', + }, +}; + +/** + * Popover content class. `side` and `align` cover the shadcn + * `` placement props; `sideOffset` sets the gap to + * the anchor. The visual layer (border, bg, padding, shadow) is + * fixed to match shadcn's default; width is opinionated at `w-72` + * (override at the call site). + * + * `m-0` clears the UA `margin: auto` so anchor positioning isn't + * fighting auto-centering, then a single directional margin sets the + * sideOffset gap. + */ +export function popoverContentClass(opts: PopoverContentOptions = {}): string { + const side = opts.side ?? 'bottom'; + const align = opts.align ?? 'center'; + const sideOffset = opts.sideOffset ?? 4; + const alignOffset = opts.alignOffset ?? 0; + // align='center' has no align axis to offset along — skip the translate. + let alignClass = ''; + if (align !== 'center' && alignOffset !== 0) { + const axis = side === 'top' || side === 'bottom' ? 'horizontal' : 'vertical'; + alignClass = ALIGN_OFFSET_CLASS[`${axis}-${align}`][alignOffset]; + } + return [ + 'w-72 m-0 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden', + POSITION_AREA_CLASS[`${side}-${align}`], + MARGIN_OFFSET_CLASS[side][sideOffset], + alignClass, + ].filter(Boolean).join(' '); } +export const popoverHeaderClass = (): string => 'flex flex-col gap-1 text-sm'; +export const popoverTitleClass = (): string => 'font-medium'; +export const popoverDescriptionClass = (): string => 'text-muted-foreground'; + // -------------------------------------------------------------------------- -// Positioning helper. Computes top/left for a content element relative to a -// trigger, given side + align + offset. Auto-flips when off-screen. +// Imperative positioning helper. Still exported for the tier-2 tooltip / +// hover-card / dropdown-menu components, which need exact placement before +// CSS anchor positioning is universally available. // -------------------------------------------------------------------------- export type PopoverSide = 'top' | 'bottom' | 'left' | 'right'; @@ -71,11 +199,17 @@ export type PopoverAlign = 'start' | 'center' | 'end'; export function positionFloating( trigger: HTMLElement, content: HTMLElement, - opts: { side?: PopoverSide; align?: PopoverAlign; sideOffset?: number } = {}, + opts: { + side?: PopoverSide; + align?: PopoverAlign; + sideOffset?: number; + alignOffset?: number; + } = {}, ): void { const side = opts.side ?? 'bottom'; const align = opts.align ?? 'center'; const sideOffset = opts.sideOffset ?? 4; + const alignOffset = opts.alignOffset ?? 0; const tr = trigger.getBoundingClientRect(); const cr = content.getBoundingClientRect(); const vw = window.innerWidth; @@ -111,7 +245,18 @@ export function positionFloating( else left = tr.left + (tr.width - cr.width) / 2; } - // Clamp into viewport + // alignOffset shifts the popover along the align axis. For align='start', + // positive shifts AWAY from the start edge; align='end' reverses; center + // is a no-op. Axis is perpendicular to the chosen side. + if (align !== 'center' && alignOffset !== 0) { + const dir = align === 'start' ? 1 : -1; + if (actualSide === 'top' || actualSide === 'bottom') { + left += dir * alignOffset; + } else { + top += dir * alignOffset; + } + } + left = Math.max(8, Math.min(left, vw - cr.width - 8)); top = Math.max(8, Math.min(top, vh - cr.height - 8)); @@ -120,118 +265,3 @@ export function positionFloating( content.setAttribute('data-side', actualSide); content.setAttribute('data-align', align); } - -// -------------------------------------------------------------------------- -// -// -------------------------------------------------------------------------- - -export class UiPopover extends Base { - static get observedAttributes(): string[] { - return ['open']; - } - - private _docClickHandler = (e: MouseEvent): void => this._onDocClick(e); - private _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e); - private _resizeHandler = (): void => this._reposition(); - - connectedCallback(): void { - installStyles(); - this.setAttribute('data-slot', 'popover'); - this._reflect(); - } - disconnectedCallback(): void { - if (this.isOpen) this._teardown(); - } - attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void { - if (name === 'open' && oldVal !== newVal) { - this._reflect(); - if (newVal !== null) this._setup(); - else this._teardown(); - this.dispatchEvent( - new CustomEvent('ui-open-change', { detail: { open: this.isOpen }, bubbles: true }), - ); - } - } - - get isOpen(): boolean { - return this.hasAttribute('open'); - } - show(): void { - this.setAttribute('open', ''); - } - hide(): void { - this.removeAttribute('open'); - } - toggle(): void { - if (this.isOpen) this.hide(); - else this.show(); - } - - _reposition(): void { - const trigger = this.querySelector(':scope > ui-popover-trigger'); - const content = this.querySelector(':scope > ui-popover-content'); - if (!trigger || !content) return; - positionFloating(trigger, content, { - side: (content.getAttribute('side') ?? 'bottom') as PopoverSide, - align: (content.getAttribute('align') ?? 'center') as PopoverAlign, - sideOffset: Number(content.getAttribute('side-offset') ?? 4), - }); - } - - private _reflect(): void { - this.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); - const content = this.querySelector(':scope > ui-popover-content'); - if (content) content.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); - } - - private _setup(): void { - queueMicrotask(() => { - this._reposition(); - document.addEventListener('click', this._docClickHandler); - document.addEventListener('keydown', this._keyHandler); - window.addEventListener('resize', this._resizeHandler); - window.addEventListener('scroll', this._resizeHandler, true); - }); - } - private _teardown(): void { - document.removeEventListener('click', this._docClickHandler); - document.removeEventListener('keydown', this._keyHandler); - window.removeEventListener('resize', this._resizeHandler); - window.removeEventListener('scroll', this._resizeHandler, true); - } - - private _onDocClick(e: MouseEvent): void { - if (!this.isOpen) return; - if (e.composedPath().some((n) => n === this)) return; - this.hide(); - } - private _onKeyDown(e: KeyboardEvent): void { - if (e.key === 'Escape' && this.isOpen) this.hide(); - } -} -defineElement('ui-popover', UiPopover); - -export class UiPopoverTrigger extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'popover-trigger'); - this.addEventListener('click', this._onClick); - } - disconnectedCallback(): void { - this.removeEventListener('click', this._onClick); - } - private _onClick = (): void => { - (this.closest('ui-popover') as UiPopover | null)?.toggle(); - }; -} -defineElement('ui-popover-trigger', UiPopoverTrigger); - -export class UiPopoverContent extends Base { - connectedCallback(): void { - this.setAttribute('data-slot', 'popover-content'); - this.setAttribute('role', 'dialog'); - this.setAttribute('tabindex', '-1'); - const userClass = this.getAttribute('class') ?? ''; - this.className = cn(popoverContentClass(), userClass); - } -} -defineElement('ui-popover-content', UiPopoverContent); diff --git a/packages/ui/packages/registry/components/tooltip.ts b/packages/ui/packages/registry/components/tooltip.ts index b1e23228..68fec1ef 100644 --- a/packages/ui/packages/registry/components/tooltip.ts +++ b/packages/ui/packages/registry/components/tooltip.ts @@ -1,12 +1,20 @@ /** - * Tooltip — hover/focus-triggered floating tip. + * Tooltip — hover/focus-triggered floating tip. The tooltip content uses + * the native Popover API in `popover="manual"` mode so it renders in the + * top layer (no z-index wars) while the custom element retains control of + * the hover-with-delay state machine. * * shadcn parity: * Tooltip, TooltipTrigger, TooltipContent, TooltipProvider. - * delay-duration attribute (ms, default 700). + * delay-duration attribute (ms, default 700) — initial hover delay + * skip-delay-duration attribute (ms, default 300) — window after a tooltip + * closes during which the + * next tooltip skips its + * delay-duration + * side / align / side-offset / align-offset — placement (positionFloating) * * Usage: - * + * * * * @@ -21,9 +29,20 @@ import { positionFloating, type PopoverSide, type PopoverAlign } from './popover export const tooltipContentClass = (): string => 'z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background'; +// `[popover]:not(:popover-open) { display: none }` is the UA default; +// we add `position: fixed` so JS-computed top/left coordinates take effect +// in the top layer. Authors who want CSS anchor positioning instead can +// override at the call site. const STYLES = ` -ui-tooltip:not([open]) ui-tooltip-content { display: none !important; } -ui-tooltip-content { display: block; position: fixed; } +ui-tooltip-content[popover] { + position: fixed; + margin: 0; + border: 0; + padding: revert; + background: revert; + color: revert; + overflow: visible; +} `; function installStyles(): void { @@ -35,6 +54,12 @@ function installStyles(): void { document.head.appendChild(style); } +// Module-level "last close" timestamp, shared across every on +// the page. When the next tooltip is hovered within `skip-delay-duration` +// ms of this stamp, it skips its `delay-duration` wait and opens +// immediately — matching shadcn's TooltipProvider.skipDelayDuration. +let lastTooltipHideAt = 0; + export class UiTooltip extends Base { static get observedAttributes(): string[] { return ['open']; @@ -57,11 +82,22 @@ export class UiTooltip extends Base { show(): void { clearTimeout(this._hideTimer); const delay = Number(this.getAttribute('delay-duration') ?? 700); + const skipDelay = Number(this.getAttribute('skip-delay-duration') ?? 300); + // Within the skip window after another tooltip just closed, open + // immediately (no delay-duration wait). + const sinceLastHide = Date.now() - lastTooltipHideAt; + if (lastTooltipHideAt > 0 && sinceLastHide < skipDelay) { + this.setAttribute('open', ''); + return; + } this._showTimer = window.setTimeout(() => this.setAttribute('open', ''), delay); } hide(): void { clearTimeout(this._showTimer); - this._hideTimer = window.setTimeout(() => this.removeAttribute('open'), 100); + this._hideTimer = window.setTimeout(() => { + this.removeAttribute('open'); + lastTooltipHideAt = Date.now(); + }, 100); } _reposition(): void { const trigger = this.querySelector(':scope > ui-tooltip-trigger'); @@ -71,12 +107,22 @@ export class UiTooltip extends Base { side: (content.getAttribute('side') ?? 'top') as PopoverSide, align: (content.getAttribute('align') ?? 'center') as PopoverAlign, sideOffset: Number(content.getAttribute('side-offset') ?? 4), + alignOffset: Number(content.getAttribute('align-offset') ?? 0), }); } private _reflect(): void { - this.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); - const c = this.querySelector(':scope > ui-tooltip-content'); - c?.setAttribute('data-state', this.isOpen ? 'open' : 'closed'); + const open = this.isOpen; + this.setAttribute('data-state', open ? 'open' : 'closed'); + const content = this.querySelector(':scope > ui-tooltip-content'); + if (!content) return; + content.setAttribute('data-state', open ? 'open' : 'closed'); + // Delegate visibility to the native popover. The popover attribute is + // wired by 's connectedCallback; here we just flip + // the popover-open state to match our `open` attribute. + if (typeof (content as HTMLElement & { showPopover?: () => void }).showPopover === 'function') { + if (open) (content as HTMLElement & { showPopover: () => void }).showPopover(); + else (content as HTMLElement & { hidePopover: () => void }).hidePopover(); + } } } defineElement('ui-tooltip', UiTooltip); @@ -104,6 +150,11 @@ export class UiTooltipContent extends Base { connectedCallback(): void { this.setAttribute('data-slot', 'tooltip-content'); this.setAttribute('role', 'tooltip'); + // Opt into the native top-layer via the Popover API in manual mode. + // We drive show/hide ourselves (hover delay), so manual is the right + // mode: auto would also dismiss on outside click, which isn't what a + // hover tooltip wants. + if (!this.hasAttribute('popover')) this.setAttribute('popover', 'manual'); const userClass = this.getAttribute('class') ?? ''; this.className = cn(tooltipContentClass(), userClass); } diff --git a/packages/ui/packages/website/app/_lib/tier.ts b/packages/ui/packages/website/app/_lib/tier.ts index 9e312f3b..b9b84604 100644 --- a/packages/ui/packages/website/app/_lib/tier.ts +++ b/packages/ui/packages/website/app/_lib/tier.ts @@ -14,22 +14,25 @@ import type { RegistryItem } from './registry.server.ts'; /** - * The 12 Tier-2 components — stateful custom elements (`` tags) + * The 9 Tier-2 components — stateful custom elements (`` tags) * that manage focus, keyboard nav, open/close state, etc. Everything * else with `type === 'registry:ui'` is Tier 1. * + * popover, accordion, and collapsible moved to Tier 1 once their + * source files were rewritten to be pure class helpers on native HTML + * primitives (the Popover API and
/). They still + * appear in the registry but are now native-HTML compositions rather + * than custom elements. + * * When adding a new component to the registry, add its name here if its * source defines `class X extends WebComponent` + `.register('ui-...')`. */ export const TIER_2_NAMES: ReadonlySet = new Set([ 'dialog', 'alert-dialog', - 'popover', 'tooltip', 'hover-card', 'tabs', - 'accordion', - 'collapsible', 'dropdown-menu', 'sonner', 'progress', diff --git a/packages/ui/packages/website/app/docs/components/[name]/component-api.ts b/packages/ui/packages/website/app/docs/components/[name]/component-api.ts index 1e195a94..fc2b27c3 100644 --- a/packages/ui/packages/website/app/docs/components/[name]/component-api.ts +++ b/packages/ui/packages/website/app/docs/components/[name]/component-api.ts @@ -229,19 +229,18 @@ export const COMPONENT_API: Record = { accordion: { subcomponents: [ - { name: '', description: 'Root — accepts type="single | multiple" and a controlled value.' }, - { name: '', description: 'One row. value attribute links it to the active set.' }, - { name: '', description: 'Clickable header inside an item.' }, - { name: '', description: 'Collapsible body inside an item.' }, + { name: '
', description: 'One row. Items sharing the same name attribute form an exclusive group (Radix type="single"); omit name for independent items (type="multiple").' }, + { name: '', description: 'Clickable header inside a
. Apply accordionTriggerClass() and hide the native ::marker.' }, + { name: 'accordionClass()', description: 'Wrapper around the column of
rows.' }, + { name: 'accordionItemClass()', description: 'Applied to each
. Adds `group` so the trigger chevron can rotate on `group-open:`.' }, + { name: 'accordionTriggerClass()', description: 'Applied to each . Hides the native disclosure marker.' }, + { name: 'accordionContentClass()', description: 'Applied to the content wrapper inside
.' }, ], props: [ - { name: 'type', type: '"single" | "multiple"', default: '"single"' }, - { name: 'collapsible', type: 'boolean (attribute)', default: 'false', description: 'On type="single" — allow closing the open item.' }, - { name: 'orientation', type: '"vertical" | "horizontal"', default: '"vertical"', description: 'Reflected to data-orientation on the host so Tailwind data-[orientation=…]:… selectors fire.' }, - { name: 'value', type: 'string | string[]', description: 'Controlled active item(s).' }, - ], - events: [ - { name: 'ui-value-change', detail: '{ value: string | string[] }', description: 'Fired when the active set changes.' }, + { name: 'open', type: 'boolean (HTML attribute on
)', default: 'absent', description: 'Set on a
to render it expanded on first paint.' }, + { name: 'name', type: 'string (HTML attribute on
)', default: 'absent', description: 'Items sharing a name form an exclusive group (only one open at a time).' }, + { name: 'disabled', type: 'boolean (argument to accordionTriggerClass)', default: 'false', description: 'Visual disabled state on the . Combine with the standard `inert` attribute on the
for full keyboard prevention — native
has no `disabled` attribute.' }, + { name: 'orientation="horizontal"', type: '— not supported', description: 'Native
/ is always vertical (summary above, content below). For a horizontal disclosure, use instead.' }, ], }, @@ -256,7 +255,7 @@ export const COMPONENT_API: Record = { subcomponents: [ { name: '', description: 'Root — owns the open state.' }, { name: '', description: 'Opens the dialog on click.' }, - { name: '', description: 'Modal panel — role="alertdialog", focus trap, no escape/overlay-close.' }, + { name: '', description: 'Modal panel. Backed by native .showModal() with role="alertdialog"; native Escape close is cancelled via the dialog cancel event, and backdrop click is intentionally not wired (user MUST choose Cancel or Action).' }, { name: '', description: 'Primary action button — applies buttonClass automatically. Accepts `variant` (default "default") and `size` (default "default"). Closes the dialog on click.' }, { name: '', description: 'Cancel button — applies buttonClass automatically. Accepts `variant` (default "outline") and `size` (default "default"). Closes the dialog on click.' }, { name: 'alertDialogHeaderClass() / TitleClass() / DescriptionClass() / FooterClass() / OverlayClass()', description: 'Class helpers for the static prose layout.' }, @@ -273,7 +272,7 @@ export const COMPONENT_API: Record = { subcomponents: [ { name: '', description: 'Root — owns the open state.' }, { name: '', description: 'Opens the dialog on click.' }, - { name: '', description: 'Modal panel — focus trap, Escape to close, body-scroll lock. Auto-injects an X close button in the top-right corner unless show-close-button="false".' }, + { name: '', description: 'Modal panel. Backed by native .showModal(): top-layer rendering, focus trap, Escape to close, and ::backdrop overlay are all provided by the browser. We add body-scroll lock + auto-injected X close button in the top-right corner unless show-close-button="false".' }, { name: '', description: 'Footer row. Optionally auto-appends a "Close" outline button when show-close-button is set.' }, { name: '', description: 'Close button — wrap any element to close on click.' }, { name: 'dialogHeaderClass() / TitleClass() / DescriptionClass() / FooterClass() / ContentClass() / OverlayClass() / CloseButtonClass()', description: 'Class helpers for prose layout + close-button positioning.' }, @@ -315,21 +314,28 @@ export const COMPONENT_API: Record = { { name: 'inset', type: 'boolean (attribute)', default: 'false', description: 'On , , and — left-pad for icon alignment so the row aligns with sibling items that have leading icons.' }, { name: 'side', type: '"top" | "right" | "bottom" | "left"', default: '"bottom" (content) / "right" (sub-content)' }, { name: 'align', type: '"start" | "center" | "end"', default: '"start"' }, + { name: 'side-offset', type: 'number (px)', default: '4 (content) / -4 (sub-content)', description: 'Attribute on and .' }, + { name: 'align-offset', type: 'number (px)', default: '0', description: 'Attribute on and . Pixels offset along the align axis.' }, + { name: 'text-value', type: 'string (attribute)', description: 'On . Override the string matched during typeahead. Defaults to the item textContent. Matches shadcn/Radix textValue.' }, ], }, popover: { subcomponents: [ - { name: '', description: 'Root — owns the open state.' }, - { name: '', description: 'Toggles the popover on click.' }, - { name: '', description: 'Floating panel — side / align / side-offset attributes.' }, + { name: ' - - -
-

Filter

-

Tag and status.

-
-
- - -
-
-
+ +
+
+

Filter

+

Tag and status.

+
+
+ + +
+
`, tooltip: ` @@ -444,27 +454,46 @@ const EXAMPLES: Record = { `, accordion: ` - - - Is it accessible? - Yes — uses the WAI-ARIA accordion pattern. - - - Is it styled? - Yes — matches shadcn's design tokens. - - +
+
+ + Is it accessible? + + +
Yes. Native disclosure widget pattern.
+
+
+ + Is it styled? + + +
Yes. Matches shadcn design tokens.
+
+
`, collapsible: ` - - - - - - Hidden content revealed on trigger click. Real content, real DOM — no animation in v1. - - +
+ + Show / Hide details + + +
+ Hidden content revealed when the disclosure widget opens. Real content, real DOM. +
+
`, 'dropdown-menu': ` diff --git a/packages/ui/packages/website/app/docs/page.ts b/packages/ui/packages/website/app/docs/page.ts index 9738cb63..aea67c01 100644 --- a/packages/ui/packages/website/app/docs/page.ts +++ b/packages/ui/packages/website/app/docs/page.ts @@ -9,9 +9,10 @@ export default function Docs() {

Webjs UI is an AI-first component library. Two tiers: pure class-helper functions (buttonClass(), cardClass(), inputClass()) - you spread onto raw native elements, plus a small set of stateful custom elements - (<ui-dialog>, <ui-tabs>, <ui-popover>) - for state the browser doesn't give you natively. You install the CLI once and add components + you spread onto raw native elements (including <dialog>, + <details>, and the popover attribute), plus a small set of + stateful custom elements (<ui-dialog>, <ui-tabs>, + <ui-dropdown-menu>) that own the behavior native HTML still lacks. You install the CLI once and add components to your project as you need them — the source is copied into your repo, so you own it and can edit it freely. Variant names and data-attribute conventions mirror shadcn so existing shadcn knowledge maps directly. diff --git a/packages/ui/packages/website/app/page.ts b/packages/ui/packages/website/app/page.ts index c64471c2..feef1ad9 100644 --- a/packages/ui/packages/website/app/page.ts +++ b/packages/ui/packages/website/app/page.ts @@ -166,9 +166,10 @@ npx webjsui add button card dialog Apply to any native element. Same variants and sizes as shadcn.

- 20 components · button, card, badge, alert, input, textarea, label, + 23 components · button, card, badge, alert, input, textarea, label, checkbox, switch, radio, native‑select, avatar, separator, skeleton, - aspect‑ratio, kbd, table, toggle, breadcrumb, pagination + aspect‑ratio, kbd, table, toggle, breadcrumb, pagination, popover, + accordion, collapsible
@@ -177,12 +178,13 @@ npx webjsui add button card dialog

<ui-dialog>, <ui-tabs>, - <ui-popover>… - Manage open/close, keyboard nav, focus trap, escape, click‑outside. + <ui-dropdown-menu>… + Manage what the platform doesn't: keyboard nav for menus + tabs, + hover‑with‑delay for tooltips, toast queue.

- 12 components · dialog, alert‑dialog, popover, tooltip, hover‑card, - tabs, accordion, collapsible, dropdown‑menu, sonner, progress, toggle‑group + 9 components · dialog, alert‑dialog, tooltip, hover‑card, + tabs, dropdown‑menu, sonner, progress, toggle‑group
diff --git a/packages/ui/test/registry-contents.test.js b/packages/ui/test/registry-contents.test.js index 7c10512d..64c17ea1 100644 --- a/packages/ui/test/registry-contents.test.js +++ b/packages/ui/test/registry-contents.test.js @@ -41,10 +41,13 @@ const V1_COMPONENTS = [ ]; // Components that are Tier 2 — must register a custom element. +// popover, accordion, collapsible moved to Tier 1 once their sources +// became pure class helpers on native HTML (Popover API, +//
/). They no longer extend Base or call defineElement. const TIER_2 = new Set([ 'progress', 'toggle', 'toggle-group', - 'dialog', 'alert-dialog', 'popover', 'tooltip', 'hover-card', - 'tabs', 'accordion', 'collapsible', + 'dialog', 'alert-dialog', 'tooltip', 'hover-card', + 'tabs', 'dropdown-menu', 'sonner', ]); @@ -135,13 +138,13 @@ test('card — exposes all 7 subpart class helpers (no custom elements)', { skip } }); -test('dialog — has focus-trap, escape, role wiring', { skip }, () => { +test('dialog — delegates to native for modal behavior', { skip }, () => { const src = readSource('dialog'); assert.match(src, /'role',\s*'dialog'|"role",\s*"dialog"|role="dialog"/); assert.match(src, /aria-modal/); - assert.match(src, /Escape/); - assert.match(src, /Tab/); - assert.match(src, /focusable/i); + // Native dialog is what owns Escape, Tab cycling, and focus restoration. + assert.match(src, /showModal/); + assert.match(src, /HTMLDialogElement/); assert.match(src, /defineElement\(['"]ui-dialog['"]/); }); @@ -149,14 +152,98 @@ test('alert-dialog — uses alertdialog role, no overlay-click-to-close', { skip const src = readSource('alert-dialog'); assert.match(src, /alertdialog/); assert.match(src, /No click-to-close/); + // Native Escape close is cancelled via the dialog's `cancel` event. + assert.match(src, /cancel/); + assert.match(src, /showModal/); }); -test('popover — positioning helper + side/align/side-offset attrs', { skip }, () => { +test('popover — tier-1 class helpers + positionFloating utility export', { skip }, () => { const src = readSource('popover'); - assert.match(src, /positionFloating/); - assert.match(src, /side/); - assert.match(src, /align/); - assert.match(src, /side-offset/); + // No custom element: pure class helpers + a positioning utility for + // sibling tier-2 components. + assert.doesNotMatch(src, /defineElement\(/); + assert.match(src, /export\s+function\s+positionFloating/); + // Parameterized helper with shadcn parity for side / align / sideOffset / alignOffset. + assert.match(src, /export\s+function\s+popoverContentClass\s*\(/); + assert.match(src, /PopoverContentOptions/); + assert.match(src, /side\??:\s*PopoverSide/); + assert.match(src, /align\??:\s*PopoverAlign/); + assert.match(src, /sideOffset/); + assert.match(src, /alignOffset/); + // position-area pre-baked classes (Tailwind 4 scanner needs literals). + assert.match(src, /\[position-area:bottom_span-right\]/); + assert.match(src, /\[position-area:top_span-left\]/); + // alignOffset translate classes baked as literals. + assert.match(src, /translate-x-\[4px\]/); + assert.match(src, /translate-x-\[-4px\]/); + assert.match(src, /translate-y-\[4px\]/); + assert.match(src, /translate-y-\[-4px\]/); + // popover invoker pattern referenced in the JSDoc. + assert.match(src, /popovertarget|popover\s+attribute|Popover API/i); +}); + +test('positionFloating — accepts alignOffset for tier-2 placement', { skip }, () => { + const src = readSource('popover'); + // The utility consumed by tooltip / hover-card / dropdown-menu must + // accept alignOffset alongside sideOffset. + assert.match(src, /alignOffset\??:\s*number/); +}); + +test('accordion / collapsible — disabled option on trigger class helper', { skip }, () => { + for (const name of ['accordion', 'collapsible']) { + const src = readSource(name); + assert.match(src, /disabled\??:\s*boolean/, `${name}: trigger class missing { disabled } option`); + assert.match(src, /pointer-events-none/, `${name}: disabled state should include pointer-events-none`); + assert.match(src, /inert/, `${name}: docs should mention the native inert attribute for full disable`); + } +}); + +test('tier-2 components — read align-offset attribute', { skip }, () => { + for (const name of ['tooltip', 'hover-card', 'dropdown-menu']) { + const src = readSource(name); + assert.match(src, /align-offset/, `${name}: should read align-offset attribute`); + assert.match(src, /alignOffset/, `${name}: should pass alignOffset to positionFloating`); + } +}); + +test('tooltip — skip-delay-duration attribute', { skip }, () => { + const src = readSource('tooltip'); + assert.match(src, /skip-delay-duration/); + assert.match(src, /lastTooltipHideAt|lastHideAt|skipDelay/i); +}); + +test('dropdown-menu — typeahead via text-value', { skip }, () => { + const src = readSource('dropdown-menu'); + assert.match(src, /typeahead/i); + assert.match(src, /text-value/); +}); + +test('accordion — tier-1 class helpers on native
/', { skip }, () => { + const src = readSource('accordion'); + assert.doesNotMatch(src, /defineElement\(/); + assert.match(src, /
/', { skip }, () => { + const src = readSource('collapsible'); + assert.doesNotMatch(src, /defineElement\(/); + assert.match(src, /
{ + for (const name of ['dropdown-menu', 'tooltip', 'hover-card']) { + const src = readSource(name); + assert.match(src, /popover/i, `${name}: should reference the Popover API`); + assert.match(src, /showPopover|hidePopover/, `${name}: should call showPopover/hidePopover`); + } }); test('tabs — exposes Arrow-key navigation + roles', { skip }, () => {