Skip to content

Commit f6a9dbf

Browse files
authored
feat(ui): native-HTML rewrite of stateful primitives + shadcn-API sweep (#5)
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 -> <button popovertarget=...> + <div popover> + CSS Anchor Positioning. popoverContentClass({ side, align, sideOffset, alignOffset }) emits literal Tailwind 4 arbitrary classes ([position-area:...], translate, etc). Implicit anchor via popovertarget removes the inline anchor-name / position-anchor styles for the common case. - accordion -> <details name=...> + <summary>. The native name attribute provides exclusive-open ("type=single"). accordionTriggerClass({ disabled }) renders the visual disabled state; docs recommend native inert for full keyboard prevention. orientation=horizontal is deliberately not supported (fights <details>). - collapsible -> <details> + <summary>. collapsibleTriggerClass({ disabled }) matches the accordion approach. Tier-2 simplified internals (custom element stays, native does the work): - dialog -> wraps <ui-dialog-content> in a programmatic <dialog> and calls .showModal(). Native focus trap, Tab cycle, Escape close, focus restore, and ::backdrop replace ~250 lines of hand-rolled JS. We retain body scroll lock + auto-injected close X. - alert-dialog -> same native <dialog> strategy; native Escape close cancelled via the cancel event; no backdrop-click dismissal (user must choose Cancel or Action). - tooltip -> content opts into popover="manual" for top-layer + z-index-free rendering. Added skip-delay-duration attribute (module-level last-hide tracker, matches shadcn TooltipProvider.skipDelayDuration). Added align-offset attribute. - hover-card -> content opts into popover="manual". Added align-offset attribute. - dropdown-menu -> content + sub-content opt into popover="manual". Added align-offset attribute and typeahead behavior (accumulated buffer cleared after 500ms inactivity, text-value attribute overrides textContent). Existing arrow-nav, submenu chain, outside-click, and Escape are unchanged. positionFloating(trigger, content, opts) — shared by all three tier-2 positioned components — extended to accept alignOffset. Tier classification + docs: - packages/ui/packages/website/app/_lib/tier.ts: TIER_2_NAMES shrinks from 12 to 9. - examples.ts: per-component code samples regenerated for the new APIs (native popovertarget, native details/summary, dialog wrappers). - component-api.ts: prop tables refreshed for every touched component; the orientation=horizontal gap on accordion is documented inline. - AGENTS.md + README.md: inventory and tier descriptions updated. Also: AGENTS.md merge-workflow rule clarified — "merge to main" always means gh pr create + gh pr merge via the GitHub CLI, never a local git merge into main. Tests: - 128/128 ui package tests pass (5 new tests cover the new options on popover, the disabled+inert recommendation on accordion/collapsible, the align-offset wiring on tier-2 components, tooltip skip-delay, and dropdown-menu typeahead). - All 8 touched docs pages SSR with HTTP 200.
1 parent 542e638 commit f6a9dbf

17 files changed

Lines changed: 913 additions & 760 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,14 @@ bypass/autonomous mode).
119119
mood, under 72 chars on the first line.
120120
4. **No AI attribution in commits.** NEVER add `Co-Authored-By: Claude`,
121121
`Generated by AI`, `AI-assisted`, or any similar trailer or prefix.
122-
5. **Pull requests.** Create a PR for every feature branch.
122+
5. **Pull requests via the GitHub CLI, always.** Create a PR for every
123+
feature branch with `gh pr create`. When the user asks to "merge to
124+
main" (or any equivalent phrasing), the workflow is ALWAYS:
125+
`gh pr create` → confirm with user → `gh pr merge`. Never run a
126+
local `git merge` / `git push origin main` to land work on main,
127+
even when the local clone has permission to push. This keeps the
128+
merge auditable, runs branch protections + CI, and produces a real
129+
PR record.
123130
6. **Never push to main.** Always push to the feature branch and create a PR.
124131
7. **NEVER merge without user permission.** Before merging ANY branch into
125132
ANY other, ask exactly:

packages/ui/AGENTS.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,17 @@ full per-directory breakdown.
163163
| 1a | `toggle` | `toggleClass({ variant, size })` — pair with native `<button>` |
164164
| 1a | `breadcrumb` | `breadcrumbListClass`, `breadcrumbItemClass`, `breadcrumbLinkClass`, `breadcrumbPageClass`, `breadcrumbSeparatorClass`, `breadcrumbEllipsisClass` |
165165
| 1a | `pagination` | `paginationClass`, `paginationContentClass`, `paginationLinkClass({ isActive, size })`, `paginationPreviousClass`, `paginationNextClass`, `paginationEllipsisClass` |
166+
| 1b | `popover` | `popoverContentClass`, `popoverHeaderClass`, `popoverTitleClass`, `popoverDescriptionClass`. Compose with `<button popovertarget="id">` + `<div popover id="id">`; positioning via CSS anchor positioning or the exported `positionFloating` helper. |
167+
| 1b | `accordion` | `accordionClass`, `accordionItemClass`, `accordionTriggerClass`, `accordionContentClass`. Compose with `<details name="...">` + `<summary>`; `name` provides exclusive-open behavior natively. |
168+
| 1b | `collapsible` | `collapsibleClass`, `collapsibleTriggerClass`, `collapsibleContentClass`. Compose with `<details>` + `<summary>`. |
166169
| 2 | `progress` | `<ui-progress value="...">` — handles indicator transform |
167170
| 2 | `toggle-group` | `<ui-toggle-group type value variant size>` + `<ui-toggle-group-item value>` |
168-
| 2 | `dialog` | `<ui-dialog>` + `<ui-dialog-trigger>` / `<ui-dialog-content>` / `<ui-dialog-close>` / overlay. Class helpers for `dialogHeader/Title/Description/Footer`. Focus trap, Escape, body-scroll lock. |
169-
| 2 | `alert-dialog` | Like dialog, role=alertdialog, no Escape/overlay-close. `<ui-alert-dialog-action>` / `<ui-alert-dialog-cancel>`. |
170-
| 2 | `popover` | `<ui-popover>` + Trigger + Content with `side`/`align`/`side-offset`. Hand-rolled positioning, auto-flip. |
171-
| 2 | `tooltip` | `<ui-tooltip delay-duration>` — hover/focus + delay. |
172-
| 2 | `hover-card` | `<ui-hover-card open-delay close-delay>` — hover with linger-keep-open. |
171+
| 2 | `dialog` | `<ui-dialog>` + `<ui-dialog-trigger>` / `<ui-dialog-content>` / `<ui-dialog-close>`. Built on native `<dialog>.showModal()` — top-layer rendering, ::backdrop overlay, focus trap, Escape close, and focus restoration are all platform-provided. We add body-scroll lock + class helpers for `dialogHeader/Title/Description/Footer`. |
172+
| 2 | `alert-dialog` | Like dialog, role=alertdialog. Native Escape close is cancelled via the `cancel` event; no backdrop-click dismissal. `<ui-alert-dialog-action>` / `<ui-alert-dialog-cancel>`. |
173+
| 2 | `tooltip` | `<ui-tooltip delay-duration>` — hover/focus + delay. Content uses `popover="manual"` for top-layer rendering. |
174+
| 2 | `hover-card` | `<ui-hover-card open-delay close-delay>` — hover with linger-keep-open. Content uses `popover="manual"` for top-layer rendering. |
173175
| 2 | `tabs` | `<ui-tabs value orientation>` + List / Trigger / Content. Arrow-key keyboard nav. |
174-
| 2 | `accordion` | `<ui-accordion type collapsible value>` + Item / Trigger / Content. |
175-
| 2 | `collapsible` | `<ui-collapsible open>` + Trigger / Content. |
176-
| 2 | `dropdown-menu` | `<ui-dropdown-menu>` + Trigger / Content / Item (variant) / Label / Separator / Shortcut / Group. ArrowUp/Down nav, Escape close. |
176+
| 2 | `dropdown-menu` | `<ui-dropdown-menu>` + Trigger / Content / Item (variant) / Label / Separator / Shortcut / Group. Content uses `popover="manual"` for top-layer rendering. ArrowUp/Down nav, Escape close. |
177177
| 2 | `sonner` | `<ui-sonner position>` + `toast()` / `toast.success` / `toast.error` / `toast.promise` API. |
178178

179179
## Public commands (binary: `webjsui`)

packages/ui/README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@ Two-tier composition designed for AI agents who reason about real HTML +
77
function calls, not for a layered React abstraction over every primitive:
88

99
- **Tier 1 — class-helper functions** (`buttonClass`, `cardClass`,
10-
`inputClass`, `labelClass`, `alertClass`, …). Pure functions that return
11-
Tailwind class strings. You spread them onto raw native elements —
12-
`<button class=${buttonClass({ variant: 'outline' })}>` — so a real
13-
`<button>` participates in form submission, autocomplete, screen readers,
14-
and devtools as itself.
15-
- **Tier 2 — stateful custom elements** (`<ui-dialog>`, `<ui-popover>`,
16-
`<ui-tabs>`, `<ui-tooltip>`, `<ui-dropdown-menu>`, `<ui-accordion>`, …).
17-
Reserved for behavior the browser doesn't give you natively: focus
18-
traps, portaled overlays, keyboard-navigated lists, body-scroll lock.
19-
Decorate the host, no shadow DOM.
10+
`inputClass`, `labelClass`, `alertClass`, `popoverContentClass`,
11+
`accordionItemClass`, `collapsibleTriggerClass`, …). Pure functions that
12+
return Tailwind class strings. You spread them onto raw native elements
13+
— including `<button class=${buttonClass({ variant: 'outline' })}>`,
14+
`<details name="faq" class=${accordionItemClass()}>`, and
15+
`<div popover class=${popoverContentClass()}>` — so real native elements
16+
participate in form submission, autocomplete, screen readers, the
17+
Popover API ancestry, and devtools as themselves.
18+
- **Tier 2 — stateful custom elements** (`<ui-dialog>`, `<ui-alert-dialog>`,
19+
`<ui-tabs>`, `<ui-tooltip>`, `<ui-hover-card>`, `<ui-dropdown-menu>`,
20+
`<ui-sonner>`, …). Reserved for the behavior the browser still doesn't
21+
give you for free: hover-with-delay tooltips, roving-focus keyboard nav
22+
for menus and tabs, toast queue with stack and dismiss. Dialog and
23+
alert-dialog use a thin custom element on top of the native
24+
`<dialog>.showModal()` — focus trap, Escape, and backdrop overlay all
25+
come from the platform. Decorate the host, no shadow DOM.
2026

2127
Works with any project that uses Tailwind CSS v4 and supports custom elements:
2228
webjs, Next, Astro, Vite, SvelteKit, Lit, vanilla HTML — as long as Tailwind
Lines changed: 77 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -1,187 +1,88 @@
11
/**
2-
* Accordion — vertical collapsible item list. Single or multiple open at a time.
2+
* Accordion — vertical collapsible list built on native <details>/<summary>.
33
*
4-
* APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
4+
* Tier-1 component (no custom element). Exclusive open behavior — what
5+
* Radix calls `type="single"` — is provided natively by giving each
6+
* <details> the same `name=""` attribute. Independent open behavior
7+
* (`type="multiple"`) is the default when `name` is omitted.
8+
*
9+
* Both modes give the user `collapsible` behavior for free: clicking the
10+
* currently-open <summary> always closes it.
511
*
612
* shadcn parity:
7-
* Accordion (type: single | multiple, collapsible: boolean, value: string|string[])
8-
* AccordionItem (value), AccordionTrigger, AccordionContent.
13+
* <Accordion type="single" collapsible>
14+
* → <div class=${accordionClass()}>
15+
* <details name="faq" class=${accordionItemClass()}>…</details>
16+
* <details name="faq" class=${accordionItemClass()}>…</details>
17+
* </div>
18+
*
19+
* <Accordion type="multiple">
20+
* → omit the `name="…"` attribute. Each <details> toggles
21+
* independently.
22+
*
23+
* <AccordionTrigger> → <summary class=${accordionTriggerClass()}>
24+
* <AccordionContent> → <div class=${accordionContentClass()}>
25+
*
26+
* Usage (single-open, exclusive):
27+
* <div class=${accordionClass()}>
28+
* <details name="faq" class=${accordionItemClass()}>
29+
* <summary class=${accordionTriggerClass()}>
30+
* <span>Is it accessible?</span>
31+
* <svg class="size-4 transition-transform group-open:rotate-180" …></svg>
32+
* </summary>
33+
* <div class=${accordionContentClass()}>
34+
* Yes. Native &lt;details&gt; implements the WAI-ARIA disclosure
35+
* widget pattern.
36+
* </div>
37+
* </details>
38+
* <details name="faq" class=${accordionItemClass()} open>
39+
* <summary class=${accordionTriggerClass()}>
40+
* <span>Is it styled?</span>
41+
* <svg class="size-4 transition-transform group-open:rotate-180" …></svg>
42+
* </summary>
43+
* <div class=${accordionContentClass()}>Yes — shadcn design tokens.</div>
44+
* </details>
45+
* </div>
46+
*
47+
* Initial state: add `open` on the <details> that should render expanded
48+
* on first paint. Programmatic toggling: `el.open = true | false`.
949
*
10-
* Usage:
11-
* <ui-accordion type="single" collapsible>
12-
* <ui-accordion-item value="item-1">
13-
* <ui-accordion-trigger>Is it accessible?</ui-accordion-trigger>
14-
* <ui-accordion-content>Yes — uses APG accordion pattern.</ui-accordion-content>
15-
* </ui-accordion-item>
16-
* <ui-accordion-item value="item-2">
17-
* <ui-accordion-trigger>Is it animated?</ui-accordion-trigger>
18-
* <ui-accordion-content>Yes (height transition).</ui-accordion-content>
19-
* </ui-accordion-item>
20-
* </ui-accordion>
50+
* `<details name="X">` is the platform's exclusive-accordion primitive:
51+
* Chrome 120+, Safari 17.2+, Firefox 130+.
2152
*
22-
* Design tokens used: --muted-foreground, --border, --ring.
53+
* Migrated from the prior <ui-accordion> / <ui-accordion-item> /
54+
* <ui-accordion-trigger> / <ui-accordion-content> custom elements.
2355
*/
24-
import { cn, Base, defineElement } from '../lib/utils.ts';
25-
26-
export const accordionItemClass = (): string => 'border-b last:border-b-0';
27-
28-
export const accordionTriggerClass = (): string =>
29-
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180';
30-
31-
export const accordionContentClass = (): string => 'overflow-hidden text-sm';
32-
33-
const STYLES = `
34-
ui-accordion-item[data-state="closed"] > ui-accordion-content { display: none !important; }
35-
ui-accordion-content > * { padding-top: 0; padding-bottom: 1rem; }
36-
`;
3756

38-
function installStyles(): void {
39-
if (typeof document === 'undefined') return;
40-
if (document.getElementById('ui-accordion-styles')) return;
41-
const style = document.createElement('style');
42-
style.id = 'ui-accordion-styles';
43-
style.textContent = STYLES;
44-
document.head.appendChild(style);
45-
}
57+
/** Root wrapper. Holds the column-of-items rhythm; no display: rules. */
58+
export const accordionClass = (): string => 'w-full';
4659

47-
export class UiAccordion extends Base {
48-
static get observedAttributes(): string[] {
49-
return ['value', 'type', 'collapsible', 'orientation'];
50-
}
51-
connectedCallback(): void {
52-
installStyles();
53-
this.setAttribute('data-slot', 'accordion');
54-
if (!this.hasAttribute('type')) this.setAttribute('type', 'single');
55-
// Radix Accordion.Root supports orientation="horizontal" too. Reflect
56-
// to data-orientation so Tailwind `data-[orientation=…]:` selectors
57-
// and a11y screen readers both see the value. Default "vertical"
58-
// matches Radix.
59-
if (!this.hasAttribute('orientation')) this.setAttribute('orientation', 'vertical');
60-
this.setAttribute('data-orientation', this.getAttribute('orientation') ?? 'vertical');
61-
this.addEventListener('ui-accordion-trigger-click', this._onTriggerClick as EventListener);
62-
queueMicrotask(() => this._sync());
63-
}
64-
disconnectedCallback(): void {
65-
this.removeEventListener('ui-accordion-trigger-click', this._onTriggerClick as EventListener);
66-
}
67-
attributeChangedCallback(name: string): void {
68-
// Keep data-orientation in lockstep with the orientation attribute
69-
// so toggling the attr at runtime updates the Tailwind selectors.
70-
if (name === 'orientation') {
71-
this.setAttribute('data-orientation', this.getAttribute('orientation') ?? 'vertical');
72-
}
73-
this._sync();
74-
}
75-
76-
private get _type(): 'single' | 'multiple' {
77-
return (this.getAttribute('type') as 'single' | 'multiple') ?? 'single';
78-
}
79-
private get _values(): Set<string> {
80-
const raw = this.getAttribute('value') ?? '';
81-
return new Set(raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : []);
82-
}
83-
private _setValues(values: Set<string>): void {
84-
const next = Array.from(values).join(',');
85-
this.setAttribute('value', next);
86-
}
87-
private _sync(): void {
88-
const values = this._values;
89-
const items = this.querySelectorAll<HTMLElement>('ui-accordion-item');
90-
items.forEach((item) => {
91-
const v = item.getAttribute('value');
92-
const open = !!v && values.has(v);
93-
item.setAttribute('data-state', open ? 'open' : 'closed');
94-
const trigger = item.querySelector<HTMLElement>('ui-accordion-trigger');
95-
trigger?.setAttribute('data-state', open ? 'open' : 'closed');
96-
trigger?.setAttribute('aria-expanded', String(open));
97-
const content = item.querySelector<HTMLElement>('ui-accordion-content');
98-
content?.setAttribute('data-state', open ? 'open' : 'closed');
99-
});
100-
}
101-
private _onTriggerClick = (e: CustomEvent): void => {
102-
const v = e.detail?.value as string | undefined;
103-
if (!v) return;
104-
const values = this._values;
105-
const collapsible = this.hasAttribute('collapsible');
106-
if (this._type === 'single') {
107-
if (values.has(v)) {
108-
if (collapsible) values.clear();
109-
else return;
110-
} else {
111-
values.clear();
112-
values.add(v);
113-
}
114-
} else {
115-
if (values.has(v)) values.delete(v);
116-
else values.add(v);
117-
}
118-
this._setValues(values);
119-
};
120-
}
121-
defineElement('ui-accordion', UiAccordion);
122-
123-
export class UiAccordionItem extends Base {
124-
connectedCallback(): void {
125-
this.setAttribute('data-slot', 'accordion-item');
126-
const userClass = this.getAttribute('class') ?? '';
127-
this.className = cn(accordionItemClass(), userClass);
128-
}
129-
}
130-
defineElement('ui-accordion-item', UiAccordionItem);
60+
/**
61+
* Item: each <details>. The `group` utility lets the trigger's chevron
62+
* rotate on open via `group-open:rotate-180`. `last:border-b-0` cleans
63+
* the trailing edge.
64+
*/
65+
export const accordionItemClass = (): string => 'group border-b last:border-b-0';
13166

132-
export class UiAccordionTrigger extends Base {
133-
connectedCallback(): void {
134-
this.setAttribute('data-slot', 'accordion-trigger');
135-
this.setAttribute('role', 'button');
136-
this.setAttribute('tabindex', '0');
137-
const userClass = this.getAttribute('class') ?? '';
138-
this.className = cn(accordionTriggerClass(), userClass);
139-
// Default chevron icon if no SVG child is provided
140-
if (!this.querySelector('svg')) {
141-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
142-
svg.setAttribute('viewBox', '0 0 24 24');
143-
svg.setAttribute('fill', 'none');
144-
svg.setAttribute('stroke', 'currentColor');
145-
svg.setAttribute('stroke-width', '2');
146-
svg.setAttribute('stroke-linecap', 'round');
147-
svg.setAttribute('stroke-linejoin', 'round');
148-
svg.setAttribute(
149-
'class',
150-
'pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200',
151-
);
152-
svg.innerHTML = '<path d="m6 9 6 6 6-6"/>';
153-
this.appendChild(svg);
154-
}
155-
this.addEventListener('click', this._onClick);
156-
this.addEventListener('keydown', this._onKeyDown);
157-
}
158-
disconnectedCallback(): void {
159-
this.removeEventListener('click', this._onClick);
160-
this.removeEventListener('keydown', this._onKeyDown);
161-
}
162-
private _onClick = (): void => {
163-
const item = this.closest('ui-accordion-item');
164-
const value = item?.getAttribute('value');
165-
if (!value) return;
166-
this.dispatchEvent(
167-
new CustomEvent('ui-accordion-trigger-click', { detail: { value }, bubbles: true }),
168-
);
169-
};
170-
private _onKeyDown = (e: KeyboardEvent): void => {
171-
if (e.key === ' ' || e.key === 'Enter') {
172-
e.preventDefault();
173-
this._onClick();
174-
}
175-
};
176-
}
177-
defineElement('ui-accordion-trigger', UiAccordionTrigger);
67+
/**
68+
* Trigger: applied to <summary>. Hides the native disclosure triangle so
69+
* authors can compose their own chevron icon (typical pattern: trailing
70+
* lucide chevron with `group-open:rotate-180`).
71+
*
72+
* `disabled: true` returns the visual disabled state (greyed out,
73+
* not-allowed cursor, no pointer events). For true keyboard prevention
74+
* — the native disabled-disclosure-widget gap — add the standard
75+
* `inert` attribute to the <details> element. shadcn's React `disabled`
76+
* prop combines both; native HTML has no `disabled` on <details>.
77+
*/
78+
export const accordionTriggerClass = (opts: { disabled?: boolean } = {}): string => {
79+
const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-4 py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden';
80+
if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`;
81+
return base;
82+
};
17883

179-
export class UiAccordionContent extends Base {
180-
connectedCallback(): void {
181-
this.setAttribute('data-slot', 'accordion-content');
182-
this.setAttribute('role', 'region');
183-
const userClass = this.getAttribute('class') ?? '';
184-
this.className = cn(accordionContentClass(), userClass);
185-
}
186-
}
187-
defineElement('ui-accordion-content', UiAccordionContent);
84+
/**
85+
* Content: <details> hides this entirely when not [open], so all we add
86+
* is the typography rhythm matching shadcn (bottom padding, small text).
87+
*/
88+
export const accordionContentClass = (): string => 'pb-4 text-sm';

0 commit comments

Comments
 (0)