Skip to content

Commit 2c76db1

Browse files
benfaceclaude
andcommitted
Listbox and select: the markup is the collection, and the focus lands where the reader can see
@monowind/ui gains a listbox and a select, each a Zag.js machine wired to the grid, with hooks in the React, Vue and Svelte packages. The items a component needs are the ones the markup marks: their values, their text and their disabled state make the collection where the props name none, so a list of static items is markup alone. A select is the anchored one — the same items and keyboard under a trigger, its value on that trigger and in a hidden native control a form posts — and both take one value or several. A list's focus is its highlight, since a composite's own cells stay plain and the grid draws no focus ring to fall back on: taking focus highlights the selection, or the first item where nothing is selected, and brings that row into view. The scroll targets the content box rather than the scrollport, the engine reserving a box's border cells as padding, and skips a focus the pointer caused. Three engine fixes came out of building them. A sized box inside a line of text now counts its own width toward a container sized to its content, where it counted the text inside it. A scroller inside an out-of-flow box is collected after the positioning pass that sizes it, so a menu's, a dialog's or a select's content mirrors its scroll to the grid at last. And a container new to a pass keeps its last offset rather than reading a position the measuring mask makes unreliable. pnpm check now refuses a package whose publishConfig exports point outside its files, which would publish an entry the tarball does not carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 011481c commit 2c76db1

57 files changed

Lines changed: 2380 additions & 375 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/architecture/core-architecture.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,9 @@ All three share one core. Concretely, the planned packages:
196196
Tailwind palettes, period fonts (int10h pack, CC BY-SA), ANSI tokens,
197197
and border glyph sets via the core theming contract (specs/theming.md).
198198
- **`@monowind/ui`** (`packages/ui`) — accessible components (menu,
199-
dialog, popover, tooltip) as Zag.js machines placed and layered by the
200-
engine (specs/ui.md), one entry per component, a vanilla mount, and a
201-
CDN bundle; **`@monowind/ui-react`**, **`@monowind/ui-vue`**,
199+
listbox, select, dialog, popover, tooltip) as Zag.js machines placed
200+
and layered by the engine (specs/ui.md), one entry per component, a
201+
vanilla mount, and a CDN bundle; **`@monowind/ui-react`**, **`@monowind/ui-vue`**,
202202
**`@monowind/ui-svelte`** fold each framework's Zag adapter in, one
203203
function per component.
204204
- **CDN mode is a build output of core, not a package** — an extra IIFE bundle

‎.agents/plans/2026-09-19-ui-components.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ The elements also serve any framework as plain markup.
6464
`onSelect`) with the argument as `detail` and `preventDefault`
6565
relayed, `api` and `destroy()` exposed, `attributeChangedCallback`
6666
into `updateProps`, `disconnectedCallback` destroying.
67-
- `menu.ts`, `submenu.ts`, `dialog.ts`, `popover.ts`, `tooltip.ts`:
68-
one subclass each with its table and callbacks; the menu mount takes
67+
- `menu.ts`, `submenu.ts`, `listbox.ts`, `select.ts`, `dialog.ts`,
68+
`popover.ts`, `tooltip.ts`: one subclass each with its table and
69+
callbacks; the menu mount takes
6970
`<mono-submenu>` roots as it takes `[data-part=submenu]` today,
7071
reading their attributes for the submenu's positioning and
7172
reflecting their `open`.

‎.agents/specs/cell-model.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,10 @@ text run iff its computed display is exactly `inline` (or `contents`).
317317
ride the run as SINGLE UNBREAKABLE UNITS, per CSS: the run holds an
318318
object-replacement marker (U+FFFC) whose advance is the box's laid-out
319319
width (shrink-to-fit against the leaf's content box), with break
320-
opportunities on both sides like browsers give replaced elements. The
320+
opportunities on both sides like browsers give replaced elements. Where
321+
the container sizes itself to its content, the marker's advance is the
322+
box's width contribution, as it is for any child: a width of its own,
323+
clamped by its min and max, else its content's. The
321324
box stays IN FLOW — the engine sizes it to exactly those cells and the
322325
browser's own line layout places it, so the two agree by construction;
323326
its interior is a normal layout subtree on the grid (`inline-flex`

‎.agents/specs/scrolling.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ truth.
2525
instead of chaining. Rounding can still put the native ceiling a
2626
pixel either side of the multiple, so "at the native ceiling" reads
2727
as "at max" and the settle parks the max cell ON the ceiling.
28+
- **A container is known once its box is.** The offsets the paint
29+
mirrors are collected from the laid-out tree, and an out-of-flow box
30+
takes its size in the positioning pass, so the collection runs again
31+
after it: a scroller inside a popover, a menu, or a select's content
32+
is a container like any other, and the grid follows its scroll.
2833
- **The browser owns scroll physics; the engine mirrors on the grid.**
2934
Authored `overflow(-x|-y): auto | scroll` stays LIVE on the light
3035
element (not normalized away like `clip`/`hidden` are), so the
@@ -46,7 +51,10 @@ truth.
4651
descendants by the cell-quantized scroll and CULLS ink at the
4752
PADDING box per CSS (reserved gutter cells excluded — the bar owns
4853
them): padding sits blank at the scroll extremes and content flows
49-
through it mid-scroll. The same rect covers `overflow: clip`. During a live grid-mode drag,
54+
through it mid-scroll, under the border's own cells where the box has
55+
one — a native `scrollIntoView` aligns to the scrollport, so a
56+
component bringing an item into view aims at the content box instead
57+
(specs/ui.md). The same rect covers `overflow: clip`. During a live grid-mode drag,
5058
scroll repaints are HELD like any structural repaint
5159
(`holdStructural`) and apply on release.
5260
- **Offsets are cell-quantized for ink; the native position settles to

‎.agents/specs/ui.md‎

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Spec: `@monowind/ui` — accessible components on the grid
22

33
Status: **implemented** (2026-09-13, the framework packages and
4-
examples 2026-09-18; `packages/ui`, one entry per component, plan
5-
`2026-09-13-ui.md`). The engine features it needs are
6-
`top-layer.md` and `anchor-positioning.md`; motion is `animations.md`.
4+
examples 2026-09-18, the listbox and the select 2026-09-21;
5+
`packages/ui`, one entry per component, plan `2026-09-13-ui.md`). The
6+
engine features it needs are `top-layer.md` and
7+
`anchor-positioning.md`; motion is `animations.md`, and a list's scroll
8+
is `scrolling.md`.
79

810
## Motivation
911

10-
A menu, a dialog, a popover, a tooltip: every application needs them,
11-
and doing them accessibly — roles and states, roving focus, typeahead,
12+
A menu, a list to choose from, a dialog, a popover, a tooltip: every
13+
application needs them, and doing them accessibly — roles and states,
14+
roving focus, typeahead,
1215
focus trapping and restore, dismissal, nested menus — is where most
1316
hand-rolled components fail. monowind is a custom element that renders
1417
whatever DOM it holds, so its components must be framework-agnostic
@@ -21,9 +24,10 @@ the engine places and layers the parts, Zag runs them.
2124
## Shape
2225

2326
- **One package, a subpath per component**: `@monowind/ui/menu`,
24-
`@monowind/ui/dialog`, `@monowind/ui/popover`, `@monowind/ui/tooltip`
25-
first; `select`, `combobox`, `tabs` and the rest follow the same
26-
shape. Versioned in lockstep with `monowind`.
27+
`@monowind/ui/listbox`, `@monowind/ui/select`, `@monowind/ui/dialog`,
28+
`@monowind/ui/popover`, and `@monowind/ui/tooltip`, in that order
29+
wherever they are listed; `combobox`, `tabs` and the rest follow the
30+
same shape. Versioned in lockstep with `monowind`.
2731
- **A core that touches no DOM**: per component, `props(p)`, the
2832
machine's props with Zag's own positioning off, Zag's `machine`
2933
re-exported, `connect(service, normalizeProps, p)` — Zag's connect
@@ -77,6 +81,53 @@ props)` is the core plus `VanillaMachine` and `spreadProps`: parts
7781
highlight, and ids are its own. It returns the API and a `destroy`
7882
that stops the machine and takes its handlers off the parts, after
7983
which a mount on the same markup wires it again.
84+
- **A component with no floating part stands in the flow.** A listbox
85+
is roles, selection, and keyboard alone, so the engine lays its parts
86+
out as it lays out any box and the entry adds no props to Zag's. It
87+
takes the mount the anchored components build on, without the
88+
trigger, positioner, and content that one spreads, and the framework
89+
packages return its API alone. Its scroll is the engine's
90+
(scrolling.md): `overflow-y-auto` with a `max-h-*` on the content
91+
scrolls natively, a select's list the same, and Zag scrolling the
92+
highlighted item into view repaints the grid on the cells it moved
93+
to. That scroll targets the content box, not the scrollport
94+
(`scrollToIndexFn` in the machine's props, an author's own over it):
95+
the engine reserves a box's border cells as padding, so a scroll into
96+
the scrollport would stop with the item under the border.
97+
- **A list's focus starts at its selection.** The content taking focus
98+
highlights the selected item, or the first where nothing is selected,
99+
as ARIA's listbox pattern has it, and brings that row into view. A
100+
composite's own cells stay plain and the grid draws no focus ring to
101+
fall back on (cell-model.md), so the highlight is the whole of the
102+
indication and a stale one misplaces it: Zag leaves the highlight
103+
empty on focus wherever a value is selected, and a pointer passing
104+
over an unfocused list leaves its own, which nothing shows until the
105+
next focus. The scroll skips a focus the pointer caused, as the
106+
machine's own does, so a press on a visible item never jumps the list
107+
out from under it.
108+
- **The markup is a listbox's collection.** Zag takes its items as a
109+
`collection` the author builds; for markup that names none, the
110+
marked items make one — the `data-value` each carries, the words of
111+
its `item-text`, and `data-disabled` — so a list of static items is
112+
markup alone, and an item the author's own collection leaves out
113+
stays plain markup. The mount's own element is the `root`, its id the
114+
markup's so the page still finds it, and `data-highlight-on-hover` on
115+
it passes Zag's `highlightOnHover` for every item: it moves the
116+
highlight, Zag keeping `data-highlighted` for the keyboard's focus
117+
and leaving the pointer's feedback to `hover:`.
118+
- **A select is a listbox on a trigger.** Zag's select is the anchored
119+
one — the same collection, items, and keyboard, its content in the
120+
top layer — so it takes the anchored mount and the placement mapping
121+
unchanged, and shares the items and the scroll with the listbox. Two
122+
parts are the mount's to fill, a markup author having nothing else to
123+
render them with: the `value-text`, whose markup text is its
124+
placeholder and whose content the mount writes from the API; and an
125+
optional `hidden-select`, the native control a form posts under the
126+
machine's `name`, filled with an option per item and set to
127+
`display: none`, which the layout skips while a form still submits
128+
it; where the select takes several values, the options carry the
129+
selection, the adapter's one assignment of `value` being a single
130+
control's.
80131
- **States are attributes**: `data-state`, `data-highlighted`,
81132
`data-disabled`, `data-placement`, as Zag sets them, so an author
82133
styles them with Tailwind's data variants
@@ -157,7 +208,8 @@ the functions; the functions change only where the elements need them
157208
to, and additively.
158209

159210
- **Elements for markup**: `<mono-menu>`, `<mono-submenu>`,
160-
`<mono-dialog>`, `<mono-popover>`, `<mono-tooltip>`, from
211+
`<mono-listbox>`, `<mono-select>`, `<mono-dialog>`,
212+
`<mono-popover>`, `<mono-tooltip>`, from
161213
`@monowind/ui/elements`, registered by `defineMonoUi()` as core's
162214
are by `defineMonoWind()` — the entry registers nothing on import,
163215
so the package's `sideEffects: false` holds; the CDN bundle calls it.
@@ -223,11 +275,15 @@ to, and additively.
223275
false`), one namespace per component with Ark UI's anatomy —
224276
`Menu.Root`, `Menu.Trigger`, `Menu.Positioner`, `Menu.Content`,
225277
`Menu.Item`, `Menu.ItemGroup`, `Menu.ItemGroupLabel`,
226-
`Menu.Separator`, `Menu.TriggerItem`; `Dialog.Root`, `.Trigger`,
278+
`Menu.Separator`, `Menu.TriggerItem`; `Listbox.Root`, `.Label`,
279+
`.Content`, `.Item`, `.ItemText`, `.ItemIndicator`, `.ItemGroup`,
280+
`.ItemGroupLabel`; `Select` the same with `.Control`, `.Trigger`,
281+
`.ValueText`, `.Indicator`, `.ClearTrigger`, `.Positioner`, and
282+
`.HiddenSelect` around them; `Dialog.Root`, `.Trigger`,
227283
`.Positioner`, `.Content`, `.Title`, `.Description`,
228284
`.CloseTrigger`; `Popover` the same; `Tooltip.Root`, `.Trigger`,
229-
`.Positioner`, `.Content` — Ark's other parts (arrows, indicators,
230-
a backdrop, checkbox and radio items) left for a later need. `Root`
285+
`.Positioner`, `.Content` — Ark's other parts (arrows, a backdrop,
286+
checkbox and radio items) left for a later need. `Root`
231287
takes the machine's props, `id` optional and generated (`useId`,
232288
Vue's `useId`, Svelte's `$props.id()`), renders nothing of its own,
233289
and hands the API down through context; `RootProvider` takes an
@@ -255,12 +311,20 @@ false`), one namespace per component with Ark UI's anatomy —
255311
## Testing
256312

257313
- Node: the placement mapping to `position-area`; the vanilla path's
258-
parts found and wired; the show and hide of the positioner around
259-
the exit's transitions; the React hooks and Vue composables rendered
260-
through their frameworks (`hooks.test.tsx`, `composables.test.ts`).
261-
- Storybook, per component, in every engine: open and close by
262-
pointer and keyboard; the floating part's cells directly under (or
263-
beside, above) the trigger and the light element's box on them, over
314+
parts found and wired, a list's collection read from its markup and a
315+
select's hidden control among them; the show and hide of the
316+
positioner around the exit's transitions; the React hooks and Vue
317+
composables rendered through their frameworks (`hooks.test.tsx`,
318+
`composables.test.ts`), a component with no floating part included.
319+
- Storybook, per component, in every engine: the listbox's roles, its
320+
value selected by press and by Enter with the indicator following it
321+
on the grid, the pointer highlight its root asks for, and the list
322+
scrolled to the item the keyboard highlights; the select's list
323+
anchored under its trigger, the value it puts on the trigger and in
324+
its form control, and that control left out of the layout; per
325+
anchored component, open and close by pointer and keyboard; the
326+
floating part's cells directly under (or beside, above) the trigger
327+
and the light element's box on them, over
264328
the page's later text; Escape and focus restore (an outside press
265329
dismisses through Zag's document listener on a deferred animation
266330
frame, which WebKit suspends in a backgrounded window, so the
@@ -297,7 +361,10 @@ false`), one namespace per component with Ark UI's anatomy —
297361
- `packages/ui`: `anchor.ts` (the placement table, the anchor name,
298362
the props and their merge, `anchoredApi`), `top-layer.ts`
299363
(`syncTopLayer`, its own `./top-layer` subpath for the framework
300-
packages), `vanilla.ts` (the parts, the mount), and `menu.ts`,
364+
packages), `vanilla.ts` (the parts, the mount, and the anchored mount
365+
over it), `items.ts` (the markup's collection and the item parts a
366+
listbox and a select share), `scroll.ts` (the highlight scrolled into
367+
the content box), and `menu.ts`, `listbox.ts`, `select.ts`,
301368
`dialog.ts`, `popover.ts`, `tooltip.ts` (each `props`, `api`, the
302369
mount); `@zag-js/vanilla` and the machines as dependencies;
303370
`dist/cdn.js` for classic scripts, `monowind.ui`.

‎README.md‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,18 @@ attributes.
124124

125125
## Components
126126

127-
`@monowind/ui` adds accessible components — a menu, a dialog, a
128-
popover, a tooltip — as [Zag.js](https://zagjs.com) state machines
129-
wired to the grid: Zag runs the roles, the keyboard, typeahead, focus,
130-
and dismissal; the engine places each floating part against its
131-
trigger in cells, in the top layer above everything, flipped where
132-
the host leaves no room. Headless, so a component styled through the
133-
theme's tokens wears whatever theme its host does. Mount one on
134-
markup marked with `data-part`, take `@monowind/ui-react`'s hooks,
135-
`@monowind/ui-vue`'s composables, or `@monowind/ui-svelte`'s functions, or
136-
use Zag's adapter for another framework with the package's `props()`
137-
and `connect()`.
127+
`@monowind/ui` adds accessible components — a menu, a listbox, a
128+
select, a dialog, a popover, a tooltip — as
129+
[Zag.js](https://zagjs.com) state machines wired to the grid: Zag runs
130+
the roles, the keyboard, typeahead, focus, and dismissal; the engine
131+
places each floating part against its trigger in cells, in the top
132+
layer above everything, flipped where the host leaves no room, and a
133+
listbox in the flow like any other box. Headless, so a component styled
134+
through the theme's tokens wears whatever theme its host does. Mount
135+
one on markup marked with `data-part`, take `@monowind/ui-react`'s
136+
hooks, `@monowind/ui-vue`'s composables, or `@monowind/ui-svelte`'s
137+
functions, or use Zag's adapter for another framework with the
138+
package's `props()` and `connect()`.
138139

139140
```ts
140141
import { menu } from "@monowind/ui/menu";

‎apps/example-svelte/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@monowind/ui-svelte": "workspace:*",
15-
"svelte": "^5.57.0"
15+
"svelte": "^5.57.1"
1616
},
1717
"devDependencies": {
1818
"@monowind/vite": "workspace:*",

‎apps/storybook/stories/helpers.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ export const cellSize = (host: HTMLElement): { width: number; height: number } =
120120
export const gridOf = (host: HTMLElement): HTMLElement => host.shadowRoot!.getElementById("grid")!;
121121
export const rowsOf = (host: HTMLElement): string[] => gridOf(host).textContent!.split("\n");
122122

123+
/** Whether the grid painted a row holding `text`. */
124+
export const showsRow = (host: HTMLElement, text: string): boolean =>
125+
rowsOf(host).some((row) => row.includes(text));
126+
127+
/** A wait for the grid to paint a row holding `text`. */
128+
export const expectRow = (host: HTMLElement, text: string): Promise<void> =>
129+
waitFor(() => expect(showsRow(host, text), `the grid paints "${text}"`).toBe(true));
130+
123131
/** The first span the grid painted holding `text`. */
124132
export const paintedSpan = (host: HTMLElement, text: string): HTMLElement | undefined =>
125133
Array.from(gridOf(host).querySelectorAll("span")).find((el) => el.textContent!.includes(text));

0 commit comments

Comments
 (0)