Skip to content

feat(ui): Tier-2 components -> WebComponent + render() + <slot> (rebased)#28

Merged
vivek7405 merged 21 commits into
mainfrom
feat/ui-tier2-slots
May 20, 2026
Merged

feat(ui): Tier-2 components -> WebComponent + render() + <slot> (rebased)#28
vivek7405 merged 21 commits into
mainfrom
feat/ui-tier2-slots

Conversation

@vivek7405
Copy link
Copy Markdown
Collaborator

@vivek7405 vivek7405 commented May 19, 2026

Summary

Migrates the 9 Tier-2 stateful custom elements from the local Base / defineElement HTMLElement-decorator pattern to Lit-shaped WebComponent from @webjskit/core. Every Tier-2 component now uses static properties, render() returning html\` 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 dev vs webjs dev distinction across every app-level AGENTS, and ships a website/.env.example with localhost UI_URL fallback.

Commits added this round (on top of the prior rebase work)

  • 768631f refactor(ui): finish Tier-2 to Lit-style WebComponent
  • 598a587 docs(ui): normalise Tier-1 component docstrings to canonical structure
  • fa4ef80 fix(docker): build ui-website tailwind CSS in the image
  • b974719 docs: clarify npm run dev vs webjs dev across all app-level AGENTS
  • fe1d426 feat(website): default UI_URL to localhost + ship .env.example

Plus 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

  • SSR named-slot routing bug in dialog and alert-dialog: previous architecture relied on setting slot="dialog-content" on a child inside connectedCallback. 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 (its render() emits the <dialog> wrapper around its slotted content), and the parent drives showModal() / close() via the open prop. Every slot is a default slot. Same architectural pattern Radix uses (Dialog.Trigger / Dialog.Content / Dialog.Close as separate primitives).
  • alert-dialog Cancel and Action nested-button bug: wrap-a-button back-compat was producing invalid nested <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.
  • alert-dialog sm-size grid layout: cancel button rendered content-width inside its grid cell. Added group-data-[size=sm]/alert-dialog-content:w-full so the inner button stretches.
  • All 81 browser tests + 936 unit tests pass (the open browser-test concern from feat(ui): Tier-2 components -> WebComponent + render() + <slot> #25 is closed).
  • Latent Dockerfile gap: RUN npx tailwindcss chain skipped packages/ui/packages/website/. The compose path (and any non-npm-start CMD) would ship without CSS. Railway dodged this because npm start fires the prestart hook at boot. Added the missing line so the behaviour matches the other three apps.

Test status

  • npm test, 936 / 936 pass
  • npm run test:browser, 81 / 81 pass
  • Pre-commit hook ran npm test before every commit on this branch

Follow-ups (tracked separately, not blocking this merge)

  • CLI: drop the unconditional @webjskit/core install in add.js; trust the per-component registry declarations (pre-existing over-eagerness, not introduced by this PR).
  • Core: document the light-DOM slot supported patterns + the connectedCallback-set-slot anti-pattern + add a webjs check rule that flags it. No implementation change to slot.js is needed; existing 130+ slot tests all pass.
  • Core: 100% Lit feature parity audit. Ship the 17 missing directives (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 when static shadow = true). Constraints: no-build compatible + type-stripping compatible only.

Continues #25 (closed).

vivek7405 and others added 21 commits May 20, 2026 01:43
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 vivek7405 merged commit 101d1ee into main May 20, 2026
@vivek7405 vivek7405 deleted the feat/ui-tier2-slots branch May 20, 2026 15:26
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.

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant