feat(ui): Tier-2 components -> WebComponent + render() + <slot> (rebased)#28
Merged
Conversation
First Tier-2 component converted from extends Base to extends
WebComponent. This is the pattern-locking conversion; remaining
nine components follow the same shape.
Pattern summary:
- extends WebComponent (was: extends Base from local utils)
- static properties = { ... reflect: true } (was: observedAttributes
getter + manual attr/property bridges)
- declare prop: Type + constructor defaults (was: imperative gets)
- render() returns html`<slot></slot>`, sets host-level class +
aria + data-state. <slot> has display: contents in every browser's
user-agent stylesheet, so children layout as direct flex children
of the host with no visible wrapper.
- firstUpdated() handles one-time event listener setup
- .register('tag-name') instead of defineElement()
Public API unchanged: tag <ui-toggle>, attributes pressed/variant/
size/disabled, ui-pressed-change event. Behavior preserved.
Toast items now live in this.state.items via setState. The
previous imperative _render() that did replaceChildren is replaced
by html`...${repeat(items, ...)} ` returning a TemplateResult.
escapeHTML manual escape is replaced by webjs's html`` interpolation
which escapes text by default; the SVG icon strings are wrapped in
unsafeHTML() since they are trusted, internal markup.
Public API unchanged: toast() function + .success/.error/.info/
.warning/.loading/.dismiss/.promise methods, <ui-sonner position>
element with imperative addToast() escape hatch.
Both classes (UiToggleGroup parent + UiToggleGroupItem children) now extend WebComponent. Children project through the slot in both cases. Item-state synchronization (data-state, aria-pressed driven by the parent's comma-separated value) runs from the parent's render() via queueMicrotask after projection settles. Item class computation reads parent attributes via closest(), preserved from the original. Public API unchanged.
All three classes (UiTooltip, UiTooltipTrigger, UiTooltipContent) convert from extends Base. UiTooltip's child queries change from :scope > ui-tooltip-content to plain ui-tooltip-content descendant search since children now live under the projection slot wrapper. The hover-with-delay state machine, native popover manual mode, positionFloating call, and skip-delay-duration window are all preserved unchanged. Public API unchanged: tags <ui-tooltip>, <ui-tooltip-trigger>, <ui-tooltip-content>; attributes delay-duration, skip-delay-duration, side, align, side-offset, align-offset, open.
Same pattern as tooltip.ts: three classes all converted to extends WebComponent + render() returning html`<slot></slot>`. Child queries change from :scope > to descendant search. Native popover manual mode, hover delays, and positionFloating preserved unchanged.
All four classes (UiTabs, UiTabsList, UiTabsTrigger, UiTabsContent) converted. Parent broadcasts active value to children via _syncChildren() scheduled in queueMicrotask after the slot projection settles. The ui-value-change event fires on actual value mutations rather than every attribute callback. Keyboard navigation (arrow keys, Home/End, Enter/Space) preserved unchanged. Public API: <ui-tabs value orientation>, <ui-tabs-list variant>, <ui-tabs-trigger value>, <ui-tabs-content value>.
This is the refactor where slots earn their keep. The previous
imperative pattern (find <ui-dialog-content>, create a <dialog>
programmatically, replaceWith + appendChild to reparent the
content into the <dialog>) is replaced by a declarative template:
render() {
return html`
<slot></slot>
<dialog ... class=${NATIVE_DIALOG_CLASS}>
<slot name="dialog-content"></slot>
</dialog>
`;
}
The user keeps writing <ui-dialog-content> as a normal child. In
connectedCallback the component sets slot="dialog-content" on
that element so the projection machinery routes it inside the
<dialog>. The user-facing API is unchanged.
scroll-lock refcounting, showModal/close native API, escape-to-
close via native cancel event, ::backdrop styling, focus trap (all
native dialog behavior) are preserved unchanged.
All five classes (UiDialog, UiDialogTrigger, UiDialogContent,
UiDialogClose, UiDialogFooter) converted to extends WebComponent.
Same shape as the dialog.ts refactor: render() returns a <dialog> template with <slot name='alert-dialog-content'> inside, and the connectedCallback routes the user's <ui-alert-dialog-content> child to that named slot. Native dialog's escape-cancel behavior preserved via the cancel event listener (preventDefault on alert dialogs). All five classes converted (UiAlertDialog, UiAlertDialogTrigger, UiAlertDialogContent, UiAlertDialogCancel, UiAlertDialogAction).
The biggest of the Tier-2 refactors. All 11 classes converted from
extends Base: UiDropdownMenu (parent), UiDropdownMenuTrigger,
UiDropdownMenuContent, UiDropdownMenuItem, UiDropdownMenuLabel,
UiDropdownMenuSeparator, UiDropdownMenuShortcut, UiDropdownMenuGroup,
UiDropdownMenuSub, UiDropdownMenuSubTrigger, UiDropdownMenuSubContent.
Each class follows the established pattern: extends WebComponent,
static properties + declare for reactive props, _userClass snapshot
on connect, host attribute writes in render(), event-listener wiring
in firstUpdated, render() returns html`<slot></slot>`.
Selector changes:
:scope > ui-dropdown-menu-content -> descendant ui-dropdown-menu-content
parentElement -> closest('ui-dropdown-menu-sub')
The latter handles the slot wrapper between the sub and its
trigger/content (parentElement is now the slot, not the sub).
closest() walks past the slot wrapper to find the ancestor sub.
Keyboard navigation, document-level click-outside, scroll/resize
reposition, type-ahead, submenu pointer-leave delay, native popover
showPopover/hidePopover, and positionFloating are all preserved
unchanged. ui-open-change-like state coordination moves into a
queueMicrotask after render so projection settles first.
The 10 Tier-2 components were rewritten from extends Base + imperative DOM walking to extends WebComponent + render() + <slot> projection. Public API (tag names, attributes, events) is unchanged: existing markup keeps working. Internals are now consistent with the rest of the webjs framework's component idiom. The slot work that landed in @webjskit/core 0.5.0 (light-DOM <slot> with full shadow-DOM spec parity) is the prerequisite that made this refactor possible. Previously the ui kit had to use extends Base because slots only worked inside shadow DOM, which the kit avoids for Tailwind compatibility.
Tests assume el.isOpen as a back-compat getter alongside the new reactive 'open' property. Added on dialog, alert-dialog, tooltip, hover-card. Sonner now imports repeat from @webjskit/core (which re-exports it from ./src/repeat.js) rather than the redundant double-import that WTR's esbuild plugin was struggling to resolve at dynamic-import time.
Three root causes behind the 35 browser-test failures on this branch:
1. queueMicrotask was too eager for nested slot projection. Parents that
queried for descendants (tabs / toggle-group / tooltip / hover-card /
dialog / alert-dialog / dropdown-menu) ran sync helpers in a
microtask after their own render, but descendant WebComponents had
not finished their own first render + slot projection by then.
Switch to requestAnimationFrame so the nested cascade settles first.
2. Listeners attached in firstUpdated were orphaned by the
disconnect/reconnect cycle of light-DOM slot projection on first
mount. firstUpdated runs once; disconnectedCallback removes
listeners. The intermediate disconnect during projection left the
reconnected element with no handlers. Move listener registration to
connectedCallback (runs every reconnect), keep removal symmetric in
disconnectedCallback.
3. showPopover() throws InvalidStateError on disconnected elements.
The RAF-scheduled sync could fire after the host was torn down (test
teardown, route transition). Add an isConnected guard in tooltip +
hover-card _syncContent.
Test cleanup:
- Stale ui-popover / ui-accordion / ui-collapsible suites removed:
those components are Tier 1 on main, no custom element to test.
- :scope > ui-X-content selectors updated to descendant search
(ui-X-content): WebComponent rendering moves content into nested
slots, so direct-child matches fail.
- Escape-closes-dialog test rewritten to fire a native close event on
the inner <dialog> (the UA-internal step). dispatching keydown on
document does not reach the native dialog Escape handler.
- Click-the-overlay test removed: <ui-dialog-overlay> was replaced by
native ::backdrop in the WebComponent dialog.
Other fixes:
- sonner.ts: import unsafeHTML from '@webjskit/core' directly (the
/directives subpath was removed when the package exports were
consolidated).
Results:
- Unit: 936/936 pass
- Browser: 81/81 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues observed on the live docs preview for /docs/components/alert-dialog
and /docs/components/toggle.
1. alert-dialog showed a "button inside a button" visual for both Cancel
and Action. The back-compat path in `applyAlertDialogButton` skipped
self-styling when the user authored a `<button>` inside the host:
if (host.querySelector('button')) return;
The old Base-class implementation kept authored children on the host,
so the query worked. With the WebComponent + light-DOM slot
architecture, `captureAuthoredChildren` moves the children OUT of the
host into an off-tree assignment table BEFORE render() runs, so the
query found nothing. The host got styled as a button AND the user's
own button rendered inside it after slot projection.
Fix: capture the "user provided a button" flag in connectedCallback
(which runs before the slot machinery hides authored children) and
store it on the instance as `_hasAuthoredButton`. render() consults
the flag, not a live DOM query.
2. toggle was classified as Tier 1 in the website's `_lib/tier.ts`.
That was correct on main (where toggle is class-helper + native
<button> + attachToggle). On this branch, toggle.ts was migrated to
a WebComponent custom element (`<ui-toggle>`), so it belongs in
TIER_2_NAMES.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates ~50 lines of connectedCallback / disconnectedCallback / firstUpdated / addEventListener / setAttribute boilerplate by rendering a native <button> inside the host's template and binding the click handler declaratively. - render() returns html`<button ...><slot></slot></button>` - @click=${this._onClick} on the inner <button> auto-wires - aria-pressed / data-state / class / disabled bound declaratively on the inner button instead of imperative setAttribute calls - Native <button> handles Enter/Space + focus + disabled semantics for free; the manual _onKeyDown handler is gone - The host's class attribute is no longer touched (no _userClass capture/merge); the inner button carries the visual class Semantics shift: aria-pressed + data-state live on the inner <button> now, not the host. More correct for screen readers (focusable element carries its state). The host's `pressed` reactive prop still reflects to a `pressed` attribute for declarative consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier-2 component classes were verbose with manual lifecycle wiring,
imperative setAttribute calls in render(), addEventListener +
removeEventListener pairs in connectedCallback/disconnectedCallback,
and `_userClass` capture-and-merge to avoid stomping author classes.
Lit-idiomatic patterns eliminate the boilerplate:
Declarative attribute bindings (`?attr=${bool}`, `attr=${val}`,
`class=${...}`) replace imperative setAttribute in render().
Declarative event bindings (`@click=${this._onClick}`) replace
manual addEventListener / removeEventListener around the host. The
framework wires/unwires them automatically as the rendered element
enters/leaves the DOM.
Inner element rendering pattern. Instead of styling the host and
catching events on it (which forces connectedCallback boilerplate
and a `_userClass` capture for class merging), each component
renders the role-bearing element inside its slot output:
<ui-toggle> renders <button>, <ui-tabs-trigger> renders <button
role="tab">, <ui-tooltip-content> renders <div popover="manual"
role="tooltip">, etc. The host stays a thin slot wrapper; the
user's class attribute on the host is untouched; semantics live on
the focusable / popover element where they belong.
Native button elements get Enter/Space activation, focus, and
disabled semantics for free; removed manual keydown handlers and
tabindex/role setAttribute calls.
Native dialog @close / @cancel event bindings replace
addEventListener + removeEventListener pairs.
Constraint: webjs core has firstUpdated() but not updated(changedProps),
so transitions across renders (e.g. "open just became true, call
showModal") use a manual `_lastOpen` flag + requestAnimationFrame in
render(). This is the documented pattern when an `updated()` hook is
absent.
Test updates: tests previously queried role / data-state / event
targets on the <ui-*> hosts. The Lit refactor moved those to the
inner rendered elements, so tests now query [data-slot="X"], [role="Y"],
or [popover] for the actual rendered element. Behavior assertions
(open transitions, value updates, event firing) are unchanged.
Browser tests: 81 / 81 pass
Unit tests: 936 / 936 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three classes of bug introduced by the previous refactor commit:
1. SSR crashes. render() runs server-side via linkedom, which doesn't
implement closest() on custom elements, dispatchEvent / CustomEvent,
or requestAnimationFrame. The refactored components called all three
unconditionally and brought the dev server down. Guards added:
- `if (typeof this.closest !== 'function') return null;` in every
parent-lookup getter
- `if (typeof requestAnimationFrame !== 'undefined') ...` around
every RAF schedule
- `if (typeof window !== 'undefined') { ... }` around the
dispatchEvent + queueMicrotask block in <ui-tabs>
The client re-renders after hydration so the omitted side effects
run at the correct time anyway.
2. Tabs content stayed visible when inactive. The old code had inline
CSS hiding `<ui-tabs-content[data-state="inactive"]>`; the refactor
removed it and relied on `?hidden` on the inner `<section>`, which
leaves the host taking visual space as an empty box. Toggle `hidden`
on the host directly via `this.toggleAttribute('hidden', !active)`
so the entire <ui-tabs-content> is removed from layout when inactive.
3. <ui-toggle-group-item> lost joined-corner styling. The refactor
moved toggleClass + data-state to an inner <button>, but the CSS
uses sibling selectors (`:first-child`, `:last-child` plus
data-spacing variants) that need the styled element to be a direct
sibling of other items. With a button inside each host, the button
is the only child of its host, so `:first-child` is always true
and the rounded-corner rules misfire. The fix: keep the host as
the styled element for this compound-component pattern. The
click + keydown listeners attach in connectedCallback (host is the
click target, not an inner element). New reactive `pressed` prop
replaces the parent's imperative `setAttribute('data-state', ...)`
walk, so the item's render() re-runs cleanly on state change.
4. Dialog X close button stopped rendering. The previous attempt set
the inner SVG via `.innerHTML=${...}` on a <ui-dialog-close>, but
the custom element's own render() overwrites the host's children
so the SVG was lost. Render the X button inline in
<ui-dialog-content>'s template using `unsafeHTML(SVG)` for the
icon, with a local @click handler that hides the parent dialog.
Tests: 81 / 81 browser pass, 936 / 936 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate the 9 Tier-2 stateful custom elements from the local Base / defineElement HTMLElement-decorator pattern to Lit-shaped WebComponent from @webjskit/core. Every component now uses static properties, render() returning html`...` templates, declarative bindings (@click, ?attr, attr=, .prop), and <slot></slot> for projecting authored children. No imperative DOM mutation in event handlers; host-styled escape hatches (toggle-group-item, tabs-content) use dataset/IDL properties only. Architectural fix for dialog and alert-dialog: the content child now owns the native <dialog> element (its render emits the <dialog> wrapper around its slotted content), and the parent drives showModal / close on the child via the open prop. Every slot is a default slot, which closes the SSR named-slot routing bug that earlier required setting slot="..." in connectedCallback (a pattern that also breaks in shadow DOM with DSD). alert-dialog Cancel and Action drop the broken wrap-a-button back-compat (SSR was producing invalid nested-button HTML). Bare-text authoring is now the canonical shape; the docs example matches. Also normalises docstrings on the 9 Tier-2 components to a canonical structure (header, prose, APG link, shadcn parity map, Usage, Attributes, Events, Programmatic API, Keyboard, Design tokens). Fixes stale architectural claims in packages/ui/AGENTS.md + README.md (which still described Tier-2 as HTMLElement subclasses) and the docs/ui page reference to ui-popover as Tier-2 (popover is Tier-1 now). Updates the registry-contents test assertions to match the new WebComponent.register + declarative role= patterns.
Standardise the top-of-file JSDoc for every Tier-1 component to the same section order: header line, prose paragraph on implementation, shadcn parity map, Usage code example, optional component-specific notes, Design tokens used. Adds explicit shadcn parity tables to input and textarea (previously missing). Tightens accordion, collapsible, popover, and progress to fit the same shape without losing their migration / browser-support notes. No code changes.
The Dockerfile RUN tailwindcss line was building CSS for website, docs, and examples/blog but skipping packages/ui/packages/website. The omission has been there since the UI website was added in f14f452. ui.webjs.dev still ships styled today because Railway invokes npm start, which fires the prestart css:build hook at container boot; but the compose path (and any non-npm-start CMD override) ships an unstyled image. Add the missing line so the behaviour matches the other three apps and cold-start CSS-build cost moves from container boot to image build.
Add guidance that npm run dev / npm start are the entrypoints inside an app, not webjs dev / webjs start. The webjs CLI commands are framework primitives (only run the server); the npm scripts compose them with the app-specific watchers (Tailwind, Prisma) via concurrently and pre* hooks. Skipping the npm wrapper produces silent breakage (stale Prisma client, missing tailwind.css, unmigrated DB). Same split Rails 7+ ships (bin/rails server vs bin/dev). In Docker / Railway, prefer npm start as the CMD so the prestart hook fires. Lands the explainer in each of the four runnable apps' AGENTS.md (website, docs, blog, ui-website), in the scaffold template AGENTS (so every newly-created app picks it up), and a one-line pointer in the framework's own root AGENTS so an agent working from the repo root knows to defer to per-app guidance. Also corrects a stray "cd docs && webjs dev" in the root README. No changes to AGENTS.md sections that pertain to framework development workflow.
UI_URL fallback in app/layout.ts and app/page.ts now points at the canonical local dev port (http://localhost:5001, matching the ui-website's webjs:dev script and the compose.yaml CMD), mirroring how DOCS_URL and BLOG_URL already default to their local ports. The production value still comes from the Railway service env. Ship a website/.env.example committed alongside (the .env itself is gitignored at the repo root) so a fresh clone documents the three sibling URLs in one place.
vivek7405
added a commit
that referenced
this pull request
May 21, 2026
…sed) (#28) * refactor(ui): toggle.ts -> WebComponent + render() + <slot> First Tier-2 component converted from extends Base to extends WebComponent. This is the pattern-locking conversion; remaining nine components follow the same shape. Pattern summary: - extends WebComponent (was: extends Base from local utils) - static properties = { ... reflect: true } (was: observedAttributes getter + manual attr/property bridges) - declare prop: Type + constructor defaults (was: imperative gets) - render() returns html`<slot></slot>`, sets host-level class + aria + data-state. <slot> has display: contents in every browser's user-agent stylesheet, so children layout as direct flex children of the host with no visible wrapper. - firstUpdated() handles one-time event listener setup - .register('tag-name') instead of defineElement() Public API unchanged: tag <ui-toggle>, attributes pressed/variant/ size/disabled, ui-pressed-change event. Behavior preserved. * refactor(ui): sonner.ts -> WebComponent + reactive state Toast items now live in this.state.items via setState. The previous imperative _render() that did replaceChildren is replaced by html`...${repeat(items, ...)} ` returning a TemplateResult. escapeHTML manual escape is replaced by webjs's html`` interpolation which escapes text by default; the SVG icon strings are wrapped in unsafeHTML() since they are trusted, internal markup. Public API unchanged: toast() function + .success/.error/.info/ .warning/.loading/.dismiss/.promise methods, <ui-sonner position> element with imperative addToast() escape hatch. * refactor(ui): toggle-group.ts -> WebComponent + render() + <slot> Both classes (UiToggleGroup parent + UiToggleGroupItem children) now extend WebComponent. Children project through the slot in both cases. Item-state synchronization (data-state, aria-pressed driven by the parent's comma-separated value) runs from the parent's render() via queueMicrotask after projection settles. Item class computation reads parent attributes via closest(), preserved from the original. Public API unchanged. * refactor(ui): tooltip.ts -> WebComponent + render() + <slot> All three classes (UiTooltip, UiTooltipTrigger, UiTooltipContent) convert from extends Base. UiTooltip's child queries change from :scope > ui-tooltip-content to plain ui-tooltip-content descendant search since children now live under the projection slot wrapper. The hover-with-delay state machine, native popover manual mode, positionFloating call, and skip-delay-duration window are all preserved unchanged. Public API unchanged: tags <ui-tooltip>, <ui-tooltip-trigger>, <ui-tooltip-content>; attributes delay-duration, skip-delay-duration, side, align, side-offset, align-offset, open. * refactor(ui): hover-card.ts -> WebComponent + render() + <slot> Same pattern as tooltip.ts: three classes all converted to extends WebComponent + render() returning html`<slot></slot>`. Child queries change from :scope > to descendant search. Native popover manual mode, hover delays, and positionFloating preserved unchanged. * refactor(ui): tabs.ts -> WebComponent + render() + <slot> All four classes (UiTabs, UiTabsList, UiTabsTrigger, UiTabsContent) converted. Parent broadcasts active value to children via _syncChildren() scheduled in queueMicrotask after the slot projection settles. The ui-value-change event fires on actual value mutations rather than every attribute callback. Keyboard navigation (arrow keys, Home/End, Enter/Space) preserved unchanged. Public API: <ui-tabs value orientation>, <ui-tabs-list variant>, <ui-tabs-trigger value>, <ui-tabs-content value>. * refactor(ui): dialog.ts -> WebComponent + render() + named slots This is the refactor where slots earn their keep. The previous imperative pattern (find <ui-dialog-content>, create a <dialog> programmatically, replaceWith + appendChild to reparent the content into the <dialog>) is replaced by a declarative template: render() { return html` <slot></slot> <dialog ... class=${NATIVE_DIALOG_CLASS}> <slot name="dialog-content"></slot> </dialog> `; } The user keeps writing <ui-dialog-content> as a normal child. In connectedCallback the component sets slot="dialog-content" on that element so the projection machinery routes it inside the <dialog>. The user-facing API is unchanged. scroll-lock refcounting, showModal/close native API, escape-to- close via native cancel event, ::backdrop styling, focus trap (all native dialog behavior) are preserved unchanged. All five classes (UiDialog, UiDialogTrigger, UiDialogContent, UiDialogClose, UiDialogFooter) converted to extends WebComponent. * refactor(ui): alert-dialog.ts -> WebComponent + render() + named slots Same shape as the dialog.ts refactor: render() returns a <dialog> template with <slot name='alert-dialog-content'> inside, and the connectedCallback routes the user's <ui-alert-dialog-content> child to that named slot. Native dialog's escape-cancel behavior preserved via the cancel event listener (preventDefault on alert dialogs). All five classes converted (UiAlertDialog, UiAlertDialogTrigger, UiAlertDialogContent, UiAlertDialogCancel, UiAlertDialogAction). * refactor(ui): dropdown-menu.ts -> WebComponent + render() + <slot> The biggest of the Tier-2 refactors. All 11 classes converted from extends Base: UiDropdownMenu (parent), UiDropdownMenuTrigger, UiDropdownMenuContent, UiDropdownMenuItem, UiDropdownMenuLabel, UiDropdownMenuSeparator, UiDropdownMenuShortcut, UiDropdownMenuGroup, UiDropdownMenuSub, UiDropdownMenuSubTrigger, UiDropdownMenuSubContent. Each class follows the established pattern: extends WebComponent, static properties + declare for reactive props, _userClass snapshot on connect, host attribute writes in render(), event-listener wiring in firstUpdated, render() returns html`<slot></slot>`. Selector changes: :scope > ui-dropdown-menu-content -> descendant ui-dropdown-menu-content parentElement -> closest('ui-dropdown-menu-sub') The latter handles the slot wrapper between the sub and its trigger/content (parentElement is now the slot, not the sub). closest() walks past the slot wrapper to find the ancestor sub. Keyboard navigation, document-level click-outside, scroll/resize reposition, type-ahead, submenu pointer-leave delay, native popover showPopover/hidePopover, and positionFloating are all preserved unchanged. ui-open-change-like state coordination moves into a queueMicrotask after render so projection settles first. * release: @webjskit/ui 0.1.0 -> 0.2.0 The 10 Tier-2 components were rewritten from extends Base + imperative DOM walking to extends WebComponent + render() + <slot> projection. Public API (tag names, attributes, events) is unchanged: existing markup keeps working. Internals are now consistent with the rest of the webjs framework's component idiom. The slot work that landed in @webjskit/core 0.5.0 (light-DOM <slot> with full shadow-DOM spec parity) is the prerequisite that made this refactor possible. Previously the ui kit had to use extends Base because slots only worked inside shadow DOM, which the kit avoids for Tailwind compatibility. * refactor(ui): add back isOpen getters + consolidate sonner imports Tests assume el.isOpen as a back-compat getter alongside the new reactive 'open' property. Added on dialog, alert-dialog, tooltip, hover-card. Sonner now imports repeat from @webjskit/core (which re-exports it from ./src/repeat.js) rather than the redundant double-import that WTR's esbuild plugin was struggling to resolve at dynamic-import time. * fix(ui): Tier-2 WebComponent lifecycle + test architecture mismatch Three root causes behind the 35 browser-test failures on this branch: 1. queueMicrotask was too eager for nested slot projection. Parents that queried for descendants (tabs / toggle-group / tooltip / hover-card / dialog / alert-dialog / dropdown-menu) ran sync helpers in a microtask after their own render, but descendant WebComponents had not finished their own first render + slot projection by then. Switch to requestAnimationFrame so the nested cascade settles first. 2. Listeners attached in firstUpdated were orphaned by the disconnect/reconnect cycle of light-DOM slot projection on first mount. firstUpdated runs once; disconnectedCallback removes listeners. The intermediate disconnect during projection left the reconnected element with no handlers. Move listener registration to connectedCallback (runs every reconnect), keep removal symmetric in disconnectedCallback. 3. showPopover() throws InvalidStateError on disconnected elements. The RAF-scheduled sync could fire after the host was torn down (test teardown, route transition). Add an isConnected guard in tooltip + hover-card _syncContent. Test cleanup: - Stale ui-popover / ui-accordion / ui-collapsible suites removed: those components are Tier 1 on main, no custom element to test. - :scope > ui-X-content selectors updated to descendant search (ui-X-content): WebComponent rendering moves content into nested slots, so direct-child matches fail. - Escape-closes-dialog test rewritten to fire a native close event on the inner <dialog> (the UA-internal step). dispatching keydown on document does not reach the native dialog Escape handler. - Click-the-overlay test removed: <ui-dialog-overlay> was replaced by native ::backdrop in the WebComponent dialog. Other fixes: - sonner.ts: import unsafeHTML from '@webjskit/core' directly (the /directives subpath was removed when the package exports were consolidated). Results: - Unit: 936/936 pass - Browser: 81/81 pass * fix(ui): alert-dialog button-in-button + classify toggle as Tier 2 Two issues observed on the live docs preview for /docs/components/alert-dialog and /docs/components/toggle. 1. alert-dialog showed a "button inside a button" visual for both Cancel and Action. The back-compat path in `applyAlertDialogButton` skipped self-styling when the user authored a `<button>` inside the host: if (host.querySelector('button')) return; The old Base-class implementation kept authored children on the host, so the query worked. With the WebComponent + light-DOM slot architecture, `captureAuthoredChildren` moves the children OUT of the host into an off-tree assignment table BEFORE render() runs, so the query found nothing. The host got styled as a button AND the user's own button rendered inside it after slot projection. Fix: capture the "user provided a button" flag in connectedCallback (which runs before the slot machinery hides authored children) and store it on the instance as `_hasAuthoredButton`. render() consults the flag, not a live DOM query. 2. toggle was classified as Tier 1 in the website's `_lib/tier.ts`. That was correct on main (where toggle is class-helper + native <button> + attachToggle). On this branch, toggle.ts was migrated to a WebComponent custom element (`<ui-toggle>`), so it belongs in TIER_2_NAMES. * refactor(ui): toggle.ts to Lit-idiomatic pattern Eliminates ~50 lines of connectedCallback / disconnectedCallback / firstUpdated / addEventListener / setAttribute boilerplate by rendering a native <button> inside the host's template and binding the click handler declaratively. - render() returns html`<button ...><slot></slot></button>` - @click=${this._onClick} on the inner <button> auto-wires - aria-pressed / data-state / class / disabled bound declaratively on the inner button instead of imperative setAttribute calls - Native <button> handles Enter/Space + focus + disabled semantics for free; the manual _onKeyDown handler is gone - The host's class attribute is no longer touched (no _userClass capture/merge); the inner button carries the visual class Semantics shift: aria-pressed + data-state live on the inner <button> now, not the host. More correct for screen readers (focusable element carries its state). The host's `pressed` reactive prop still reflects to a `pressed` attribute for declarative consumers. * refactor(ui): Lit-idiomatic Tier-2 components Tier-2 component classes were verbose with manual lifecycle wiring, imperative setAttribute calls in render(), addEventListener + removeEventListener pairs in connectedCallback/disconnectedCallback, and `_userClass` capture-and-merge to avoid stomping author classes. Lit-idiomatic patterns eliminate the boilerplate: Declarative attribute bindings (`?attr=${bool}`, `attr=${val}`, `class=${...}`) replace imperative setAttribute in render(). Declarative event bindings (`@click=${this._onClick}`) replace manual addEventListener / removeEventListener around the host. The framework wires/unwires them automatically as the rendered element enters/leaves the DOM. Inner element rendering pattern. Instead of styling the host and catching events on it (which forces connectedCallback boilerplate and a `_userClass` capture for class merging), each component renders the role-bearing element inside its slot output: <ui-toggle> renders <button>, <ui-tabs-trigger> renders <button role="tab">, <ui-tooltip-content> renders <div popover="manual" role="tooltip">, etc. The host stays a thin slot wrapper; the user's class attribute on the host is untouched; semantics live on the focusable / popover element where they belong. Native button elements get Enter/Space activation, focus, and disabled semantics for free; removed manual keydown handlers and tabindex/role setAttribute calls. Native dialog @close / @cancel event bindings replace addEventListener + removeEventListener pairs. Constraint: webjs core has firstUpdated() but not updated(changedProps), so transitions across renders (e.g. "open just became true, call showModal") use a manual `_lastOpen` flag + requestAnimationFrame in render(). This is the documented pattern when an `updated()` hook is absent. Test updates: tests previously queried role / data-state / event targets on the <ui-*> hosts. The Lit refactor moved those to the inner rendered elements, so tests now query [data-slot="X"], [role="Y"], or [popover] for the actual rendered element. Behavior assertions (open transitions, value updates, event firing) are unchanged. Browser tests: 81 / 81 pass Unit tests: 936 / 936 pass * fix(ui): SSR + visual regressions in the Lit-idiomatic refactor Three classes of bug introduced by the previous refactor commit: 1. SSR crashes. render() runs server-side via linkedom, which doesn't implement closest() on custom elements, dispatchEvent / CustomEvent, or requestAnimationFrame. The refactored components called all three unconditionally and brought the dev server down. Guards added: - `if (typeof this.closest !== 'function') return null;` in every parent-lookup getter - `if (typeof requestAnimationFrame !== 'undefined') ...` around every RAF schedule - `if (typeof window !== 'undefined') { ... }` around the dispatchEvent + queueMicrotask block in <ui-tabs> The client re-renders after hydration so the omitted side effects run at the correct time anyway. 2. Tabs content stayed visible when inactive. The old code had inline CSS hiding `<ui-tabs-content[data-state="inactive"]>`; the refactor removed it and relied on `?hidden` on the inner `<section>`, which leaves the host taking visual space as an empty box. Toggle `hidden` on the host directly via `this.toggleAttribute('hidden', !active)` so the entire <ui-tabs-content> is removed from layout when inactive. 3. <ui-toggle-group-item> lost joined-corner styling. The refactor moved toggleClass + data-state to an inner <button>, but the CSS uses sibling selectors (`:first-child`, `:last-child` plus data-spacing variants) that need the styled element to be a direct sibling of other items. With a button inside each host, the button is the only child of its host, so `:first-child` is always true and the rounded-corner rules misfire. The fix: keep the host as the styled element for this compound-component pattern. The click + keydown listeners attach in connectedCallback (host is the click target, not an inner element). New reactive `pressed` prop replaces the parent's imperative `setAttribute('data-state', ...)` walk, so the item's render() re-runs cleanly on state change. 4. Dialog X close button stopped rendering. The previous attempt set the inner SVG via `.innerHTML=${...}` on a <ui-dialog-close>, but the custom element's own render() overwrites the host's children so the SVG was lost. Render the X button inline in <ui-dialog-content>'s template using `unsafeHTML(SVG)` for the icon, with a local @click handler that hides the parent dialog. Tests: 81 / 81 browser pass, 936 / 936 unit pass. * refactor(ui): finish Tier-2 to Lit-style WebComponent Migrate the 9 Tier-2 stateful custom elements from the local Base / defineElement HTMLElement-decorator pattern to Lit-shaped WebComponent from @webjskit/core. Every component now uses static properties, render() returning html`...` templates, declarative bindings (@click, ?attr, attr=, .prop), and <slot></slot> for projecting authored children. No imperative DOM mutation in event handlers; host-styled escape hatches (toggle-group-item, tabs-content) use dataset/IDL properties only. Architectural fix for dialog and alert-dialog: the content child now owns the native <dialog> element (its render emits the <dialog> wrapper around its slotted content), and the parent drives showModal / close on the child via the open prop. Every slot is a default slot, which closes the SSR named-slot routing bug that earlier required setting slot="..." in connectedCallback (a pattern that also breaks in shadow DOM with DSD). alert-dialog Cancel and Action drop the broken wrap-a-button back-compat (SSR was producing invalid nested-button HTML). Bare-text authoring is now the canonical shape; the docs example matches. Also normalises docstrings on the 9 Tier-2 components to a canonical structure (header, prose, APG link, shadcn parity map, Usage, Attributes, Events, Programmatic API, Keyboard, Design tokens). Fixes stale architectural claims in packages/ui/AGENTS.md + README.md (which still described Tier-2 as HTMLElement subclasses) and the docs/ui page reference to ui-popover as Tier-2 (popover is Tier-1 now). Updates the registry-contents test assertions to match the new WebComponent.register + declarative role= patterns. * docs(ui): normalise Tier-1 component docstrings to canonical structure Standardise the top-of-file JSDoc for every Tier-1 component to the same section order: header line, prose paragraph on implementation, shadcn parity map, Usage code example, optional component-specific notes, Design tokens used. Adds explicit shadcn parity tables to input and textarea (previously missing). Tightens accordion, collapsible, popover, and progress to fit the same shape without losing their migration / browser-support notes. No code changes. * fix(docker): build ui-website tailwind CSS in the image The Dockerfile RUN tailwindcss line was building CSS for website, docs, and examples/blog but skipping packages/ui/packages/website. The omission has been there since the UI website was added in 3d20a85. ui.webjs.dev still ships styled today because Railway invokes npm start, which fires the prestart css:build hook at container boot; but the compose path (and any non-npm-start CMD override) ships an unstyled image. Add the missing line so the behaviour matches the other three apps and cold-start CSS-build cost moves from container boot to image build. * docs: clarify npm run dev vs webjs dev across all app-level AGENTS Add guidance that npm run dev / npm start are the entrypoints inside an app, not webjs dev / webjs start. The webjs CLI commands are framework primitives (only run the server); the npm scripts compose them with the app-specific watchers (Tailwind, Prisma) via concurrently and pre* hooks. Skipping the npm wrapper produces silent breakage (stale Prisma client, missing tailwind.css, unmigrated DB). Same split Rails 7+ ships (bin/rails server vs bin/dev). In Docker / Railway, prefer npm start as the CMD so the prestart hook fires. Lands the explainer in each of the four runnable apps' AGENTS.md (website, docs, blog, ui-website), in the scaffold template AGENTS (so every newly-created app picks it up), and a one-line pointer in the framework's own root AGENTS so an agent working from the repo root knows to defer to per-app guidance. Also corrects a stray "cd docs && webjs dev" in the root README. No changes to AGENTS.md sections that pertain to framework development workflow. * feat(website): default UI_URL to localhost + ship .env.example UI_URL fallback in app/layout.ts and app/page.ts now points at the canonical local dev port (http://localhost:5001, matching the ui-website's webjs:dev script and the compose.yaml CMD), mirroring how DOCS_URL and BLOG_URL already default to their local ports. The production value still comes from the Railway service env. Ship a website/.env.example committed alongside (the .env itself is gitignored at the repo root) so a fresh clone documents the three sibling URLs in one place. ---------
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates the 9 Tier-2 stateful custom elements from the local
Base/defineElementHTMLElement-decorator pattern to Lit-shapedWebComponentfrom@webjskit/core. Every Tier-2 component now usesstatic properties,render()returninghtml\`templates, declarative bindings (@click,?attr,attr=,.prop), and` for projecting authored children.Beyond the core refactor, this branch closes the SSR named-slot routing bug (compositional fix to dialog and alert-dialog), normalises docstrings across all 32 registry components, patches a latent Dockerfile gap, documents the
npm run devvswebjs devdistinction across every app-level AGENTS, and ships awebsite/.env.examplewith localhost UI_URL fallback.Commits added this round (on top of the prior rebase work)
768631frefactor(ui): finish Tier-2 to Lit-style WebComponent598a587docs(ui): normalise Tier-1 component docstrings to canonical structurefa4ef80fix(docker): build ui-website tailwind CSS in the imageb974719docs: clarify npm run dev vs webjs dev across all app-level AGENTSfe1d426feat(website): default UI_URL to localhost + ship .env.examplePlus the 11 commits from the prior rebase of #25 (per-component Lit-style migrations and the @webjskit/ui 0.2.0 version bump).
What was unblocked in this round
slot="dialog-content"on a child insideconnectedCallback. Lifecycle hooks don't fire server-side (linkedom has no upgrade), so SSR projected the content to the default slot, outside the native<dialog>element. Fix: the content child now owns the native<dialog>element (itsrender()emits the<dialog>wrapper around its slotted content), and the parent drivesshowModal()/close()via the open prop. Every slot is a default slot. Same architectural pattern Radix uses (Dialog.Trigger/Dialog.Content/Dialog.Closeas separate primitives).<button>HTML at SSR time, which the browser flattened into siblings (empty outer button + bare inner button text). Dropped the back-compat; bare-text authoring is now canonical.group-data-[size=sm]/alert-dialog-content:w-fullso the inner button stretches.RUN npx tailwindcsschain skippedpackages/ui/packages/website/. The compose path (and any non-npm-start CMD) would ship without CSS. Railway dodged this becausenpm startfires the prestart hook at boot. Added the missing line so the behaviour matches the other three apps.Test status
npm test, 936 / 936 passnpm run test:browser, 81 / 81 passnpm testbefore every commit on this branchFollow-ups (tracked separately, not blocking this merge)
@webjskit/coreinstall inadd.js; trust the per-component registry declarations (pre-existing over-eagerness, not introduced by this PR).webjs checkrule that flags it. No implementation change toslot.jsis needed; existing 130+ slot tests all pass.classMap,styleMap,ifDefined,ref,map,cache,keyed,when,choose,guard,asyncAppend,asyncReplace,until,range,join,templateContent,unsafeSVG), the 6 missing lifecycle hooks (updated(changedProps),willUpdate,shouldUpdate,getUpdateComplete,update,performUpdate), and audit Lit's shadow-DOM constructs (applicable whenstatic shadow = true). Constraints: no-build compatible + type-stripping compatible only.Continues #25 (closed).