Brings the windowing primitive onto this branch so it and the select engine can be shaped against each other: the component, its bridge modifier, the library wall over `@tanstack/virtual-core` (pinned 3.17.5), its stylesheet, both test suites and a 10k-row styleguide demo. `DSelect` is the intended first consumer; nothing is wired to it yet. The primitive's own suites pass unchanged here, and the ui-kit suite goes from 663 to 678 with no failures. The styleguide page also needed two things the prototype lacked: a section title, without which its nav link is unreachable, and an entry in the smoke test's section inventory. This reverses Decision 5, which the RFC now records rather than absorbing silently. That decision chose bounded reveal over virtualization but left the latter "a future drop-in if a genuine browse-the-whole-list case ever appears" — and it appeared: a value at index 3000 of 5000 is not merely unfocused but unreachable by any amount of scrolling, because the render cap stops at 200. The invariant becomes "every mounted option is a real DOM node" rather than "every rendered option", and the cost the original named — `dRovingFocus` enumerates options from the DOM and assumes all are present — is now owed as follow-up work alongside the wiring itself.
90 KiB
Vendored
RFC + Plan: Replacing select-kit with a ui-kit select family
Context
select-kit is Discourse's dropdown/select library (~90 files under
frontend/discourse/select-kit/), used in ~282 consumer files across core app/,
admin/, and bundled plugins. It is showing its age: a 1,339-line classic-Ember
god base (select-kit/components/select-kit.js), deep inheritance chains, a
hand-rolled @floating-ui positioning stack that predates and bypasses FloatKit,
triplicated keyboard handling, and a partial/incorrect ARIA pattern
(role="menu" + menuitemradio) that has caused repeated, documented
accessibility failures for screen-reader users over the years (dev topics
#25410, #28314 Blizzard audit, #46013 NVDA/JAWS — 88 posts). Its author (Joffrey)
has repeatedly said we should stop investing in it and replace it
(dev #185600, #160892).
A replacement has already begun organically but unevenly on main: DSelect
(native <select>), DMultiSelect (async multi, but with an HTML-validity bug
and hand-rolled keyboard/debounce), and DIconGridPicker (grid single-select on
DMenu + DAsyncContent). There is no unified abstraction — three one-offs, each
re-solving trigger/filter/selection/keyboard.
This plan defines the target architecture, the extension API, the migration strategy, an uncompromising accessibility contract, and a concrete first build (Phase 0). It is written to double as an RFC for alignment with Joffrey/Martin before the large migration.
Goal
A single, modern, accessible-by-construction and async-first ui-kit select
family that replaces select-kit: excellent keyboard + screen-reader support, and
first-class async (loading / empty / error status, debounce, request cancellation,
out-of-order safety, and preselected-value resolution) as a core capability rather
than a per-picker bolt-on — the opposite of select-kit, whose base is a synchronous
content array with async layered on by overriding search(). Built only on
sanctioned foundations (FloatKit, DAsyncContent, dRovingFocus), extended via
the standard transformer API, with a migration path that keeps every existing
third-party contract working — select-kit stays (deprecated) for out-of-repo
consumers, while core and bundled plugins migrate off it and are banned from using it.
API refinement — gap analysis & decisions (2026-07-07, post-Phase-0 review)
After Phase 0 shipped (draft PR #41534) we audited the proposed API against (a) select-kit's full customization surface, (b) the RFC feedback on dev #187302, and (c) two modern references — shadcn/Base-UI Combobox and React-Aria ComboBox. This section records the gaps and the decisions; it refines the component API for Phase 1 and supersedes the "Interaction modes" section and the mobile "Open decision" below where they conflict.
Already at modern baseline: controlled @value, async-first with AbortSignal,
create-on-the-fly (@allowCreate/@createItem), disabled options (item.disabled),
action rows (item.onSelect), selected-value resolution, WAI-ARIA combobox/listbox,
optional :item/:selection slots (the render-prop equivalent), with a
@labelField fallback when either is omitted.
Decision 1 — Typeahead is the default trigger (button becomes a variant)
chapoi (UX) asked for a true typeahead where the control itself is the search input
(as mini-tag-chooser already does); it is the default in React-Aria and shadcn; and it
is our own accessibility thesis — the fix for select-kit's #1 failure ("typing
into a button") is a real role="combobox" input, so the editable input should be
present by default, not only after opening. Three variants via @variant (default
typeahead):
typeahead(default, searchable): the trigger IS arole="combobox"input. With the default field presentation, it shows the resolved selected label, selects that label on focus, and replaces it with the query on the first edit. A custom rich:selectionrenders beside the input instead and is hidden while editing. Selecting fills the presentation and closes. Selection stays controlled via@value/@onChange; the typed query is engine-internalfilterstate — so we need not expose@queryin v1.button(variant, searchable): the current button trigger + in-panelDFilterInput. For rich value display the input can't render — a category badge (with color), a user avatar, a menu-style value picker;:selectionrenders it.static(variant): short lists, no search; the listbox takes focus (rovingfocusmode). The native-<select>replacement.
@variant is the single canonical control (review finding). @searchable can't
distinguish typeahead from button (both are searchable), so it's the wrong axis.
It is brand new in Phase 0 and unreleased — no external consumers, only our own
tests and (soon) presets — so there's nothing to deprecate: replace @searchable
outright with @variant and update the Phase-0 component + tests
(@searchable={{false}} → @variant="static"). Presets pick per use case
(user/tag/topic → typeahead; category-with-badge, notification-level → button).
Per-breakpoint (verified against FloatKit): desktop typeahead keeps focus in the
trigger input with @trapTab={{false}} + dRovingFocus active mode pointing
controllerElement at that input. Open behavior defaults to React-Aria's
menuTrigger:"input" — open on typing / click / ArrowDown, not on bare focus,
so tabbing through a form doesn't pop every select open (wired manually via
componentArgs.show, since FloatKit has no built-in "input" trigger; an @openOn
knob can offer focus/manual). On mobile, an
external input cannot hold focus behind FloatKit's aria-modal DModal, so typeahead
opens DMenu's modal with the input inside it (real focus there) — which is also
the mobile a11y fallback (Decision 3) and the better small-screen UX. Use DMenu for
all variants: it accepts the typeahead's non-button trigger component, yields the
expanded state needed by the input's ARIA wiring, matches the trigger width, and owns
the mobile modal. The typeahead input intercepts Tab so DMenu does not forward focus
into the portaled content.
Multi × typeahead — the shadcn Base-UI multiple pattern (user-endorsed).
(https://ui.shadcn.com/docs/components/base/combobox#multiple; select-kit's
useHeaderFilter / multi-select-header.gjs:70-98 is the migration parity target.)
The multi trigger is a ComboboxChips-style box: selected items render as chips
inline with a flexible role="combobox" input (input after the chips, growing to
fill and wrap), the input — not the Phase-0 separate expand button — as the focus
target. Behavior:
- Selecting adds a chip and keeps the popover open; the chosen item stays in the
list with a selected indicator (checkmark /
aria-selected="true") and toggles on re-click. This supersedes Phase-0 multi behavior — the engine currently removes selected items from the list inbuildItems(and a Phase-0 test asserts "the picked item leaves the list"); change multibuildItemsto keep + mark selected and update that test + add a checkmark toSelectItem. - Backspace on the empty input removes the last chip;
@clearableis the clear-all (distinct from per-chip removes). - Placeholder ("Add …") in the inline input, suppressed once chips are present.
@valueis the id array;aria-activedescendanton the inline input (active mode).- Mobile: chips + input move into the modal surface (Decision 1's mobile arm).
Multi's default variant is also typeahead (consistent with single); button
stays available for rich-display multi. resolveSelection already returns an array for
multi, so the data layer is ready — the Phase-1 work is the chips+inline-input trigger,
focus/keyboard, and the keep-selected buildItems change.
Typeahead build requirements (review findings — bigger than "invert the trigger"). The pivot relocates focus and the input into the trigger, which breaks three things the Phase-0 panel structure got for free:
- Skeleton can't live in an
<input>. The "content-only trigger skeleton" is button-variant-only. Typeahead needs the resolving skeleton rendered outside the input (overlay/adjacent), and a display getter that returns the placeholder (never a skeleton, never a raw id) whileresolveSelectionis pending. - Display-value vs edit-value split.
DFilterInputbinds one value; typeahead needs the input to show the resolved label unfocused and the query focused. Add a focus-aware@displayValue(or a purpose-built input), and reset the filter to "" on blur/close so the revert is a cached/sync resolve, not a re-fetch flash. - Escape + click wiring is explicit, not inherited. FloatKit's close-on-escape
handler lives on the portaled body; with focus kept on the host input, Escape never
reaches it — route Escape/close through
engine.requestClose()on the input path. Register with emptytriggers/untriggersand drive open/close from input events (type / ArrowDown / click-to-open; Escape / select / blur to close), or a click on the open input will untrigger and close it. - Two DOM topologies. Desktop = input-as-trigger; mobile = collapsed trigger +
input inside the modal. The single
filterInput/controllerElement/getElementwiring must branch per arm. - Bridge anchor stays host-DOM.
legacy.getElementmust resolve to a node inside#reply-controlon both arms — do not point it at the modal input on mobile (it's portaled out, so AI content callbacks'.closest("#reply-control")returns null). The positioning trigger and the legacy anchor may be two distinct nodes. - Consider
aria-ownson the input → portaled listbox for SR relationship (matrix).
Decision 1b — Trigger & list rendering states (typeahead × single/multi × async)
The hardest part of the design: how the shared primitives (:selection, :item,
DSkeleton, DAsyncContent's loading/empty/error) compose in a typeahead where the
trigger is an <input>. Planned exhaustively.
Foundational architecture — separate default and rich-presentation paths. An
<input>'s value is a plain string, so the default single-select path can use it for
both a resolved @labelField display value and the active query, with explicit editing
state separating the two. Skeletons, chips, and rich :selection markup cannot render
inside an input, so those stay in the composite box as siblings, laid out
inline-start → inline-end:
[leading @icon] · [selection presentation] · [query <input role=combobox>] · [clear] · [caret]
For a block-free single select, the input displays the synchronously available resolved
label until editing begins; while editing it displays engine.filter, which resets to
"" on close/select so the cached label returns. If resolution finishes while the
input is focused, the reactive cache updates the same mounted input and selects the new
label. When :selection is supplied, that rich presentation is a sibling and the input
carries only the query. Multi selection chips and resolving skeletons are also siblings.
Two independent async surfaces, both on the improved DAsyncContent:
- Selection surface (trigger):
resolveSelection(@value)→ bound id(s) → display. - List surface (panel):
loadItems(query)→ options. They never block each other: the list can be loading while the selection resolves, and vice-versa.
Selection surface (trigger) — state × arity:
@value state |
single renders | multi renders |
|---|---|---|
| none | input placeholder | input placeholder ("Add …") |
| resolving (pending) | text/rich skeleton sibling; hidden while the user is actively typing (shown again on blur) | cached chips render immediately; uncached ids resolve in ONE batch (@resolveValues), skeleton chips until it returns |
| resolved | input value from @labelField, or custom :selection content (sibling) |
label-field or custom :selection chip per id (removable) |
| unresolvable (404 / restricted / omitted) | a fallback presentation (see below) — never hidden, never blank | a fallback chip per unresolved id (see below) — never dropped |
List surface (panel) — independent:
| state | renders |
|---|---|
| initial load | :loadingItem compound skeleton rows × @skeletonCount (mirrors :item) |
| re-filter (query changed, had results) | keep prior rows (@retainWhileReloading) + aria-busy; no blank flash |
| load-more (pagination) | existing rows + an end-of-list load-more skeleton |
| content | :item rows; multi marks selected rows with a checkmark and keeps them |
| empty | :empty block / no-results role=status (polite announce) |
| error | :error + retry |
Skeleton taxonomy — three distinct shapes (all DSkeleton, reduced-motion-aware):
- List-row skeleton —
:loadingItem(compound: avatar + text for user/topic rows), repeated@skeletonCount; default a single text bar. - Chip skeleton (multi trigger) — a chip-shaped pill (avatar + short text for user),
one per unresolved id; overridable via
:selectionLoading. - Single-trigger skeleton — a text-width (or rich) skeleton for the one resolving
value; overridable via
:selectionLoading.
Focus / query lifecycle (typeahead):
- Unfocused with a selection → the input shows the label-field fallback, or a custom selection presentation is shown with an empty input.
- Open (per
@openOn: type / ArrowDown / click, not bare focus) → list loads (query=""→ initial results). - Typing → the first edit switches the input from its fallback label to the query; single hides any custom selection presentation (query owns the box); multi keeps chips, input flows after them. Clearing the query does not leave edit mode; close/select restores the selection presentation.
- Select — single → commit
@value, close, reset query; the picked item was cached on select, so the new label resolves synchronously (no skeleton flash). - Select — multi → append id, popover stays open, new chip appears (cached → sync),
reset query to
"", focus stays in the input. - Blur / Escape without selecting → reset query to
""; selection presentation returns (cached → sync, no re-fetch flash); close.
Edge cases (explicitly handled):
- Resolving while typing: no conflict — the input is the query (editable); the single skeleton is hidden behind the query and reappears resolved on blur; multi skeleton-chips resolve inline as the user keeps typing.
- Picked-from-list items never skeleton —
select()caches the full item. - Partial multi (
@value=[1,2,3], 1–2 cached, 3 uncached): chips 1–2 render immediately from cache; the uncached id is resolved in the single batch request and shows a skeleton chip until it returns (one request, not per-chip — see below). - Long single label → ellipsis; many chips → wrap, box grows.
- Clear-all mid-resolve → clears
@value, the resolve no-ops. - List loading with focus retained →
aria-busylistbox, input keeps focus +aria-activedescendant. - IME / composition input (CJK, dead keys): do not fire the filter mid-composition
— gate query updates on
compositionstart/compositionendso a half-composed string doesn't search or open. (DFilterInputdoes not handle this today — a real gap.) - Mobile placement split: on mobile the closed trigger (host DOM) shows an inert selection preview (single label / multi chips with no interactive controls) so the user sees the value without opening; tapping opens the modal that holds the live composite — chips + query input + list (chips move into the modal per line ~118; the closed trigger's copy is a non-interactive preview, not the editable chips). So the interactive presentation and the query input live together in the modal, while the closed trigger carries only a read-only echo. (Finalized in Phase-1 mobile M5.)
- Create row × multi → selecting the synthetic create row appends a chip like any pick; toggling an already-selected row removes it (checkmark model).
- Chip add/remove + result count announced politely via the
a11yservice.
Selection resolution — batch, fallbacks, and model normalization (design directives):
Batch, never fan-out. Resolving a multi selection uses a single batch request —
@resolveValues(ids, { signal }) returns items for the uncached ids in one call (single
uses @resolveValue(id, { signal })). We require a batch-capable endpoint rather
than resolving per-chip: N chips must never mean N requests (server flood). Cached ids
(picked from the list, or seeded via @selected/@selectedItems) never hit the
network; only the uncached remainder is batched. This supersedes the earlier "per-chip
DAsyncContent" idea — multi resolveSelection stays a single DAsyncContent over the
whole value driven by @resolveValues (drop the Promise.all at select-engine.js:292-295).
Fallback for every id — never hide a held value. A bound id may fail to resolve: a
404, a restricted topic/category the user can't see (single), or an id the batch
response omits (multi). We must never drop the chip or blank the single trigger, or
@value silently disagrees with what's shown. So resolution always maps each id to
either a resolved item or a synthetic fallback { [valueField]: id, __unresolved: true }
— the engine catches resolve errors and fills omissions rather than rejecting, so the
trigger's DAsyncContent never enters error/flash mode (a review finding: the trigger
resolve has no :error block, so a rejection would render a role=alert flash inside
the trigger). The fallback renders a default "unavailable" presentation (a translatable
label / the id shown minimally / a muted style) and stays removable/clearable; a
:unresolved block (defaulting to :selection, which can branch on item.__unresolved)
lets a preset customize it (e.g. "Restricted topic"). This makes the topic-picker's
restricted-404 case first-class.
Model normalization (no id assumption). Not every model has an id, yet the listbox
keys {{#each … key="id"}} (d-select.gjs:327) and chips key on identity — both break
for @valueField="username", primitive-value lists, and synthetic rows (create /
special / unresolved) that carry no natural id. So the engine normalizes every
rendered and selected entry into an internal descriptor { key, value, item, flags }:
key = the @valueField value (or a synthesized unique key for synthetic rows),
value = the id, item = the raw model yielded untouched to :item/:selection,
flags = { selected, disabled, group, __create, __unresolved }. The template keys on
key (always present; never collides across paginated pages or on id-less rows), and
consumers still render their own fields from the raw item. @valueField/@labelField
become the single source of truth for identity and label; key="id" is removed.
Normalization is the final step before render — the select-content transformer,
the modifySelectKit bridge, and item.onSelect all still operate on raw items;
only the rendered/selected descriptors wrap them, so the extension pipeline is unchanged.
Additional review findings folded:
reload()must show loading despite@retainWhileReloading— the AI spinner→results flow (nonce bump,select-engine.js:386-389) is the headline case; retain currently suppresses the spinner (d-async-content.gjs:72-73). A forced reload must show a visible busy state (bypass retain, or overlay a spinner on the retained rows).- Re-filter from an empty result shows a busy indicator, not a frozen "No results".
- Retained list needs
aria-busy+ a stable listbox id/role — the loading<ul>(d-select.gjs:296-304) must carry the sameid+role="listbox"so the combobox'saria-controls/aria-activedescendantnever dangle during the initial load;DAsyncContentneeds a reloading flag to drivearia-busyon retained content. - Chip removal keeps the popover open and moves focus to the input — removing the
focused chip
<button>must not blur-close the menu (d-select.gjs:170-174). - Dedup multi
@valueon read — a controlled parent may pass duplicate ids; the value getter de-duplicates so chips don't collide onkey. - Auto-highlight / active-descendant run against the CURRENT rows — while stale rows are retained, don't reset the highlight until the new rows land; on replacement, reset and scroll the active option into view.
- Immutable
@valuecontract — in-place array mutation is unsupported (it would update list checkmarks but not the trigger chips);@onChangemust be applied as a new value. - Eager resolution is by design — the single/multi trigger resolves on mount so the
saved label shows before first open (the fetch-to-display point); mitigate page-wide
fan-out via the cache + the
@selected/@selectedItemsescape hatch, not lazy resolve. - Dev-warning when
@valueis set on a server source with neither@selectednor@resolveValue/@resolveValues(no way to get a label → silent empty trigger). - Panel repositions as the multi trigger grows/wraps (
@matchTriggerWidth+ autoUpdate).
Engine/component changes (Phase 1): batch @resolveValues (one request, no
per-chip); resolution never returns undefined (synthetic __unresolved fallback item
on error/omission); an item normalization layer ({ key, value, item, flags }
descriptor, replacing key="id"); keep-selected-with-checkmark buildItems for multi;
query reset on blur/close/select; multi @value dedup; a reload()-shows-busy path; a
DAsyncContent reloading flag for aria-busy; and the :selectionLoading / :unresolved
blocks.
Decision 2 — Group/section-aware item model designed in now
select-kit supports hierarchy (category subcategories) and both references treat
sections as a core collection concept. Retrofitting grouping into buildItems +
dRovingFocus + ARIA later is painful, so we design the model now (build the UI when
the category family needs it, Phase 4):
- Options may declare a group (an item
groupkey, or a@groupByfield/fn); the listbox rendersrole="group"sections with a header. - The engine + transformers keep operating on a flat item array — grouping is a
presentation view over it — so
select-contentand the bridge are unaffected. dRovingFocusneeds no change:itemSelector="[role=option]"already skips non-option header rows, so keyboard nav stays correct.
Groups × pagination (boundary). Because grouping is a pure client-side view over the accumulated flat list, it composes with complete/client lists but collides with infinite/linear server pagination: an appended page's items can slot into earlier group sections (above the load-more sentinel), reflowing content above the viewport. So v1 does not combine grouping with paged loading in one picker — in practice they serve different pickers (grouped = category hierarchy / complete sectioned lists; paged = flat ranked user/tag search). A future grouped-and-paged picker would need the server to return complete groups (or top-N per group) per page — a 2D-pagination extension out of v1 scope. Keyboard/roving is unaffected (headers and the load-more sentinel are non-option rows, skipped).
Decision 3 — Mobile: don't block on d-sheets
Mobile screen readers ignore aria-activedescendant, so searchable comboboxes need a
real-focus fallback in DModal (input inside the surface, per Decision 1's mobile
arm). Ship that now and adopt the in-flight d-sheets framework when it lands rather
than blocking on it (coordinate with jaffeux/renato). Tracked as an open question with
an on-device SR check; simple/static mobile already uses real focus.
Decision 4 — RTL & i18n (cross-cutting)
Yes, RTL is first-class (Discourse ships RTL locales; select-kit hand-rolled direction-aware placement — we improve on that):
- CSS logical properties only — no physical
left/right. The Phase-0d-combobox.scssalready complies (margin-inline-start, symmetric padding); the caret, clear button, leading icon, and chips must position via inline-start/end so they mirror. - Positioning is RTL-aware for free — FloatKit's floating-ui
-start/-endplacements resolve against reading direction, so the defaultbottom-startmirrors correctly without the manual direction computation select-kit needed. - Input text direction follows the document/locale; no forced
text-align. - Both reference libraries (Base-UI, React-Aria) treat RTL as standard; we match.
- Added to the verification matrix (RTL locale × both themes) below.
Decision 5 — Large-list performance: bounded reveal, not virtualization
A synchronous source can hand us thousands of items; rendering them all on open hangs the tab (we've hit this pain before). Pagination fixes server sources but not a known- complete 5k client list — so this must be designed in from the start. Approaches:
| Approach | How | Pros | Cons |
|---|---|---|---|
| A. True DOM virtualization | render only the visible window over the full array, recycle on scroll | scroll the whole list continuously; constant DOM at any N | net-new in core (no primitive; post-stream cloaking isn't reusable and keeps all rows in the DOM anyway); breaks our a11y/keyboard layer — dRovingFocus + both existing pickers enumerate options from the DOM (querySelectorAll/offsetTop/index) and assume all are present; needs net-new aria-setsize/aria-posinset + windowing-aware keyboard + render-then-focus for off-screen active rows + row-height measurement. Fights the headline "accessible by construction". |
| B. Bounded reveal (recommended) | render a capped prefix (internal chunk; server page size auto-detected); reveal more via the SAME DLoadMore sentinel up to a hard MAX_RENDERED cap, then "filter to narrow" — server fetches the next page, a sync source slices the next chunk (no network); filtering re-slices |
reuses DLoadMore + d-observe-intersection; bounded DOM; dRovingFocus/keyboard/ARIA work unchanged (every option is a real node); matches select-kit's idiomatic "server limit + filter-for-more"; trivial perf (slice + ~100 rows); unifies sync + async |
can't scroll all 5k continuously — must reveal-more or (the intended path) filter |
| C. Hybrid | ship B now; keep A behind the same seam for a future browse-all need | safe one-way door | — |
SUPERSEDED (2026-07-21) — virtualization is adopted after all. The reasoning below stands as written for the case it was decided on, and one of its own conditions came true: it left virtualization "a future drop-in if a genuine browse-the-whole-list case ever appears", and that case appeared. A bounded prefix cannot reach a selection deep in a large set — a value at index 3000 of 5000 is not merely unfocused but unreachable by any amount of scrolling, because the cap stops at 200. That defect was deferred twice before being traced to this decision rather than to an implementation gap.
What changes: the invariant is no longer "every rendered option is a real DOM node" but every mounted option is a real DOM node. The
DVirtualListprimitive owns the render window, row measurement, and per-row position ARIA; the engine owns the row set and its totals. Reason (1) below is the cost now being paid deliberately —dRovingFocusenumerates options from the DOM and assumes all are mounted, which is false over a moving window, so the keyboard layer needs the windowing-aware work this decision was written to avoid. Reasons (2)–(5) are simply overtaken: the primitive now exists in core, so virtualization is no longer net-new.This reversal is deliberate and was not folded in quietly: the original was deep-planned with four adversarial lenses, and reversing it is a plan-level call recorded here.
Recommendation: B, framed as C. Bounded reveal is the shipped model; the seam
(and aria-setsize/aria-posinset from day one) leaves virtualization a future
drop-in if a genuine browse-the-whole-list case ever appears. Reasons: (1) preserves
accessibility-by-construction — every rendered option is a real DOM node, no new
keyboard/focus machinery; virtualization would reintroduce exactly the hand-rolled
focus/ARIA complexity this project exists to eliminate; (2) reuses proven infra
(DLoadMore) + select-kit's idiomatic capping, vs net-new virtualization (post-stream
cloaking is not reusable); (3) the 5k-node hang is eliminated at the source (never
rendered); (4) the intended interaction for a huge list is filter, not scroll, so B
makes the happy path fast and honest; (5) unifies sync and async — one reveal
model, sync slices locally, server pages, both via the same sentinel + "N of M" hint.
Design:
- No public
@pageSize. The render chunk is internal: a client source uses a sensible internal default (~50–100); a server source auto-detects its page size from the first response's length — the backend owns its paging default, so we don't dictate or second-guess it. - Hard
MAX_RENDEREDupper bound (a fixed constant, a few hundred rows) on the total items ever rendered/loaded — the guard against the degenerate case while there is no true virtualization. It caps both sources regardless of how many times the user reveals more. - Reveal = a
DLoadMoresentinel inside the listbox scroll container (@root= the listbox). On reveal: sync → widen the slice (no network); server → fetch + append the next page — up toMAX_RENDERED. - The sentinel serves both pointer and keyboard in v1. Arrowing down scrolls the
listbox (
dRovingFocusscrolls the container, never the page), which trips the sentinel's prefetch margin before the last mounted row is reached. An explicit "ArrowDown at the last row reveals, then lands on the new row" hook is deferred to its own cycle: an adversarial pass found the obvious shape unimplementable — the engine never holds the list's promise (DAsyncContentowns it), so there is nothing to await; awaiting anyway races the render flush and cannot keeppreventDefaultsynchronous; and a generation guard keyed on the item list cancels its own continuation. The shape that survives is flag-and-reconcile — set a pending-advance flag, let the nextmodify()(which runs after render by construction) consume it. - At
MAX_RENDEREDwith more still available → stop loading and show a "keep typing to narrow the list" message (thefilter-for-moreaffordance); never keep loading into the degenerate case. Filtering shrinks the set back under the cap. aria-setsize= the true total when known (client: full filtered count; server: the reported total, or the accumulated count once the source asserts completeness — by declaring no more pages, or by declaring nothing at all),aria-posinsetper option — so SR users know the list extends beyond the rendered prefix / the cap. A size inferred from paging behaviour is never reported: a source stopped by the duplicate-page brake, or one whose overflow was clipped at the cap, falls back to-1, the honest unknown-size encoding. A wrong number is worse than no number.- The engine renders a bounded prefix of the final (filtered → transformed →
normalized) descriptor list; a query change or
reloadresets the reveal to the first chunk. - Groups × reveal: client reveal (slicing a known-complete list) composes
with
@groupBy— the full order is known, headers stay stable. This refines Decision 2: only server infinite pagination + groups is out of scope (reflow); client reveal + groups is fine.
Decision 6 — Author the family in TypeScript (PR #41478)
Since #41478 core can author
.ts/.gts with full TypeScript (reference migration: app/ui-kit/d-button.gts;
docs: docs/developer-guides/docs/03-code-internals/26-types.md; tsconfig not yet
strict). This was floated on the RFC (David) and we adopt it — the select family is a
clean greenfield to pilot proper TS, and types make the "coherent API" enforceable
rather than documented.
Execution prerequisite — rebase first. The worktree is ~23+ commits behind
origin/main and predates #41478, so its build cannot compile .ts/.gts. First
execution step on approval: rebase select-kit-rework onto latest origin/main
(resolve any conflicts in the touched shared files), then convert. Re-aim draft PR
#41534 to ship TS.
Convert to TS. The net-new select-family files — select-engine.js → .ts,
d-select.gjs → .gts, -internals/select-item.gjs → .gts,
-internals/modify-select-kit-bridge.js → .ts, and the new engine/bridge/component
tests → .ts/.gts — plus the three foundational primitives this family owns or
co-lands (user-approved):
d-async-content.gjs → .gts— a widely-consumed shared primitive, so the conversion must preserve its public signature (.gjs/.gtsconsumers import it transparently); type its args + block params (asyncData,context, and the:loading/:content/:empty/:erroryields).modifiers/d-roving-focus.js → .tsandd-skeleton.gjs → .gts— the two ported primitives; converting means the editor branch adopts these TS versions when it reconciles (drops its copies) — coordinate that hand-off.
Stays JS: only d-icon-grid-picker/content.gjs — a pre-existing component we merely
touched for a11y (0g); it isn't central to the select family and carries a larger,
unrelated surface, so converting it is out of scope for this PR.
Typed surfaces (the design win): SelectEngineOptions; the raw-item generic + the
normalized descriptor { key, value, item, flags }; the @value type (scalar for
single, array for multi); the @variant union ("typeahead" | "button" | "static");
@resolveValue/@resolveValues, @load, @filterBy signatures;
onChange(nextValue, item | items); the transformer/bridge context types; the
DSelect Glint Signature (Args / Blocks / Element); and typed Signatures for
the converted primitives — DAsyncContent (generic over the resolved value),
DSkeleton, and the dRovingFocus modifier.
Strictness scope: write strict-grade TS regardless — the loose global config is
not a license for sloppiness. Author as if strict were on: no any (implicit or as
an escape hatch), proper null/undefined handling, real generics over the item/value
types, precise Signatures, and no @ts-ignore/@ts-nocheck crutches; the new files
should type-check clean even under strict. The only thing we must not do is edit the
shared tsconfig-base.json to flip the repo-wide flags in this PR — that's a
separate, repo-wide initiative #41478 flagged for later and would ripple into unrelated
files. (Optionally, a strict-scoped tsconfig covering app/ui-kit/select/** could
enforce the bar locally without touching the global config — nice-to-have, not
required.)
Reference-library reconciliation (React-Aria + Base-UI props → our proposal)
Every React-Aria ComboBox and Base-UI Combobox prop was mapped against our surface.
The bulk map directly (selection, disabled options, sections, clear, async +
cancellation, placeholder, custom filter fn, auto-highlight, chips/multi, labels via
...attributes, custom-value). The genuine deltas and their disposition:
| Reference prop | Our proposal | Disposition |
|---|---|---|
RA inputValue/onInputChange (controlled query) |
internal filter state |
Deferred v1 — expose @query/@onFilter only if a picker needs external query control (async-first pickers rarely do) |
RA menuTrigger (input/focus/manual) |
open behavior | Phase 1 — default "input" (type/click/ArrowDown, not bare focus); expose @openOn for focus/manual |
RA shouldFocusWrap (wrap at ends) |
dRovingFocus wrap arg (already supported, default clamp) |
Phase 1 — pass through as @focusWrap |
RA onKeyDown/onKeyUp, onFocus/onBlur |
— | Deferred — add on demand |
RA/Base-UI controlled open/onOpenChange |
@onShow/@onClose hooks |
Have hooks; controlled @open deferred |
RA renderEmptyState / Base-UI Empty |
@noResultsLabel text only |
Phase 1 — expose an :empty block override, not just text |
| RA ListBox virtualization | DLoadMore pagination |
Declined v1 — pagination over virtualization; revisit only for huge client lists |
RA allowsCustomValue |
@allowCreate + @createItem (create row) |
Have (create-row form); free-text-commit via createItem |
RA isRequired/isInvalid/validate, name/form/formValue |
— | Parked → FormKit (Phase 2) |
Net: the reference-prop deltas are deliberate deferrals plus small Phase-1 additions; a deeper adversarial sweep (below) surfaced the more consequential gaps.
Adversarial review — additional findings folded
Correctness / completeness:
castIntegeris a latent equality bug already in the Phase-0 engine (not a mere migration arg):isSelectedand#resolveOneSynccompare with strict===(select-engine.js~219/493/497), so@value="5"againstid:5neither highlights the row nor resolves the trigger label — it silently shows the placeholder. Reclassify as an engine equality contract: pull a normalized/coercing comparator (or a@valueEqualityhook — select-kit'scastIntegeris the common case) into Phase 1; latent now only because the sole consumer is the controlled test harness.- Multi-select pipe-paste (
|-separated → bulk add;multi-select-filter.gjs) has no home and affects every multi-select (tags, groups, watched-words). Add a bulkengine.append(values)path + paste handling to the multi input (Phase 1). - In-list messages, not just form chrome. select-kit renders max/min + validation
as a collection inside the listbox (
ERRORS_COLLECTION), and a "filter for more" sentinel row distinct fromDLoadMore(filter-for-more.gjs, used by category-drop/tag-drop/group-chooser). Map both:@maximum/@minimummessage renders in-list; add a "filter for more" sentinel for the category/tag families (Phase 3). expandedOnInsert(auto-open on mount; core consumerd-access-control) — add"mount"to@openOn(or a small@autoOpen).limitMatches(cap rendered rows),autofocus(focus closed trigger on mount) — low; a preset can bakelimitMatchesinto@load; note, don't prioritize.- Cleared as non-gaps:
triggerOnChangeOnTab(dead code), Tab-commits (our Tab-never-selects is a deliberate improvement), Home/End (dRovingFocus already has them), type-to-jump on a static list (select-kit doesn't either — optional nicety),mandatoryValues/hiddenValues/formName/focusAfterOnChange(parked/subsumed).
Chrome cross-product (resolve these undefined cells):
- Caret in a typeahead input is opt-in and, when shown, a real toggle
<button aria-label>, not a decorative glyph.@caretIconshould map select-kit'scaretUpIcon/caretDownIconpair (flips on open) or acceptfalseto hide — a single static glyph loses parity. @clearablein multi = a distinct clear-all control (inline-end), coexisting with per-chip removes.- Trigger content order (layout + RTL determinism), inline-start → inline-end:
leading
@icon, chips (multi), input, clear, caret. @allowCreate+staticis impossible (create needs a filter term) → dev-warn/ no-op, documented.@groupByedges: the synthetic create row +@specialItemssit ungrouped/pinned. (Selected items now stay in-list with a checkmark, so groups no longer empty out from selection.)
Naming (unify in the doc): use @openOn consistently (the reconciliation table
said menuTrigger), noting it deliberately diverges from both FloatKit's triggers
array and React-Aria's menuTrigger; @focusWrap forwards dRovingFocus's wrap
(keep @focusWrap, or rename @wrap to match the primitive); map select-kit's
caretUpIcon/caretDownIcon pair in the codemod table.
Folded into Phase 1 (small, decided — not forks)
Close these select-kit / modern-lib customization gaps as part of "complete the family":
@clearable— clear/reset button in the trigger (select-kit clearable, shadcnshowClear; chapoi).DFilterInput @onClearInput(already supported) →engine.clear().@caretIcon— an open/closed pair that flips:{ open, closed }(default{ open: "angle-up", closed: "angle-down" }), a single string for both, orfalseto hide (select-kit'scaretUpIcon/caretDownIcon). Intypeahead, when shown, it is a real toggle<button aria-label>, not a decorative glyph.@icon— a leading trigger icon (select-kit icon/icons).@disabled/@readonly— whole-select disabled/read-only (select-kit disabled; React-AriaisDisabled/isReadOnly); needed for FormKit.@onShow/@onClose— open/close hooks (select-kit onOpen/onClose).@placement/@offset— position passthrough to the float primitive.- Auto-highlight the first match so Enter commits the obvious choice (React-Aria).
@focusWrap— pass throughdRovingFocus's existingwraparg (default clamp); arrow-key wrap at list ends (React-AriashouldFocusWrap).@openOn— open behavior: default"input"(type/click/ArrowDown, not bare focus);"focus"/"manual"opt-in (React-AriamenuTrigger).:emptyblock override — let a consumer render the no-results/empty content, not just set@noResultsLabeltext (React-AriarenderEmptyState).@minChars— minimum query length before a server search fires (default 0); below it, show a "type to search" prompt instead of querying (select-kit'sskipSearch/eagerCompleteSearch, e.g. user search). Plus optional@debounceMs(override the derived debounce for slow endpoints) and avalidateCreate(term)hook on@allowCreate(reject invalid create entries, e.g. tag rules).
Parked (later phase, mapped — not dropped)
@maximum/@minimum, mandatory/hidden values → Phase 1 with multi/chips.- Loading-state granularity (
loadingvsfilteringvsloadingMore) → Phase 1 withDLoadMore(React-ArialoadingState). castInteger/value coercion (string-id vs number-id compare viavalueField) → Phase 2 migration concern.- Native
name/form submit,isInvalid/validation chrome → FormKit (Phase 2). - Full trigger/row component override (
headerComponent/modifyComponentForRow) →:selection/:itemblocks cover content; revisit only if a picker needs a full component/behavior override.
Migration-parity note
:item/:selection are our modifyComponentForRow/headerComponent equivalent;
@specialItems + @placeholder cover none/autoInsertNoneItem; item.onSelect
covers action rows; @allowCreate covers allowAny. The only select-kit behaviors
with no home yet are the "parked" ones above — none block Phase 1.
Documentation & progress tracking (deliverable)
This plan is long; keep it usable across the multi-phase effort via three in-repo artifacts (working docs in the worktree; committing them is a per-commit call — the master RFC was kept local for Phase 0):
- Master reference —
SELECT-KIT-REPLACEMENT-RFC.md(worktree root; exists but is now stale). Fold this whole refinement into it — Decisions 1–6, the Decision-1b state model, batch/fallback/normalization, reveal, tokenization, the reconciliation table, and the adversarial findings — so it mirrors this plan. Single source of truth for the design. - Per-phase tracker docs —
docs/select-kit-replacement/PHASE-0.md … PHASE-5.md(one per roadmap phase). Each is lean, not a copy of the RFC: a goal, a task checklist with status (☐ pending / ◐ in-progress / ☑ done), exit criteria, and cross-links to the relevant RFC decision sections.PHASE-0.mdis retro-filled (0a–0g mostly done; the TS conversion and the typeahead-default rework are the pending items). The checklist is updated as we execute, so state is always visible. - Index —
docs/select-kit-replacement/README.md: the phase list with a one-line status + link each, so "what's pending vs done" is answerable at a glance.
Reconcile the roadmap numbering: this plan's roadmap is Phases 0–5, while the Dev RFC topic #187302 lists a finer 0–11; the per-phase docs follow this plan's phases and must note how the Dev breakdown maps in (so the public roadmap and our trackers don't drift). Keep the master RFC and the trackers in sync as decisions change. The public Dev topic is a separate, outward-facing update (only if requested).
Mechanism & invariants (how select-kit works today; what we must preserve)
- Public surface: angle-bracket tags (
<ComboBox>,<CategoryChooser>,<TagChooser>,<UserChooser>, …) + direct imports fromdiscourse/select-kit/components/…and the bare legacyselect-kit/…specifier (both resolve viaselect-kit/compat-modules.js). - Extension:
api.modifySelectKit(identifier).{appendContent, prependContent, replaceContent, onChange}(select-kit/lib/plugin-api.js) — four ops, keyed onpluginApiIdentifiers, which accumulate down the class chain (a callback on"combo-box"fires for every combo-box-derived select). PlusregisterComposerAction/addComposerToolbarPopupMenuOption,addUserSearchOption/CUSTOM_USER_SEARCH_OPTIONS, and theresolveComponentstring-name path. - Row-action escape hatch already exists:
select-kit.jsrunsitem.onSelect(this.selectKit, item)and returns without selecting/closing — the exact primitive the AI RFC (#185600) wanted. - Invariants to hold: (1) no third-party breakage overnight — old tags,
imports, and
modifySelectKitextensions of core's selects must keep working; (2) conservation of the four content ops' ordering semantics (prepend, then append, then replace-wins); (3) selection correctness (single closes + no over-select; multi toggles); (4) accessibility must be a hard gate, not a follow-up.
Chosen approach — Hybrid (and why the alternatives lost)
A plain, DOM-free SelectEngine class owns the internal UI state (filter,
result loading, resolved-item cache) and the selection logic, and drives the
foundations. The value is controlled — the parent owns @value; the engine
derives isSelected/display from it and emits @onChange(nextValue, item|items),
keeping no internal selection state (matching FormKit's @value/@field.set). The public API is one component, DSelect — single-select by
default, multi-select via @multiple (a capability flag the engine already
models, not a separate component) — plus domain presets (DCategorySelect,
DTagSelect, …) that pass @multiple through, so one preset serves both arities.
DMultiSelect survives only as a thin @multiple={{true}} alias for its existing
name/consumers. Presets take @load + a :item block. The composition parts (Filter /
Results /
Option) stay internal — core component authors compose freely from them, but
they are not a public contract.
- vs. Config-driven family (engine is the component): rejected because the base reinflates with args as specialized pickers accumulate — structurally recreating select-kit's god-component failure mode — and its state logic is only testable through rendering. Its merit (one concept, smallest surface) is real but loses to long-term maintainability.
- vs. Headless + public parts: rejected because publishing the parts creates a large permanent surface, invites inconsistent hand-assembled selects, and lets a consumer render raw results that bypass the transformer pipeline and silently drop plugin rows. Its merit (max flexibility) is retained internally — core wrappers use the parts; we can promote them to public later if a real need appears (a one-way door in the safe direction).
Hybrid keeps the shared core tiny (specialization goes into thin wrappers, or a subclassed/composed plain engine — both unit-testable), makes the public surface small and safe, and makes plugin-extensibility guaranteed rather than optional.
Extension model — transformers (not a bespoke registry)
Use the standard transformer API, which already models this exact shape:
composer-actions-new.gjs extends a dropdown of items via
applyValueTransformer("composer-actions-content", …) +
applyBehaviorTransformer("composer-actions-on-select", …), extended by
discourse-post-voting.
- Content → a value transformer
applyValueTransformer("select-content", items, ctx). Plugins prepend/append/replace by returning a modified array. - Selection side effects →
applyBehaviorTransformer("select-on-change", defaultFn, ctx). - Action items (run a callback instead of selecting) → an
item.onSelect(engine, item)closure on the item (plain data, authored by whoever injected it; the engine honors it before selecting/closing). - Transformer names are frozen at boot (
app/lib/transformer.js+registry/transformers.js+ thefreeze-valid-transformersinit), so we use a fixed generic name pair with the select's identifier(s) inctx(thecomposer-actions-content/context.actionidiom). Registerselect-content(value) andselect-on-change(behavior) inVALUE_TRANSFORMERS/BEHAVIOR_TRANSFORMERS; plugins branch onctx.identifiers.
Legacy modifySelectKit bridge (the hard part — findings folded in)
Re-implement api.modifySelectKit(id).{append,prepend,replace,onChange} on top of
the new pipeline so third-party extensions of core's selects keep firing after
core swaps to the new components. The review surfaced non-trivial fidelity
requirements the bridge must meet:
- Preserve the 3-phase ordering (all prepends → all appends → replace-wins)
within the bridge; do not lean on transformer registration order (which is
global and would flip cross-plugin ordering). Keep bridged callbacks in their
own ordering stage, separate from native
select-contentregistrations. - Stable per-instance facade: memoize exactly one
legacyComponentFacadeper engine instance — the AI suggester keys aWeakMapon the instance; a fresh facade per call makes every state lookup miss. - The facade must be
EmberObject-backed (callbacks use.get()/.set(),selectKit.select/close/isLoading,component.action, nested paths), not a plain object — and must expose.element+ owner (the AI content callbacks callgetOwner(component)andcomponent.element.closest("#reply-control")to build their context; without these the AI row silently never renders). This means the value-transformerctxfor bridged selects must carryelement/owner — an explicit, documented exception to "plain ctx." onChangemust reconstruct the selected-item argument (old signature passes(component, value, items); the behavior-transformer ctx must surfaceitems).- Keep an array of identifiers (
["combo-box", "category-chooser"]), not one string, to preserve base-class fan-out that the coreapi-test.gjsasserts. - Suppress double-registration:
composer-actionsalready dual-registersmodifySelectKitand the transformer; the bridge must not forward identifiers that already have a native transformer path (else rows insert twice). - Emit a deprecation on every use — the bridge is not a silent shim. Each time
a
modifySelectKit(id)callback fires through a new component it emits adeprecated(...)under a dedicated id (discourse.select-kit.modify-select-kit) naming the replacement (api.registerValueTransformer("select-content", …)/"select-on-change"). This nudges the author and gives the deprecation collector a real per-use signal, so the bridge can be removed once usage hits zero.
Library shape — taxonomy, naming & file layout
The family lives under frontend/discourse/app/ui-kit/ (flat d-* convention, no
barrel/index; multi-part components use a d-<name>/ folder per the
d-icon-grid-picker/ and d-otp/ precedent). Layout:
app/ui-kit/select/— the machinery:select-engine.js(headless class), the transformer registration, and a-internals/subfolder (the leading-dash "private, don't import directly" marker already used inapp/lib/blocks/-internals/) holding the composition parts: combobox filter,role="listbox",role="option", trigger, and multi-select chip.- Public entries as flat
d-*modules soui-kit-shims.jscan alias them:d-select.gjs(the one combobox; arity via@multiple) andd-multi-select.gjs(a thin@multiple={{true}}alias kept for its existing name/consumers). Presets (d-category-select.gjs,d-tag-select.gjs, …) are flatd-*too — thin compositions of the engine + a:itemblock that pass@multiplethrough. - Name collision & native deprecation:
d-select.gjscurrently is the native<select>wrapper (~12 consumers). Rename itd-native-select.gjs(DNativeSelect) with a shim alias and mark it deprecated (new deprecation id), freeingDSelectfor the combobox. For coherence there is one select family:DSelect's simple/static mode (see Interaction modes) covers the short static lists native<select>was used for, soDNativeSelectis a migration bridge, not a kept peer — FormKit's plainselectcontrol and the ~12 consumers migrate toDSelect.
Consolidating the ad-hoc components
| Existing (ui-kit) | Disposition |
|---|---|
DSelect (native <select>, ~12) |
Deprecate. Rename → DNativeSelect (deprecated shim) for the window; DSelect's simple/static mode replaces it; FormKit plain select control + the ~12 consumers migrate to the family |
DMultiSelect (DMenu+DDropdownMenu, hand-rolled async/keyboard, ~8) |
Becomes a thin @multiple alias of DSelect on the engine (roadmap P1) — name/consumers survive, but the logic is one component; retires the hand-rolled debounce, keyboard, and inline skeleton |
DIconGridPicker (DMenu+DAsyncContent grid, ~22) |
Re-home on the engine as the grid variant; migrate loading to DSkeleton and announcements to the a11y service |
DDropdownMenu (<ul> action menu, ~91) |
Stays as action-menu chrome; the select family renders its own role="listbox" (not DDropdownMenu — whose <ul> is what the current DMultiSelect invalidly nests <div>/<li> into) |
DFilterInput (~43) |
Reused as the combobox filter input |
DAutocompleteResults + d-autocomplete modifier (~13) |
Separate engine (inline @-mention typeahead), out of scope for the select family now; long-term its result list could share the -internals listbox/option parts — flagged, not committed |
There are two dropdown engines in the codebase — FloatKit DMenu (declarative) and
the imperative d-autocomplete modifier. The select family standardizes on DMenu.
Data strategies — async is first-class (client-only is the degenerate case)
Async is a first-class capability, not a bolt-on. The engine is async-native: its
core is one loader that models loading / content / empty / error, debounce, request
cancellation, and out-of-order safety for every select. A client-only list is
simply the case where the source resolves synchronously — the same loader, not a
separate path. (Contrast select-kit, whose base is a synchronous content array and
whose async pickers each override search() and hand-toggle an isLoading flag,
with no built-in error, cancellation, or race handling.) Two sources, one loader:
- Client-only (
@options/localContent: a sync array or a() => array) — the engine filters synchronously in JS on each keystroke; the source returns a raw array, so there is no pending phase, no skeleton, instant. Forlist-setting, static enum settings, preloaded categories on small sites. - Server-backed (
@load/loadFn(filter, { signal })→ Promise) — the source returns a promise, soDAsyncContentshows loading → content/error, debounced, with request cancellation. For tag/user/category search. - Dual-mode presets (e.g.
category-chooser: async on lazy-load sites, preloaded on small ones) choose the source at construction — a single ternary, no special-casing in the engine. - Large sets:
DLoadMoreinside the listbox for paginated/infinite loading (a capability select-kit had; wired at the server-backed path).
Data layer — one unified path on an improved DAsyncContent
Both sources normalize, once at construction, into a single
source(filter, { signal }) → items[] | Promise<items[]> closure — local returns a raw
array, server returns a promise, dual-mode returns either per call. The component
feeds that one closure straight to DAsyncContent, so there is one template, no
local/server branch:
<DAsyncContent @asyncData={{@engine.source}} @context={{@engine.loadContext}}
@debounce={{@engine.isAsync}}>
<:loading><DSkeleton … /></:loading>
<:content as |value|>{{! engine.buildItems value → <ul role="listbox"> }}</:content>
<:empty>…</:empty>
<:error as |e retry|>…</:error>
</DAsyncContent>
We strengthen DAsyncContent rather than side-step it — two small,
backward-compatible additions that benefit every async consumer, not just selects:
- Accept a synchronous return. Today the
@asyncDatafunction throws if it returns a non-promise (d-async-content.gjs:106-110). Instead, wrap a non-promise directly inTrackedAsyncData, which resolves it synchronously (verified:tracked-async-data.js—if (!isPromiseLike(data)) this.#state.data = ['RESOLVED', data]). With@debounceoff, a local source then renders content with no pending phase and no skeleton flash — the sync fast-path, inside the shared primitive. (Backward-compatible: turns a throw into a success.) - Thread an
AbortSignal.DAsyncContentcreates a controller per fetch, callsasyncData(context, { signal }), and aborts the superseded request on context change / teardown — the cancellation it lacks today. (Backward-compatible: existing functions ignore the extra param.)
DAsyncContent already discards a stale TrackedAsyncData when @context changes,
so out-of-order display safety is inherited; the added signal covers the network.
@debounce={{engine.isAsync}} (a value, not a branch) keeps local filtering instant
and debounces server keystrokes.
First-class async guarantees — now all delivered by the shared foundation:
- Debounce server keystrokes; cancel superseded requests + abort on
teardown (the new signal); out-of-order safety (stale
TrackedAsyncDatadiscarded). - Async selection resolution: when the bound value is an id whose label isn't in
the current results (a preselected category/user/tag), the engine resolves its
display label through the same
source— a core step, not the ad-hoc per-picker lazy-load select-kit hand-rolls (category-chooser.js:29-43). - Re-run with an unchanged filter (the AI spinner→results flow): the engine bumps
a nonce inside
loadContextsoDAsyncContentrecomputes even when the filter string is identical (or a small@reloadKeyarg added toDAsyncContent). - Retry on error via the
:errorblock's yielded retry; loading/empty/error areDAsyncContentstates, not manual flags.
The engine owns filter / result loading / buildItems / keyboard (the value is
controlled — see Value model); all async state stays in DAsyncContent, improved once for everyone (e.g. DIconGridPicker gains
cancellation for free). This keeps the library on solid, composable foundations
rather than a per-component hand-roll (the sin of select-kit and today's
DMultiSelect).
Selected-value resolution — fetch-to-display (first-class)
A bound value is often just an identifier; showing the selected item in the
trigger (or as chips) may require fetching its full record. The library does this
automatically so a picker never flashes a raw id — a general capability exercised
by, not shaped by, the topic-picker use case
(~/.claude/handoffs/discourse/2026-07-06-dtopicselect-topic-picker.md) and the
per-picker lazy-loads select-kit hand-rolls (category-chooser.js:29-43):
@resolveValue(value, { signal }) → item | Promise<item>(multi:@resolveValues) — resolves id(s) → displayable item(s) on the same improvedDAsyncContent(sync fast-path + abort + cache).- Escape hatch
@selected/@selectedItems— when the caller already holds the full object (e.g. an invite that embeds its topic), pass it and no fetch happens (synchronously resolved, no flash). - Content-only skeleton in the trigger while resolving: the frame / caret / clear
stay; only the value text is a
DSkeleton @variant="text"(each unresolved chip in multi). Overridable via:selectionLoading. - Cache + short-circuit: picking from the open list feeds the full item straight in (no resolve); resolved values are cached (reopen / re-render never refetch); a value already in the current results resolves synchronously.
- Deleted / restricted / rejected → a synthetic
__unresolvedfallback item (never hidden, never a bare raw id) — see "Selection resolution — batch, fallbacks, and model normalization" in Decision 1b. A held value is always represented and clearable; batch resolution (@resolveValues) is required for multi.
This is a second async surface, independent of list-loading: the trigger
resolves the selected value while the dropdown loads options — both on the
improved DAsyncContent.
Interaction modes (orthogonal to data source)
The native <select> is deprecated, so DSelect covers three trigger variants via
@variant (default typeahead) — see API refinement › Decision 1 above for the
full rationale and the per-breakpoint FloatKit details:
typeahead(default, searchable): the trigger IS arole="combobox"input;dRovingFocusactivemode moves a virtual highlight while focus stays in the input (desktop) or in the modal/sheet input (mobile).button(variant, searchable): button trigger + in-panelDFilterInput, for rich value display (badge/avatar) or menu-style pickers.static(variant): no filter input; the listbox itself takes focus anddRovingFocusfocusmode (real roving tabindex) navigates. Noaria-activedescendantmobile-SR weakness, so short static selects are strongly accessible on mobile for free — the native-<select>replacement.
States — loading, empty, error, create, filter
- Loading — two independent surfaces, both
DAsyncContent-driven:- List / results (dropdown fetching options): placeholder rows in the
listbox.
:loadingItemrenders one placeholder row, repeated@skeletonCounttimes (default ~5), so a compound row (e.g. acircleavatar +texttitle/subtitle bars) mirrors the real:item;:loadingoverrides the whole region. Default = a singleDSkeleton @variant="text"row. Retire the bespoke inlineSkeletonind-multi-select.gjsand the icon-picker's plain.spinner; standardize onDSkeleton(structural convention,topic-card.gjs). - Trigger / chips (resolving the selected value): the content-only skeleton from Selected-value resolution above — just the value text, not the whole combo.
- List / results (dropdown fetching options): placeholder rows in the
listbox.
- Empty (
DAsyncContent's:empty): two cases — "no data" and "no matches for the current filter" — each arole="status"message routed throughthis.a11y.announce(…, "polite")(not a hand-rolled live region). Uniform for local and server. - Error (
DAsyncContent's:error, server only): the block's inlineDFlashMessageplus its yielded retry; announced. - Create-on-the-fly: ui-kit has no precedent — port select-kit's
allowAny/createContentFromInput. The engine appends a synthetic{ __create: true }row when the filter is non-empty and matches no exact option; the:itemblock renders "Create: %{term}". Selecting it adds the typed value (tags, list-setting free text). - Filter: the combobox input is
DFilterInput(@value,@filterAction,@onClearInput,@icons); client path filterslocalContent, server path re-runsloadFn— both keyed off the same engine filter token that also feedsdRovingFocus'sitemsKey.
Authoring a new select — DevEx
The payoff of the hybrid is that adding a select is composition, not subclassing. A
new specialized select is a thin .gjs wrapper over DSelect (arity via @multiple,
not a second component) that supplies a source and domain args. Plain rows and
selections need no blocks: both fall back to @labelField (name by default). Add
:item and/or :selection only when that surface needs custom markup. The wrapper
inherits filtering, all async state, keyboard, ARIA, positioning, and transformer
extensibility for free.
The source contract (how sync/async stays ergonomic)
An author declares where rows come from, never how to fetch-and-track them:
@options— an array or() => array. Client-only; the engine filters it (@filterBy= a field name or(item, term) => booleanto customize matching).@load—(filter, { signal, offset, limit }) => items[] | { items, total?, hasMore? }, synchronously or as a promise. A paginated source reportstotalwhen it knows the set size andhasMorewhen it only knows another page exists. Declaring neither — including returning a bare array — means the response is the complete set, so a source that paginates must say so or only its first page is ever shown. The general form: return an array for a sync/client source, a promise for a server source — and a dual-mode select is a single function that returns either per call (return this.lazy ? this.server(filter, { signal }) : this.local(filter)). No second code path, noisLoadingflag, no try/catch — the engine derives loading/empty/error/retry, debounces, cancels viasignal, and guards races.
(@options is just sugar for @load={{() => array}}; both normalize to the one
internal source closure from the Data layer section.)
A new select, end to end (contrast with select-kit)
// d-user-select.gjs — ONE preset, single OR multi via @multiple, ~15 lines
export default class DUserSelect extends Component {
@service store;
#search = (filter, { signal }) => this.store.searchUsers(filter, { signal });
<template>
<DSelect @identifier="user-chooser" @multiple={{@multiple}}
@value={{@value}} @onChange={{@onChange}}
@load={{this.#search}} @labelField="username">
<:item as |user|><UserRow @user={{user}} /></:item>
<:selection as |user|>{{user.username}}</:selection>
</DSelect>
</template>
}
Arity is a flag, not a component: <DUserSelect /> is single,
<DUserSelect @multiple={{true}} /> is multi — one preset, no fork. (@value is
scalar when single and an array when multi; :selection renders chips only in multi;
the engine's multiple flag drives close-on-select and value shape.) Compare
select-kit: a subclass of MultiSelectComponent overriding search() and
modifyComponentForRow(), a @selectKitOptions({…}) bag, a bespoke row component
class, and manual isLoading toggling.
What the author never writes (owned by the engine/parts)
Fetch state machine · loading skeleton · empty/error/retry UI · debounce · request cancellation · out-of-order guarding · preselected-value resolution · filter input · keyboard nav · combobox/listbox ARIA · overlay positioning · mobile modal.
The authoring surface (blocks & args)
- Sources:
@options/@filterBy, or@load;@minChars(min query length before a server search fires) /@debounceMs. - Presentation: optional
:item(each option) and:selection/:triggeroverrides; when omitted, option rows, triggers, and chips render@labelField(nameby default). Also:empty(override);:loadingItem(one compound placeholder row, repeated@skeletonCount) or:loading(whole-region override);:selectionLoading(trigger/chip skeleton while the selected value resolves);:unresolved(fallback for a 404/restricted/ omitted id — defaults to:selectionbranching onitem.__unresolved). - Values:
@value(controlled source of truth, applied immutably) +@onChange(nextValue, item|items),@valueField/@labelField(the single source of truth for identity/label — items are normalized, noidassumption). FormKit forwardsid/name/disabled/aria-*onto the combobox input. - Behaviors:
@allowCreate+@createItem,@specialItems,@resolveValue/@resolveValues+ the@selected/@selectedItemsescape hatch (fetch-to-display of a bound id — see Selected-value resolution),@identifier(transformer extension key),@variant(typeahead|button|static, defaulttypeahead; replaces the Phase-0@searchable) /@multiple,@disabled/@readonly,@onShow/@onClose. - Chrome (see API refinement):
@clearable,@caretIcon(customize/hide),@icon(leading),@placement/@offset,@groupBy(section headers),@openOn(open behavior),@focusWrap. (Reveal is automatic — no@pageSize: client uses an internal chunk, server auto-detects page size; hardMAX_RENDEREDcap.)
Testing DevEx
The engine is a headless class → unit-test source/value/filter logic with
no render. A preset inherits the base's a11y acceptance tests, so a new select needs
only a small render test for its :item/domain wiring — not a re-test of
keyboard/ARIA/async.
Visual design & styling precautions
The family is intentionally not a new visual language — it reuses the existing
DMenu chrome, DDropdownMenu/DFilterInput/DSkeleton look, and theme tokens, so
it matches the icon picker and FormKit menu by default and inherits their polish.
- Fully tokenized — expose a component custom-property surface, don't just consume
the palette. Mirror the existing
--d-button-*convention (--d-button-default-bg-color,…--hover,…-border,…-icon-color, radius, …): define a--d-select-*(combobox) token surface — trigger/option/chip bg · text · border · icon · radius, per-state--hover/--focus/--selected/--disabled, and layout tokens (gap/padding/max-height) — defaulting to the theme palette (--primary/--d-hover/--primary-low/--tertiary/--primary-medium). Themes and presets then restyle via tokens, not selector overrides. No hard-coded colors (select-kit shipped a#ffffallback). Freshd-select__*BEM classes, not the ported.select-kit-*contract. Refactor the Phase-0d-combobox.scss(which currently reads raw palette vars) onto this token surface. - Design-conformance was reviewed at plan time (sanctioned primitives, BEM,
FloatKit/FormKit, ui-kit reuse, Sentence case). A
discourse-visual-reviewpixel gate runs at implementation across light/dark themes and mobile (dark-mode regressions bit select-kit's a11y rollout). - Reduced-motion is honored for free via
DSkeleton(shimmer only underprefers-reduced-motion: no-preference).
Accessibility contract (hard gate — the headline requirement)
Historical select-kit a11y failures all trace to five root causes; the new library makes each structurally impossible:
| Root cause (historical) | Structural fix |
|---|---|
| Typing "into a button"; no real input | A real role="combobox" text input (DFilterInput) is the filter |
Wrong ARIA (role="menu"/menuitemradio) |
WAI-ARIA editable-combobox + role="listbox"/option pattern |
| Focus via side-effects/hacks | Managed focus from FloatKit/DModal + hardened dRovingFocus; no hand-rolled focus |
| Keys overloaded (Tab selects + opens modal) | Tab only moves focus; selection is Enter/click; no surprise actions |
| Async-then-focus (iOS rejects) | Focus the input synchronously on open, before async load resolves |
ARIA ownership (who sets what):
- Filter input (
DFilterInput, notDTextField— the latter'sattributeBindingssilently drops combobox ARIA):role="combobox",aria-expanded,aria-controls={listboxId},aria-autocomplete="list",aria-activedescendant, accessible name. - Results: real
<ul role="listbox" id={listboxId}>(fixes the currentd-multi-selectdiv-in-ul/li-under-divinvalidity),aria-multiselectable="true"for multi. - Option:
<li role="option" id={stableId} aria-selected>. The engine ownsaria-selected(dRovingFocusdeliberately never sets it). Disabled options carryaria-disabled="true"(already skipped by the modifier's#isUsable).
Focus & keyboard: synchronous input focus on open; Escape closes + returns
focus to trigger; chip remove is a real <button aria-label="Remove %{name}">
with focus moved to the next chip/input after removal; Tab never selects.
Screen-reader announcements: use the a11y service
(this.a11y.announce(msg, "polite")) — the app-wide live-region host
(components/a11y/live-regions.gjs) renders the region once, so components must
not hand-roll aria-live and must never use role="alert" per keystroke
(select-kit's noise bug). Announce result counts (debounced), empty state, and
selection changes politely. Migrate DIconGridPicker off its inline
aria-live region (content.gjs:401) to the service as part of this work.
Mobile: on mobile DMenu renders inside DModal. Simple/static selects already
use dRovingFocus focus mode (real focus), which is solid on mobile SRs. The open
concern is searchable comboboxes: aria-activedescendant (virtual focus) has
weak iOS-VoiceOver / Android-TalkBack support, so the searchable mobile path likely
needs a real-focus fallback — a fork to validate on-device. The live region must
be within the announced context.
The dRovingFocus blocker (must fix first — verified)
dRovingFocus's active (combobox) mode is not combobox-ready and has zero
production users (untested). Verified in d-roving-focus.js: the editable-target
guard (line 127) deliberately exempts active mode; Space and Enter both call
onActivate + preventDefault() whenever current >= 0 (lines 172–178), and
#currentIndex floors to 0 in active mode (line 324) — so a space can never be
typed into the filter. Home/End (166–170) and, at the default grid
orientation, Left/Right (146–164) are also hijacked from the caret, and
onActivate receives a DOM element, not a data item (line 176).
Because dRovingFocus is a pre-release modifier we own (and is the only
dependency not yet on main), we harden it in this work:
- When the controller is a text input, do not intercept printable keys,
Space,Home/End, orLeft/Right— reserve onlyArrowUp/Down,Enter,Escape(+ APG combobox extras). - Represent "no active item" (don't floor to 0), so
Enterwith nothing highlighted can submit/create rather than pick item 0. - Resolve activation without an element→item map:
onActivate(el)callsel.click(), and each option's own click handler (closing over its item) runsengine.activate(item)(which honorsitem.onSelect) — the topic-picker use case validates this pattern. - Ship dedicated keyboard unit tests (no existing safety net).
Migration strategy — deprecate + ban from core/bundled; no deletion
- Introduce new components; freeze select-kit in place (untouched, so its
contract is literally identical for third parties); codemod core + bundled
plugins off the old tags; bridge
modifySelectKitinto the new pipeline; deprecate everywhere. Select-kit is NOT deleted — third-party customizations in the wild depend on it, so it stays available (deprecated) indefinitely. - Every modernized component is deprecated, per-component. When a facade renders
the new component under an old tag (
<ComboBox>,<CategoryChooser>, …) — or an oldselect-kit/components/…module is imported — it emits its owndeprecated(...)under a component-scoped id (e.g.discourse.select-kit.combo-box→ "use<DSelect>"),since= the current date-version. This is the real component-usage signal — used to confirm core + bundled plugins have migrated and for third-party telemetry, not to schedule a deletion. - Done = core + bundled plugins fully off select-kit, and banned from reaching for
it again — enforced by a lint rule (
no-restricted-importsforselect-kit/*+ a template rule for the old tags) scoped to coreapp/,admin/, and bundledplugins/, so new code can't reintroduce it. Reaching that requires: (a) porting the ~19 bundled plugins that subclass select-kit or useselectKitOptions/modifyComponentForRow(not codemoddable — e.g.plugins/chat/.../chat-channel-chooser.js,discourse-assign/.../assign-actions-dropdown.js,discourse-activity-pub/.../*-dropdown.js,discourse-workflows,discourse-adplugin); (b) rewriting the test infra hard-keyed to.select-kit-*classes — the Ruby page objectspec/system/page_objects/components/select_kit.rb(~67 dependent specs) andtests/helpers/select-kit-helper.js(~54 acceptance files). Select-kit itself, its SCSS,compat-modules, themodifySelectKitbridge, andDNativeSelectall remain (deprecated) so third parties keep working. - Codemod: no codemod harness exists in-repo — it must be built
(
ember-template-recastfor templates, jscodeshift for imports), handle both import specifiers, and replicate select-kit's runtime arg-alias table. High-volume resistant patterns:@options={{hash …}}(~161 files) → flat args; static@content(~94 files) → async@load; stringheaderComponent/etc. The codemod is published so third parties can run it themselves. - CSS: the ~1,495 lines under
common/select-kit/are rewritten as fresh BEM with theme tokens (not reused); the old SCSS stays frozen alongside old JS for third parties.
Phase 0 — build now (in the worktree)
Scope chosen: harden the primitive + build a new component beside DMultiSelect;
defer the in-place DMultiSelect rewrite.
0a. Logistics: create worktree ~/discourse/core/worktrees/select-kit-rework
off main; port from feature-wireframe-plugin the two dependencies not yet on
main: d-roving-focus.js (+ test) and d-skeleton.gjs (+ d-skeleton.scss +
test). Coordination note: their first landing on main is via this branch; the
editor branch later rebases and drops its copies — avoid diverging edits.
Add the Worktrees rule to CLAUDE.local.md. Save this RFC as a markdown doc
inside the worktree.
0b. Harden dRovingFocus active mode for text comboboxes (per the blocker
section) + keyboard unit tests.
0c. Improve DAsyncContent first (both backward-compatible, with tests): accept a
synchronous @asyncData return (resolve immediately, no pending) and thread an
AbortSignal into asyncData(context, { signal }) with abort-on-supersede/teardown.
0d. SelectEngine (plain class) that feeds the improved DAsyncContent:
normalizes both inputs into the single source(filter, { signal }) closure; owns
only filter + reload nonce + a resolved-item cache. The value is controlled
— the engine reads @value via a getValue thunk and derives isSelected/display;
activate (honors item.onSelect), select/deselect/clear compute the next
value and emit @onChange(nextValue, item|items) (no internal selection). Plus
buildItems, resolveSelection (the id→item ladder feeding trigger / chips /
onChange), and reload (bumps the nonce so an unchanged-filter re-run still
re-fetches — the AI spinner→results flow).
DAsyncContent owns loading/empty/error/abort. Reads site/currentUser/i18n
via constructor args, not owner injection where possible. Expose only frozen
projections / query methods — never the live engine (honor the "no raw mutable
state" rule); parts receive derived values + callbacks.
0e. Transformers: register select-content (value) + select-on-change
(behavior); implement buildItems to run them (identifiers in ctx). Implement the
modifySelectKit bridge with all six fidelity requirements above.
0f. One new component, DSelect — built in single mode this cycle (rename native
DSelect→DNativeSelect first, then claim DSelect); @multiple (chips + array
value) follows in P1 on the same component — built on the engine +
internal parts (DFilterInput combobox, <ul role="listbox">,
<li role="option">), wired to a11y.announce, using DAsyncContent's :loading
(DSkeleton) / :empty / :error blocks. Prove
both data strategies (client-only sync @options, no skeleton; server-backed
@load, skeleton + empty + error + retry) and both interaction modes
(simple/static with focus-mode nav — the native-<select> replacement;
searchable with hardened active-mode nav).
Fresh integration + a11y acceptance tests (keyboard incl. spaces, arrows,
Enter, Escape, Tab-doesn't-select; axe-core roles/names/wiring; no role=alert).
0g. Migrate DIconGridPicker announcements to the a11y service (removes its
inline aria-live region) — validates the pattern.
Roadmap — the whole effort (higher-level program)
Each phase is one or more approval cycles, individually shippable and reversible. The end state is core + bundled plugins migrated off select-kit and banned from using it; select-kit itself is never deleted (deprecated only, for third parties). Phase 0 (above) is the foundation. Sizes (S/M/L/XL) are relative effort, not calendar.
Phase 1 — Complete & consolidate the core family (M→L)
- Typeahead-default rework (API refinement › Decision 1): invert the trigger so
the
typeaheadvariant (input-as-trigger) is the default, built onDMenuwith a non-button trigger component. Focus stays in the host input on desktop and moves to the query input insideDMenu's modal on mobile; keep the Phase-0 button+filter-in-panel as the@variant="button"case;staticstays. Auto-highlight the first match. Default rows/selections use@labelField; custom:itemand:selectionblocks remain independent overrides. - Add
@multipletoDSelect(array value + chips) and rewrite the on-mainDMultiSelectas a thin@multiplealias on the engine (fix theul/livalidity bug; keep its 3 consumers + test suite green); chip UX (keyboard remove + focus move),@maximum/@minimum; create-on-the-fly;DLoadMorepagination + loading-state granularity. - Large-list reveal (Decision 5), from the start: internal render chunk (client
default; server page size auto-detected from the first response); reveal via the
DLoadMoresentinel up to a hardMAX_RENDEREDcap, then stop + "filter to narrow" message;aria-setsize/aria-posinset. Ship with the 5k-sync performance gate. Not deferrable — it guards every client-source picker. - Chrome args (API refinement):
@clearable,@caretIcon,@icon,@disabled/@readonly,@onShow/@onClose,@placement/@offset,@focusWrap,@openOn, and an:emptyblock override. - Group/section-aware item model (Decision 2): flat engine list +
role="group"rendering +@groupBy; UI exercised later by the category family. - Re-home
DIconGridPickeron the engine (grid variant). - Exit: generic single + multi cover every data strategy × all three variants, each with a11y acceptance tests (desktop + on-device mobile SR check for typeahead); the ad-hoc components are consolidated.
Phase 2 — Extension API GA + tooling + first high-traffic migrations (L)
- Finalize the
select-content/select-on-changetransformers + themodifySelectKitbridge (all six fidelity requirements); register new deprecation ids and wirediscourse-deprecation-collector. - Build the codemod harness (
ember-template-recast+ jscodeshift), handling both import specifiers and the runtime arg-alias table. - Migrate the highest-traffic pickers behind facades under their old tags
(
combo-box,category-chooser); migrate FormKit'sselectcontrol + the ~12 native consumers; deprecateDNativeSelect. - Exit: extension API documented + GA; codemod runs clean on core; deprecation telemetry live.
Phase 3 — Specialized pickers (breadth) (XL)
- Category family (chooser / drop / selector / admin-dropdown); tag family
(tag-chooser / mini-tag / tag-drop / tag-group / intersection +
tag-utils); user family (user-chooser / email-group-user-chooser +addUserSearchOption); and the long tail (timezone, future-date, flair, form-template, group, list-setting, color-palette(s), period, homepage-style, font). DTopicSelect(topic picker) — the acceptance case for selected-value resolution (id→title, content-only trigger skeleton,@selectedescape hatch); fold in the parked handoff design.TopicChooseris deprecated not deleted (out-of-repo consumers + thetype: topicsite-setting).- Port
discourse-aioffmodifySelectKitto the transformer API — the real-world acceptance test for the extension model; reback FormKittag-chooser. - Exit: every select-kit picker has a new-family equivalent behind a facade; core call sites codemodded.
Phase 4 — Bespoke dropdowns + test infrastructure (L)
composer-actions(its own registry —registerComposerAction/addComposerToolbarPopupMenuOption), the notifications-button family, and the remainingdropdown-select-boxvariants (categories-admin, bulk-select-bookmarks, user-notifications).- Rewrite the test infra hard-keyed to
.select-kit-*: the JS helper (tests/helpers/select-kit-helper.js, ~54 acceptance files) and the Ruby page object (spec/system/page_objects/components/select_kit.rb, ~67 specs) to the new BEM/roles; re-verify system specs. - Exit: core + bundled plugins fully off select-kit; test infra migrated.
Phase 5 — Ban from core/bundled + finalize deprecation (L)
- Port the ~19 bundled plugins that subclass select-kit bases off them (the last in-repo consumers).
- Add the ban lint rule (forbid
select-kit/*imports + old tags in coreapp/,admin/, bundledplugins/) so new code can't reintroduce it. - Publish the codemod + a migration guide for third-party plugins/themes (optional adoption; deprecation warnings + telemetry nudge them).
- No deletion: select-kit, its SCSS,
compat-modules, themodifySelectKitbridge, andDNativeSelectremain (deprecated) for out-of-repo consumers. - Exit: core + bundled fully migrated and banned from select-kit; select-kit stays, deprecated, for third parties.
Cross-cutting (every phase): the a11y acceptance gate (automated + manual SR
matrix) and discourse-visual-review; coordination with Joffrey (select-kit author)
and the editor branch on the dRovingFocus/DSkeleton rebase.
Open decisions (for the user / RFC readers)
- Searchable mobile keyboard model: RESOLVED (API refinement › Decision 3) — real-focus fallback inside DModal now, adopt d-sheets when it lands. Remaining: the on-device SR validation and the d-sheets adoption timing (coordinate with jaffeux/renato).
composer-actions: RESOLVED — excluded; it's an action menu, already being replaced by its owncomposer-actions-newupcoming change on DMenu. Just ensure it is off select-kit before the final ban.DSelectname: RESOLVED — rename the native<select>wrapper →DNativeSelect(deprecated shim), giveDSelectto the combobox (Martin agreed, dev #187302).- Trigger default: RESOLVED —
typeahead(API refinement › Decision 1).
Risks & verification
- Riskiest:
dRovingFocushardening is unproven infra with no users; themodifySelectKitbridge fidelity (AI suggester is the acceptance test). Mitigate by proving both against fresh tests in Phase 0 before any migration. - Typeahead-default rework is the biggest Phase-1 risk (per the adversarial
review): it needs a purpose-built display-value-vs-edit-value input, explicit
open/Escape/click wiring on
DInlineFloat, a desktop/mobile two-DOM split, and a host-DOM bridge anchor — materially more than "invert the trigger." Prototype the input + focus/Escape model first and prove it before the multi-typeahead and preset work. - Latent Phase-0 equality bug: the engine's strict-
===value compare mishandles string-vs-number ids (castIntegercase) — fix the equality contract in Phase 1 (or a Phase-0 follow-up) before any real picker binds a string id. - Verification:
- Phase 0 unit/integration:
bin/quniton the new component + engine + modifier tests; the a11y acceptance suite (keyboard + axe-core) must pass. - Manual SR matrix before each phase ships: NVDA + JAWS (Firefox/Chrome), VoiceOver (Safari desktop + iOS), TalkBack (Android) — each completing the four historically-broken flows (pick a category, add/remove a PM recipient, change topic tracking, create a tag).
discourse-visual-reviewacross light/dark themes and an RTL locale at implementation time (caret/clear/chip/icon mirroring, placement).- Thorough edge-case + performance suite (the state model is the spec): exhaustive
tests over the Decision-1b matrix (single/multi × typeahead/button/static ×
selection-surface states × list-surface states); resolution fallbacks (404 /
restricted / batch-omitted → a clearable
__unresolvedchip/label, never hidden); batch@resolveValues(one request, cached ids skipped); normalization with a non-id@valueField+ synthetic-row keys; IME composition; multi@valuededup; chip-remove keeps focus + keeps the popover open;reload()shows a busy state despite retain; re-filter-from-empty shows busy; immutable-@valuecontract. Performance gate: a 5k-item synchronous source renders only one render chunk on open (assert DOM node count ≤MAX_RENDERED, no hang) and stays responsive; reveal widens the window (sync) / pages (server) up toMAX_RENDERED, then shows the "filter to narrow" message;aria-setsize/aria-posinsetreflect the true total. bin/lint --fixon all changes; run the full suite green before merge (select-kit is on the production hot path).
- Phase 0 unit/integration: