@charcuterie/ui 2.10.0 → 2.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/package.json +2 -2
- package/src/Listbox/Listbox.test.tsx +56 -1
package/README.md
CHANGED
|
@@ -172,11 +172,16 @@ rename breaks only the rendered page).
|
|
|
172
172
|
widths, via `ContainerBoard`), `Interactive` (the complete keyboard path).
|
|
173
173
|
4. Export from `src/index.ts` — the one sanctioned barrel. Components import each other
|
|
174
174
|
directly, never through it.
|
|
175
|
-
5. **If it is an overlay,
|
|
176
|
-
`showModal()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
5. **If it is an overlay, portal it to the body** — `FloatingPortal`, through
|
|
176
|
+
`OverlayPanel`, never a hand-rolled `<dialog>` + `showModal()` or `popover="manual"`. The
|
|
177
|
+
top layer wins paint order but is still laid out in place, so a `transform` or
|
|
178
|
+
`overflow: hidden` ancestor still clips a fixed panel; a portal to `document.body` has no
|
|
179
|
+
ancestor to be clipped by
|
|
180
|
+
([decision](../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md),
|
|
181
|
+
superseding [M4's](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
|
|
182
|
+
The old objection stands answered rather than waved: `useRole` writes
|
|
183
|
+
`aria-controls`/`aria-labelledby` across the boundary, and the stories scope panel queries
|
|
184
|
+
to `body`.
|
|
180
185
|
6. **If it is built on a registering kind, look at its first paint.** Members register
|
|
181
186
|
from effects, so before those run a `RovingFocus` has no active value and a
|
|
182
187
|
`SinglePicker` / `VisibilityGroup` has no selected key — only a pending one. Two of M4's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@charcuterie/ui",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.2",
|
|
4
4
|
"description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@charcuterie/logic": "1.2.0",
|
|
52
|
-
"@charcuterie/tokens": "1.
|
|
52
|
+
"@charcuterie/tokens": "1.5.0",
|
|
53
53
|
"@floating-ui/react": "^0.27.20",
|
|
54
54
|
"@tanstack/react-virtual": "^3.13.12"
|
|
55
55
|
},
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Listbox.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Default, Interactive } =
|
|
10
|
+
const { AllStates, AllVariants, Default, Interactive } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("a button opens a listbox whose rows are options", async () => {
|
|
@@ -196,3 +196,58 @@ test("Escape dismisses without choosing, and there is one tab stop", async () =>
|
|
|
196
196
|
role: "button",
|
|
197
197
|
})
|
|
198
198
|
})
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The rich-option story is the reason `Listbox` exists — a native
|
|
202
|
+
* `<option>` cannot hold a second line — and it was the one story
|
|
203
|
+
* `Listbox.test.tsx` never mounted. So the `content.muted` detail
|
|
204
|
+
* line inside a row had **no** axe coverage in any state, let alone
|
|
205
|
+
* the highlighted one, while it sat at 4.11:1 on
|
|
206
|
+
* `intent.neutral.surfaceHover` in the fleet's default variant.
|
|
207
|
+
*
|
|
208
|
+
* This is the same shape as the `ButtonLink` miss: there,
|
|
209
|
+
* `AllVariants` *was* audited but forced no `:hover`, so the failing
|
|
210
|
+
* hex passed locally and only the token gate caught it. Auditing the
|
|
211
|
+
* resting row would repeat that mistake exactly, so the row is
|
|
212
|
+
* driven to its highlighted state and the fill is **asserted** —
|
|
213
|
+
* without that assertion a silently-resting row would make this test
|
|
214
|
+
* pass for the wrong reason.
|
|
215
|
+
*/
|
|
216
|
+
test("a highlighted rich option row has no contrast violation", async () => {
|
|
217
|
+
const { body, canvas } = await mountStory(AllVariants)
|
|
218
|
+
|
|
219
|
+
await userEvent.click(
|
|
220
|
+
expectAgentDrivable(canvas, {
|
|
221
|
+
name: "Choose a track",
|
|
222
|
+
role: "button",
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const listbox = expectAgentDrivable(body, {
|
|
227
|
+
role: "listbox",
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// Opening focuses the first option, and `ListboxOption` fills the
|
|
231
|
+
// focused row with `focus:bg-intent-neutral-surface-hover`. Prove
|
|
232
|
+
// the row really is painted before trusting what axe says about it.
|
|
233
|
+
const highlighted = body.getAllByRole("option")[0]
|
|
234
|
+
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
expect(document.activeElement).toBe(highlighted)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const rowFill =
|
|
240
|
+
getComputedStyle(highlighted).backgroundColor
|
|
241
|
+
const panelFill =
|
|
242
|
+
getComputedStyle(listbox).backgroundColor
|
|
243
|
+
|
|
244
|
+
expect(rowFill).not.toBe("rgba(0, 0, 0, 0)")
|
|
245
|
+
expect(rowFill).not.toBe(panelFill)
|
|
246
|
+
|
|
247
|
+
// The detail line is the `content.muted` half of the pair.
|
|
248
|
+
expect(
|
|
249
|
+
highlighted.querySelector(".text-content-muted"),
|
|
250
|
+
).not.toBeNull()
|
|
251
|
+
|
|
252
|
+
await expectNoAxeViolations(listbox)
|
|
253
|
+
})
|