From 48a42b822f48418407c92f947842792953314607 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 31 Jul 2026 15:46:09 +0000 Subject: [PATCH 01/43] =?UTF-8?q?Add=20a=20shared=20dense-preset=20for=20t?= =?UTF-8?q?he=20=C3=97=200.88=20density=20scale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-editor's Chakra theme shrank the numeric spacing/sizes grid to Chakra's × 0.88 and fontSizes md+ to × 0.9 (the 2022 "make everything smaller" change), and its migration replicated that in its app preset. classroom's theme turns out to carry the identical scale — byte-identical values, same change — so it belongs in one shared place rather than in two app presets. New optional `@microbit/ui/dense-preset`, stacked between the base preset and the app preset by the two dense apps. ml-trainer and data-microbit-org don't use it. Whether the family keeps this density or aligns on one scale is still open; this is now the single place that answer lands. See migration-playbook gotcha #25, which is what makes a global scale override worth being explicit and shared about. --- packages/ui/README.md | 6 +- packages/ui/package.json | 1 + packages/ui/src/dense-preset.ts | 108 ++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/dense-preset.ts diff --git a/packages/ui/README.md b/packages/ui/README.md index 1710caf..5c2f00b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,8 +19,10 @@ an app must do: 1. **Panda preset stack** (`panda.config.ts`): `@pandacss/preset-base`, then the **base preset** (`@microbit/ui/base-preset` — the complete micro:bit - design system), then optionally the app's own preset, then optionally a - **private brand preset** (Foundation colours, licensed fonts). + design system), then optionally `@microbit/ui/dense-preset` (the × 0.88 + spacing / × 0.9 font-size density the information-dense apps use), then + optionally the app's own preset, then optionally a **private brand + preset** (Foundation colours, licensed fonts). Later presets override earlier ones token-by-token — the base recipes and semantic tokens reference the brand tokens, which is how a brand swap diff --git a/packages/ui/package.json b/packages/ui/package.json index 35f6ab5..ed58b76 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,6 +8,7 @@ ".": "./src/index.ts", "./base-preset": "./src/base-preset.ts", "./base-tokens": "./src/base-tokens.ts", + "./dense-preset": "./src/dense-preset.ts", "./messages": "./src/messages.ts", "./postcss-legacy-safari": "./postcss-legacy-safari.cjs", "./reset.css": "./reset.css", diff --git a/packages/ui/src/dense-preset.ts b/packages/ui/src/dense-preset.ts new file mode 100644 index 0000000..134354a --- /dev/null +++ b/packages/ui/src/dense-preset.ts @@ -0,0 +1,108 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { definePreset } from "@pandacss/dev"; + +const toTokens = (values: Record) => + Object.fromEntries( + Object.entries(values).map(([k, value]) => [k, { value }]), + ) as Record; + +/** + * The numeric spacing/size grid, Chakra's 0.25rem step × 0.88. + * + * Only the numeric scale is touched: the named `sizes` (`xs`…`8xl`, `max`, + * `full`, `container.*`) stay at their base-preset values, as they did in + * both apps' Chakra themes. + */ +const scale = toTokens({ + px: "1px", + 0.5: "0.11rem", + 1: "0.22rem", + 1.5: "0.33rem", + 2: "0.44rem", + 2.5: "0.55rem", + 3: "0.66rem", + 3.5: "0.77rem", + 4: "0.88rem", + 5: "1.1rem", + 6: "1.32rem", + 7: "1.54rem", + 8: "1.76rem", + 9: "1.98rem", + 10: "2.2rem", + 12: "2.64rem", + 14: "3.08rem", + 16: "3.52rem", + 20: "4.4rem", + 24: "5.28rem", + 28: "6.16rem", + 32: "7.04rem", + 36: "7.92rem", + 40: "8.8rem", + 44: "9.68rem", + 48: "10.56rem", + 52: "11.44rem", + 56: "12.32rem", + 60: "13.2rem", + 64: "14.08rem", + 72: "15.84rem", + 80: "17.6rem", + 96: "21.12rem", +}); + +/** + * Font sizes from `md` up, × 0.9. `xs`/`sm` keep their full size so small + * text never gets too small, and `3xs`/`2xs` (which neither app's theme + * listed) stay at their base-preset values. + */ +const denseFontSizes = toTokens({ + md: "0.9rem", + lg: "1.012rem", + xl: "1.125rem", + "2xl": "1.35rem", + "3xl": "1.687rem", + "4xl": "2.025rem", + "5xl": "2.7rem", + "6xl": "3.375rem", + "7xl": "4.05rem", + "8xl": "5.4rem", + "9xl": "7.2rem", +}); + +/** + * The dense preset — an optional density override for the information-dense + * apps in the family. Stacks between the base preset and the app preset: + * + * ```ts + * presets: ["@pandacss/preset-base", basePreset, densePreset, appPreset] + * ``` + * + * Both python-editor and classroom shipped the same "make everything + * smaller" Chakra theme change (2022): the numeric spacing/sizes grid at + * × 0.88 and `fontSizes` from `md` up at × 0.9. The two themes' values were + * byte-identical, so the scale lives here rather than being replicated in + * each app preset (migration-playbook gotcha #25 — a global scale override + * hides from every safeguard, so it needs to be explicit and shared). + * + * Whether this density stays or the family aligns on one scale is an open + * design question; when it is answered, this preset is the single place the + * answer lands (deleting it from an app's stack un-shrinks that app). + * ml-trainer and data-microbit-org do not use it. + */ +export const densePreset = definePreset({ + name: "microbit-ui-dense", + theme: { + extend: { + tokens: { + spacing: scale, + sizes: scale, + fontSizes: denseFontSizes, + }, + }, + }, +}); + +export default densePreset; From a185a29930ecb0f0415f02de2bcd9cfac0b0485d Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 31 Jul 2026 15:46:20 +0000 Subject: [PATCH 02/43] Button: primary/secondary colours via button.* semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family splits 2–2 on button colour idiom: brand-coloured buttons (ml-trainer, python-editor) and a black-on-white system (classroom, data-microbit-org — black solid, black outline, and a blackAlpha wash on hover/press instead of a border-colour change). Without tokens, both apps on the second side fork the `primary`/`secondary` variants in their own presets: the same divergence, written twice. So the two variants now resolve their colours through `button.*` semantic tokens, defaulting to today's brand values — resolved output is unchanged for the two migrated apps (verified: the recipe rules differ only by var indirection). classroom overrides nine values. `primary`'s text stays a literal `white` and `ghost` needs no tokens: both are the same in all four apps. Also refreshes the playbook's v1-surface list, which had drifted from what's actually built, and records the decision against a shared Table component (python-editor's one table site is a styled.table, which reads better than a slot recipe over native table semantics). --- docs/migration-playbook.md | 66 +++++++++++++++++++++++--------- packages/ui/README.md | 6 +-- packages/ui/src/Button.recipe.ts | 28 ++++++++++---- packages/ui/src/base-preset.ts | 21 ++++++++++ 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 4545f08..282e742 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -463,6 +463,11 @@ from the library extraction. compare `space`/`sizes`/`fontSizes` (any global scale) against Chakra defaults and replicate overrides in the OSS app preset (the private preset stacks on top — no mirror needed). + **classroom then turned out to carry the identical scale** (the same + 2022 theme change, byte-identical values), so it now lives in + `@microbit/ui/dense-preset`, stacked between the base and app presets + by both apps — one explicit, shared place for the override this gotcha + is about, and one place to answer the keep-vs-align question. 26. **Slider announced values: react-aria has no `aria-valuetext` passthrough.** Chakra sliders often passed `aria-valuetext="20 °C"`; @@ -585,27 +590,37 @@ a single current consumer — family-wide consistency is a goal; app-local builds recreate the divergence being retired. Only genuinely app-flavoured pieces stay app-side. +Built since (check `packages/ui/src/index.ts` before assuming a gap — this +list is what was outstanding when the censuses were taken): Collapse + Fade, +Menu checkable items/sections/separator, Modal `role="alertdialog"`, +Radio/RadioGroup, NumberField, Kbd/Code, `useMediaQuery`/`usePrevious`/ +`useClipboard`/`useBreakpointValue`. + +Still outstanding, in classroom's likely order of need: + - **Select/ComboBox** — retires react-select family-wide (classroom's `SelectDropdown`/`SelectWithIcon` wrappers sketch the API; RAC ComboBox + - `useAsyncList` covers the async school-lookup case). -- **Collapse + Fade** transition primitives (python-editor ~14 files; - classroom/data one-offs). -- **Tabs** (recipe in the library; python-editor's branded sidebar variant - is preset-side styling). -- Menu: checkable items (RAC has selection natively), sections, separator. -- Modal: `role="alertdialog"` mode + least-destructive initial focus (every - app has a ConfirmDialog). -- Radio/RadioGroup (promote from ml-trainer's raw RAC usage); **GridList** - (promote from classroom's hand-rolled react-aria hooks; also ml-trainer's - parked projects-page idea). -- Table, TextField error slot, input adornments, Portal-as-primitive, - Skeleton/SkeletonText, Breadcrumb, Avatar, NumberInput; cheap typography - wrappers (Kbd/Code/Tag/Mark) as first needed. -- Hooks: `useMediaQuery`, `usePrevious`, `useClipboard`, - `usePrefersReducedMotion`. + `useAsyncList` covers the async school-lookup case). 4 classroom sites, + 1 data site. +- **GridList** (promote from classroom's hand-rolled react-aria hooks; also + ml-trainer's parked projects-page idea). +- **Avatar** (+ badge) — classroom's class-roster identity; data has one + site. classroom's theme adds a `2md` size. +- Portal-as-primitive, TextField error slot, input adornments, + Skeleton/SkeletonText, Breadcrumb, NumberInput; cheap typography + wrappers (Tag/Mark) as first needed. +- `usePrefersReducedMotion`. +- **Tabs** — stayed app-side in python-editor (special-purpose sidebar + chrome); waits for a second consumer, at which point the RAC markup and a + generalised recipe extract cleanly. - Stays app-side: classroom's `active` button variant, Stepper, app-chrome compositions (ActionBar stays an app component over shared primitives + `statusBarBg`-family tokens). +- **Table: decided against** a shared component. python-editor's one table + (the About dialog's version/dependency list) is a `styled.table` with + Panda styles at the site, and that reads better than a slot recipe + wrapping native table semantics. Both remaining apps have ~1 table site + each; do the same unless one grows a real data table. ### App order and notes @@ -637,9 +652,10 @@ pieces stay app-side. sidebar chrome; a generic library Tabs waits for a second consumer — the RAC markup and a generalised recipe extract cleanly), likewise SplitView; the app's teal is a _code/content_ semantic, not `brand2` - (see Cross-app vocabulary); its bespoke density scale (spacing × 0.88, - fontSizes md+ × 0.9, see gotcha #25) is replicated in its app preset - pending a keep-vs-align-with-family-scale discussion. + (see Cross-app vocabulary); its density scale (spacing × 0.88, fontSizes + md+ × 0.9, see gotcha #25) turned out to be shared verbatim with + classroom and now comes from `@microbit/ui/dense-preset`, still pending a + keep-vs-align-with-family-scale discussion. 3. **data-microbit-org** — whenever convenient; by then the surface is covered. Census highlights: fully private repo, no theme-package split; brand assets committed in-repo. **Multi-root**: three apps in one repo, @@ -681,6 +697,18 @@ component in three apps. Default button variant differs (`secondary` in ml-trainer/classroom/data, `outline` in python-editor) — recipes' `defaultVariants` must stay preset-overridable per app. +**Two button colour idioms, one recipe.** The `primary`/`secondary` variants +split 2–2: brand-coloured (ml-trainer, python-editor) vs black-on-white +(classroom, data-microbit-org — black solid, black outline, and a +blackAlpha wash on hover/press instead of a border-colour change). Both +resolve through `button.*` semantic tokens in the base preset +(`primaryBg`/`primaryHoverBg`/`primaryActiveBg`, +`secondaryText`/`secondaryBorder`/`secondaryHover*`/`secondaryActive*`), so +the second idiom is nine token values in an app preset rather than a forked +variant — which is what the two apps on that side would otherwise both +write. `primary`'s text stays a literal `white` (4/4 apps) and `ghost` +needs no tokens (black + blackAlpha in 4/4). + **Same slot number ≠ same role — check usage semantics before mapping an app's second hue onto `brand2`.** python-editor's investigation: ml-trainer's `brand2` is a general secondary accent (LED/progress/toggle/ diff --git a/packages/ui/README.md b/packages/ui/README.md index 5c2f00b..3f4d113 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -164,9 +164,9 @@ keep them stable: - Brand/app presets may change token _values_, never token _names_. Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`, -`controlCheckedBg`, `focusBorder`, …) are the extension points brand presets -override; they resolve through var indirection, so overrides apply wherever -the token is consumed. +`button.*`, `controlCheckedBg`, `focusBorder`, …) are the extension points +brand presets override; they resolve through var indirection, so overrides +apply wherever the token is consumed. ## Runtime token lookups diff --git a/packages/ui/src/Button.recipe.ts b/packages/ui/src/Button.recipe.ts index 890a9c3..48b395a 100644 --- a/packages/ui/src/Button.recipe.ts +++ b/packages/ui/src/Button.recipe.ts @@ -24,7 +24,7 @@ const transitionCommon = * Chakra variants plus the family-wide `language`/`toolbar` variants; a * consuming app's preset extends it with app vocabulary (e.g. ml-trainer's * `led`/`record*`/`secondary-disabled`). Brand divergence within a variant is - * token-driven (see the `languageText` semantic tokens). + * token-driven (see the `button.*` and `languageText` semantic tokens). * * Registered in the base preset (base-preset.ts). */ @@ -92,13 +92,22 @@ export const button = defineRecipe({ textDecoration: "underline", }, }, + // Colours come from the `button.*` semantic tokens so the family's two + // button idioms (brand-coloured vs black-on-white) share this recipe — + // see the token block in base-preset.ts. secondary: { borderWidth: "2px", - borderColor: "brand.500", - color: "brand.700", + borderColor: "button.secondaryBorder", + color: "button.secondaryText", bg: "transparent", - _hover: { borderColor: "brand.600" }, - _active: { bg: "brand.50", borderColor: "brand.700" }, + _hover: { + borderColor: "button.secondaryHoverBorder", + bg: "button.secondaryHoverBg", + }, + _active: { + bg: "button.secondaryActiveBg", + borderColor: "button.secondaryActiveBorder", + }, }, ghost: { color: "black", @@ -116,9 +125,12 @@ export const button = defineRecipe({ }, primary: { color: "white", - bg: "brand.500", - _hover: { bg: "brand.600", _disabled: { bg: "brand.500" } }, - _active: { bg: "brand.700" }, + bg: "button.primaryBg", + _hover: { + bg: "button.primaryHoverBg", + _disabled: { bg: "button.primaryBg" }, + }, + _active: { bg: "button.primaryActiveBg" }, }, // 600/700, matching what python-editor's Chakra outline + red // colorScheme resolved to. (Extracted from ml-trainer at 500/600, but diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index b0fccda..f41696e 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -158,6 +158,27 @@ export const basePreset = definePreset({ // follows; OSS language buttons are brand blue.) languageText: { value: "{colors.brand.500}" }, languageTextHover: { value: "{colors.brand.600}" }, + // The `primary`/`secondary` button variants' colours. Two brand + // idioms exist in the family: brand-coloured buttons (ml-trainer, + // python-editor — the defaults below) and a black-on-white system + // (classroom, data-microbit-org: black solid, black outline, no + // border colour change on hover but a blackAlpha wash instead). + // Tokens rather than per-app recipe overrides so both idioms share + // one recipe — a `variant` fork would be duplicated by every app on + // the far side of it. `primary`'s text colour stays a literal + // `white`: every app in the family puts white on a dark solid. + // `ghost` needs no tokens (black + blackAlpha in all four apps). + button: { + primaryBg: { value: "{colors.brand.500}" }, + primaryHoverBg: { value: "{colors.brand.600}" }, + primaryActiveBg: { value: "{colors.brand.700}" }, + secondaryText: { value: "{colors.brand.700}" }, + secondaryBorder: { value: "{colors.brand.500}" }, + secondaryHoverBorder: { value: "{colors.brand.600}" }, + secondaryHoverBg: { value: "transparent" }, + secondaryActiveBorder: { value: "{colors.brand.700}" }, + secondaryActiveBg: { value: "{colors.brand.50}" }, + }, // Toast status colours: the Chakra-era toast Alert restyle (teal for // every status except error) shared across the app family. toastInfoBg: { value: "{colors.teal.800}" }, From 286258cf7967de4fc729a6cb0cbd2fa317b55331 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 31 Jul 2026 16:15:30 +0000 Subject: [PATCH 03/43] Add cy and it catalogs, backfilled from classroom's translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classroom ships Welsh and Italian, which this package's Crowdin locale set didn't include, so those two locales fell back to English for every shared-ui string. Both are now in bin/i18n-packages.cjs, and the catalogs are backfilled from classroom's existing translations of the same strings — the only source in the family for these locales. Direct matches, same string and (for close) the same description: close-action → Cau / Chiudi, and Warning → Rhybudd / Attenzione. "Error" had no standalone entry, so it is taken from the noun as it appears in classroom's own translations of longer error strings ("Bu gwall wrth…" → Gwall; "Errore scaricando…" → Errore). Both are the ordinary dictionary form, but they are derived rather than translated, so worth a reviewer's eye when these locales next round-trip through Crowdin. "Information" and "Success" have no source string in any sibling app; the tidy script leaves them as English, which is the documented fallback. --- bin/i18n-packages.cjs | 2 ++ packages/ui/lang/ui.cy.json | 22 ++++++++++++++++++++++ packages/ui/lang/ui.it.json | 22 ++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 packages/ui/lang/ui.cy.json create mode 100644 packages/ui/lang/ui.it.json diff --git a/bin/i18n-packages.cjs b/bin/i18n-packages.cjs index 21be923..64fc8a2 100644 --- a/bin/i18n-packages.cjs +++ b/bin/i18n-packages.cjs @@ -16,8 +16,10 @@ module.exports = [ crowdinDir: "new/packages/microbit-ui", languages: [ "ca", + "cy", "es-ES", "fr", + "it", "ja", "ko", "nl", diff --git a/packages/ui/lang/ui.cy.json b/packages/ui/lang/ui.cy.json new file mode 100644 index 0000000..61c8d00 --- /dev/null +++ b/packages/ui/lang/ui.cy.json @@ -0,0 +1,22 @@ +{ + "ui.close-action": { + "defaultMessage": "Cau", + "description": "Close button text or label" + }, + "ui.toast-status-error": { + "defaultMessage": "Gwall", + "description": "Announced by screen readers before an error notification" + }, + "ui.toast-status-info": { + "defaultMessage": "Information", + "description": "Announced by screen readers before an informational notification" + }, + "ui.toast-status-success": { + "defaultMessage": "Success", + "description": "Announced by screen readers before a success notification" + }, + "ui.toast-status-warning": { + "defaultMessage": "Rhybudd", + "description": "Announced by screen readers before a warning notification" + } +} diff --git a/packages/ui/lang/ui.it.json b/packages/ui/lang/ui.it.json new file mode 100644 index 0000000..850f38e --- /dev/null +++ b/packages/ui/lang/ui.it.json @@ -0,0 +1,22 @@ +{ + "ui.close-action": { + "defaultMessage": "Chiudi", + "description": "Close button text or label" + }, + "ui.toast-status-error": { + "defaultMessage": "Errore", + "description": "Announced by screen readers before an error notification" + }, + "ui.toast-status-info": { + "defaultMessage": "Information", + "description": "Announced by screen readers before an informational notification" + }, + "ui.toast-status-success": { + "defaultMessage": "Success", + "description": "Announced by screen readers before a success notification" + }, + "ui.toast-status-warning": { + "defaultMessage": "Attenzione", + "description": "Announced by screen readers before a warning notification" + } +} From ddcd55a9176e2e56061c53c6629289f1b5a69fc6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Fri, 31 Jul 2026 16:42:31 +0000 Subject: [PATCH 04/43] Playbook: hr box-model change at the kill-switch (#30); refine #9/#17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from classroom's leaf-primitive port. #30: Chakra's reset carries normalize's `hr { box-sizing: content-box }` and Panda's preflight sets border-box on everything with no `hr` exception, so an `
` with an explicit height plus top/bottom borders silently changes height at the flip. The zero-size-hr double-edge trick (1px on all four sides of a 0-width hr, the side borders reading as one 2px rule) is exactly that shape and was in three apps; Divider's `thickness` variant is height-stable instead. classroom's logo divider was 35px rather than the 33px its code asked for, and was the only pixel difference across five screens. #9/#17: confirmed from the other direction that a literal utility-named prop does survive a plain wrapper — `h={23}` extracted and emitted `height: 23px`, matching Chakra's unitless-number handling exactly. So #9's real scope is non-literal values and non-utility prop names, which is worth saying plainly next to the wrapper advice it seemed to contradict. --- docs/migration-playbook.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 282e742..3932f25 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -360,6 +360,15 @@ from the library extraction. wrapper-forwarding observation — extraction is per-prop-name, not per-component). A custom prop named like a utility (`content`) emits a broken CSS rule; avoid utility names for non-style props. + Confirmed from the other direction in classroom: `` on a _plain_ wrapper that spreads onto a `styled()` svg + extracted fine and emitted `height: 23px` — Panda appends `px` to + unitless numbers for dimension properties, and resolves a number to a + token when one exists for that key, both exactly as Chakra did. So + #9's real scope is _non-literal_ values and non-utility prop names; + a literal utility prop survives a plain wrapper as long as the wrapper + forwards it to something styled. Verify in the generated CSS either way + — the class name may not be the one you guess (`h_23`, not `h_23px`). 18. **An `include` glob that matches nothing fails silently**, and recipe styling still works via preset `staticCss`, so a wrong package-source path shows up only as broken non-recipe styling. In npm workspaces the @@ -533,6 +542,26 @@ from the library extraction. supported hook), making the preflight itself carry the Chakra-parity default. +30. **`
` changes box model at the kill-switch.** Chakra's reset carries + normalize's `hr { box-sizing: content-box }`; Panda's preflight sets + `box-sizing: border-box` on everything and has **no `hr` exception**. So + an `
` with an explicit height _plus_ top/bottom borders is one height + before the flip and 2px shorter after it, with nothing in the diff to + show why (same family as #22 — a preflight difference that only bites at + the flip). Panda's preflight also gives `hr` a `border-top-width: 1px` + that Chakra's didn't; the library Divider's `border: 0` base covers it. + Watch for the **zero-size-`
` double-edge trick** — `borderWidth: 1px` + on all four sides of a 0-width `
`, the two side borders reading as a + single 2px rule. It was in three apps, and besides being obscure it makes + the rule's length depend on the box model. `Divider`'s + `thickness="thick"` draws 2px on the orientation's own edge with no + top/bottom borders, so its height is whatever it is told — identical + either side of the kill-switch. classroom's logo divider was 35px + (33 + 2 borders) under Chakra and is now the 33px its code asks for: a + deliberate 2px change, taken in exchange for being box-model-stable. + It was also the _only_ pixel difference across five screens when the + leaf primitives were ported. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native From 5a4024448f386700fd60a2f86d7e91445613c978 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 1 Aug 2026 21:28:23 +0000 Subject: [PATCH 05/43] Icon: accept svgr components; playbook gotcha #31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Icon`'s `as` was typed as react-icons' `IconType`, which an svgr import (`import X from "./x.svg?react"`) doesn't satisfy — only by its return type (`ReactNode` vs `ReactElement`), not its props. Chakra's `` took either, and classroom has five such icons, so the narrow type was a porting blocker rather than a real constraint. Widened to a new exported `IconComponent`: any component accepting the props Icon actually passes. Deliberately no wider than that, so both react-icons and svgr components stay assignable without variance games over element-typed event handlers. Exported because call sites that hold an icon in their own props need to name the type (classroom's ConnectionErrorIndicator does). Also gotcha #31, from the same sweep: a recipe variant's flat value can't override another variant group's responsive one, because Panda hoists every media query into a block after all the base rules — so classroom's `` rendered 26.99px above `md` where Chakra gave 32.4px. Includes the distinction from #8 that the investigation turned up: a styled() factory's own props *do* beat its recipe, since Panda merges base + variants + props before emitting; #8's atomic race is between separate css() calls. --- docs/migration-playbook.md | 27 +++++++++++++++++++++++++++ packages/ui/src/Icon.tsx | 20 +++++++++++++++++--- packages/ui/stories/Icon.stories.tsx | 23 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 3932f25..d7781b0 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -562,6 +562,33 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg It was also the _only_ pixel difference across five screens when the leaf primitives were ported. +31. **A recipe variant's flat value cannot override another variant group's + responsive one.** Chakra merged `size` and `variant` in JS before emitting, + so `` got the variant's flat + `fontSize: 4xl` at every width. Panda emits each variant as its own class + and hoists **every** media query into a block after all the base rules, so + above `md` the _size_ variant's media rule wins on source order no matter + how the recipe declares them — classroom measured 26.99px where Chakra gave + 32.4px. Nothing in the types or the generated class names hints at it; it + only shows above the breakpoint. Rules: + + - An app-preset variant that sets a property the shared `size` variant sets + responsively must be paired with a **flat** size (`md`/`sm`/`xs`) or with + no size at all — check what `defaultVariants` then supplies, since it is + still in play (the `heading` recipe defaults to `size="xl"`, whose `md` + fontSize happens to be `4xl`, which is why dropping `size` reproduced + Chakra exactly). + - Do not reach for declaration order, `compoundVariants` ordering or a + matching responsive value in the variant: within the media block the + order is Panda's, not the recipe's. + - Distinct from #8: **a `styled()` factory's own props _do_ beat its recipe + base and variants**, because Panda merges base + variants + props before + emitting, so the element carries one class per property. #8's atomic race + is between separate `css()` calls cx'd together. Verified on Divider: + `borderLeftWidth={0}` over the recipe's `1px`, and a `borderColor` tint + over its `gray.200`, both take effect even though the recipe's classes + sit later in the stylesheet. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native diff --git a/packages/ui/src/Icon.tsx b/packages/ui/src/Icon.tsx index 7f49b2c..c814f4e 100644 --- a/packages/ui/src/Icon.tsx +++ b/packages/ui/src/Icon.tsx @@ -3,13 +3,27 @@ * * SPDX-License-Identifier: MIT */ -import { IconType } from "react-icons/lib"; +import { ComponentType, SVGProps } from "react"; import { css, cx } from "styled-system/css"; import { SystemStyleObject } from "styled-system/types"; +/** + * Any component that renders an `` from svg props. Deliberately no + * narrower than the props `Icon` actually passes, so it accepts both + * react-icons' `IconType` and svgr components (`import X from "./x.svg?react"`, + * which the apps use for their custom-path icons — Chakra's `` + * took either). + */ +export type IconComponent = ComponentType< + Pick< + SVGProps, + "className" | "focusable" | "role" | "aria-label" | "aria-hidden" + > +>; + export interface IconProps { - /** The react-icons component to render. */ - as: IconType; + /** The icon component to render: a react-icons glyph or an svgr import. */ + as: IconComponent; /** Panda style overrides (size via fontSize/boxSize, colour, etc.). */ css?: SystemStyleObject; className?: string; diff --git a/packages/ui/stories/Icon.stories.tsx b/packages/ui/stories/Icon.stories.tsx index 388a518..243728a 100644 --- a/packages/ui/stories/Icon.stories.tsx +++ b/packages/ui/stories/Icon.stories.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: MIT */ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SVGProps } from "react"; import { RiDownload2Line, RiErrorWarningLine, @@ -40,6 +41,28 @@ export const Icons: Story = { Icons are aria-hidden unless given an aria-label. + + + + `as` also takes an svgr component (the shape of a ?react{" "} + svg import), not just react-icons. + + ), }; + +/** + * Stands in for `import X from "./x.svg?react"` — the same + * `ComponentType>` shape, without needing svgr wired + * into the library's own build. Its paths carry no `fill`, so they inherit + * Icon's `fill: currentColor`. + */ +const SvgrShapedIcon = (props: SVGProps) => ( + + + +); From 0e4a1e98adafa6e0b1a0634be8123de52b72b240 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 10:45:34 +0000 Subject: [PATCH 06/43] Playbook: Chakra CSS variables in app values (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value like var(--chakra-colors-brand-500) written by hand into a gradient or shadow keeps resolving while ChakraProvider is mounted, so it survives the port of its own component and every screenshot comparison, then silently becomes invalid at the kill-switch — gradients fail to nothing, so the element just loses its background. One grep audits it, and Panda's {colors.*} string interpolation is the direct replacement. Found in classroom's homepage banner. --- docs/migration-playbook.md | 137 ++++++++++++++++++++----------------- 1 file changed, 76 insertions(+), 61 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index d7781b0..cd73585 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -261,67 +261,67 @@ Numbering is stable — ml-trainer's doc, commit messages and reviews reference these by number. #1–#16 are from ml-trainer's migration, #17–#18 from the library extraction. -1. **CSS layer conflict (the big one).** Unlayered CSS always beats layered - CSS regardless of specificity. During coexistence Chakra/Emotion are - unlayered — hence `bin/unlayer-panda.mjs`. After the kill-switch the rule - applies to **third-party stylesheets**: any unlayered vendor CSS beats - every Panda rule (Swiper's `.swiper-slide { width: 100% }` collapsed - ml-trainer's carousels). Import vendor stylesheets into the `vendor` - cascade layer (`@import "..." layer(vendor)`), which `layers.css` orders - between `reset` and `base` so vendor CSS beats the preflight but loses to - app styling. Runtime CSS-in-JS (react-select's Emotion) cannot be - layered — replace the component instead. -2. **RAC interaction states.** The base preset widens Panda's `hover`/ - `active`/`focusVisible`/`disabled` conditions to also match RAC's - `data-*` attributes, so Chakra-shaped `_hover`/`_active` style objects - work unchanged on RAC. -3. **`staticCss` for recipe variants.** Components forward `variant`/`size` - as runtime props, invisible to Panda's static analysis. The base preset - carries `staticCss` for library recipes; if an **app preset** adds - recipes or variants selected at runtime, it must extend `staticCss` in - the preset too (never only in `panda.config.ts` — a consumer that drops - it silently loses variants; this migration's signature failure class). -4. **Responsive recipe variants must be symmetric.** Panda applies the - base-breakpoint variant's CSS unconditionally; if `full` sets more props - than `4xl`, they leak into desktop. Every non-full dialog size restates - the box props so the larger breakpoint fully overrides `full`. -5. **Know exactly which ramps the app's theme overrode.** ml-trainer's OSS - `brand2` is Chakra's _unmodified_ gray, not the locally overridden - `gray` — conflating them made card text near-invisible. Check ramp - provenance token-by-token (the differ helps). -6. **OSS vs private divergence → semantic tokens.** Structural forks - (variant colours, fonts, gradients) are driven by semantic tokens the - private preset overrides (`languageText*`, the `display` font, - `statusBarBg`), keeping recipes shared. Recipe extension is the escape - hatch, not the plan. -7. **Icons inherit `currentColor`.** Don't pass `fill` to react-icons (it - overrides their default `fill="currentColor"` → black). `Icon`/ - `CloseIcon` set `fill: currentColor` in CSS. -8. **Atomic overrides: same-property conflicts across separate `css()` - calls race on stylesheet order** — cx'ing a base class with an override - class does NOT mean the override wins; the winner is whichever atomic - rule is emitted later. Merge base + overrides into a _single_ - `css(base, cssProp)` call so conflicts resolve at merge time. Related: - longhand beats shorthand across calls; and a border shorthand plus - separate `borderColor` in one object is order-dependent — use - width/style longhands with `borderColor`. -9. **Styles must be literals at the JSX/`css()` site.** Panda's extractor - only reads `css` prop object literals and `css()` call literals where - they appear — not objects returned from helper functions, not computed - values (`rowSpan={n + 1}`, ``w={`${x}px`}``, ``w={`calc(...)`}``), - not style props forwarded through a _plain_ wrapper component. It fails - silently: classes are applied but no CSS exists, and a coincidental - identical class from another call site can mask the miss — verify - against the generated CSS, not the rendered page. What works: same-file - consts, ternaries of literals, literal arithmetic, custom-named - object-literal JSX props, and style props on `styled()`-factory - components (cross-file). Fixes: wrap shared styling in a component with - an inline `css` literal; give wrappers a `css` prop instead of - forwarding style props; prefer recipe variants for dimensions (generated - via `staticCss`, extraction-independent); use inline `style` (with - runtime `token()` lookups) for data-driven values. After porting a - file, grep it for non-literal style props. The `BoxProps`-forwarding - count in each census is this gotcha's per-app budget. +1. **CSS layer conflict (the big one).** Unlayered CSS always beats layered + CSS regardless of specificity. During coexistence Chakra/Emotion are + unlayered — hence `bin/unlayer-panda.mjs`. After the kill-switch the rule + applies to **third-party stylesheets**: any unlayered vendor CSS beats + every Panda rule (Swiper's `.swiper-slide { width: 100% }` collapsed + ml-trainer's carousels). Import vendor stylesheets into the `vendor` + cascade layer (`@import "..." layer(vendor)`), which `layers.css` orders + between `reset` and `base` so vendor CSS beats the preflight but loses to + app styling. Runtime CSS-in-JS (react-select's Emotion) cannot be + layered — replace the component instead. +2. **RAC interaction states.** The base preset widens Panda's `hover`/ + `active`/`focusVisible`/`disabled` conditions to also match RAC's + `data-*` attributes, so Chakra-shaped `_hover`/`_active` style objects + work unchanged on RAC. +3. **`staticCss` for recipe variants.** Components forward `variant`/`size` + as runtime props, invisible to Panda's static analysis. The base preset + carries `staticCss` for library recipes; if an **app preset** adds + recipes or variants selected at runtime, it must extend `staticCss` in + the preset too (never only in `panda.config.ts` — a consumer that drops + it silently loses variants; this migration's signature failure class). +4. **Responsive recipe variants must be symmetric.** Panda applies the + base-breakpoint variant's CSS unconditionally; if `full` sets more props + than `4xl`, they leak into desktop. Every non-full dialog size restates + the box props so the larger breakpoint fully overrides `full`. +5. **Know exactly which ramps the app's theme overrode.** ml-trainer's OSS + `brand2` is Chakra's _unmodified_ gray, not the locally overridden + `gray` — conflating them made card text near-invisible. Check ramp + provenance token-by-token (the differ helps). +6. **OSS vs private divergence → semantic tokens.** Structural forks + (variant colours, fonts, gradients) are driven by semantic tokens the + private preset overrides (`languageText*`, the `display` font, + `statusBarBg`), keeping recipes shared. Recipe extension is the escape + hatch, not the plan. +7. **Icons inherit `currentColor`.** Don't pass `fill` to react-icons (it + overrides their default `fill="currentColor"` → black). `Icon`/ + `CloseIcon` set `fill: currentColor` in CSS. +8. **Atomic overrides: same-property conflicts across separate `css()` + calls race on stylesheet order** — cx'ing a base class with an override + class does NOT mean the override wins; the winner is whichever atomic + rule is emitted later. Merge base + overrides into a _single_ + `css(base, cssProp)` call so conflicts resolve at merge time. Related: + longhand beats shorthand across calls; and a border shorthand plus + separate `borderColor` in one object is order-dependent — use + width/style longhands with `borderColor`. +9. **Styles must be literals at the JSX/`css()` site.** Panda's extractor + only reads `css` prop object literals and `css()` call literals where + they appear — not objects returned from helper functions, not computed + values (`rowSpan={n + 1}`, ``w={`${x}px`}``, ``w={`calc(...)`}``), + not style props forwarded through a _plain_ wrapper component. It fails + silently: classes are applied but no CSS exists, and a coincidental + identical class from another call site can mask the miss — verify + against the generated CSS, not the rendered page. What works: same-file + consts, ternaries of literals, literal arithmetic, custom-named + object-literal JSX props, and style props on `styled()`-factory + components (cross-file). Fixes: wrap shared styling in a component with + an inline `css` literal; give wrappers a `css` prop instead of + forwarding style props; prefer recipe variants for dimensions (generated + via `staticCss`, extraction-independent); use inline `style` (with + runtime `token()` lookups) for data-driven values. After porting a + file, grep it for non-literal style props. The `BoxProps`-forwarding + count in each census is this gotcha's per-app budget. 10. **Removing Chakra/Emotion from a file isn't enough — also remove it from `panda.config.ts`'s `exclude` list**, or Panda silently skips extraction for the whole file (classes applied, no rules generated). @@ -589,6 +589,21 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg over its `gray.200`, both take effect even though the recipe's classes sit later in the stylesheet. +32. **App code that reads Chakra's CSS variables breaks at the kill-switch, not + when you port the component.** `var(--chakra-colors-brand-500)` inside a + hand-written value — a gradient, a shadow, a border — keeps resolving for as + long as `ChakraProvider` is mounted, so it survives the port of its own + component and every screenshot comparison, then silently becomes an invalid + value when the provider goes. Gradients are the common case and they fail + to _nothing_, so the element just loses its background. + + Audit it up front, not at the flip: `grep -rn -- "--chakra-" src/`. Panda + resolves `{colors.brand.500}` inside an arbitrary string value at build + time, which is the direct replacement (`background="linear-gradient(90deg, + + {colors.brand.500} 0%, …)"`emits`var(--colors-brand-500)`). classroom had + one live instance, its homepage banner, plus one in a comment. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native From 2ff9d1da4dd47d9f03a3e106c3b3d7ce792ba7b8 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 12:06:23 +0000 Subject: [PATCH 07/43] Re-export Container from the layout patterns system.ts exists so consumers import the patterns from one place rather than reaching into the generated styled-system, and Container was the one standard pattern missing from the list. classroom's homepage banner needs it; the census has data-microbit-org using one too. --- packages/ui/src/system.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/system.ts b/packages/ui/src/system.ts index 0475e4a..401b268 100644 --- a/packages/ui/src/system.ts +++ b/packages/ui/src/system.ts @@ -24,6 +24,7 @@ export type { export { AspectRatio, Box, + Container, Flex, Stack, HStack, From 8be7bca0982f0768a3c958b5ae87a5178cc895c4 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 13:36:51 +0000 Subject: [PATCH 08/43] =?UTF-8?q?Playbook:=20correct=20#11=20=E2=80=94=20n?= =?UTF-8?q?ative=20aspect-ratio=20is=20below=20the=20family=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gotcha told apps to swap Panda's AspectRatio pattern for the native property. Native aspect-ratio needs Safari 15 / iOS 15 / Firefox 89, and the family floor is 14.1 / 14.5 / 88, so on three of five targets the declaration is dropped and the box collapses to content height — no fallback, and nothing for lightningcss to downlevel. Panda's pattern is the same padding-bottom hack as Chakra's and works everywhere; classroom measured the two identical. The child-override conflict the gotcha is actually about only arises when the child is a Chakra component with its own position, so porting the child first makes the pattern safe. Flags the sites in ml-trainer and python-editor that took the old advice. --- docs/migration-playbook.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index cd73585..d9f7114 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -328,8 +328,30 @@ from the library extraction. 11. **Panda's `AspectRatio` pattern positions its child via a `&>*` selector that a still-Chakra child's own `position` style beats** (Emotion injects later at equal specificity). Symptom: the `::before` - padding spacer stacks above the child. Use the native `aspectRatio` - CSS property instead — arguably the better permanent form anyway. + padding spacer stacks above the child. + + **Check the support floor before reaching for the native `aspectRatio` + property, which this gotcha used to recommend outright.** Native + `aspect-ratio` needs Safari 15, iOS 15 and Firefox 89; the family's floor + is `safari >= 14.1`, `ios_saf >= 14.5`, `firefox >= 88`, so three of five + targets don't have it and the declaration is simply dropped — the box + collapses to content height, with no fallback and nothing for lightningcss + to downlevel. Panda's pattern is the padding-bottom hack, exactly like + Chakra's, so it works everywhere: classroom measured the two identical + (wrapper 185x151, `::before` padding-bottom 150.922px, child absolute with + `object-fit: cover`). + + The conflict this gotcha is really about only arises when the child is a + **Chakra** component carrying its own `position`. A plain element or an + already-ported child is fine, so during coexistence order the child's port + before the wrapper's and the pattern is safe. + + **Worth auditing in the completed migrations**: ml-trainer has the same + floor and uses native `aspectRatio` in `tours.tsx` (x2) and + `NativeBluetoothConnectBatteryDialog.tsx`; python-editor's floor is + `Safari >= 14`/`iOS >= 14` and it uses it in the docs content, the ideas + page and `YoutubeVideoEmbed`. Those may want the pattern instead. + 12. **RAC popovers unmount on close** (Chakra kept menu lists mounted), so a hidden file input must live _outside_ a menu or its change event is dropped mid-pick — render it as a sibling and call it via ref. From e541aca7a1991bdc7f41ad39d52f7d6feba3dd42 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 14:17:52 +0000 Subject: [PATCH 09/43] Playbook: track the aspect-ratio audit as open work, not just a gotcha note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gotcha #11 mentions the affected sites, but a corrected gotcha isn't where anyone looks for outstanding work on a migration that's already signed off. Adds an 'open across the completed migrations' section to the roadmap with the specific files in ml-trainer and python-editor, and is explicit that nobody has yet confirmed them visibly broken on a real Safari 14 — that check comes before any change. --- docs/migration-playbook.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index d9f7114..c2c143f 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -676,6 +676,20 @@ Priority: **python-editor-v3 and classroom are what matter**; data-microbit-org can trail by months. ml-trainer is done (the pilot). Censuses were taken July 2026 against Chakra v2.10 in all apps. +### Open across the completed migrations + +- **Native `aspect-ratio` below the support floor** (see gotcha #11, corrected + 2026-08-02 — it previously recommended exactly this). It needs Safari 15 / + iOS 15 / Firefox 89; where an app's floor is lower the declaration is dropped + and the box collapses to content height, silently and with no fallback. + Panda's `AspectRatio` pattern is the padding-bottom hack and works at any + floor. **To check**: ml-trainer (floor 14.1 / 14.5 / 88) at `tours.tsx` x2 + and `NativeBluetoothConnectBatteryDialog.tsx`; python-editor (floor + `Safari >= 14`, `iOS >= 14`) in the docs content, the ideas page and + `YoutubeVideoEmbed`. Not verified as visibly broken on those browsers — + someone with a device or a Safari 14 VM should confirm before deciding + whether to swap them back to the pattern. + ### v1 surface (build in the library, on demand) Policy: anything _clearly core_ design-system goes in the library even with From a00d619271f20a6cdc5a00045f276ed0e05cd261 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 15:09:53 +0000 Subject: [PATCH 10/43] Button: generate responsive size variants in staticCss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call site ported from Chakra's `size={["md", "lg"]}` puts `sm:btn--size_lg` on the element, but staticCss only generated the plain variant classes — so the class had no rule behind it and the button silently fell back to the base size. classroom's homepage measured 143px against Chakra's 171px before the fix, and matches exactly after. Same treatment `dialog` already had for the same reason; button hadn't needed it because no app had passed a responsive size until now. Variants stay non-responsive — nothing passes those conditionally. --- packages/ui/src/base-preset.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index f41696e..44d3d6a 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -261,7 +261,11 @@ export const basePreset = definePreset({ // can silently lose runtime-prop variants. staticCss: { recipes: { - button: ["*"], + // Size is passed responsively at call sites ported from Chakra's + // `size={["md", "lg"]}`, so generate the breakpoint-prefixed variants + // too — otherwise the class lands on the element with no rule behind it + // and the button silently falls back to the base size. + button: [{ size: ["*"], responsive: true }, { variant: ["*"] }], checkbox: ["*"], heading: ["*"], card: ["*"], From f69cf02d7c63ff2466026a2c334284ba210a9b70 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 15:24:14 +0000 Subject: [PATCH 11/43] Button recipe: note the two outline shapes with no variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classroom hit Chakra's default-colorScheme `outline` and its `outline` + `whiteAlpha` and found nothing in the recipe for either — `warning` is the destructive outline, not a neutral one. Both are restated per call site there for now; recording the shapes next to the variants so the next app to want one finds the question rather than rediscovering it. The neutral one is likely already a two-app shape, since the playbook has `outline` as python-editor's default variant. `warningSolid` needed no such note: it mapped exactly onto Chakra solid+red, measured identical. --- packages/ui/src/Button.recipe.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ui/src/Button.recipe.ts b/packages/ui/src/Button.recipe.ts index 48b395a..2e857f4 100644 --- a/packages/ui/src/Button.recipe.ts +++ b/packages/ui/src/Button.recipe.ts @@ -136,6 +136,17 @@ export const button = defineRecipe({ // colorScheme resolved to. (Extracted from ml-trainer at 500/600, but // its one warning button tolerates the darkening; python-editor's // "Reset project" was visibly lighter than its Chakra self.) + // NOTE (2026-08-02, from classroom): two Chakra `outline` shapes have + // no home here and are currently restated per call site in that app — + // worth considering as variants once a second consumer wants them. + // - a neutral outline (transparent, 1px gray.200, inherited text, + // gray.50/gray.100 hover/press): Chakra's default-colorScheme + // `outline`, and python-editor's *default* variant per the + // playbook's cross-app vocabulary, so likely already a 2-app shape. + // - an on-colour outline (white 2px + white text over a coloured bar, + // whiteAlpha hover/press): Chakra's `outline` + `whiteAlpha`. + // `warning` below is the *destructive* outline and is not a substitute + // for either; `warningSolid` did map exactly onto Chakra solid+red. warning: { borderWidth: "2px", borderColor: "danger.600", From eacf4c44bc7a0e9ce72770d4e592ddeab4f3276d Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 16:07:33 +0000 Subject: [PATCH 12/43] Menu: checkbox option groups, and lift the popover above modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things classroom's menu port needs. MenuOptionGroup gains Chakra's `type`. The default stays radio (`menuitemradio`, single-select); `type="checkbox"` is multi-select and renders `menuitemcheckbox`, each option toggling independently. classroom's MakeCode settings menu has a lone screen-reader-mode toggle that was a Chakra checkbox group and had no counterpart here. An option's own `onAction` fires on every press, including the one that deselects it, so a lone toggle can be driven from the item rather than the group — tested, since it is the shape classroom uses. Also tested and now documented: choosing an option leaves the menu open, which matches Chakra's checkbox groups but is a behaviour change for radio groups, which closed. The `content` slot moves from zIndex `dropdown` (1000) to `popover` (1500). A RAC Popover always portals to the body, so a menu opened from inside a Modal (zIndex `modal`, 1400) escapes the modal's stacking context and paints behind it — invisible, in the full-screen case classroom has. Chakra never hit this because its MenuList rendered inline unless explicitly portalled. Nothing else sits between 1400 and the toast/tooltip layer. The tests need a setup file: jsdom 29 ships no global CSS object, and react-aria calls CSS.escape whenever a collection mounts, so every Menu/ListBox/GridList test throws without the polyfill. One test asserts a trap rather than a feature: a non-collection child inside a MenuList silently drops the rest of the collection — no throw, no warning. It guards the reason dialogs have to be hoisted out of menus, so we notice if react-aria ever starts reporting it. --- packages/ui/src/Menu.recipe.ts | 7 +- packages/ui/src/Menu.tsx | 78 +++++++++++----- packages/ui/stories/Menu.stories.tsx | 30 +++++- packages/ui/tests/Menu.test.tsx | 135 +++++++++++++++++++++++++++ packages/ui/tests/setup.ts | 17 ++++ packages/ui/vitest.config.ts | 1 + 6 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 packages/ui/tests/Menu.test.tsx create mode 100644 packages/ui/tests/setup.ts diff --git a/packages/ui/src/Menu.recipe.ts b/packages/ui/src/Menu.recipe.ts index e01b4ff..2453154 100644 --- a/packages/ui/src/Menu.recipe.ts +++ b/packages/ui/src/Menu.recipe.ts @@ -35,7 +35,12 @@ export const menu = defineSlotRecipe({ color: "inherit", minWidth: "3xs", py: "2", - zIndex: "dropdown", + // `popover` (1500), not `dropdown` (1000): a RAC Popover always portals + // to the body, so a menu opened from inside a Modal (zIndex `modal`, + // 1400) escapes the modal's stacking context and would paint behind it. + // Chakra never hit this — its MenuList rendered inline unless explicitly + // portalled. Nothing else lives between 1400 and the toast/tooltip layer. + zIndex: "popover", borderRadius: "md", borderWidth: "1px", borderColor: "gray.200", diff --git a/packages/ui/src/Menu.tsx b/packages/ui/src/Menu.tsx index 738ee3e..8bfd88c 100644 --- a/packages/ui/src/Menu.tsx +++ b/packages/ui/src/Menu.tsx @@ -156,45 +156,75 @@ export const MenuItem = ({ ); }; -export interface MenuOptionGroupProps { +interface MenuOptionGroupBaseProps { /** Group heading shown above the options (Chakra's `title`). */ title?: ReactNode; - /** The selected `MenuItemOption`'s value (radio semantics). */ - value?: string; - /** Called with the newly selected option's value. */ - onChange?: (value: string) => void; /** `MenuItemOption` children. */ children: ReactNode; css?: SystemStyleObject; className?: string; } +export interface MenuOptionGroupRadioProps extends MenuOptionGroupBaseProps { + type?: "radio"; + /** The selected `MenuItemOption`'s value. */ + value?: string; + /** Called with the newly selected option's value. */ + onChange?: (value: string) => void; +} + +export interface MenuOptionGroupCheckboxProps extends MenuOptionGroupBaseProps { + type: "checkbox"; + /** The checked `MenuItemOption`s' values. */ + value?: string[]; + /** Called with the full set of checked values after a toggle. */ + onChange?: (value: string[]) => void; +} + +export type MenuOptionGroupProps = + | MenuOptionGroupRadioProps + | MenuOptionGroupCheckboxProps; + /** - * MenuOptionGroup — a single-select (radio) group of `MenuItemOption`s within - * a menu, replacing Chakra's `MenuOptionGroup type="radio"`. Selection is - * section-scoped (RAC MenuSection), so a menu can mix action items and option - * groups. + * MenuOptionGroup — a group of checkable `MenuItemOption`s within a menu, + * replacing Chakra's `MenuOptionGroup`. `type="radio"` (the default) is + * single-select and renders `menuitemradio`; `type="checkbox"` is multi-select + * and renders `menuitemcheckbox`, each option toggling independently. + * + * Selection is section-scoped (RAC MenuSection), so a menu can mix action items + * and option groups. An option's own `onAction` still fires on every press, + * including the press that deselects it — so a lone toggle can be driven either + * by the group's `onChange` or by the item's `onAction`. + * + * Choosing an option leaves the menu open (a plain `MenuItem` closes it), which + * is what Chakra's checkbox groups did. Chakra's *radio* groups closed on + * select, so a ported radio group is a deliberate behaviour change. */ -export const MenuOptionGroup = ({ - title, - value, - onChange, - children, - css: cssProp, - className, -}: MenuOptionGroupProps) => { +export const MenuOptionGroup = (props: MenuOptionGroupProps) => { + const { title, children, css: cssProp, className } = props; const slots = menu(); + const selectedKeys = + props.type === "checkbox" + ? props.value ?? [] + : props.value != null + ? [props.value] + : []; return ( { - if (keys !== "all") { - const key = keys.values().next().value; - if (key != null) { - onChange?.(String(key)); - } + if (keys === "all") { + return; + } + const values = [...keys].map(String); + if (props.type === "checkbox") { + props.onChange?.(values); + } else if (values.length > 0) { + // Radio: a press that clears the selection reports nothing, matching + // Chakra, which had no way to express an empty radio group. + props.onChange?.(values[0]); } }} > diff --git a/packages/ui/stories/Menu.stories.tsx b/packages/ui/stories/Menu.stories.tsx index 27d6c5e..0f6412a 100644 --- a/packages/ui/stories/Menu.stories.tsx +++ b/packages/ui/stories/Menu.stories.tsx @@ -43,7 +43,7 @@ export const Basic: Story = { }; /** - * MenuOptionGroup gives radio semantics to a section of the menu; action + * MenuOptionGroup defaults to radio semantics for a section of the menu; action * items can sit alongside in the same menu. */ export const OptionGroups: Story = { @@ -65,3 +65,31 @@ export const OptionGroups: Story = { ); }, }; + +/** + * `type="checkbox"` makes the options toggle independently, as + * `menuitemcheckbox`. A lone toggle can equally be driven by the option's own + * `onAction`, which fires on the press that unchecks it as well as the one that + * checks it. + */ +export const CheckboxOptionGroup: Story = { + render: () => { + const [shown, setShown] = useState(["grid"]); + return ( + + + + + Grid + Rulers + + + + ); + }, +}; diff --git a/packages/ui/tests/Menu.test.tsx b/packages/ui/tests/Menu.test.tsx new file mode 100644 index 0000000..eaf0b44 --- /dev/null +++ b/packages/ui/tests/Menu.test.tsx @@ -0,0 +1,135 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + Button, + MenuItem, + MenuItemOption, + MenuList, + MenuOptionGroup, + MenuTrigger, +} from "../src"; + +afterEach(cleanup); + +const open = () => + fireEvent.click(screen.getByRole("button", { name: "Open" })); + +const option = (name: string) => + screen.getByText(name).closest("[role]") as HTMLElement; + +const attr = (name: string, key: string) => option(name).getAttribute(key); + +it("renders a radio group as menuitemradio", () => { + const onChange = vi.fn(); + render( + + + + + Name + Size + + + , + ); + open(); + expect(attr("Name", "role")).toBe("menuitemradio"); + expect(attr("Name", "aria-checked")).toBe("true"); + expect(attr("Size", "aria-checked")).toBe("false"); + + fireEvent.click(option("Size")); + expect(onChange).toHaveBeenCalledWith("size"); +}); + +it("renders a checkbox group as menuitemcheckbox and toggles independently", () => { + const Probe = () => { + const [shown, setShown] = useState(["grid"]); + return ( + + + + + Grid + Rulers + + + + ); + }; + render(); + + open(); + expect(attr("Grid", "role")).toBe("menuitemcheckbox"); + expect(attr("Grid", "aria-checked")).toBe("true"); + expect(attr("Rulers", "aria-checked")).toBe("false"); + // The check indicator is driven by data-selected, in this mode too. + expect(attr("Grid", "data-selected")).not.toBeNull(); + + // Checking an option leaves the menu open, as Chakra's checkbox groups did. + fireEvent.click(option("Rulers")); + expect(attr("Grid", "aria-checked")).toBe("true"); + expect(attr("Rulers", "aria-checked")).toBe("true"); + + fireEvent.click(option("Grid")); + expect(attr("Grid", "aria-checked")).toBe("false"); + expect(attr("Rulers", "aria-checked")).toBe("true"); +}); + +it("fires an option's onAction on the press that unchecks it", () => { + const onAction = vi.fn(); + const Probe = () => { + const [on, setOn] = useState(true); + return ( + + + + + { + onAction(); + setOn((v) => !v); + }} + > + Toggle + + + + + ); + }; + render(); + + open(); + fireEvent.click(option("Toggle")); + expect(attr("Toggle", "aria-checked")).toBe("false"); + fireEvent.click(option("Toggle")); + expect(attr("Toggle", "aria-checked")).toBe("true"); + expect(onAction).toHaveBeenCalledTimes(2); +}); + +it("silently drops the rest of the collection after a non-collection child", () => { + // Not desired behaviour — a regression guard for the trap itself, so that if + // react-aria ever starts throwing or warning here we notice and can delete + // the workarounds that hoist dialogs out of menus. + render( + + + + First +
not a collection node
+ Second +
+
, + ); + open(); + expect(screen.queryAllByRole("menuitem").map((e) => e.textContent)).toEqual([ + "First", + ]); +}); diff --git a/packages/ui/tests/setup.ts b/packages/ui/tests/setup.ts new file mode 100644 index 0000000..1e62ea4 --- /dev/null +++ b/packages/ui/tests/setup.ts @@ -0,0 +1,17 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +// jsdom (29.x) ships no global CSS object, but react-aria's selection code +// calls CSS.escape when a collection mounts, so any test rendering a Menu, +// ListBox or GridList throws without this. Real browsers all have it. +if (typeof globalThis.CSS === "undefined") { + (globalThis as { CSS?: unknown }).CSS = {}; +} +if (typeof CSS.escape !== "function") { + // Enough for the identifiers react-aria generates; not a spec implementation. + CSS.escape = (value: string) => + String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`); +} diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 60e6d64..6b06fd1 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -18,5 +18,6 @@ export default defineConfig({ test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], + setupFiles: ["./tests/setup.ts"], }, }); From c62b21a85cc70d951f4018ed52a089c22a2d1b8c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 16:41:13 +0000 Subject: [PATCH 13/43] Playbook: the silent collection truncation, and popovers vs stacking contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gotchas from classroom's menu port, plus two expected deltas. #33 is the one that cost a preparatory commit: a RAC collection ends at the first non-collection child, silently — no throw, no warning, in dev or prod. A component that returns a fragment leading with a dialog empties the whole menu, not just the items after it, and that is exactly the shape of a "menu item that owns its dialog". Hoist the dialog and hand the item an opener. #34: a RAC popover always portals to the body, so it leaves the stacking context it was opened from. A menu inside a modal needs a z-index above the modal or it is painted behind it — invisible, not merely clipped, when the modal is full-screen. Chakra never showed this because its MenuList rendered inline unless portalled, so it appears precisely at the port. #12 gains the corollary that Chakra's keep-mounted menu lists mislead verification scripts, which is how a measurement pass first reported five menus sharing one geometry. --- docs/migration-playbook.md | 56 +++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index c2c143f..3b1eaa1 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -354,7 +354,11 @@ from the library extraction. 12. **RAC popovers unmount on close** (Chakra kept menu lists mounted), so a hidden file input must live _outside_ a menu or its change event is - dropped mid-pick — render it as a sibling and call it via ref. + dropped mid-pick — render it as a sibling and call it via ref. Putting one + inside is doubly wrong: see #33, where a non-collection child silently + deletes the rest of the menu. Chakra's keep-mounted behaviour also trips up + verification scripts — `document.querySelector('[role=menu]')` finds a + closed menu from an earlier step, so scope to the visible one. 13. **RAC popovers have `role="dialog"`** (menus included, lingering briefly with `data-exiting` while animating out), so a bare Playwright `getByRole("dialog")` can hit strict-mode ambiguity when a dialog opens @@ -626,6 +630,49 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg {colors.brand.500} 0%, …)"`emits`var(--colors-brand-500)`). classroom had one live instance, its homepage banner, plus one in a comment. +33. **A non-collection child silently truncates a RAC collection.** A `Menu`, + `ListBox` or `GridList` builds its children into a collection, and anything + that is not a collection node — a `
`, a dialog, a plain element — ends + the collection at that point. Everything after it disappears. There is no + throw and no console warning, in dev or prod, so a typecheck and a unit test + that only asserts "renders" both pass. + + Measured in classroom's port: `
` renders one + item, and a component returning a fragment that _leads_ with a `
` + renders none at all — the whole menu comes back empty. The second shape is + the dangerous one, because it is what a "menu item that owns its dialog" + component looks like: + + ```tsx + // Deletes every item in whatever menu renders it. + const LanguageMenuItem = () => ( + <> + + Language + + ); + ``` + + Fragments, `null`, `false`, arrays and custom components are all fine, so + long as everything they resolve to is a collection node. Hoist dialogs (and + file inputs, per #12) out of the menu: give the opener to the item through + context or a prop, and render the dialog beside the `MenuTrigger` or at the + app root. classroom added a `LanguageDialogProvider` for exactly this, since + the item is rendered by six different menus. + + `@microbit/ui` has a regression test asserting the truncation, so if + react-aria ever starts reporting it we can drop the workarounds. + +34. **A RAC popover leaves the stacking context it was opened from.** It always + portals to the body, so a menu, tooltip or select opened from inside a modal + is no longer painted by the modal — it needs a `z-index` above it or it + disappears behind. Chakra never showed this because its MenuList rendered + inline unless explicitly portalled, so the bug appears exactly at the port + and, in a full-screen modal, the menu is invisible rather than merely + clipped. The `menu` recipe now sits at `popover` (1500) rather than + `dropdown` (1000) for this reason; check any new overlay recipe against + `modal` (1400) before assuming the default scale is right. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native @@ -644,6 +691,13 @@ accepted — expect them, don't chase them as bugs: machine (unbuilt). - **Focus rings show after mouse interaction** in places Chakra hid them (auto-focused dialog buttons, slider thumbs). +- **A menu opened with the mouse focuses no item.** Chakra highlighted the + first one however the menu was opened; RAC highlights it only for a keyboard + open, and Escape still returns focus to the trigger (both verified in + classroom). It is the whole of the remaining screenshot diff on a faithful + menu port, so expect it and don't chase it. +- **Choosing an option in a `MenuOptionGroup` leaves the menu open.** That + matches Chakra's checkbox groups; Chakra's radio groups closed. - **Dialogs open with focus on the dialog element itself** (announces the title — an a11y improvement) unless something has `autoFocus`; Chakra focused the first control (see gotcha #15 for when to add `autoFocus` From 75c0af76da978b8ca0390e6f74735b2441262021 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 16:50:27 +0000 Subject: [PATCH 14/43] Modal: forward data attributes to the dialog box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classroom's dialogs carry a data-testid on ModalContent and its end-to-end suite addresses them that way, and two of its shells (ConfirmDialog, CommonAlertDialog) forward whatever data attributes their caller passed. The shell had nowhere to put them, so `data-*` now lands on the dialog box — where Chakra's went. Also corrects ModalHeader's `level` doc: the default is 2, not 3. RAC's Dialog supplies that through HeadingContext for the title slot, so it already matches the

Chakra call sites put inside their header. --- packages/ui/src/Modal.tsx | 17 +++++++++++- packages/ui/tests/Modal.test.tsx | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/ui/tests/Modal.test.tsx diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index fd3d7f0..e71c377 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -96,6 +96,12 @@ export interface ModalProps { */ "aria-label"?: string; children: ReactNode; + /** + * `data-*` attributes land on the dialog box (where Chakra's went, on + * ModalContent), so end-to-end tests can address a dialog. Shells that + * forward their caller's data attributes can spread them straight in. + */ + [key: `data-${string}`]: unknown; } /** @@ -119,7 +125,11 @@ export const Modal = ({ finalFocusRef, "aria-label": ariaLabel, children, + ...rest }: ModalProps) => { + const dataProps = Object.fromEntries( + Object.entries(rest).filter(([key]) => key.startsWith("data-")), + ); const slots = dialog({ size, centered: isCentered }); const motionlessClass = motionless ? css({ @@ -154,6 +164,7 @@ export const Modal = ({ > { const { slots } = useDialog(); return ( diff --git a/packages/ui/tests/Modal.test.tsx b/packages/ui/tests/Modal.test.tsx new file mode 100644 index 0000000..7781401 --- /dev/null +++ b/packages/ui/tests/Modal.test.tsx @@ -0,0 +1,46 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import { afterEach, expect, it } from "vitest"; +import { Modal, ModalBody, ModalHeader } from "../src"; + +afterEach(cleanup); + +const renderModal = (props: Record = {}) => + render( + + undefined} {...props}> + Title + Body + + , + ); + +it("puts data attributes on the dialog box, as Chakra's ModalContent did", () => { + renderModal({ "data-testid": "the-dialog", "data-state": "open" }); + const box = screen.getByTestId("the-dialog"); + expect(box.getAttribute("data-state")).toBe("open"); + // The box, not the backdrop: the dialog itself is inside it. + expect(box.querySelector('[role="dialog"]')).not.toBeNull(); +}); + +it("labels the dialog from the header, at h2 by default", () => { + // RAC's Dialog supplies level 2 through HeadingContext for the title slot, + // so this matches Chakra call sites that put an

in the header. + renderModal({ "data-testid": "d" }); + expect(screen.getByRole("heading", { name: "Title" }).tagName).toBe("H2"); + cleanup(); + + render( + + undefined}> + Title + + , + ); + expect(screen.getByRole("heading", { name: "Title" }).tagName).toBe("H3"); +}); From 39858f678ac71f3cd2c7a6881476bc7b71830fb7 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 16:51:36 +0000 Subject: [PATCH 15/43] Modal: data attributes on the header, body, footer and close button too Same reason as the dialog box: classroom's end-to-end suite addresses a dialog's header and its close button by data-testid, not just the box. --- packages/ui/src/Modal.tsx | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index e71c377..7028cf6 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -127,9 +127,7 @@ export const Modal = ({ children, ...rest }: ModalProps) => { - const dataProps = Object.fromEntries( - Object.entries(rest).filter(([key]) => key.startsWith("data-")), - ); + const dataProps = dataAttrs(rest); const slots = dialog({ size, centered: isCentered }); const motionlessClass = motionless ? css({ @@ -186,14 +184,22 @@ interface SlotProps { children?: ReactNode; css?: SystemStyleObject; className?: string; + /** `data-*` attributes land on the slot element, as they did on Chakra's. */ + [key: `data-${string}`]: unknown; } +const dataAttrs = (props: object) => + Object.fromEntries( + Object.entries(props).filter(([key]) => key.startsWith("data-")), + ); + /** Modal title. Rendered as RAC's labelling heading for the dialog. */ export const ModalHeader = ({ children, css: cssProp, className, level, + ...rest }: SlotProps & { /** * Heading element level. Defaults to 2: RAC's Dialog supplies that through @@ -204,6 +210,7 @@ export const ModalHeader = ({ const { slots } = useDialog(); return ( { +export const ModalBody = ({ + children, + css: cssProp, + className, + ...rest +}: SlotProps) => { const { slots } = useDialog(); return (
{children} @@ -232,10 +245,12 @@ export const ModalFooter = ({ children, css: cssProp, className, + ...rest }: SlotProps) => { const { slots } = useDialog(); return (
{ const intl = useIntl(); const { slots, onClose } = useDialog(); return ( Date: Sun, 2 Aug 2026 16:56:30 +0000 Subject: [PATCH 16/43] Spinner: forward data attributes classroom's end-to-end suite waits on the loading spinner by data-testid. --- packages/ui/src/Spinner.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/Spinner.tsx b/packages/ui/src/Spinner.tsx index c5bde24..dc08667 100644 --- a/packages/ui/src/Spinner.tsx +++ b/packages/ui/src/Spinner.tsx @@ -23,6 +23,8 @@ export interface SpinnerProps { * "Loading..." by default, so a nameless spinner would regress on it. */ "aria-label": string; + /** `data-*` attributes land on the spinner, for tests that wait on it. */ + [key: `data-${string}`]: unknown; } /** @@ -35,8 +37,12 @@ export const Spinner = ({ css: cssProp, className, "aria-label": ariaLabel, + ...rest }: SpinnerProps) => ( key.startsWith("data-")), + )} role="status" aria-label={ariaLabel} style={speed ? ({ "--spinner-speed": speed } as CSSProperties) : undefined} From 9e1094d03890afc17bd93a54a3a89bbea78730e5 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 17:09:55 +0000 Subject: [PATCH 17/43] Playbook: dialog gotchas from classroom's area 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #35: the library Modal puts an element between the dialog box and its children, where Chakra's ModalContent was their direct parent — so call sites that centred their content by styling the box silently stop centring it, while every box measurement stays identical. classroom's loading spinner sat 141px off centre. #36: run the Panda codegen before a verification pass. `npx vite` skips the prestart hook, so the stylesheet is stale and any newly-introduced atomic class is missing from it. That reads as a botched port — a heading at the slot's default size rather than the one the css prop asks for — with nothing wrong in the code. Also two dialog-shaped expected deltas: `scrollBehavior` has no equivalent (the library always scrolls the backdrop, Chakra's `outside`), and `preserveScrollBarGap`/`blockScrollOnMount` are simply dropped. --- docs/migration-playbook.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 3b1eaa1..caf7d89 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -673,6 +673,24 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg `dropdown` (1000) for this reason; check any new overlay recipe against `modal` (1400) before assuming the default scale is right. +35. **The library Modal inserts an element between the dialog box and its + children.** Chakra's ModalContent was their direct parent, so a call site + that laid out its content by styling the box — `display: flex` plus + centring, most often — silently stops working: `contentCss` styles the box, + but the children are inside a flex-column `inner` element within it. The + tell is nasty, because every box measurement stays identical and only the + content moves (classroom's loading spinner drifted 141px off centre). Put + the layout on a wrapper inside the dialog instead. + +36. **Run the Panda codegen before any verification pass.** `npx vite` and + friends skip the `prestart`/`prebuild` hook, so `styled-system.css` is + whatever the last codegen produced and every atomic class introduced since + is missing from it. The failure looks exactly like a botched port — a + heading rendering at the slot's default 18px instead of the 43.2px the + `css` prop asks for — and the code looks right, because it is. Check the + generated CSS for the class before believing a measurement: + `grep -o "md\\:fs_5xl" src/styled-system.css`. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native @@ -702,6 +720,14 @@ accepted — expect them, don't chase them as bugs: title — an a11y improvement) unless something has `autoFocus`; Chakra focused the first control (see gotcha #15 for when to add `autoFocus` back). +- **`scrollBehavior` is gone.** Chakra defaulted to `inside` (the box capped + at the viewport, its body scrolling); the library always scrolls the + backdrop, which is Chakra's `outside`. Nothing in the family needed the + distinction — classroom's eight `outside` sites simply dropped the prop — + but a dialog taller than the viewport now grows the page rather than + scrolling internally. +- **`preserveScrollBarGap` and `blockScrollOnMount` have no equivalent**; RAC + does its own scroll locking. Drop them. - **Toast semantics**: one top-centre region (no per-call `position`/ `variant`); `duration` defaults to 5000ms and there is no `duration: null` — use `persistent: true` (which forces the close From 9c42a47c431d84c88d3908729bc7573bc69ffccd Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 17:31:22 +0000 Subject: [PATCH 18/43] Input/TextField: forward every recipe variant, not just size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both components hand-picked `size` and passed it to the recipe, leaving any other variant group in the rest-spread — so it landed on the DOM as an unknown attribute and the styling silently did nothing. The base recipe only has `size`, which is why nothing caught it; classroom's app preset adds a `variant` group, and `variant="classroom"` was rendering as a plain outline input with `variant="classroom"` sitting on the element. They now use the recipe's own `splitVariantProps`, so a preset that adds a variant group keeps working without the component knowing about it — which is the point of the extension point. --- packages/ui/src/Input.tsx | 8 ++++++-- packages/ui/src/TextField.tsx | 8 +++++--- packages/ui/tests/Input.test.tsx | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 packages/ui/tests/Input.test.tsx diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx index 20887d4..64593e6 100644 --- a/packages/ui/src/Input.tsx +++ b/packages/ui/src/Input.tsx @@ -23,14 +23,18 @@ export interface InputProps * labelled field with help/error text use TextField instead. */ export const Input = forwardRef(function Input( - { size, css: cssProp, className, ...rest }, + { css: cssProp, className, ...props }, ref, ) { + // splitVariantProps, not a hand-picked `size`: an app preset can add variant + // groups to the recipe (classroom adds `variant`), and cherry-picking would + // silently drop them onto the DOM as unknown attributes instead. + const [variantProps, rest] = input.splitVariantProps(props); return ( ( helperTextCss, onFocus, autoCapitalize, - size, - ...rest + ...props }, ref, ) { + // As Input: forward every recipe variant group, not just `size`, so a + // preset that adds one keeps working. + const [variantProps, rest] = input.splitVariantProps(props); const slots = field(); return ( @@ -67,7 +69,7 @@ export const TextField = forwardRef( diff --git a/packages/ui/tests/Input.test.tsx b/packages/ui/tests/Input.test.tsx new file mode 100644 index 0000000..9290101 --- /dev/null +++ b/packages/ui/tests/Input.test.tsx @@ -0,0 +1,35 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { Input, TextField } from "../src"; + +afterEach(cleanup); + +// The base recipe has only a `size` group, but an app preset can add more +// (classroom adds `variant`). These assert the component asks the recipe for +// its variants rather than hand-picking, and that nothing leaks to the DOM. +it("puts recipe variants on the class, not on the element", () => { + render(); + const el = screen.getByTestId("i"); + expect(el.className).toContain("input--size_lg"); + expect(el.getAttribute("size")).toBeNull(); +}); + +it("passes native attributes through and keeps the recipe class", () => { + render(); + const el = screen.getByTestId("i"); + expect(el.className).toContain("input"); + expect(el.getAttribute("placeholder")).toBe("p"); + expect(el.getAttribute("name")).toBe("f"); +}); + +it("TextField sizes its input from the recipe too", () => { + render(); + const el = screen.getByRole("textbox"); + expect(el.className).toContain("input--size_lg"); + expect(el.getAttribute("size")).toBeNull(); +}); From d6d66d79df47078304420eff8f4ed81ff901fcd1 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 17:37:20 +0000 Subject: [PATCH 19/43] Playbook: the recipe-variant trap, and form/toast deltas #37 is the one worth grepping for elsewhere: a library component that hand-picks recipe variants silently breaks the preset extension point. Input and TextField passed only `size` to the recipe, so classroom's `variant="classroom"` inputs rendered as plain outline boxes with the prop sitting on the DOM. Nothing caught it because the base recipes only have `size`. Plus the measured toast padding delta and the missing Progress stripes. --- docs/migration-playbook.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index caf7d89..a24e105 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -691,6 +691,23 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg generated CSS for the class before believing a measurement: `grep -o "md\\:fs_5xl" src/styled-system.css`. +37. **A component that hand-picks recipe variants breaks the preset extension + point.** `Input` and `TextField` destructured `size` and passed `{ size }` + to the recipe, leaving anything else in the rest-spread — so an app preset + that _adds_ a variant group got no styling at all and the prop landed on + the DOM as an unknown attribute. Nothing caught it because the base recipes + only had `size`; classroom's `variant="classroom"` inputs had been + rendering as plain outline boxes. Library components should use the + recipe's generated `splitVariantProps` so later presets keep working: + + ```tsx + const [variantProps, rest] = input.splitVariantProps(props); + ; + ``` + + Worth grepping for when adding any component: a literal variant name inside + a recipe call is the smell. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native @@ -728,6 +745,14 @@ accepted — expect them, don't chase them as bugs: scrolling internally. - **`preserveScrollBarGap` and `blockScrollOnMount` have no equivalent**; RAC does its own scroll locking. Drop them. +- **Toast padding is roomier than Chakra's** (measured in classroom: 14.08px + vertical against 10.56px, and 35.2px against 28.16px on the close-button + side, at the same width). Long descriptions that fitted on one line may wrap. +- **Chakra's `Progress` `hasStripe`/`isAnimated` have no equivalent.** The + ProgressBar takes a percentage, not value+max, and needs an explicit + `aria-label` where Chakra's had none. If the stripes matter, restate them at + the call site with a `barCss` gradient over a keyframe in the app preset + (classroom's ProgressDialog does). - **Toast semantics**: one top-centre region (no per-call `position`/ `variant`); `duration` defaults to 5000ms and there is no `duration: null` — use `persistent: true` (which forces the close From 0f31ca40bcbc8dd27372fda8216669163b4a091c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 18:13:30 +0000 Subject: [PATCH 20/43] Select and ComboBox: the pair that retires react-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two dropdown pickers the family has been missing, sharing one `select` slot recipe so a searchable and a non-searchable picker can't drift apart visually. `trigger` is styled from the Chakra outline Input field so a select sits level with a TextField beside it; `content` matches the `menu` recipe's card so every dropdown surface agrees. Select is a listbox behind a button. ComboBox is a text input that filters the same listbox, and carries the three things classroom's react-select call sites actually rely on: - `emptyState`, react-select's `noOptionsMessage`. It implies RAC's `allowsEmptyCollection`, without which the popover simply closes as soon as nothing matches and the message never appears. - `indicator={null}`, for a plain autocomplete with no chevron. - `isPopoverHidden`, to withhold the list until a query is long enough. react-aria has no minimum-length prop, and rendering an empty list still opens an empty card — classroom makes students type two characters before it offers names, deliberately, so they pick their own. Options are children, so they cannot see the variant their parent was given; the parent passes its resolved slots down through context, as Modal does. Both components split variant props off via the recipe rather than hand-picking them (gotcha #37), so an app preset adding a `variant` group reaches every slot including the options. Deferred until something needs them: sections, multi-select, and async loading via `useAsyncList` (the roadmap wants that for data-microbit-org's school lookup). --- packages/ui/src/ComboBox.tsx | 132 ++++++++++++++++++++ packages/ui/src/Select.recipe.ts | 165 +++++++++++++++++++++++++ packages/ui/src/Select.tsx | 138 +++++++++++++++++++++ packages/ui/src/base-preset.ts | 2 + packages/ui/src/index.ts | 2 + packages/ui/stories/Select.stories.tsx | 78 ++++++++++++ packages/ui/tests/Select.test.tsx | 113 +++++++++++++++++ 7 files changed, 630 insertions(+) create mode 100644 packages/ui/src/ComboBox.tsx create mode 100644 packages/ui/src/Select.recipe.ts create mode 100644 packages/ui/src/Select.tsx create mode 100644 packages/ui/stories/Select.stories.tsx create mode 100644 packages/ui/tests/Select.test.tsx diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx new file mode 100644 index 0000000..12e6b34 --- /dev/null +++ b/packages/ui/src/ComboBox.tsx @@ -0,0 +1,132 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ReactNode } from "react"; +import { + Button as RACButton, + ComboBox as RACComboBox, + ComboBoxProps as RACComboBoxProps, + Input as RACInput, + Label as RACLabel, + ListBox as RACListBox, + Popover, + PopoverProps, +} from "react-aria-components"; +import { RiArrowDownSLine } from "react-icons/ri"; +import { css, cx } from "styled-system/css"; +import { select, SelectVariantProps } from "styled-system/recipes"; +import { SystemStyleObject } from "styled-system/types"; +import { Icon } from "./Icon"; +import { SelectSlotProvider } from "./Select"; + +export interface ComboBoxProps + extends Omit, "className" | "children" | "style">, + SelectVariantProps { + /** Visible label. Use `aria-label` instead where the design has none. */ + label?: ReactNode; + placeholder?: string; + /** `SelectOption`s. */ + children: ReactNode; + /** + * Replaces the chevron; pass `null` for none, which is what a plain + * autocomplete wants (react-select's `dropdownIndicator: display none`). + */ + indicator?: ReactNode | null; + /** + * Shown in place of the list when nothing matches (react-select's + * `noOptionsMessage`). Implies `allowsEmptyCollection`, since RAC otherwise + * closes the popover the moment the collection empties. + */ + emptyState?: ReactNode; + /** + * Keep the dropdown shut until this prop is true. For gating on a minimum + * query length — react-aria has no `minLength`, and rendering an empty list + * still opens an empty card. + */ + isPopoverHidden?: boolean; + placement?: PopoverProps["placement"]; + /** Per-instance overrides for the input. */ + css?: SystemStyleObject; + /** Per-instance overrides for the dropdown card. */ + contentCss?: SystemStyleObject; + className?: string; +} + +/** + * ComboBox — a text input that filters a listbox, for choosing one of a known + * set where typing to narrow it down is the point. Use Select where the list + * is short enough to just pick from. + * + * Note the react-select difference this replaces: react-select filtered on + * `label` and kept the menu open on selection unless told otherwise, whereas + * react-aria filters on each item's `textValue` and closes on selection. + */ +export const ComboBox = ({ + label, + placeholder, + children, + indicator, + emptyState, + isPopoverHidden, + placement = "bottom start", + css: cssProp, + contentCss, + className, + ...props +}: ComboBoxProps) => { + // As Select: forward whatever variant groups the merged recipe has. + const [variantProps, rest] = select.splitVariantProps(props); + const slots = select(variantProps); + return ( + + )} + className={cx(slots.root, className)} + > + {label != null && {label}} +
+ + {indicator !== null && ( + + {indicator ?? } + + )} +
+ {!isPopoverHidden && ( + +
{emptyState}
+ : undefined + } + > + {children} +
+
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts new file mode 100644 index 0000000..dcffbc2 --- /dev/null +++ b/packages/ui/src/Select.recipe.ts @@ -0,0 +1,165 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { defineSlotRecipe } from "@pandacss/dev"; + +// Chakra's transition.property.common, inlined (Panda has no transitionProperty +// token category). +const transitionCommon = + "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform"; + +/** + * Select slot recipe — the dropdown pair, shared by `Select` (a listbox behind + * a button) and `ComboBox` (a listbox behind a text input). One recipe because + * the two differ only in what the control is: keeping them together is what + * stops a searchable and a non-searchable picker drifting apart visually. + * + * `trigger` is styled from the Chakra outline Input field so a select sits + * level with a TextField beside it; `content` matches the `menu` recipe's card + * so every dropdown surface in the family agrees. + * + * Apps restyle it through the `variant` group — classroom's `classroom` + * variant is the rounded pill its join form uses. + * + * Registered in the base preset (base-preset.ts). + */ +export const select = defineSlotRecipe({ + className: "select", + slots: [ + "root", + "label", + "trigger", + "value", + "indicator", + "content", + "list", + "option", + "optionIndicator", + "empty", + ], + base: { + root: { + display: "flex", + flexDirection: "column", + width: "100%", + }, + label: { + fontSize: "md", + fontWeight: "medium", + marginEnd: "3", + mb: "2", + }, + trigger: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "2", + width: "100%", + minWidth: 0, + outline: "none", + appearance: "none", + font: "inherit", + textAlign: "start", + cursor: "pointer", + transitionProperty: transitionCommon, + transitionDuration: "normal", + border: "1px solid", + borderColor: "gray.200", + borderRadius: "md", + bg: "white", + color: "inherit", + h: "10", + px: "4", + _hover: { borderColor: "gray.300" }, + "&[data-focus-visible]": { + focusShadow: "outline", + borderColor: "focusBorder", + }, + // A ComboBox's control is an input, which is focused whenever it is open. + "&[data-focused]": { focusShadow: "outline", borderColor: "focusBorder" }, + "&[data-invalid]": { borderColor: "danger.500" }, + "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, + }, + value: { + flex: "1", + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + // RAC sets this on SelectValue when nothing is chosen. + "&[data-placeholder]": { color: "gray.500" }, + }, + indicator: { + display: "inline-flex", + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + fontSize: "1.25em", + color: "inherit", + // No pointer-events:none here: in a ComboBox this slot is the button + // that opens the list. Select's is an aria-hidden span inside the + // trigger, so it needs no help being inert. + background: "transparent", + border: "none", + cursor: "pointer", + outline: "none", + "&[data-focus-visible]": { focusShadow: "outline" }, + }, + content: { + bg: "white", + color: "inherit", + py: "2", + zIndex: "popover", + borderRadius: "md", + borderWidth: "1px", + borderColor: "gray.200", + boxShadow: "sm", + // Matches the menu recipe's fade/scale. + transformOrigin: "top", + opacity: 1, + transform: "scale(1)", + transition: "opacity 0.1s ease-out, transform 0.1s ease-out", + "&[data-entering]": { opacity: 0, transform: "scale(0.95)" }, + "&[data-exiting]": { opacity: 0, transform: "scale(0.95)" }, + _motionReduce: { transition: "none" }, + }, + list: { + outline: "none", + maxHeight: "inherit", + overflowY: "auto", + }, + option: { + display: "flex", + alignItems: "center", + gap: "2", + py: "1.5", + px: "3", + cursor: "pointer", + color: "inherit", + outline: "none", + transitionProperty: "background", + transitionDuration: "ultra-fast", + transitionTimingFunction: "ease-in", + "&[data-focused]": { bg: "gray.100" }, + "&[data-pressed]": { bg: "gray.200" }, + "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, + }, + optionIndicator: { + display: "inline-flex", + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + marginStart: "auto", + fontSize: "0.8em", + opacity: 0, + "[data-selected] &": { opacity: 1 }, + }, + empty: { + px: "3", + py: "2", + color: "gray.600", + }, + }, +}); diff --git a/packages/ui/src/Select.tsx b/packages/ui/src/Select.tsx new file mode 100644 index 0000000..fd60fed --- /dev/null +++ b/packages/ui/src/Select.tsx @@ -0,0 +1,138 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { createContext, ReactNode, useContext } from "react"; +import { + Button as RACButton, + Label as RACLabel, + ListBox as RACListBox, + ListBoxItem as RACListBoxItem, + ListBoxItemProps as RACListBoxItemProps, + Popover, + PopoverProps, + Select as RACSelect, + SelectProps as RACSelectProps, + SelectValue, +} from "react-aria-components"; +import { RiArrowDownSLine } from "react-icons/ri"; +import { css, cx } from "styled-system/css"; +import { select, SelectVariantProps } from "styled-system/recipes"; +import { SystemStyleObject } from "styled-system/types"; +import { Icon } from "./Icon"; + +export type SelectSlots = ReturnType; + +// Options are children, so they can't see the variant their Select was given. +// The parent hands its resolved slots down, as Modal does for its own slots. +const SlotContext = createContext(select({})); + +export const useSelectSlots = () => useContext(SlotContext); + +export const SelectSlotProvider = SlotContext.Provider; + +export interface SelectProps + extends Omit< + RACSelectProps, + "className" | "children" | "style" | "placeholder" + >, + SelectVariantProps { + /** Visible label. Use `aria-label` instead where the design has none. */ + label?: ReactNode; + /** Shown in the trigger while nothing is chosen (Chakra's placeholder). */ + placeholder?: string; + /** `SelectOption`s. */ + children: ReactNode; + /** Replaces the chevron. */ + indicator?: ReactNode; + /** Placement of the dropdown relative to the trigger. */ + placement?: PopoverProps["placement"]; + /** Per-instance overrides for the trigger. */ + css?: SystemStyleObject; + /** Per-instance overrides for the dropdown card. */ + contentCss?: SystemStyleObject; + className?: string; +} + +/** + * Select — a listbox behind a button, for choosing one of a known set. + * Replaces Chakra-era react-select at non-searchable call sites; use ComboBox + * where the user should be able to type to filter. + */ +export const Select = ({ + label, + placeholder, + children, + indicator, + placement = "bottom start", + css: cssProp, + contentCss, + className, + ...props +}: SelectProps) => { + // splitVariantProps, not a hand-picked list: an app preset can add variant + // groups to the recipe and they have to reach it (playbook gotcha #37). + const [variantProps, rest] = select.splitVariantProps(props); + const slots = select(variantProps); + return ( + + )} + className={cx(slots.root, className)} + > + {label != null && {label}} + + + {({ isPlaceholder, defaultChildren }) => + isPlaceholder ? (placeholder ?? "") : defaultChildren + } + + + {indicator ?? } + + + + {children} + + + + ); +}; + +export interface SelectOptionProps + extends Omit { + children?: ReactNode; + css?: SystemStyleObject; + className?: string; +} + +/** A row in a `Select` or `ComboBox` list. */ +export const SelectOption = ({ + children, + css: cssProp, + className, + ...rest +}: SelectOptionProps) => { + const slots = useSelectSlots(); + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 44d3d6a..e1097aa 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -32,6 +32,7 @@ import { heading } from "./Heading.recipe"; import { input } from "./Input.recipe"; import { numberField } from "./NumberField.recipe"; import { menu } from "./Menu.recipe"; +import { select } from "./Select.recipe"; import { slider } from "./Slider.recipe"; import { switchRecipe } from "./Switch.recipe"; import { dialog } from "./Modal.recipe"; @@ -205,6 +206,7 @@ export const basePreset = definePreset({ menu, numberField, radio, + select, slider, switchRecipe, toast, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e27dc46..03092fe 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -25,6 +25,7 @@ export * from "./ProgressBar"; export * from "./Radio"; export * from "./Slide"; export * from "./Slider"; +export * from "./Select"; export * from "./Spinner"; export * from "./Svg"; export * from "./Switch"; @@ -42,6 +43,7 @@ export * from "./Kbd"; export * from "./Divider"; export * from "./Drawer"; export * from "./List"; +export * from "./ComboBox"; export * from "./Menu"; export * from "./Modal"; export * from "./PopoverArrow"; diff --git a/packages/ui/stories/Select.stories.tsx b/packages/ui/stories/Select.stories.tsx new file mode 100644 index 0000000..3f14df8 --- /dev/null +++ b/packages/ui/stories/Select.stories.tsx @@ -0,0 +1,78 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { ComboBox, Select, SelectOption, Stack } from "../src"; + +const meta = { + title: "Forms/Select", +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const FRUIT = ["Apple", "Banana", "Cherry", "Damson", "Elderberry"]; + +const options = FRUIT.map((f) => ( + + {f} + +)); + +/** A listbox behind a button: pick one of a known set, no typing. */ +export const Basic: Story = { + render: () => ( + + + + + ), +}; + +/** Type to filter. `emptyState` is react-select's `noOptionsMessage`. */ +export const Combo: Story = { + render: () => ( + + + {options} + + + ), +}; + +/** + * `isPopoverHidden` withholds the list entirely — for gating on a minimum + * query length, which react-aria has no prop for. classroom uses it to make + * students type two characters before it offers names. + */ +export const GatedOnQueryLength: Story = { + render: () => { + const [query, setQuery] = useState(""); + return ( + + + {options} + + + ); + }, +}; diff --git a/packages/ui/tests/Select.test.tsx b/packages/ui/tests/Select.test.tsx new file mode 100644 index 0000000..e40869c --- /dev/null +++ b/packages/ui/tests/Select.test.tsx @@ -0,0 +1,113 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { ComboBox, Select, SelectOption } from "../src"; + +afterEach(cleanup); + +const FRUIT = ["Apple", "Banana", "Cherry"]; + +const renderSelect = (props = {}) => + render( + , + ); + +it("shows the placeholder until something is chosen, then the choice", () => { + const onSelectionChange = vi.fn(); + renderSelect({ onSelectionChange }); + const trigger = screen.getByRole("button"); + expect(trigger.textContent).toContain("Pick one"); + + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("option", { name: "Banana" })); + expect(onSelectionChange).toHaveBeenCalledWith("Banana"); + expect(screen.getByRole("button").textContent).toContain("Banana"); +}); + +it("gives its options the parent's slot classes, so an app variant reaches them", () => { + renderSelect(); + fireEvent.click(screen.getByRole("button")); + for (const opt of screen.getAllByRole("option")) { + expect(opt.className).toContain("select__option"); + } +}); + +it("ComboBox filters as you type and reports the selection", () => { + const onSelectionChange = vi.fn(); + render( + + {FRUIT.map((f) => ( + + {f} + + ))} + , + ); + const input = screen.getByRole("combobox") as HTMLInputElement; + act(() => input.focus()); + fireEvent.change(input, { target: { value: "an" } }); + const names = screen.getAllByRole("option").map((o) => o.textContent); + expect(names).toEqual(["Banana"]); + + fireEvent.click(screen.getByRole("option", { name: "Banana" })); + expect(onSelectionChange).toHaveBeenCalledWith("Banana"); +}); + +it("ComboBox can withhold the popover entirely", () => { + const Probe = () => { + const [q, setQ] = useState(""); + return ( + + {FRUIT.map((f) => ( + + {f} + + ))} + + ); + }; + render(); + const input = screen.getByRole("combobox") as HTMLInputElement; + act(() => input.focus()); + fireEvent.change(input, { target: { value: "a" } }); + expect(screen.queryAllByRole("option")).toHaveLength(0); + fireEvent.change(input, { target: { value: "ap" } }); + expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([ + "Apple", + ]); +}); + +it("ComboBox shows an empty state and can drop the indicator", () => { + render( + + {FRUIT.map((f) => ( + + {f} + + ))} + , + ); + // indicator={null} means no toggle button beside the input. + expect(screen.queryAllByRole("button")).toHaveLength(0); + const input = screen.getByRole("combobox") as HTMLInputElement; + act(() => input.focus()); + fireEvent.change(input, { target: { value: "zzz" } }); + expect(screen.getByText("Nothing found")).toBeDefined(); +}); From 19a392440d3be729ad59d570a2efac5bc3445b32 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 18:16:32 +0000 Subject: [PATCH 21/43] ComboBox: startContent, and a staticCss entry for the select recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ComboBox's control is a text input, so unlike a Select it cannot show anything but text for the current value — react-select did it with a custom SingleValue. classroom's classroom-name pickers show an emoji beside the chosen word, so the control needs a slot before the input. The select recipe also needs its staticCss entry, as input has, so an app preset's variant is generated whether or not a call site names it literally. --- packages/ui/src/ComboBox.tsx | 9 +++++++++ packages/ui/src/base-preset.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index 12e6b34..1ba7961 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -27,6 +27,13 @@ export interface ComboBoxProps /** Visible label. Use `aria-label` instead where the design has none. */ label?: ReactNode; placeholder?: string; + /** + * Rendered inside the control, before the input — an icon for the current + * value, say. A ComboBox's control is a text input, so unlike a Select it + * cannot show anything but text for what is chosen; this is the way round + * that (react-select did it with a custom `SingleValue`). + */ + startContent?: ReactNode; /** `SelectOption`s. */ children: ReactNode; /** @@ -66,6 +73,7 @@ export interface ComboBoxProps export const ComboBox = ({ label, placeholder, + startContent, children, indicator, emptyState, @@ -88,6 +96,7 @@ export const ComboBox = ({ > {label != null && {label}}
+ {startContent} Date: Sun, 2 Aug 2026 18:17:43 +0000 Subject: [PATCH 22/43] ComboBox: forward a ref to the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call sites focus the field to report a validation failure — classroom's join form focuses the first name part when the classroom is not found. --- packages/ui/src/ComboBox.tsx | 42 +++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index 1ba7961..a6853f8 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { ReactNode } from "react"; +import { ForwardedRef, forwardRef, ReactNode } from "react"; import { Button as RACButton, ComboBox as RACComboBox, @@ -70,20 +70,23 @@ export interface ComboBoxProps * `label` and kept the menu open on selection unless told otherwise, whereas * react-aria filters on each item's `textValue` and closes on selection. */ -export const ComboBox = ({ - label, - placeholder, - startContent, - children, - indicator, - emptyState, - isPopoverHidden, - placement = "bottom start", - css: cssProp, - contentCss, - className, - ...props -}: ComboBoxProps) => { +const ComboBoxInner = ( + { + label, + placeholder, + startContent, + children, + indicator, + emptyState, + isPopoverHidden, + placement = "bottom start", + css: cssProp, + contentCss, + className, + ...props + }: ComboBoxProps, + ref: ForwardedRef, +) => { // As Select: forward whatever variant groups the merged recipe has. const [variantProps, rest] = select.splitVariantProps(props); const slots = select(variantProps); @@ -98,6 +101,7 @@ export const ComboBox = ({
{startContent} ({ ); }; + +/** + * forwardRef with generics needs the cast (React's types cannot express it), + * so the ref lands on the input — call sites focus it for validation. + */ +export const ComboBox = forwardRef(ComboBoxInner) as ( + props: ComboBoxProps & { ref?: ForwardedRef }, +) => ReturnType; From 9765951cd301b45448d0438c7b7b7db294d27219 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 18:21:18 +0000 Subject: [PATCH 23/43] ComboBox: anchor the card to the control, and size it to the trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover defaulted to the bare input inside the control, so it hung off the text baseline and was as narrow as the input rather than lining up under the field. It now anchors to the control wrapper, and the recipe sizes the card to `--trigger-width` — what a select should do, and what react-select did. --- packages/ui/src/ComboBox.tsx | 11 +++++++++-- packages/ui/src/Select.recipe.ts | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index a6853f8..5e70416 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { ForwardedRef, forwardRef, ReactNode } from "react"; +import { ForwardedRef, forwardRef, ReactNode, useRef } from "react"; import { Button as RACButton, ComboBox as RACComboBox, @@ -90,6 +90,9 @@ const ComboBoxInner = ( // As Select: forward whatever variant groups the merged recipe has. const [variantProps, rest] = select.splitVariantProps(props); const slots = select(variantProps); + // Anchor the card to the whole control, not to the bare input inside it — + // otherwise it hangs off the text baseline and is as narrow as the input. + const triggerRef = useRef(null); return ( ( className={cx(slots.root, className)} > {label != null && {label}} -
+
{startContent} (
{!isPopoverHidden && ( Date: Sun, 2 Aug 2026 18:22:20 +0000 Subject: [PATCH 24/43] Select/ComboBox: maxHeight passthrough (react-select's maxMenuHeight) A prop rather than a contentCss rule: RAC writes its own max-height inline while positioning, which beats any class. --- packages/ui/src/ComboBox.tsx | 8 ++++++++ packages/ui/src/Select.tsx | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index 5e70416..9630fee 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -54,6 +54,12 @@ export interface ComboBoxProps */ isPopoverHidden?: boolean; placement?: PopoverProps["placement"]; + /** + * Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather + * than a `contentCss` rule because RAC writes its own max-height inline + * while positioning, which beats any class. + */ + maxHeight?: number; /** Per-instance overrides for the input. */ css?: SystemStyleObject; /** Per-instance overrides for the dropdown card. */ @@ -80,6 +86,7 @@ const ComboBoxInner = ( emptyState, isPopoverHidden, placement = "bottom start", + maxHeight, css: cssProp, contentCss, className, @@ -129,6 +136,7 @@ const ComboBoxInner = ( indicator?: ReactNode; /** Placement of the dropdown relative to the trigger. */ placement?: PopoverProps["placement"]; + /** + * Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather + * than a `contentCss` rule because RAC writes its own max-height inline + * while positioning, which beats any class. + */ + maxHeight?: number; /** Per-instance overrides for the trigger. */ css?: SystemStyleObject; /** Per-instance overrides for the dropdown card. */ @@ -66,6 +72,7 @@ export const Select = ({ children, indicator, placement = "bottom start", + maxHeight, css: cssProp, contentCss, className, @@ -87,7 +94,7 @@ export const Select = ({ > {({ isPlaceholder, defaultChildren }) => - isPlaceholder ? (placeholder ?? "") : defaultChildren + isPlaceholder ? placeholder ?? "" : defaultChildren } @@ -96,6 +103,7 @@ export const Select = ({ Date: Sun, 2 Aug 2026 18:23:57 +0000 Subject: [PATCH 25/43] Select recipe: minWidth from the trigger, not width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact for Select, whose trigger is the button RAC measures. For ComboBox the var is the input's width — the control's content box, so narrower than the field by its padding and border — and forcing it as `width` made the card visibly narrower than the control. minWidth lets the card size to its content instead, and a call site that needs it flush can say so through contentCss. --- packages/ui/src/Select.recipe.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts index 0b68071..07c6339 100644 --- a/packages/ui/src/Select.recipe.ts +++ b/packages/ui/src/Select.recipe.ts @@ -109,8 +109,12 @@ export const select = defineSlotRecipe({ }, content: { // Line the card up with the control, as a select should and as - // react-select did. RAC measures the popover's trigger into this var. - width: "var(--trigger-width)", + // react-select did. Exact for `Select`, whose trigger is the button RAC + // measures; for `ComboBox` the var is the *input's* width, which is the + // control's content box — narrower than the field by its padding and + // border. Hence minWidth rather than width, so a wider card still fits + // its content, and `contentCss` where a call site needs it flush. + minWidth: "var(--trigger-width)", bg: "white", color: "inherit", py: "2", From 486c98c86fe0db7d08c4450f06048bc76becba02 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 19:01:14 +0000 Subject: [PATCH 26/43] Select recipe: one 'value' slot for both controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select's SelectValue and ComboBox's input are the same thing — what shows the current value — so they share a slot. ComboBox's input had its styling inline instead, which meant an app restyling `value` (classroom draws the placeholder black, as react-select did) reached a Select but not a ComboBox. --- packages/ui/src/ComboBox.tsx | 10 +--------- packages/ui/src/Select.recipe.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index 9630fee..5348fdb 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -116,15 +116,7 @@ const ComboBoxInner = ( {indicator !== null && ( diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts index 07c6339..cc7dbdd 100644 --- a/packages/ui/src/Select.recipe.ts +++ b/packages/ui/src/Select.recipe.ts @@ -82,14 +82,23 @@ export const select = defineSlotRecipe({ "&[data-invalid]": { borderColor: "danger.500" }, "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, }, + // Whatever shows the current value: Select's SelectValue, ComboBox's + // input. One slot for both, so an app restyling the placeholder (say) + // does not have to know which kind of control it is looking at. value: { flex: "1", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - // RAC sets this on SelectValue when nothing is chosen. + outline: "none", + bg: "transparent", + color: "inherit", + font: "inherit", + // RAC sets data-placeholder on SelectValue when nothing is chosen; the + // ComboBox input uses the real placeholder attribute. "&[data-placeholder]": { color: "gray.500" }, + _placeholder: { color: "gray.500" }, }, indicator: { display: "inline-flex", From f36e811d3a2624795f0eb6e95d1cf5d2e01bc7dd Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 19:05:30 +0000 Subject: [PATCH 27/43] Playbook: what react-select gives you that a ComboBox does not #38 is the checklist for the next app that retires react-select: opening on click, prefix matching, the no-options message needing allowsEmptyCollection, and gating on query length needing a real prop rather than display:none. Plus the empty state rendering as a role="option" row, which quietly breaks tests that count options. #39: `--trigger-width` measures a ComboBox's input, not its control, so a card sized from it comes out narrower than the field. Select/ComboBox comes off the outstanding list; sections, multi-select and useAsyncList remain. --- docs/migration-playbook.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index a24e105..63f14e1 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -708,6 +708,32 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg Worth grepping for when adding any component: a literal variant name inside a recipe call is the smell. +38. **react-select's behaviours are props on a ComboBox, not styling.** Four + of them, all of which classroom's sites relied on and none of which comes + free: + + - It **opened its menu on click**; react-aria waits for typing + (`menuTrigger="focus"` restores it), which otherwise leaves the chevron + as the only way in. + - It **filtered on `label`, with `matchFrom: "start"` available**; + react-aria filters static children on `textValue`, always substring, so + prefix matching means filtering the children yourself. + - Its **`noOptionsMessage`** needs `allowsEmptyCollection`, or the popover + closes the moment nothing matches and the message never shows. + - Sites that **hid the menu with `display: none`** to gate on a query + length need a real "don't render the popover" prop; an empty list still + opens an empty card. + + Also: react-aria renders a listbox's empty state as a `role="option"` row, + so a test that counts options counts "no matches" as a match. + +39. **`--trigger-width` is the input's width in a ComboBox, not the control's.** + RAC measures the element it anchors to, which for a ComboBox is the text + input inside the control — narrower than the field by its padding and + border, so a card sized from the var comes out visibly narrow. `Select` is + fine (its trigger is the button). Size the card at the call site, or accept + `minWidth`. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native @@ -810,10 +836,10 @@ Radio/RadioGroup, NumberField, Kbd/Code, `useMediaQuery`/`usePrevious`/ Still outstanding, in classroom's likely order of need: -- **Select/ComboBox** — retires react-select family-wide (classroom's - `SelectDropdown`/`SelectWithIcon` wrappers sketch the API; RAC ComboBox + - `useAsyncList` covers the async school-lookup case). 4 classroom sites, - 1 data site. +- ~~**Select/ComboBox**~~ — **built** (classroom, area 6), retiring + react-select there; one `select` slot recipe behind both. Sections, + multi-select and async loading via `useAsyncList` are still unbuilt — the + data-microbit-org school lookup will want the last of those. - **GridList** (promote from classroom's hand-rolled react-aria hooks; also ml-trainer's parked projects-page idea). - **Avatar** (+ badge) — classroom's class-roster identity; data has one From f9913829df6cc58b8d7e1df9bcc08ae587e8adb4 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 19:31:09 +0000 Subject: [PATCH 28/43] ComboBox: size the card from the control, not from RAC's trigger var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAC's --trigger-width measures the element it anchors to, which for a ComboBox is the text input inside the control — so a card sized from it came out narrower than the field by the padding and border. Visible in the library's own storybook, and papered over in classroom by setting the width per site. The ComboBox now measures its control and sets the width inline. State rather than reading the ref while rendering: the popover is mounted from the first render, before the ref is set, and nothing would re-render it — which is why the obvious version of this quietly did nothing. A ResizeObserver keeps it right when the field is responsive, guarded for jsdom. Also adds stories for the props that arrived after the first pass — startContent, maxHeight and the invalid state — and notes on the Combo story that react-aria opens the list on typing rather than on click, with the chevron as the click affordance. All six stories drive correctly in a browser. --- packages/ui/src/ComboBox.tsx | 30 ++++++++- packages/ui/src/Select.recipe.ts | 8 +-- packages/ui/stories/Select.stories.tsx | 92 +++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index 5348fdb..fc3968d 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -3,7 +3,14 @@ * * SPDX-License-Identifier: MIT */ -import { ForwardedRef, forwardRef, ReactNode, useRef } from "react"; +import { + ForwardedRef, + forwardRef, + ReactNode, + useLayoutEffect, + useRef, + useState, +} from "react"; import { Button as RACButton, ComboBox as RACComboBox, @@ -100,6 +107,26 @@ const ComboBoxInner = ( // Anchor the card to the whole control, not to the bare input inside it — // otherwise it hangs off the text baseline and is as narrow as the input. const triggerRef = useRef(null); + // RAC's --trigger-width measures the input it anchors a ComboBox to, which + // is the control's content box — so a card sized from it is narrower than + // the field by the padding and border. Measure the control instead. State + // rather than reading the ref at render time: the popover is mounted from + // the first render, before the ref is set, and nothing would re-render it. + const [triggerWidth, setTriggerWidth] = useState(); + useLayoutEffect(() => { + const el = triggerRef.current; + if (!el) { + return; + } + const update = () => setTriggerWidth(el.offsetWidth); + update(); + if (typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); return ( ( triggerRef={triggerRef} placement={placement} maxHeight={maxHeight} + style={triggerWidth ? { width: triggerWidth } : undefined} className={cx( slots.content, contentCss ? css(contentCss) : undefined, diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts index cc7dbdd..5d10836 100644 --- a/packages/ui/src/Select.recipe.ts +++ b/packages/ui/src/Select.recipe.ts @@ -118,11 +118,9 @@ export const select = defineSlotRecipe({ }, content: { // Line the card up with the control, as a select should and as - // react-select did. Exact for `Select`, whose trigger is the button RAC - // measures; for `ComboBox` the var is the *input's* width, which is the - // control's content box — narrower than the field by its padding and - // border. Hence minWidth rather than width, so a wider card still fits - // its content, and `contentCss` where a call site needs it flush. + // react-select did. `Select` gets this from RAC, whose trigger is the + // button it measures; `ComboBox` measures its own control and sets the + // width inline, because RAC's var is the *input's* width there. minWidth: "var(--trigger-width)", bg: "white", color: "inherit", diff --git a/packages/ui/stories/Select.stories.tsx b/packages/ui/stories/Select.stories.tsx index 3f14df8..cc84ebe 100644 --- a/packages/ui/stories/Select.stories.tsx +++ b/packages/ui/stories/Select.stories.tsx @@ -5,7 +5,8 @@ */ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { ComboBox, Select, SelectOption, Stack } from "../src"; +import { RiCloudLine, RiFireLine, RiSnowyLine } from "react-icons/ri"; +import { ComboBox, Icon, Select, SelectOption, Stack } from "../src"; const meta = { title: "Forms/Select", @@ -36,7 +37,13 @@ export const Basic: Story = { ), }; -/** Type to filter. `emptyState` is react-select's `noOptionsMessage`. */ +/** + * Type to filter. `emptyState` is react-select's `noOptionsMessage`. + * + * Note react-aria's default: the list opens when you *type*, not when you + * click the field — the chevron is the click affordance. Pass + * `menuTrigger="focus"` for react-select's behaviour, as the story below does. + */ export const Combo: Story = { render: () => ( @@ -76,3 +83,84 @@ export const GatedOnQueryLength: Story = { ); }, }; + +const WEATHER = [ + { value: "cloudy", label: "Cloudy", icon: RiCloudLine }, + { value: "hot", label: "Hot", icon: RiFireLine }, + { value: "snowy", label: "Snowy", icon: RiSnowyLine }, +]; + +/** + * `startContent` puts something before the input — an icon for the current + * value, say. A ComboBox's control is a text input, so unlike a Select it + * cannot otherwise show anything but text for what is chosen. + */ +export const WithAnIconForTheValue: Story = { + render: () => { + const [key, setKey] = useState("cloudy"); + const [query, setQuery] = useState("Cloudy"); + const chosen = WEATHER.find((w) => w.value === key); + return ( + + { + setKey(k as string); + const w = WEATHER.find((x) => x.value === k); + if (w) { + setQuery(w.label); + } + }} + startContent={ + chosen ? : undefined + } + > + {WEATHER.map((w) => ( + + + {w.label} + + ))} + + + ); + }, +}; + +/** + * `maxHeight` is react-select's `maxMenuHeight`. It has to be a prop rather + * than a style: RAC writes its own max-height inline while positioning, which + * beats any class. + */ +export const LongListWithACappedHeight: Story = { + render: () => ( + + + + ), +}; + +/** Invalid state, as a form would set it. */ +export const Invalid: Story = { + render: () => ( + + + + {options} + + + ), +}; From 0c8cf10dfcd98db5895218d517376df48896dde6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 19:32:33 +0000 Subject: [PATCH 29/43] Playbook: #39 is fixed in the library, and note why the obvious fix fails --- docs/migration-playbook.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 63f14e1..46e4770 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -731,8 +731,14 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg RAC measures the element it anchors to, which for a ComboBox is the text input inside the control — narrower than the field by its padding and border, so a card sized from the var comes out visibly narrow. `Select` is - fine (its trigger is the button). Size the card at the call site, or accept - `minWidth`. + fine (its trigger is the button). The library's ComboBox measures its own + control instead, so consumers need do nothing; the trap is worth knowing if + you build another popover on RAC. + + The obvious fix — reading the trigger ref while rendering the popover — + quietly does nothing: RAC mounts the popover from the first render, before + the ref is set, and nothing re-renders it afterwards. It needs state set + from a layout effect. Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — From 1cc9ef20ffb02567cec7f28d1d0f46b87b73c184 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:02:48 +0000 Subject: [PATCH 30/43] Add Avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chakra's avatar: a circle showing a photo, the initials of a name, or a generic glyph, plus a corner badge. classroom's class roster is the first consumer (four sites); data-microbit-org has one. The name-derived colour is Chakra's `randomColor({ string })` hash, reproduced exactly so a migrating app's avatars keep the colours they had, along with its brightness rule for white-or-dark text. Both arrive as inline custom properties the recipe reads, for two reasons: an inline *property* would beat a call site's `css` override, and a state selector (`&[data-…]`) would outrank it on specificity anywhere cascade layers aren't in play — which is every app still coexisting with Chakra. classroom's offline students, greyed out from the call site, are exactly that case, and measured it: gray.800 where Chakra gave gray.600. The size variant sets the font size on the root and the label separately, as Chakra did through one variable, because they turn out to be different wishes: the root's is the em basis a badge measures against, the label's is how big the initials are. classroom overrides one and not the other. --- packages/ui/src/Avatar.recipe.ts | 168 +++++++++++++++++++ packages/ui/src/Avatar.tsx | 218 +++++++++++++++++++++++++ packages/ui/src/base-preset.ts | 6 + packages/ui/src/index.ts | 2 + packages/ui/stories/Avatar.stories.tsx | 112 +++++++++++++ packages/ui/tests/Avatar.test.tsx | 79 +++++++++ packages/ui/tests/Select.test.tsx | 8 +- 7 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/Avatar.recipe.ts create mode 100644 packages/ui/src/Avatar.tsx create mode 100644 packages/ui/stories/Avatar.stories.tsx create mode 100644 packages/ui/tests/Avatar.test.tsx diff --git a/packages/ui/src/Avatar.recipe.ts b/packages/ui/src/Avatar.recipe.ts new file mode 100644 index 0000000..95a3ad6 --- /dev/null +++ b/packages/ui/src/Avatar.recipe.ts @@ -0,0 +1,168 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { defineSlotRecipe } from "@pandacss/dev"; + +/** + * Avatar slot recipe — Chakra's avatar: a circle showing an image, the + * initials of a name, or a generic person glyph, optionally with a badge + * pinned to one corner. + * + * The background and text colour come from `var(--avatar-bg)` and + * `var(--avatar-color)` rather than being flat values, because the component + * derives them from the name (see Avatar.tsx) and writes them as inline custom + * properties — exactly as Chakra did. Two reasons, both about letting a call + * site win with a plain `css={{ bg: …, color: … }}`: an inline *property* + * would beat any class, where an inline *variable* only feeds this + * declaration; and both must stay single-class selectors, since a state + * selector like `&[data-light-bg]` outranks the call site's utility class on + * specificity wherever cascade layers aren't in play — which is every app + * still coexisting with Chakra (playbook gotcha #40). + * + * Sizes are Chakra's, with its `calc(size / 2.5)` font size resolved per size + * so an app preset can restate either independently (classroom's avatars are + * a grade larger than Chakra's). + * + * Registered in the base preset (base-preset.ts), which also has the + * `staticCss` entry that keeps the runtime-prop variants generated. + */ +export const avatar = defineSlotRecipe({ + className: "avatar", + slots: ["root", "label", "image", "badge"], + base: { + root: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + position: "relative", + verticalAlign: "top", + textAlign: "center", + textTransform: "uppercase", + fontWeight: "medium", + borderRadius: "full", + // Chakra's no-name defaults; the name-derived pair arrives inline. + background: "var(--avatar-bg, token(colors.gray.400))", + color: "var(--avatar-color, token(colors.white))", + borderColor: "white", + }, + label: { + lineHeight: "1", + }, + image: { + width: "100%", + height: "100%", + objectFit: "cover", + borderRadius: "inherit", + }, + badge: { + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "full", + // em-relative, so a badge keeps its proportions at every avatar size. + borderWidth: "0.2em", + borderStyle: "solid", + borderColor: "white", + }, + }, + variants: { + // Chakra's scale: the container size, and Chakra's `calc(size / 2.5)` + // font size kept as a calc over the same token so both track a preset + // that rescales `sizes` (the dense preset does, by 0.88). + // + // The font size lands on the root *and* the label, as Chakra's did + // (through one variable). They are separate declarations so an app can + // move one without the other: the root's is the em basis for a badge, + // the label's is how big the initials are, and the two are not always + // the same wish. + size: { + "2xs": { + root: { + width: "4", + height: "4", + fontSize: "calc(token(sizes.4) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.4) / 2.5)" }, + }, + xs: { + root: { + width: "6", + height: "6", + fontSize: "calc(token(sizes.6) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.6) / 2.5)" }, + }, + sm: { + root: { + width: "8", + height: "8", + fontSize: "calc(token(sizes.8) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.8) / 2.5)" }, + }, + md: { + root: { + width: "12", + height: "12", + fontSize: "calc(token(sizes.12) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.12) / 2.5)" }, + }, + lg: { + root: { + width: "16", + height: "16", + fontSize: "calc(token(sizes.16) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.16) / 2.5)" }, + }, + xl: { + root: { + width: "24", + height: "24", + fontSize: "calc(token(sizes.24) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.24) / 2.5)" }, + }, + "2xl": { + root: { + width: "32", + height: "32", + fontSize: "calc(token(sizes.32) / 2.5)", + }, + label: { fontSize: "calc(token(sizes.32) / 2.5)" }, + }, + }, + /** Which corner the badge sits in. Chakra's placements, same offsets. */ + placement: { + "top-start": { + badge: { + top: "0", + insetStart: "0", + transform: "translate(-25%, -25%)", + }, + }, + "top-end": { + badge: { top: "0", insetEnd: "0", transform: "translate(25%, -25%)" }, + }, + "bottom-start": { + badge: { + bottom: "0", + insetStart: "0", + transform: "translate(-25%, 25%)", + }, + }, + "bottom-end": { + badge: { bottom: "0", insetEnd: "0", transform: "translate(25%, 25%)" }, + }, + }, + }, + defaultVariants: { + size: "md", + placement: "bottom-end", + }, +}); diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx new file mode 100644 index 0000000..46fbe80 --- /dev/null +++ b/packages/ui/src/Avatar.tsx @@ -0,0 +1,218 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + cloneElement, + CSSProperties, + HTMLAttributes, + isValidElement, + ReactElement, + ReactNode, + SVGProps, + useState, +} from "react"; +import { css, cx } from "styled-system/css"; +import { avatar, AvatarVariantProps } from "styled-system/recipes"; +import { token } from "styled-system/tokens"; +import { SystemStyleObject } from "styled-system/types"; + +/** + * Chakra's `randomColor({ string })`, reproduced exactly: a djb2-style hash of + * the name, its low three bytes read as a colour. Not random despite the name + * — the same name is always the same colour, which is the point, and + * reproducing the hash means avatars keep the colours they had under Chakra. + */ +const colorFromName = (name: string): string => { + let hash = 0; + for (let i = 0; i < name.length; i += 1) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + let color = "#"; + for (let j = 0; j < 3; j += 1) { + const value = (hash >> (j * 8)) & 255; + color += `00${value.toString(16)}`.slice(-2); + } + return color; +}; + +/** + * Chakra's contrast rule for the generated background: perceived brightness + * (the classic 299/587/114 weighting) below 128 counts as dark, and dark + * backgrounds take white text. + */ +const isLight = (hex: string): boolean => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return (r * 299 + g * 587 + b * 114) / 1000 >= 128; +}; + +/** Chakra's `initials`: first letter of the first and last words. */ +export const initials = (name: string): string => { + const names = name.trim().split(" "); + const firstName = names[0] ?? ""; + const lastName = names.length > 1 ? names[names.length - 1] : ""; + return firstName && lastName + ? `${firstName.charAt(0)}${lastName.charAt(0)}` + : firstName.charAt(0); +}; + +/** + * Chakra's generic person glyph, the fallback when there is no name. Chakra + * hardcoded it white; here it inherits `currentColor`, which is the same white + * on the no-name grey background and stays visible if a call site recolours. + */ +export const GenericAvatarIcon = (props: SVGProps) => ( + + + + +); + +export interface AvatarProps + extends Omit, "color" | "children">, + Pick { + /** + * The person. Shown as initials, and hashed into the background colour, so + * two people are unlikely to share one. + */ + name?: string; + /** Photo. Falls back to the initials or the icon until it loads. */ + src?: string; + srcSet?: string; + /** Shown when there is no name. Defaults to Chakra's person glyph. */ + icon?: ReactNode; + /** Accessible name for the icon fallback. Chakra's default was " avatar". */ + iconLabel?: string; + /** Override how a name becomes initials. */ + getInitials?: (name: string) => string; + /** Chakra's `showBorder`: a 2px ring in the avatar's border colour. */ + showBorder?: boolean; + /** An `AvatarBadge`. */ + children?: ReactNode; + /** Per-instance style overrides, merged after the recipe. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * Avatar — Chakra's : a circular identity marker showing a photo, the + * initials of a name, or a generic glyph, in a colour derived from the name. + * + * Decorative in most designs — pass `aria-hidden` where the name is already + * beside it, as Chakra's call sites did. + */ +export const Avatar = ({ + name, + src, + srcSet, + icon, + iconLabel = " avatar", + getInitials = initials, + showBorder, + size, + children, + css: cssProp, + className, + style, + ...rest +}: AvatarProps) => { + const [isLoaded, setIsLoaded] = useState(false); + const slots = avatar({ size }); + // Only while the image isn't showing, matching Chakra's `:not([data-loaded])`. + const bg = name && !isLoaded ? colorFromName(name) : undefined; + return ( + + {src ? ( + {name setIsLoaded(true)} + /> + ) : name ? ( + + {getInitials(name)} + + ) : // The icon is labelled in place rather than wrapped, as Chakra did: + // a wrapper would make it an inline child with a line box of its own, + // where directly in the flex container it is a flex item and centres + // exactly. + isValidElement(icon) ? ( + cloneElement(icon as ReactElement>, { + role: "img", + "aria-label": iconLabel, + }) + ) : ( + icon ?? + )} + {children} + + ); +}; + +export interface AvatarBadgeProps + extends Omit, "color">, + Pick { + children?: ReactNode; + /** + * Per-instance style overrides. The badge has no size of its own — Chakra's + * didn't either, so call sites set one (`boxSize: "1.5em"` scales with the + * avatar). + */ + css?: SystemStyleObject; + className?: string; +} + +/** AvatarBadge — a status dot pinned to a corner of its `Avatar`. */ +export const AvatarBadge = ({ + placement, + children, + css: cssProp, + className, + ...rest +}: AvatarBadgeProps) => ( +
+ {children} +
+); diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 00354df..726b86d 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -23,11 +23,13 @@ import { } from "./base-tokens"; // Config recipes are colocated with the shared-ui components they style; this // preset registers them so Panda merges them at codegen time. +import { avatar } from "./Avatar.recipe"; import { button } from "./Button.recipe"; import { card } from "./Card.recipe"; import { checkbox } from "./Checkbox.recipe"; import { radio } from "./Radio.recipe"; import { drawer } from "./Drawer.recipe"; +import { gridList } from "./GridList.recipe"; import { heading } from "./Heading.recipe"; import { input } from "./Input.recipe"; import { numberField } from "./NumberField.recipe"; @@ -198,11 +200,13 @@ export const basePreset = definePreset({ text, }, slotRecipes: { + avatar, card, checkbox, dialog, drawer, field, + gridList, menu, numberField, radio, @@ -267,6 +271,7 @@ export const basePreset = definePreset({ // `size={["md", "lg"]}`, so generate the breakpoint-prefixed variants // too — otherwise the class lands on the element with no rule behind it // and the button silently falls back to the base size. + avatar: ["*"], button: [{ size: ["*"], responsive: true }, { variant: ["*"] }], checkbox: ["*"], heading: ["*"], @@ -275,6 +280,7 @@ export const basePreset = definePreset({ // as a runtime prop, so generate the breakpoint-prefixed variants too. dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }], drawer: ["*"], + gridList: ["*"], input: ["*"], radio: ["*"], select: ["*"], diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 03092fe..15cc68c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,6 +9,7 @@ * match the Chakra theme; behaviour follows react-aria patterns. */ export * from "./system"; +export * from "./Avatar"; export * from "./Button"; export * from "./LinkButton"; export * from "./ButtonGroup"; @@ -41,6 +42,7 @@ export * from "./Collapse"; export * from "./Fade"; export * from "./Kbd"; export * from "./Divider"; +export * from "./GridList"; export * from "./Drawer"; export * from "./List"; export * from "./ComboBox"; diff --git a/packages/ui/stories/Avatar.stories.tsx b/packages/ui/stories/Avatar.stories.tsx new file mode 100644 index 0000000..a8b3a15 --- /dev/null +++ b/packages/ui/stories/Avatar.stories.tsx @@ -0,0 +1,112 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RiCheckLine, RiTeamLine } from "react-icons/ri"; +import { Avatar, AvatarBadge, HStack, Stack, Text } from "../src"; + +const meta = { + title: "Data display/Avatar", + component: Avatar, + args: { name: "Ada Lovelace" }, + argTypes: { + size: { + control: "select", + options: ["2xs", "xs", "sm", "md", "lg", "xl", "2xl"], + }, + showBorder: { control: "boolean" }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Sizes: Story = { + render: () => ( + + {(["2xs", "xs", "sm", "md", "lg", "xl", "2xl"] as const).map((size) => ( + + ))} + + ), +}; + +/** + * The background is hashed from the name, so a roster reads as distinct + * people, and the text flips to dark on the lighter results. + */ +export const Names: Story = { + render: () => ( + + {[ + "Ada Lovelace", + "Grace Hopper", + "Alan Turing", + "Katherine Johnson", + "Tim Berners-Lee", + ].map((name) => ( + + + {name} + + ))} + + ), +}; + +/** No name: the generic glyph, or one supplied by the call site. */ +export const Fallbacks: Story = { + render: () => ( + + + } iconLabel="Everyone" /> + + + ), +}; + +/** + * A badge takes its size from the avatar's font size (`1.5em`), so one set of + * numbers works at every size. + */ +export const Badges: Story = { + render: () => ( + + {(["sm", "md", "lg"] as const).map((size) => ( + + + + + + ))} + + + + + ), +}; + +/** + * A `css` prop beats the derived colour — the recipe reads it through a + * custom property so that an override at the call site wins. + */ +export const Overridden: Story = { + render: () => ( + + + + + + ), +}; diff --git a/packages/ui/tests/Avatar.test.tsx b/packages/ui/tests/Avatar.test.tsx new file mode 100644 index 0000000..99b9223 --- /dev/null +++ b/packages/ui/tests/Avatar.test.tsx @@ -0,0 +1,79 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { Avatar, AvatarBadge, token } from "../src"; + +afterEach(cleanup); + +it("shows the initials of the first and last words", () => { + render(); + expect(screen.getByRole("img", { name: "Ada Lovelace" }).textContent).toBe( + "AL", + ); +}); + +it("shows one initial for a single-word name", () => { + render(); + expect(screen.getByRole("img", { name: "Ada" }).textContent).toBe("A"); +}); + +// The hash is Chakra's, and these are the colours Chakra produced for these +// names — the fidelity contract for apps migrating a roster of avatars. +it.each([ + ["Ada Lovelace", "#8b409d"], + ["Tim Berners-Lee", "#9e72e7"], + ["", undefined], +])("derives Chakra's background colour from %s", (name, expected) => { + const { container } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.getPropertyValue("--avatar-bg") || undefined).toBe( + expected, + ); +}); + +it("darkens the text over a light derived background", () => { + // #9e72e7 is bright enough to need dark text; #8b409d is not. The colour + // is a custom property, not a state selector, so a call site's `css` still + // beats it where cascade layers aren't in play (playbook gotcha #40). + const { container: light } = render(); + expect( + (light.firstElementChild as HTMLElement).style.getPropertyValue( + "--avatar-color", + ), + ).toBe(token("colors.gray.800")); + const { container: dark } = render(); + expect( + (dark.firstElementChild as HTMLElement).style.getPropertyValue( + "--avatar-color", + ), + ).toBe(token("colors.white")); +}); + +it("labels a supplied icon in place rather than wrapping it", () => { + const { container } = render( + } iconLabel="Everyone" />, + ); + const icon = screen.getByTestId("icon"); + expect(icon.getAttribute("aria-label")).toBe("Everyone"); + expect(icon.parentElement).toBe(container.firstElementChild); +}); + +it("falls back to the generic glyph with no name and no icon", () => { + render(); + expect(screen.getByRole("img", { name: "avatar" }).tagName).toBe("svg"); +}); + +it("gives the badge the placement it asks for", () => { + render( + + + , + ); + expect(screen.getByTestId("badge").className).toContain( + "placement_top-start", + ); +}); diff --git a/packages/ui/tests/Select.test.tsx b/packages/ui/tests/Select.test.tsx index e40869c..7a7b111 100644 --- a/packages/ui/tests/Select.test.tsx +++ b/packages/ui/tests/Select.test.tsx @@ -3,7 +3,13 @@ * * SPDX-License-Identifier: MIT */ -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { useState } from "react"; import { afterEach, expect, it, vi } from "vitest"; import { ComboBox, Select, SelectOption } from "../src"; From 502d06aad7f06151d3839e6677c3457833ffd30d Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:02:58 +0000 Subject: [PATCH 31/43] Add GridList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list of selectable rows whose contents stay interactive — the reason to reach for it over a ListBox, where an option is a leaf and a button inside one is unreachable. Promoted from classroom's hand-rolled react-aria v3 hooks, which are what its class roster has been running on; ml-trainer has a parked projects-page idea for the same shape. Chakra had no equivalent, so the greys in the recipe are the family's neutral list styling rather than a Chakra look to match. An app with its own selection colour restates them, which classroom does. `Key` and `Selection` are re-exported from system.ts: a call site handling selection needs them and shouldn't have to import react-aria-components itself. --- packages/ui/src/GridList.recipe.ts | 46 ++++++++ packages/ui/src/GridList.tsx | 81 +++++++++++++++ packages/ui/src/system.ts | 2 + packages/ui/stories/GridList.stories.tsx | 127 +++++++++++++++++++++++ packages/ui/tests/GridList.test.tsx | 54 ++++++++++ 5 files changed, 310 insertions(+) create mode 100644 packages/ui/src/GridList.recipe.ts create mode 100644 packages/ui/src/GridList.tsx create mode 100644 packages/ui/stories/GridList.stories.tsx create mode 100644 packages/ui/tests/GridList.test.tsx diff --git a/packages/ui/src/GridList.recipe.ts b/packages/ui/src/GridList.recipe.ts new file mode 100644 index 0000000..e05b869 --- /dev/null +++ b/packages/ui/src/GridList.recipe.ts @@ -0,0 +1,46 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { defineSlotRecipe } from "@pandacss/dev"; + +/** + * GridList slot recipe — a vertical list of selectable rows, each of which may + * hold its own interactive controls (which is what makes it a grid rather than + * a listbox: the roving tab index moves through rows, and the controls inside + * a row are reachable without leaving it). + * + * Chakra had no equivalent, so there is no Chakra look to match: the greys + * here are the family's neutral list styling, and an app with a strong + * selection colour restates them (classroom's roster does). + * + * Registered in the base preset (base-preset.ts), which also has the + * `staticCss` entry that keeps the runtime-prop variants generated. + */ +export const gridList = defineSlotRecipe({ + className: "grid-list", + slots: ["root", "item"], + base: { + root: { + // The list takes the roving tab index, so it is focusable itself and + // would otherwise draw the platform ring around the whole list. + outline: "none", + }, + item: { + display: "flex", + alignItems: "center", + position: "relative", + // A row is interactive by definition — it selects, or it acts. + cursor: "pointer", + outline: "none", + transitionProperty: "background", + transitionDuration: "ultra-fast", + transitionTimingFunction: "ease-in", + _hover: { bg: "gray.50" }, + "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } }, + "&[data-focus-visible]": { focusShadow: "outline" }, + "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, + }, + }, +}); diff --git a/packages/ui/src/GridList.tsx b/packages/ui/src/GridList.tsx new file mode 100644 index 0000000..dce04cc --- /dev/null +++ b/packages/ui/src/GridList.tsx @@ -0,0 +1,81 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ReactNode } from "react"; +import { + GridList as RACGridList, + GridListItem as RACGridListItem, + GridListItemProps as RACGridListItemProps, + GridListProps as RACGridListProps, +} from "react-aria-components"; +import { css, cx } from "styled-system/css"; +import { gridList } from "styled-system/recipes"; +import { SystemStyleObject } from "styled-system/types"; + +export interface GridListProps + extends Omit, "className" | "style" | "children"> { + /** `GridListItem`s, or a render function when `items` is given. */ + children: RACGridListProps["children"]; + /** Per-instance style overrides for the list, merged after the recipe. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * GridList — react-aria-components' : a list of selectable rows, + * each of which may contain its own buttons and menus. + * + * Reach for it over a `ListBox` when the rows carry controls: a listbox option + * is a leaf, so a button inside one is unreachable by keyboard, where a grid + * row's contents are part of the grid's navigation. + */ +export const GridList = ({ + css: cssProp, + className, + children, + ...rest +}: GridListProps) => { + const slots = gridList(); + return ( + + {children} + + ); +}; + +export interface GridListItemProps + extends Omit, "className" | "style" | "children"> { + children?: ReactNode; + /** Per-instance style overrides for the row, merged after the recipe. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * A row in a `GridList`. Its children are laid out by the row itself — the + * gridcell react-aria puts between them is `display: contents`. + * + * Give every row a `textValue`: react-aria derives typeahead text from string + * children only, and a row is usually a composition rather than a string. + */ +export const GridListItem = ({ + css: cssProp, + className, + children, + ...rest +}: GridListItemProps) => { + const slots = gridList(); + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/system.ts b/packages/ui/src/system.ts index 401b268..7389632 100644 --- a/packages/ui/src/system.ts +++ b/packages/ui/src/system.ts @@ -11,6 +11,8 @@ export { css, cva, sva, cx } from "styled-system/css"; export { token } from "styled-system/tokens"; export type { SystemStyleObject } from "styled-system/types"; +// react-aria collection types call sites need for selection handlers. +export type { Key, Selection } from "react-aria-components"; export type { BoxProps, FlexProps, diff --git a/packages/ui/stories/GridList.stories.tsx b/packages/ui/stories/GridList.stories.tsx new file mode 100644 index 0000000..6d7868a --- /dev/null +++ b/packages/ui/stories/GridList.stories.tsx @@ -0,0 +1,127 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { MdMoreVert } from "react-icons/md"; +import { + Avatar, + GridList, + GridListItem, + HStack, + IconButton, + Key, + MenuItem, + MenuList, + MenuTrigger, + Selection, + Text, +} from "../src"; + +const PEOPLE = [ + "Ada Lovelace", + "Grace Hopper", + "Alan Turing", + "Katherine Johnson", +]; + +const meta = { + title: "Data display/GridList", + component: GridList, + // Every story renders its own list; `children` is required by the type. + args: { "aria-label": "People", children: null }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => ( + + {PEOPLE.map((name) => ( + + {name} + + ))} + + ), +}; + +/** + * The reason to choose a GridList over a ListBox: each row owns controls that + * are reachable by keyboard without leaving the row. + */ +export const RowsWithControls: Story = { + render: function RowsWithControls() { + const [selected, setSelected] = useState( + new Set(["Ada Lovelace"]), + ); + return ( + + {PEOPLE.map((name) => ( + + + {name} + + + + + + + Rename + Remove + + + + + ))} + + ); + }, +}; + +export const Disabled: Story = { + render: () => ( + + {PEOPLE.map((name) => ( + + {name} + + ))} + + ), +}; diff --git a/packages/ui/tests/GridList.test.tsx b/packages/ui/tests/GridList.test.tsx new file mode 100644 index 0000000..ef05aa2 --- /dev/null +++ b/packages/ui/tests/GridList.test.tsx @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { Button, GridList, GridListItem } from "../src"; + +afterEach(cleanup); + +const PEOPLE = ["Ada", "Grace", "Alan"]; + +const renderList = (props = {}, item?: (name: string) => React.ReactNode) => + render( + + {PEOPLE.map((name) => ( + + {item ? item(name) : name} + + ))} + , + ); + +it("renders a row per item and reports the one selected", () => { + const onSelectionChange = vi.fn(); + renderList({ onSelectionChange }); + expect(screen.getAllByRole("row")).toHaveLength(3); + + fireEvent.click(screen.getByRole("row", { name: "Grace" })); + // react-aria's Selection is a Set subclass carrying anchor/current keys, so + // compare the contents rather than the object. + expect([...onSelectionChange.mock.calls[0][0]]).toEqual(["Grace"]); +}); + +it("marks the selected row so the recipe can style it", () => { + renderList({ selectedKeys: ["Alan"] }); + expect( + screen.getByRole("row", { name: "Alan" }).hasAttribute("data-selected"), + ).toBe(true); +}); + +// The reason a roster is a grid rather than a listbox: a listbox option is a +// leaf, so a button inside one is unreachable. +it("keeps a button inside a row operable", () => { + const onPress = vi.fn(); + renderList({}, (name) => ( + + )); + fireEvent.click(screen.getByRole("button", { name: "Edit Grace" })); + expect(onPress).toHaveBeenCalledTimes(1); +}); From 965885772fffc3636548e824ca6b9c1085b8e88c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:14:51 +0000 Subject: [PATCH 32/43] Add ListBox, and a Checkbox that can drop its box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone list of choosable options — distinct from the `select` recipe's list slots, which style the same react-aria primitive inside a dropdown card. Options are leaves, so rows carrying their own controls still want GridList. classroom's send-code dialog is the first consumer, replacing another hand-rolled useListBox/useOption pair. `control={false}` on Checkbox drops the box and the label wrapper with it, so the children own the row and draw the selected state themselves — the selectable-tile shape. That dialog's "all students" toggle is one: an avatar that grows the same green tick its individual students do. Children may now be a function of the checkbox's state, which is what makes that drawable. --- packages/ui/src/Checkbox.tsx | 86 ++++++++++------ packages/ui/src/ListBox.recipe.ts | 43 ++++++++ packages/ui/src/ListBox.tsx | 88 ++++++++++++++++ packages/ui/src/base-preset.ts | 3 + packages/ui/src/index.ts | 1 + packages/ui/stories/ListBox.stories.tsx | 128 ++++++++++++++++++++++++ packages/ui/tests/ListBox.test.tsx | 70 +++++++++++++ 7 files changed, 390 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/ListBox.recipe.ts create mode 100644 packages/ui/src/ListBox.tsx create mode 100644 packages/ui/stories/ListBox.stories.tsx create mode 100644 packages/ui/tests/ListBox.test.tsx diff --git a/packages/ui/src/Checkbox.tsx b/packages/ui/src/Checkbox.tsx index 426bc23..7a2048a 100644 --- a/packages/ui/src/Checkbox.tsx +++ b/packages/ui/src/Checkbox.tsx @@ -12,13 +12,31 @@ import { css, cx } from "styled-system/css"; import { checkbox, CheckboxVariantProps } from "styled-system/recipes"; import { SystemStyleObject } from "styled-system/types"; +/** What a render-prop child is told about the checkbox. */ +export interface CheckboxState { + isSelected: boolean; + isFocusVisible: boolean; + isDisabled: boolean; +} + export interface CheckboxProps extends Omit, CheckboxVariantProps { /** Per-instance style overrides for the root, merged after the recipe. */ css?: SystemStyleObject; className?: string; - children?: ReactNode; + /** + * The label. A function receives the checkbox's state, for a label that + * changes with it. + */ + children?: ReactNode | ((state: CheckboxState) => ReactNode); + /** + * `false` drops the box, for a checkbox whose children draw the selected + * state themselves — a selectable tile, or an avatar that grows a tick. + * The label wrapper goes with it, so the children own the whole row, + * including the focus ring the box would otherwise carry. + */ + control?: false; } /** @@ -31,6 +49,7 @@ export const Checkbox = ({ css: cssProp, className, children, + control, ...rest }: CheckboxProps) => { const slots = checkbox({ size }); @@ -39,38 +58,47 @@ export const Checkbox = ({ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)} {...rest} > - {({ isSelected, isFocusVisible, isDisabled }) => ( - <> - - {isSelected && ( - - - - )} - - {children != null && ( + {({ isSelected, isFocusVisible, isDisabled }) => { + const content = + typeof children === "function" + ? children({ isSelected, isFocusVisible, isDisabled }) + : children; + if (control === false) { + return content; + } + return ( + <> - {children} + {isSelected && ( + + + + )} - )} - - )} + {content != null && ( + + {content} + + )} + + ); + }} ); }; diff --git a/packages/ui/src/ListBox.recipe.ts b/packages/ui/src/ListBox.recipe.ts new file mode 100644 index 0000000..0726a9a --- /dev/null +++ b/packages/ui/src/ListBox.recipe.ts @@ -0,0 +1,43 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { defineSlotRecipe } from "@pandacss/dev"; + +/** + * ListBox slot recipe — a standalone list of choosable options, single or + * multiple. Distinct from the `select` recipe's `list`/`option` slots, which + * style the same react-aria primitive inside a dropdown card: this one sits + * inline on the page, so it carries no surface of its own. + * + * An option is a leaf — if the rows need their own buttons or menus, they + * want `GridList` instead. + * + * Registered in the base preset (base-preset.ts), which also has the + * `staticCss` entry that keeps the runtime-prop variants generated. + */ +export const listBox = defineSlotRecipe({ + className: "list-box", + slots: ["root", "option"], + base: { + root: { + // The listbox holds the roving tab index, so it is focusable itself and + // would otherwise draw the platform ring around the whole list. + outline: "none", + }, + option: { + display: "flex", + alignItems: "center", + cursor: "pointer", + outline: "none", + transitionProperty: "background", + transitionDuration: "ultra-fast", + transitionTimingFunction: "ease-in", + _hover: { bg: "gray.50" }, + "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } }, + "&[data-focus-visible]": { focusShadow: "outline" }, + "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" }, + }, + }, +}); diff --git a/packages/ui/src/ListBox.tsx b/packages/ui/src/ListBox.tsx new file mode 100644 index 0000000..9840acf --- /dev/null +++ b/packages/ui/src/ListBox.tsx @@ -0,0 +1,88 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + ListBox as RACListBox, + ListBoxItem as RACListBoxItem, + ListBoxItemProps as RACListBoxItemProps, + ListBoxProps as RACListBoxProps, +} from "react-aria-components"; +import { css, cx } from "styled-system/css"; +import { listBox } from "styled-system/recipes"; +import { SystemStyleObject } from "styled-system/types"; + +export interface ListBoxProps + extends Omit, "className" | "style" | "children"> { + /** `ListBoxOption`s, or a render function when `items` is given. */ + children: RACListBoxProps["children"]; + /** Per-instance style overrides for the list, merged after the recipe. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * ListBox — react-aria-components' , standing on the page rather + * than inside a dropdown (that is `Select`/`ComboBox`, which share their own + * recipe). + * + * Options are leaves: a button inside one is unreachable by keyboard, so rows + * carrying their own controls want `GridList`. + */ +export const ListBox = ({ + css: cssProp, + className, + children, + ...rest +}: ListBoxProps) => { + const slots = listBox(); + return ( + + {children} + + ); +}; + +export interface ListBoxOptionProps + extends Omit, "className" | "style" | "children"> { + /** + * The option's content. A function receives the option's state, for a row + * that draws its own selected marker rather than taking the recipe's + * background. + */ + children?: RACListBoxItemProps["children"]; + /** Per-instance style overrides for the option, merged after the recipe. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * An option in a `ListBox`. + * + * Give it a `textValue` where its children aren't a plain string: react-aria + * derives typeahead text from string children only. + */ +export const ListBoxOption = ({ + css: cssProp, + className, + children, + ...rest +}: ListBoxOptionProps) => { + const slots = listBox(); + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 726b86d..0d2b8ca 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -32,6 +32,7 @@ import { drawer } from "./Drawer.recipe"; import { gridList } from "./GridList.recipe"; import { heading } from "./Heading.recipe"; import { input } from "./Input.recipe"; +import { listBox } from "./ListBox.recipe"; import { numberField } from "./NumberField.recipe"; import { menu } from "./Menu.recipe"; import { select } from "./Select.recipe"; @@ -207,6 +208,7 @@ export const basePreset = definePreset({ drawer, field, gridList, + listBox, menu, numberField, radio, @@ -281,6 +283,7 @@ export const basePreset = definePreset({ dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }], drawer: ["*"], gridList: ["*"], + listBox: ["*"], input: ["*"], radio: ["*"], select: ["*"], diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 15cc68c..4a1b4a3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -45,6 +45,7 @@ export * from "./Divider"; export * from "./GridList"; export * from "./Drawer"; export * from "./List"; +export * from "./ListBox"; export * from "./ComboBox"; export * from "./Menu"; export * from "./Modal"; diff --git a/packages/ui/stories/ListBox.stories.tsx b/packages/ui/stories/ListBox.stories.tsx new file mode 100644 index 0000000..1f75b85 --- /dev/null +++ b/packages/ui/stories/ListBox.stories.tsx @@ -0,0 +1,128 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RiCheckLine } from "react-icons/ri"; +import { + Avatar, + AvatarBadge, + Checkbox, + ListBox, + ListBoxOption, + Stack, + Text, +} from "../src"; + +const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing"]; + +const meta = { + title: "Data display/ListBox", + component: ListBox, + // Every story renders its own list; `children` is required by the type. + args: { "aria-label": "People", children: null }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => ( + + {PEOPLE.map((name) => ( + + {name} + + ))} + + ), +}; + +export const MultipleSelection: Story = { + render: () => ( + + {PEOPLE.map((name) => ( + + {name} + + ))} + + ), +}; + +/** + * Selection drawn by the rows rather than by a background — with a matching + * "all of them" toggle above, which is a `Checkbox` whose control is its own + * content (`control={false}`). + */ +export const CustomSelectedState: Story = { + render: () => ( + + + {({ isSelected }) => ( + <> + + {isSelected && ( + + + + )} + + Everyone + + )} + + + {PEOPLE.map((name) => ( + + {({ isSelected }) => ( + <> + + {isSelected && ( + + + + )} + + {name} + + )} + + ))} + + + ), +}; diff --git a/packages/ui/tests/ListBox.test.tsx b/packages/ui/tests/ListBox.test.tsx new file mode 100644 index 0000000..e22f16f --- /dev/null +++ b/packages/ui/tests/ListBox.test.tsx @@ -0,0 +1,70 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { Checkbox, ListBox, ListBoxOption } from "../src"; + +afterEach(cleanup); + +const PEOPLE = ["Ada", "Grace", "Alan"]; + +it("reports every option chosen in multiple-selection mode", () => { + const onSelectionChange = vi.fn(); + render( + + {PEOPLE.map((name) => ( + + {name} + + ))} + , + ); + expect(screen.getAllByRole("option")).toHaveLength(3); + + fireEvent.click(screen.getByRole("option", { name: "Ada" })); + fireEvent.click(screen.getByRole("option", { name: "Alan" })); + expect([...onSelectionChange.mock.calls.at(-1)![0]]).toEqual(["Ada", "Alan"]); +}); + +it("marks the selected option so the recipe can style it", () => { + render( + + {PEOPLE.map((name) => ( + + {name} + + ))} + , + ); + expect( + screen.getByRole("option", { name: "Grace" }).hasAttribute("data-selected"), + ).toBe(true); +}); + +// The selectable-tile shape: the children draw the state, so the box goes. +it("Checkbox with control={false} renders only its children, told the state", () => { + const onChange = vi.fn(); + const { container } = render( + + {({ isSelected }) => {isSelected ? "on" : "off"}} + , + ); + const box = container.querySelector('[class*="checkbox__control"]'); + expect(box).toBeNull(); + expect(screen.getByText("off")).toBeTruthy(); + + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalledWith(true); + expect(screen.getByText("on")).toBeTruthy(); +}); From c037fa72c71480f34e747eaa3569698f85d099e4 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:15:34 +0000 Subject: [PATCH 33/43] Playbook: gotcha #40, and the area 7 components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #40 is the one classroom's roster port kept tripping over: gotcha #21's "a flat utility override wins every state" is a post-kill-switch fact, and during coexistence the layers are stripped, so recipe and call site argue on specificity instead. Both halves were caught by measuring, not by reading — an offline student's avatar text came out a grade too dark, and a selected, hovered row kept a background it had been told to drop. Also marks GridList, Avatar and ListBox built on the v1 surface list. --- docs/migration-playbook.md | 39 ++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 46e4770..2e12667 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -740,6 +740,33 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg the ref is set, and nothing re-renders it afterwards. It needs state set from a layout effect. +40. **During coexistence a call site's `css` beats a recipe only on + specificity — there are no layers to settle it.** Gotcha #21 says a flat + utility override wins every state; that is true _after_ the kill-switch, + where `utilities` outranks `recipes` as a layer. While Chakra is still + mounted the layers are stripped (#1), so the two are ordinary rules and the + winner is the more specific one, or the later one at equal specificity + (utilities are emitted after recipes, so equal-specificity ties go to the + call site). Two consequences, both measured in classroom's roster port: + + - **A recipe declaration a call site is expected to override must be a + single-class selector.** The Avatar's contrast rule was + `&[data-light-bg] { color: gray.800 }`, at (0,2,0), and it beat a call + site's `css={{ color: "gray.600" }}` at (0,1,0) — a greyed-out offline + student came out gray.800. State-derived values belong in a custom + property the base declaration reads (`color: var(--avatar-color, …)`), + which is also what Chakra did and what keeps an inline value from + beating the call site outright. + - **Restating a state is not enough if the recipe combines two of them.** + A ListBox option's `&[data-selected] { _hover: … }` is (0,3,0); an + override's `_hover` (0,2,0) and `&[data-selected]` (0,2,0) both lose to + it, so a selected _and_ hovered row keeps the recipe's background. Match + the combination: `"&[data-selected]": { bg: …, _hover: { bg: … } }`. + + Both disappear at the kill-switch, which makes them easy to write off — but + they are wrong for the whole coexistence period, i.e. for every screenshot + anyone compares. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native @@ -846,10 +873,14 @@ Still outstanding, in classroom's likely order of need: react-select there; one `select` slot recipe behind both. Sections, multi-select and async loading via `useAsyncList` are still unbuilt — the data-microbit-org school lookup will want the last of those. -- **GridList** (promote from classroom's hand-rolled react-aria hooks; also - ml-trainer's parked projects-page idea). -- **Avatar** (+ badge) — classroom's class-roster identity; data has one - site. classroom's theme adds a `2md` size. +- ~~**GridList**~~, ~~**Avatar** (+ badge)~~ and ~~**ListBox**~~ — **built** + (classroom, area 7), retiring the last of its hand-rolled react-aria v3 + hooks. Avatar reproduces Chakra's name-hash colour and its contrast rule + exactly, so a migrating roster keeps its colours. ListBox arrived with the + GridList because the two are the halves of the same question: rows with + their own controls need the grid, leaf options the listbox. `Checkbox` + gained `control={false}` at the same time, for a checkbox whose children + draw the selected state (a selectable tile). - Portal-as-primitive, TextField error slot, input adornments, Skeleton/SkeletonText, Breadcrumb, NumberInput; cheap typography wrappers (Tag/Mark) as first needed. From df2aa9dd3f2069863e8ad78d2acca5a49553527f Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:44:01 +0000 Subject: [PATCH 34/43] Add Skeleton, useDisclosure, and a Tooltip recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last three things classroom needs from the library, all Chakra parity. Tooltip's styles become a recipe because tooltip typography is set once for all of them, not per call site: classroom's Chakra theme says `fontSize: md`, and a `css` override at today's two call sites would quietly not apply to tomorrow's. Skeleton pulses between the same pair of custom properties Chakra used, so a retinted skeleton animates between its own colours. Chakra's 0.4s fade-in of the real content is not reproduced — wrap in `Fade` where it matters. useDisclosure is a `useState` wrapper, worth having for the same reason usePrevious and useClipboard are: it is the shape a migrating app's dialog call sites are already written in. classroom has fourteen. --- packages/ui/src/Skeleton.tsx | 146 +++++++++++++++++++++++ packages/ui/src/Tooltip.recipe.ts | 32 +++++ packages/ui/src/Tooltip.tsx | 21 +--- packages/ui/src/base-preset.ts | 17 ++- packages/ui/src/hooks/useDisclosure.ts | 32 +++++ packages/ui/src/index.ts | 3 + packages/ui/stories/Skeleton.stories.tsx | 47 ++++++++ packages/ui/tests/Skeleton.test.tsx | 74 ++++++++++++ 8 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/Skeleton.tsx create mode 100644 packages/ui/src/Tooltip.recipe.ts create mode 100644 packages/ui/src/hooks/useDisclosure.ts create mode 100644 packages/ui/stories/Skeleton.stories.tsx create mode 100644 packages/ui/tests/Skeleton.test.tsx diff --git a/packages/ui/src/Skeleton.tsx b/packages/ui/src/Skeleton.tsx new file mode 100644 index 0000000..3d12a3f --- /dev/null +++ b/packages/ui/src/Skeleton.tsx @@ -0,0 +1,146 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { HTMLAttributes, ReactNode } from "react"; +import { css, cx } from "styled-system/css"; +import { SystemStyleObject } from "styled-system/types"; + +/** + * The placeholder block, as an object rather than a precomputed class so a + * caller's `css` is merged into one `css()` call and its overrides win + * (playbook gotcha #8). + * + * The colours are Chakra's, through the same pair of custom properties, so a + * call site can retint one skeleton without knowing how the animation works. + */ +const skeletonBase: SystemStyleObject = { + "--skeleton-start-color": "token(colors.gray.100)", + "--skeleton-end-color": "token(colors.gray.400)", + background: "var(--skeleton-start-color)", + borderColor: "var(--skeleton-end-color)", + opacity: 0.7, + borderRadius: "sm", + boxShadow: "none", + backgroundClip: "padding-box", + cursor: "default", + color: "transparent", + pointerEvents: "none", + userSelect: "none", + // Chakra hid the content rather than unmounting it, so a skeleton sized + // from real children keeps their dimensions. + "&::before, &::after, *": { visibility: "hidden" }, + animation: + "skeletonFade var(--skeleton-speed, 0.8s) linear infinite alternate", + _motionReduce: { animation: "none" }, +}; + +export interface SkeletonProps + extends Omit, "color"> { + /** Show the children instead of the placeholder. */ + isLoaded?: boolean; + /** Seconds per pulse (Chakra's `speed`, default 0.8). */ + speed?: number; + children?: ReactNode; + /** Per-instance style overrides, merged after the base. */ + css?: SystemStyleObject; + className?: string; +} + +/** + * Skeleton — Chakra's loading placeholder: a block pulsing between two greys + * until its content is ready. + * + * Chakra faded the real content in over 0.4s when `isLoaded` turned true; + * here it simply appears. Wrap in `Fade` where that transition matters. + */ +export const Skeleton = ({ + isLoaded, + speed, + children, + css: cssProp, + className, + style, + ...rest +}: SkeletonProps) => { + if (isLoaded) { + return ( +
+ {children} +
+ ); + } + return ( +
+ {children} +
+ ); +}; + +export interface SkeletonTextProps extends SkeletonProps { + /** How many lines to draw (Chakra's default is 3). */ + noOfLines?: number; + /** Gap between the lines. Any CSS length. */ + spacing?: string; + /** Height of each line. Any CSS length. */ + skeletonHeight?: string; +} + +/** + * SkeletonText — a paragraph-shaped `Skeleton`: evenly spaced lines, the last + * one short, as Chakra drew them. + */ +export const SkeletonText = ({ + noOfLines = 3, + spacing = "0.5rem", + skeletonHeight = "0.5rem", + isLoaded, + speed, + children, + css: cssProp, + className, + ...rest +}: SkeletonTextProps) => { + if (isLoaded) { + return ( +
+ {children} +
+ ); + } + return ( +
+ {Array.from({ length: noOfLines }, (_, index) => ( + 1 && index === noOfLines - 1 ? "80%" : "100%", + marginBottom: index === noOfLines - 1 ? "0" : spacing, + }} + /> + ))} +
+ ); +}; diff --git a/packages/ui/src/Tooltip.recipe.ts b/packages/ui/src/Tooltip.recipe.ts new file mode 100644 index 0000000..9f401e9 --- /dev/null +++ b/packages/ui/src/Tooltip.recipe.ts @@ -0,0 +1,32 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { defineRecipe } from "@pandacss/dev"; + +/** + * Tooltip recipe — Chakra's dark tooltip. + * + * A recipe rather than styles inside the component because tooltip typography + * is the kind of thing an app sets once for all of them: classroom's Chakra + * theme did exactly that (`fontSize: md`), and a `css` override at today's + * call sites would quietly not apply to tomorrow's. + * + * Registered in the base preset (base-preset.ts). + */ +export const tooltip = defineRecipe({ + className: "tooltip", + base: { + bg: "gray.700", + color: "white", + px: "2", + py: "1", + borderRadius: "md", + fontSize: "sm", + fontWeight: "medium", + boxShadow: "md", + maxW: "xs", + zIndex: "tooltip", + }, +}); diff --git a/packages/ui/src/Tooltip.tsx b/packages/ui/src/Tooltip.tsx index bfa920a..6e81851 100644 --- a/packages/ui/src/Tooltip.tsx +++ b/packages/ui/src/Tooltip.tsx @@ -5,26 +5,11 @@ */ import { ReactElement, ReactNode, RefObject } from "react"; import { Tooltip as RACTooltip, TooltipTrigger } from "react-aria-components"; -import { css } from "styled-system/css"; +import { css, cx } from "styled-system/css"; +import { tooltip } from "styled-system/recipes"; import { SystemStyleObject } from "styled-system/types"; import { PopoverArrow } from "./PopoverArrow"; -// Base as an object (not a precomputed class) so a caller's `css` override is -// merged into a single css() call — Panda then dedupes conflicting utilities -// (e.g. px/py) so overrides actually win. -const tooltipBase: SystemStyleObject = { - bg: "gray.700", - color: "white", - px: "2", - py: "1", - borderRadius: "md", - fontSize: "sm", - fontWeight: "medium", - boxShadow: "md", - maxW: "xs", - zIndex: "tooltip", -}; - export interface TooltipProps { /** * Tooltip body (Chakra's `label`). Not named `content`: Panda extracts @@ -82,7 +67,7 @@ export const Tooltip = ({ triggerRef={triggerRef} placement={placement} offset={hasArrow ? 8 : 4} - className={css({ ...tooltipBase, ...cssProp })} + className={cx(tooltip(), cssProp ? css(cssProp) : undefined)} > {hasArrow && } {label} diff --git a/packages/ui/src/base-preset.ts b/packages/ui/src/base-preset.ts index 0d2b8ca..feb42d4 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -40,6 +40,7 @@ import { slider } from "./Slider.recipe"; import { switchRecipe } from "./Switch.recipe"; import { dialog } from "./Modal.recipe"; import { text } from "./Text.recipe"; +import { tooltip } from "./Tooltip.recipe"; import { field } from "./TextField.recipe"; import { toast } from "./Toast.recipe"; @@ -69,11 +70,23 @@ export const basePreset = definePreset({ theme: { breakpoints, keyframes: { - // Spinner's revolution (the only keyframe a shared-ui component uses). + // Spinner's revolution. spin: { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" }, }, + // Skeleton's pulse, over the pair of custom properties the component + // sets, so a retinted skeleton animates between its own colours. + skeletonFade: { + from: { + borderColor: "var(--skeleton-start-color)", + background: "var(--skeleton-start-color)", + }, + to: { + borderColor: "var(--skeleton-end-color)", + background: "var(--skeleton-end-color)", + }, + }, }, tokens: { colors: { @@ -199,6 +212,7 @@ export const basePreset = definePreset({ heading, input, text, + tooltip, }, slotRecipes: { avatar, @@ -289,6 +303,7 @@ export const basePreset = definePreset({ select: ["*"], switchRecipe: ["*"], text: ["*"], + tooltip: ["*"], // Toast status is chosen at runtime from the toast content. toast: ["*"], }, diff --git a/packages/ui/src/hooks/useDisclosure.ts b/packages/ui/src/hooks/useDisclosure.ts new file mode 100644 index 0000000..630c99f --- /dev/null +++ b/packages/ui/src/hooks/useDisclosure.ts @@ -0,0 +1,32 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { useCallback, useMemo, useState } from "react"; + +export interface Disclosure { + isOpen: boolean; + onOpen: () => void; + onClose: () => void; + onToggle: () => void; +} + +/** + * useDisclosure — Chakra's hook of the same name: the open/closed state of a + * dialog, menu or drawer, and the three functions that change it. + * + * A thin `useState` wrapper, kept because it is the shape a Chakra app's + * dialog call sites are written in, and because a stable object means a + * disclosure can be passed to a memoised child without re-rendering it. + */ +export const useDisclosure = (defaultIsOpen = false): Disclosure => { + const [isOpen, setIsOpen] = useState(defaultIsOpen); + const onOpen = useCallback(() => setIsOpen(true), []); + const onClose = useCallback(() => setIsOpen(false), []); + const onToggle = useCallback(() => setIsOpen((open) => !open), []); + return useMemo( + () => ({ isOpen, onOpen, onClose, onToggle }), + [isOpen, onOpen, onClose, onToggle], + ); +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4a1b4a3..5c13291 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -24,6 +24,7 @@ export * from "./NativeSelect"; export * from "./NumberField"; export * from "./ProgressBar"; export * from "./Radio"; +export * from "./Skeleton"; export * from "./Slide"; export * from "./Slider"; export * from "./Select"; @@ -56,5 +57,7 @@ export * from "./Toast"; export * from "./VisuallyHidden"; export { useBreakpointValue } from "./hooks/useBreakpointValue"; export { useClipboard } from "./hooks/useClipboard"; +export { useDisclosure } from "./hooks/useDisclosure"; +export type { Disclosure } from "./hooks/useDisclosure"; export { useMediaQuery } from "./hooks/useMediaQuery"; export { usePrevious } from "./hooks/usePrevious"; diff --git a/packages/ui/stories/Skeleton.stories.tsx b/packages/ui/stories/Skeleton.stories.tsx new file mode 100644 index 0000000..a7fe1a3 --- /dev/null +++ b/packages/ui/stories/Skeleton.stories.tsx @@ -0,0 +1,47 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Skeleton, SkeletonText, Stack, Text } from "../src"; + +const meta = { + title: "Feedback/Skeleton", + component: Skeleton, + argTypes: { isLoaded: { control: "boolean" } }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + children: Loaded content, + css: { width: "20rem", height: "2rem" }, + }, +}; + +export const Text_: Story = { + name: "SkeletonText", + render: () => ( + + + + + ), +}; + +/** A skeleton sized by the content it is standing in for. */ +export const SizedByContent: Story = { + render: () => ( + + + Something the width of this sentence. + + + Something the width of this sentence. + + + ), +}; diff --git a/packages/ui/tests/Skeleton.test.tsx b/packages/ui/tests/Skeleton.test.tsx new file mode 100644 index 0000000..2dc7dad --- /dev/null +++ b/packages/ui/tests/Skeleton.test.tsx @@ -0,0 +1,74 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + act, + cleanup, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { Skeleton, SkeletonText, useDisclosure } from "../src"; + +afterEach(cleanup); + +it("draws a line per noOfLines, the last one short", () => { + const { container } = render(); + const lines = [...container.firstElementChild!.children] as HTMLElement[]; + expect(lines).toHaveLength(5); + expect(lines.map((l) => l.style.width)).toEqual([ + "100%", + "100%", + "100%", + "100%", + "80%", + ]); + // The gap goes between the lines, not after the last. + expect(lines.map((l) => l.style.marginBottom)).toEqual([ + "1rem", + "1rem", + "1rem", + "1rem", + "0px", + ]); +}); + +it("a single line is full width", () => { + const { container } = render(); + const line = container.firstElementChild!.firstElementChild as HTMLElement; + expect(line.style.width).toBe("100%"); +}); + +it("shows its children once loaded, without the placeholder styling", () => { + const { container, rerender } = render( + + Ready + , + ); + expect((container.firstElementChild as HTMLElement).className).toContain( + "skeleton", + ); + + rerender( + + Ready + , + ); + expect((container.firstElementChild as HTMLElement).className).toBe(""); + expect(screen.getByText("Ready")).toBeTruthy(); +}); + +it("useDisclosure opens, closes and toggles", () => { + const { result } = renderHook(() => useDisclosure()); + expect(result.current.isOpen).toBe(false); + act(() => result.current.onOpen()); + expect(result.current.isOpen).toBe(true); + act(() => result.current.onToggle()); + expect(result.current.isOpen).toBe(false); + act(() => result.current.onToggle()); + act(() => result.current.onClose()); + expect(result.current.isOpen).toBe(false); +}); From 298315bd79fcc01b1697d529d5cb2b544428ecb3 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 20:51:10 +0000 Subject: [PATCH 35/43] Playbook: gotcha #41, the styled re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `styled.table` styles nothing when `styled` comes from @microbit/ui rather than styled-system/jsx — Panda identifies its factory by the import, and a re-export is not that module. The `styled(Component)` form is unaffected, which is what makes it a trap: ml-trainer uses the re-export happily. Found in classroom's About dialog, whose ported table lost every rule it had. --- docs/migration-playbook.md | 10 ++++++++++ packages/ui/src/system.ts | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 2e12667..9246633 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -767,6 +767,16 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg they are wrong for the whole coexistence period, i.e. for every screenshot anyone compares. +41. **`styled` must be imported from `styled-system/jsx` to use the + `styled.tag` form.** `@microbit/ui` re-exports it, and the re-export is + fine for `styled(Component)` — but not for ``: Panda decides + whether a member expression is its factory by looking at where the + identifier was imported from, and a re-export is not that module. The JSX + renders, the classes land on the element, and no CSS exists for them — + gotcha #9's silent failure with a new cause. classroom's About-dialog + table lost every style this way (`border-collapse`, the caption, the row + rules), and only a grep of the generated CSS showed it. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native diff --git a/packages/ui/src/system.ts b/packages/ui/src/system.ts index 7389632..ff81022 100644 --- a/packages/ui/src/system.ts +++ b/packages/ui/src/system.ts @@ -23,6 +23,12 @@ export type { } from "styled-system/jsx"; // Layout patterns — the Panda-native equivalents of Chakra's Box/Flex/Stack/etc. +// +// `styled` is re-exported for the `styled(Component)` form, which works from +// anywhere. The `styled.tag` JSX form does NOT: Panda recognises the factory by +// the module it was imported from, so `` on a `styled` +// imported from here silently produces no CSS. Import it from +// "styled-system/jsx" for that (playbook gotcha #41). export { AspectRatio, Box, From 3647a45001b19830539e8dc3db2f11d5e6a2cc56 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 2 Aug 2026 21:02:32 +0000 Subject: [PATCH 36/43] Tooltip: Chakra's colour, padding and radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against a Chakra build while porting classroom's two tooltips: the radius was 6px where Chakra drew 2px, the text plain white where Chakra used whiteAlpha.900, and the vertical padding twice Chakra's. The values had drifted when the styles were written inline in the component; now they are a recipe, they are worth being exact. ml-trainer and python-editor take the correction with it — in their direction too, since it is what their Chakra builds looked like. --- packages/ui/src/Tooltip.recipe.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/Tooltip.recipe.ts b/packages/ui/src/Tooltip.recipe.ts index 9f401e9..2e59346 100644 --- a/packages/ui/src/Tooltip.recipe.ts +++ b/packages/ui/src/Tooltip.recipe.ts @@ -13,16 +13,21 @@ import { defineRecipe } from "@pandacss/dev"; * theme did exactly that (`fontSize: md`), and a `css` override at today's * call sites would quietly not apply to tomorrow's. * + * The colour, vertical padding and radius are Chakra's exactly. They had + * drifted (white, `py: 1`, `borderRadius: md`) while this lived inside the + * component, which classroom's port measured: a 6px radius where Chakra drew + * 2px. ml-trainer and python-editor pick the correction up too. + * * Registered in the base preset (base-preset.ts). */ export const tooltip = defineRecipe({ className: "tooltip", base: { bg: "gray.700", - color: "white", + color: "whiteAlpha.900", px: "2", - py: "1", - borderRadius: "md", + py: "0.5", + borderRadius: "sm", fontSize: "sm", fontWeight: "medium", boxShadow: "md", From cc399a4c53175f1e308459472b00cb6ec924aacb Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 08:29:41 +0000 Subject: [PATCH 37/43] Icon: restore Chakra's vertical-align Chakra's Icon rendered `verticalAlign: middle` on the svg itself; this one did not, so an inline-block icon sat ~3px off wherever the surrounding line box mattered. Panda's preflight sets it on every svg, which is why no app noticed: it only shows in an app still coexisting with Chakra, whose preflight is off. classroom measured it at its kill-switch, as the icons moved back. --- packages/ui/src/Icon.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/Icon.tsx b/packages/ui/src/Icon.tsx index c814f4e..6e48424 100644 --- a/packages/ui/src/Icon.tsx +++ b/packages/ui/src/Icon.tsx @@ -57,6 +57,12 @@ export const Icon = ({ lineHeight: "1em", flexShrink: 0, fill: "currentColor", + // Chakra's Icon set this on the element itself, and an inline-block + // icon sits ~3px off without it. Panda's preflight happens to set it + // on every svg, which hid the omission in apps that had already + // flipped — classroom measured the difference at its kill-switch, + // where the preflight arrived and moved every icon back. + verticalAlign: "middle", ...cssProp, }), className, From f039b71c95ca78368156431dae88d645f71a162a Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 08:43:33 +0000 Subject: [PATCH 38/43] Playbook: gotcha #42, globalCss selectors don't merge across presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app preset's globalCss entry replaces the base preset's for the same selector rather than merging into it — unlike every other part of a preset. classroom's kill-switch lost the body colour and the kerning to a two-line font-smoothing addition, and the measurement was the only thing that said so. --- docs/migration-playbook.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 9246633..05d0101 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -777,6 +777,27 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg table lost every style this way (`border-collapse`, the caption, the row rules), and only a grep of the generated CSS showed it. +42. **An app preset's `globalCss` entry REPLACES the base preset's for the + same selector — it does not merge into it.** Everywhere else in a Panda + preset stack, later presets deep-merge; `globalCss` keys do not. So an app + that adds, say, `body: { WebkitFontSmoothing: "antialiased" }` silently + drops the base preset's whole `body` block — its colour, font family, + kerning, line height and background — because the base preset also keys on + `body`. + + It is invisible in the source (both files look like additions), invisible + to a typecheck, and page-wide when it lands: classroom's text went from + gray.800 to black at its kill-switch, and would have lost `font-feature- +settings: "kern"` too, which shifts every glyph on every screen. + + Rules: an app preset's `globalCss` must not use a selector the base preset + uses (currently `html`, `body`, `*::placeholder`, `button, [role='button']` + and `h1, h2, h3, h4, h5, h6`), or must restate what it is replacing. Group + selectors count as distinct keys, so `"html, body, #root"` is safe where + `body` is not — and an inherited property (font smoothing, colour) can + simply ride on a group selector that includes `html`. Check the generated + CSS for the base preset's body block after adding anything. + Also remember (from the RAC component work, not numbered): RAC re-selects a pressed radio value against current state after any earlier handler runs — "click the selected option again to deselect" interactions need a native From d8ac83184288f3412543450438d242a9ab70f814 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 09:24:29 +0000 Subject: [PATCH 39/43] Modal: an uncontrolled mode, via DialogTrigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isOpen`/`onClose` become optional. Inside react-aria's `DialogTrigger` the Modal reads the trigger's state instead, so a dialog with a single trigger beside it needs no app state at all — the same shape MenuTrigger already has, and the react-aria-native way to write a dialog. `useDialogClose()` exposes the close function ModalCloseButton already used, so a footer's own Cancel/Done button works whichever mode is driving it. Verified in a browser (the new story): opens from the trigger, focus lands on the dialog, and closing — from the close button or from a footer button — restores focus to the trigger. The unit tests cover both modes; the focus restoration is react-aria's and doesn't settle under jsdom, so it is asserted in the browser rather than there. Controlled stays the right answer for a dialog with more than one opener, one opened from a menu item (a dialog cannot live inside a menu — it truncates the collection, gotcha #33), or one opened from a handler. classroom, as it happens, has no dialog that qualifies for the trigger form: of its fourteen, six are opened from inside a handler, two from menu items, one is prop-drilled into a child, and the remaining five have more than one opener — including both toolbar buttons, each of which is duplicated as a mobile menu item. Also fixes gotcha #42's formatting, which prettier had mangled. --- docs/migration-playbook.md | 4 +- packages/ui/src/Modal.tsx | 61 ++++++++++++++++++++++++-- packages/ui/stories/Modal.stories.tsx | 39 +++++++++++++++++ packages/ui/tests/Modal.test.tsx | 63 +++++++++++++++++++++++++-- 4 files changed, 158 insertions(+), 9 deletions(-) diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 05d0101..fcc7bac 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -787,8 +787,8 @@ w={23} />` on a _plain_ wrapper that spreads onto a `styled()` svg It is invisible in the source (both files look like additions), invisible to a typecheck, and page-wide when it lands: classroom's text went from - gray.800 to black at its kill-switch, and would have lost `font-feature- -settings: "kern"` too, which shifts every glyph on every screen. + gray.800 to black at its kill-switch, and lost the kerning + (`fontFeatureSettings`) with it, which shifts every glyph on every screen. Rules: an app preset's `globalCss` must not use a selector the base preset uses (currently `html`, `body`, `*::placeholder`, `button, [role='button']` diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index 7028cf6..2a9e24f 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -13,9 +13,11 @@ import { import { Button as RACButton, Dialog, + DialogTrigger as RACDialogTrigger, Heading as RACHeading, Modal as RACModal, ModalOverlay, + OverlayTriggerStateContext, } from "react-aria-components"; import { css, cx } from "styled-system/css"; import { dialog } from "styled-system/recipes"; @@ -49,8 +51,14 @@ export type ModalSize = ConditionalValue< >; export interface ModalProps { - isOpen: boolean; - onClose: () => void; + /** + * Whether the dialog is showing. Required unless the Modal is inside a + * `DialogTrigger`, which owns the state itself — pass both this and + * `onClose`, or neither. + */ + isOpen?: boolean; + /** Called when the dialog asks to close. Pairs with `isOpen`. */ + onClose?: () => void; size?: ModalSize; /** Allow closing by clicking the backdrop (default true; Escape always closes). */ isDismissable?: boolean; @@ -108,6 +116,18 @@ export interface ModalProps { * Modal — a focus-trapping dialog. Collapses Chakra's * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader, * ModalBody and ModalFooter inside. + * + * Two ways to drive it: + * + * - **Controlled** (`isOpen` + `onClose`), which is what a Chakra app ports + * to, and what any dialog with more than one opener needs — a menu item and + * a toolbar button opening the same dialog, or one opened from a handler + * after an async result. + * - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the + * open state, the trigger opens it, and `ModalCloseButton` and the footer's + * `useDialogClose()` still close it. Prefer this where a dialog has exactly + * one trigger sitting next to it — there is no state to hold, and none to + * get out of step. */ export const Modal = ({ isOpen, @@ -127,6 +147,11 @@ export const Modal = ({ children, ...rest }: ModalProps) => { + // Set by a DialogTrigger (or any react-aria overlay trigger) above us. When + // `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit + // prop over the context, and so do we for the close function. + const triggerState = useContext(OverlayTriggerStateContext); + const close = onClose ?? (() => triggerState?.close()); const dataProps = dataAttrs(rest); const slots = dialog({ size, centered: isCentered }); const motionlessClass = motionless @@ -149,7 +174,7 @@ export const Modal = ({ isOpen={isOpen} onOpenChange={(open) => { if (!open) { - onClose(); + close(); } }} isDismissable={isDismissable} @@ -171,7 +196,7 @@ export const Modal = ({ )} > - + {children} @@ -180,6 +205,34 @@ export const Modal = ({ ); }; +/** + * DialogTrigger — react-aria-components' : wrap a trigger + * element and a `Modal`, and the open state is theirs rather than yours. + * + * ```tsx + * + * + * + * Settings + * … + * + * + * ``` + * + * Only for a dialog with a single trigger beside it. A dialog opened from + * more than one place, from a menu item (which cannot hold a dialog — a + * non-collection child truncates the menu), or from a handler, wants the + * controlled `Modal` instead. + */ +export const DialogTrigger = RACDialogTrigger; + +/** + * The current dialog's close function — the same one `ModalCloseButton` uses, + * for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's + * content need not know which is driving it. + */ +export const useDialogClose = () => useDialog().onClose; + interface SlotProps { children?: ReactNode; css?: SystemStyleObject; diff --git a/packages/ui/stories/Modal.stories.tsx b/packages/ui/stories/Modal.stories.tsx index 102e1de..fe76a7c 100644 --- a/packages/ui/stories/Modal.stories.tsx +++ b/packages/ui/stories/Modal.stories.tsx @@ -8,12 +8,14 @@ import { useState } from "react"; import { Button, ButtonGroup, + DialogTrigger, Modal, ModalBody, ModalCloseButton, ModalFooter, ModalHeader, Text, + useDialogClose, } from "../src"; const sizes = [ @@ -71,6 +73,43 @@ export const Basic: Story = { }; /** Chakra's AlertDialog: role="alertdialog" for interrupting confirmations. */ +/** + * With a single trigger beside it, a dialog needs no state at all: wrap the + * two in a `DialogTrigger` and react-aria holds it. A dialog opened from more + * than one place — or from a menu item, or from a handler — wants the + * controlled form above instead. + */ +export const WithTrigger: Story = { + render: (args) => ( + + + + No state required + + + + The trigger owns whether this is showing, and returns focus to + itself when it closes. + + + + + + + + ), +}; + +/** A footer button closes the dialog it is in, either way it is driven. */ +const DoneButton = () => { + const close = useDialogClose(); + return ( + + ); +}; + export const AlertDialog: Story = { render: () => { const [isOpen, setOpen] = useState(false); diff --git a/packages/ui/tests/Modal.test.tsx b/packages/ui/tests/Modal.test.tsx index 7781401..572393f 100644 --- a/packages/ui/tests/Modal.test.tsx +++ b/packages/ui/tests/Modal.test.tsx @@ -3,10 +3,23 @@ * * SPDX-License-Identifier: MIT */ -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { IntlProvider } from "react-intl"; -import { afterEach, expect, it } from "vitest"; -import { Modal, ModalBody, ModalHeader } from "../src"; +import { afterEach, expect, it, vi } from "vitest"; +import { + Button, + DialogTrigger, + Modal, + ModalBody, + ModalCloseButton, + ModalHeader, +} from "../src"; afterEach(cleanup); @@ -44,3 +57,47 @@ it("labels the dialog from the header, at h2 by default", () => { ); expect(screen.getByRole("heading", { name: "Title" }).tagName).toBe("H3"); }); + +// The uncontrolled mode: a DialogTrigger holds the state, so the call site +// holds none. +it("opens from a DialogTrigger and closes from inside, with no app state", async () => { + render( + + + + + Body + + + + , + ); + expect(screen.queryByRole("dialog")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Open" })); + expect(screen.getByRole("dialog")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /close/i })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + // Focus restoration to the trigger is react-aria's, and doesn't settle + // under jsdom — it is asserted in a browser, not here. +}); + +it("a controlled Modal ignores an ambient trigger state", () => { + const onClose = vi.fn(); + render( + + + + + Body + + + + , + ); + // Open despite the trigger never being pressed, and closing calls the prop. + expect(screen.getByRole("dialog")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); +}); From 3abfae853e2cb4591de1c479cd4e9cafa4d790fa Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 09:40:03 +0000 Subject: [PATCH 40/43] Review nits: naming, symmetry and a shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small things from an external review of the migration's library work: - ComboBox's `css` doc said "the input"; the class lands on the control, as Select's does. Says so now, and points at the recipe's `value` slot for the input itself. - Select silently ignored `indicator={null}` — `ReactNode` accepts null, so the type promised something the `??` didn't honour, and ComboBox supports it. Select honours it too now, with a line on why a chevron-less select is rarely what you want: it is the only thing marking the trigger as a dropdown, where a ComboBox's text input speaks for itself. - `initials` was too broad a name to export from the package root; it is `avatarInitials`. Nothing depended on it yet. - Modal and Spinner each had their own copy of the `data-*` filter; it is one internal helper now. - Checkbox's `control?: false` made `control={true}` a type error for no reason. Plain `boolean`, defaulting to true. --- packages/ui/src/Avatar.tsx | 10 +++++++--- packages/ui/src/Checkbox.tsx | 12 +++++++----- packages/ui/src/ComboBox.tsx | 6 +++++- packages/ui/src/Modal.tsx | 6 +----- packages/ui/src/Select.tsx | 17 ++++++++++++----- packages/ui/src/Spinner.tsx | 5 ++--- packages/ui/src/data-attrs.ts | 16 ++++++++++++++++ packages/ui/tests/Avatar.test.tsx | 7 ++++++- packages/ui/tests/Select.test.tsx | 9 +++++++++ 9 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/data-attrs.ts diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx index 46fbe80..c053fc2 100644 --- a/packages/ui/src/Avatar.tsx +++ b/packages/ui/src/Avatar.tsx @@ -50,8 +50,12 @@ const isLight = (hex: string): boolean => { return (r * 299 + g * 587 + b * 114) / 1000 >= 128; }; -/** Chakra's `initials`: first letter of the first and last words. */ -export const initials = (name: string): string => { +/** + * Chakra's `initials`: first letter of the first and last words. Prefixed + * because it is exported from the package root, where a bare `initials` + * would be a broad name to claim. + */ +export const avatarInitials = (name: string): string => { const names = name.trim().split(" "); const firstName = names[0] ?? ""; const lastName = names.length > 1 ? names[names.length - 1] : ""; @@ -117,7 +121,7 @@ export const Avatar = ({ srcSet, icon, iconLabel = " avatar", - getInitials = initials, + getInitials = avatarInitials, showBorder, size, children, diff --git a/packages/ui/src/Checkbox.tsx b/packages/ui/src/Checkbox.tsx index 7a2048a..4027407 100644 --- a/packages/ui/src/Checkbox.tsx +++ b/packages/ui/src/Checkbox.tsx @@ -31,12 +31,14 @@ export interface CheckboxProps */ children?: ReactNode | ((state: CheckboxState) => ReactNode); /** - * `false` drops the box, for a checkbox whose children draw the selected - * state themselves — a selectable tile, or an avatar that grows a tick. - * The label wrapper goes with it, so the children own the whole row, - * including the focus ring the box would otherwise carry. + * Whether to draw the box. `false` is for a checkbox whose children draw + * the selected state themselves — a selectable tile, or an avatar that + * grows a tick. The label wrapper goes with it, so the children own the + * whole row, including the focus ring the box would otherwise carry. + * + * @default true */ - control?: false; + control?: boolean; } /** diff --git a/packages/ui/src/ComboBox.tsx b/packages/ui/src/ComboBox.tsx index fc3968d..3349540 100644 --- a/packages/ui/src/ComboBox.tsx +++ b/packages/ui/src/ComboBox.tsx @@ -67,7 +67,11 @@ export interface ComboBoxProps * while positioning, which beats any class. */ maxHeight?: number; - /** Per-instance overrides for the input. */ + /** + * Per-instance overrides for the control — the box around the input, its + * `startContent` and its indicator, which is what `Select`'s `css` styles + * too. Reach the input itself through the `select` recipe's `value` slot. + */ css?: SystemStyleObject; /** Per-instance overrides for the dropdown card. */ contentCss?: SystemStyleObject; diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index 2a9e24f..dc7cbc2 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -24,6 +24,7 @@ import { dialog } from "styled-system/recipes"; import { ConditionalValue, SystemStyleObject } from "styled-system/types"; import { useIntl } from "react-intl"; import { CloseIcon } from "./CloseIcon"; +import { dataAttrs } from "./data-attrs"; import { uiMessage } from "./messages"; import { UnmountCallback } from "./UnmountCallback"; @@ -241,11 +242,6 @@ interface SlotProps { [key: `data-${string}`]: unknown; } -const dataAttrs = (props: object) => - Object.fromEntries( - Object.entries(props).filter(([key]) => key.startsWith("data-")), - ); - /** Modal title. Rendered as RAC's labelling heading for the dialog. */ export const ModalHeader = ({ children, diff --git a/packages/ui/src/Select.tsx b/packages/ui/src/Select.tsx index 03689f0..5f2d576 100644 --- a/packages/ui/src/Select.tsx +++ b/packages/ui/src/Select.tsx @@ -44,8 +44,13 @@ export interface SelectProps placeholder?: string; /** `SelectOption`s. */ children: ReactNode; - /** Replaces the chevron. */ - indicator?: ReactNode; + /** + * Replaces the chevron; `null` removes it. Rarely right on a Select — the + * chevron is the only thing marking its trigger as a dropdown rather than + * a label, where a ComboBox's text input speaks for itself (which is why + * classroom's chevron-less autocomplete is a ComboBox). + */ + indicator?: ReactNode | null; /** Placement of the dropdown relative to the trigger. */ placement?: PopoverProps["placement"]; /** @@ -97,9 +102,11 @@ export const Select = ({ isPlaceholder ? placeholder ?? "" : defaultChildren } - - {indicator ?? } - + {indicator !== null && ( + + {indicator ?? } + + )}
( key.startsWith("data-")), - )} + {...dataAttrs(rest)} role="status" aria-label={ariaLabel} style={speed ? ({ "--spinner-speed": speed } as CSSProperties) : undefined} diff --git a/packages/ui/src/data-attrs.ts b/packages/ui/src/data-attrs.ts new file mode 100644 index 0000000..d98d5bc --- /dev/null +++ b/packages/ui/src/data-attrs.ts @@ -0,0 +1,16 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +/** + * The `data-*` entries of a props object, for components that let a caller + * put test hooks on an inner element rather than the one their props land on. + * + * Internal: not exported from the package. + */ +export const dataAttrs = (props: object): Record => + Object.fromEntries( + Object.entries(props).filter(([key]) => key.startsWith("data-")), + ); diff --git a/packages/ui/tests/Avatar.test.tsx b/packages/ui/tests/Avatar.test.tsx index 99b9223..5cd86e3 100644 --- a/packages/ui/tests/Avatar.test.tsx +++ b/packages/ui/tests/Avatar.test.tsx @@ -5,7 +5,7 @@ */ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, expect, it } from "vitest"; -import { Avatar, AvatarBadge, token } from "../src"; +import { Avatar, AvatarBadge, avatarInitials, token } from "../src"; afterEach(cleanup); @@ -77,3 +77,8 @@ it("gives the badge the placement it asks for", () => { "placement_top-start", ); }); + +it("exports its initials helper under a name worth claiming globally", () => { + expect(avatarInitials("Ada Lovelace")).toBe("AL"); + expect(avatarInitials(" Ada ")).toBe("A"); +}); diff --git a/packages/ui/tests/Select.test.tsx b/packages/ui/tests/Select.test.tsx index 7a7b111..c4b3ea2 100644 --- a/packages/ui/tests/Select.test.tsx +++ b/packages/ui/tests/Select.test.tsx @@ -117,3 +117,12 @@ it("ComboBox shows an empty state and can drop the indicator", () => { fireEvent.change(input, { target: { value: "zzz" } }); expect(screen.getByText("Nothing found")).toBeDefined(); }); + +it("drops the chevron when asked, rather than silently keeping it", () => { + const { container } = render( + , + ); + expect(container.querySelector('[class*="select__indicator"]')).toBeNull(); +}); From a89efaa26bf4a17b47fa3a7241db70420d82bee6 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 09:46:04 +0000 Subject: [PATCH 41/43] Avatar: show the fallback until a photo loads, and keep it if it fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mounted immediately, so an avatar with a photo showed an empty coloured circle while it loaded and the browser's broken-image glyph forever if the URL was bad — with an onLoad but no onError, and an isLoaded that went stale when `src` changed. On a roster of flaky photo URLs that is a plain regression from Chakra, whose Avatar showed the initials or the icon until the image was ready and went on showing them when it never arrived. So the photo loads out of band, as Chakra's `useImage` did: the element is only mounted once the load succeeds, which is what keeps a broken image out of the circle rather than merely covering it up. A new `src` starts again, so an avatar cannot keep showing the previous person. Tests drive the loader for all three transitions (loading → loaded, loading → failed, and a changed src), including that the name-derived background gives way to the photo. Verified in a browser too — the Fallbacks story now carries a loaded photo and a broken URL side by side, with the photo inlined so the story needs no network. --- packages/ui/src/Avatar.tsx | 62 ++++++++++++++- packages/ui/stories/Avatar.stories.tsx | 17 +++- packages/ui/tests/Avatar.test.tsx | 105 ++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/Avatar.tsx b/packages/ui/src/Avatar.tsx index c053fc2..59339c4 100644 --- a/packages/ui/src/Avatar.tsx +++ b/packages/ui/src/Avatar.tsx @@ -11,6 +11,7 @@ import { ReactElement, ReactNode, SVGProps, + useEffect, useState, } from "react"; import { css, cx } from "styled-system/css"; @@ -82,6 +83,56 @@ export const GenericAvatarIcon = (props: SVGProps) => ( ); +type ImageStatus = "pending" | "loading" | "loaded" | "failed"; + +/** + * Chakra's `useImage`: load the photo out of band and report how it went, so + * the avatar can show the initials or the icon meanwhile and keep showing + * them if it never arrives. + * + * The element is only mounted once this says "loaded", which is what + * keeps a broken URL from leaving the browser's broken-image glyph inside the + * circle — the failure mode a fallback exists to prevent. + */ +const useImageStatus = (src?: string, srcSet?: string): ImageStatus => { + const [status, setStatus] = useState( + src ? "loading" : "pending", + ); + useEffect(() => { + if (!src) { + setStatus("pending"); + return; + } + // A new src starts again: without this the avatar would keep showing the + // previous person's photo, or stay stuck on a fallback it has outgrown. + setStatus("loading"); + const img = new Image(); + let current = true; + img.onload = () => { + if (current) { + setStatus("loaded"); + } + }; + img.onerror = () => { + if (current) { + setStatus("failed"); + } + }; + // srcSet before src, so the browser has the candidates to choose from + // when the load starts. + if (srcSet) { + img.srcset = srcSet; + } + img.src = src; + return () => { + current = false; + img.onload = null; + img.onerror = null; + }; + }, [src, srcSet]); + return status; +}; + export interface AvatarProps extends Omit, "color" | "children">, Pick { @@ -90,7 +141,10 @@ export interface AvatarProps * two people are unlikely to share one. */ name?: string; - /** Photo. Falls back to the initials or the icon until it loads. */ + /** + * Photo. The initials (or the icon) show until it has loaded, and go on + * showing if it fails — the avatar never renders a broken image. + */ src?: string; srcSet?: string; /** Shown when there is no name. Defaults to Chakra's person glyph. */ @@ -130,7 +184,8 @@ export const Avatar = ({ style, ...rest }: AvatarProps) => { - const [isLoaded, setIsLoaded] = useState(false); + const status = useImageStatus(src, srcSet); + const isLoaded = status === "loaded"; const slots = avatar({ size }); // Only while the image isn't showing, matching Chakra's `:not([data-loaded])`. const bg = name && !isLoaded ? colorFromName(name) : undefined; @@ -159,13 +214,12 @@ export const Avatar = ({ : style } > - {src ? ( + {isLoaded ? ( {name setIsLoaded(true)} /> ) : name ? ( diff --git a/packages/ui/stories/Avatar.stories.tsx b/packages/ui/stories/Avatar.stories.tsx index a8b3a15..6b5708e 100644 --- a/packages/ui/stories/Avatar.stories.tsx +++ b/packages/ui/stories/Avatar.stories.tsx @@ -7,6 +7,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { RiCheckLine, RiTeamLine } from "react-icons/ri"; import { Avatar, AvatarBadge, HStack, Stack, Text } from "../src"; +// Inline so the story needs no network — the point is the loaded state, not +// where the bytes came from. +const photo = + "data:image/svg+xml;utf8," + + encodeURIComponent( + ``, + ); + const meta = { title: "Data display/Avatar", component: Avatar, @@ -58,13 +66,18 @@ export const Names: Story = { ), }; -/** No name: the generic glyph, or one supplied by the call site. */ +/** + * No name: the generic glyph, or one supplied by the call site. A photo is + * loaded out of band, so the initials show until it arrives — and go on + * showing if it never does, rather than leaving a broken image in the circle. + */ export const Fallbacks: Story = { render: () => ( } iconLabel="Everyone" /> - + + ), }; diff --git a/packages/ui/tests/Avatar.test.tsx b/packages/ui/tests/Avatar.test.tsx index 5cd86e3..c40c390 100644 --- a/packages/ui/tests/Avatar.test.tsx +++ b/packages/ui/tests/Avatar.test.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, expect, it } from "vitest"; import { Avatar, AvatarBadge, avatarInitials, token } from "../src"; @@ -82,3 +82,106 @@ it("exports its initials helper under a name worth claiming globally", () => { expect(avatarInitials("Ada Lovelace")).toBe("AL"); expect(avatarInitials(" Ada ")).toBe("A"); }); + +// The photo is loaded out of band, so these drive that loader rather than a +// DOM : jsdom fetches nothing, and the element only mounts once loaded. +const imageLoader = () => { + const instances: { + src?: string; + onload?: () => void; + onerror?: () => void; + }[] = []; + class FakeImage { + onload?: () => void; + onerror?: () => void; + srcset?: string; + #src?: string; + constructor() { + instances.push(this as never); + } + set src(value: string) { + this.#src = value; + } + get src() { + return this.#src!; + } + } + const original = globalThis.Image; + globalThis.Image = FakeImage as never; + return { + instances, + restore: () => { + globalThis.Image = original; + }, + }; +}; + +it("shows the initials until the photo loads, then the photo", async () => { + const loader = imageLoader(); + try { + render(); + expect(screen.getByRole("img", { name: "Ada Lovelace" }).textContent).toBe( + "AL", + ); + expect(document.querySelector("img")).toBeNull(); + + await act(async () => loader.instances[0].onload!()); + const img = document.querySelector("img")!; + expect(img.getAttribute("src")).toBe("ada.png"); + expect(img.getAttribute("alt")).toBe("Ada Lovelace"); + } finally { + loader.restore(); + } +}); + +it("keeps the fallback when the photo fails, rather than a broken image", async () => { + const loader = imageLoader(); + try { + render(); + await act(async () => loader.instances[0].onerror!()); + expect(document.querySelector("img")).toBeNull(); + expect(screen.getByRole("img", { name: "Ada Lovelace" }).textContent).toBe( + "AL", + ); + } finally { + loader.restore(); + } +}); + +it("starts again when the src changes", async () => { + const loader = imageLoader(); + try { + const { rerender } = render(); + await act(async () => loader.instances[0].onload!()); + expect(document.querySelector("img")!.getAttribute("src")).toBe("ada.png"); + + // The next person's photo: the old one must not linger while it loads. + rerender(); + expect(document.querySelector("img")).toBeNull(); + expect(screen.getByRole("img", { name: "Grace Hopper" }).textContent).toBe( + "GH", + ); + + await act(async () => loader.instances[1].onload!()); + expect(document.querySelector("img")!.getAttribute("src")).toBe( + "grace.png", + ); + } finally { + loader.restore(); + } +}); + +it("derives no background colour once the photo is showing", async () => { + const loader = imageLoader(); + try { + const { container } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.style.getPropertyValue("--avatar-bg")).toBe("#8b409d"); + + await act(async () => loader.instances[0].onload!()); + expect(root.style.getPropertyValue("--avatar-bg")).toBe(""); + expect(root.hasAttribute("data-loaded")).toBe(true); + } finally { + loader.restore(); + } +}); From 51329643589128449e67c134dbb7b5155076dc6c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 09:51:58 +0000 Subject: [PATCH 42/43] Modal: enforce the isOpen/onClose pairing in the types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making both optional for DialogTrigger lost a real compile error: `isOpen` without `onClose` type-checked, and the close button then silently did nothing, falling back to a trigger context that isn't there. Every call site in all three apps is controlled, so that error was protecting all of them. `ModalProps` is a union again — both halves or neither. The plain union broke the one pattern that forwards modal props (`Omit`, three of ml-trainer's dialogs): a spread cannot be matched against a union, because nothing tells TypeScript which half an `isOpen?: boolean` satisfies. So the controlled half is exported as `ControlledModalProps`, which is what a forwarding shell should say — and reads better than the union did there. **Breaking for forwarders**: a component typed `Omit` becomes `Omit`. Three files in ml-trainer, none in classroom or python-editor, and it fails loudly at compile time. The type-level tests are the enforcement: `tsc` runs them, and the `@ts-expect-error` on a half-specified Modal fails the build if that error ever stops being reported. --- packages/ui/src/Modal.tsx | 33 ++++++++++---- packages/ui/tests/Modal.types.test.tsx | 59 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 packages/ui/tests/Modal.types.test.tsx diff --git a/packages/ui/src/Modal.tsx b/packages/ui/src/Modal.tsx index dc7cbc2..2cdec06 100644 --- a/packages/ui/src/Modal.tsx +++ b/packages/ui/src/Modal.tsx @@ -51,15 +51,7 @@ export type ModalSize = ConditionalValue< | "full" >; -export interface ModalProps { - /** - * Whether the dialog is showing. Required unless the Modal is inside a - * `DialogTrigger`, which owns the state itself — pass both this and - * `onClose`, or neither. - */ - isOpen?: boolean; - /** Called when the dialog asks to close. Pairs with `isOpen`. */ - onClose?: () => void; +export interface ModalOwnProps { size?: ModalSize; /** Allow closing by clicking the backdrop (default true; Escape always closes). */ isDismissable?: boolean; @@ -113,6 +105,29 @@ export interface ModalProps { [key: `data-${string}`]: unknown; } +/** + * A Modal you drive yourself. Also the type for a component that *forwards* + * modal props — `Omit` — because a spread + * cannot be matched against the union `ModalProps` is: TypeScript has no way + * to know which half of it an object with `isOpen?: boolean` satisfies. + */ +export type ControlledModalProps = ModalOwnProps & { + /** Whether the dialog is showing. */ + isOpen: boolean; + /** Called when the dialog asks to close. */ + onClose: () => void; +}; + +/** + * The props of a `Modal`: its own, plus an open state that is either entirely + * yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen` + * without `onClose` leaves the close button and Escape with nothing to call, + * so the pair is enforced rather than merely documented. + */ +export type ModalProps = + | ControlledModalProps + | (ModalOwnProps & { isOpen?: never; onClose?: never }); + /** * Modal — a focus-trapping dialog. Collapses Chakra's * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader, diff --git a/packages/ui/tests/Modal.types.test.tsx b/packages/ui/tests/Modal.types.test.tsx new file mode 100644 index 0000000..964de61 --- /dev/null +++ b/packages/ui/tests/Modal.types.test.tsx @@ -0,0 +1,59 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +/** + * Type-level tests: `tsc --noEmit` is what actually runs them, and the + * `@ts-expect-error` lines fail the build if the error they expect ever stops + * being reported. The runtime assertion is here so vitest has something to + * run. + */ +import { expect, it } from "vitest"; +import { ControlledModalProps, Modal, ModalBody, ModalProps } from "../src"; + +/** Controlled: both halves of the pair. */ +export const Controlled = () => ( + undefined}> + Body + +); + +/** Inside a DialogTrigger: neither half. */ +export const Uncontrolled = () => ( + + Body + +); + +/** Half the pair is the mistake the union exists to catch. */ +export const Broken = () => ( + // @ts-expect-error `isOpen` without `onClose` leaves nothing to close it. + + Body + +); + +/** + * A dialog shell that forwards its caller's modal props — the shape three of + * ml-trainer's dialogs use. `ControlledModalProps`, not `ModalProps`: a + * spread cannot be matched against a union. + */ +export const Shell = ({ + onClose, + ...props +}: Omit) => ( + + Body + +); + +it("has both prop shapes available", () => { + const controlled: ModalProps = { + isOpen: true, + onClose: () => undefined, + children: null, + }; + const uncontrolled: ModalProps = { children: null }; + expect([controlled.isOpen, uncontrolled.isOpen]).toEqual([true, undefined]); +}); From ce2433d0601974b261975d1679513253159d1f79 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 3 Aug 2026 10:01:54 +0000 Subject: [PATCH 43/43] Missing stories from recent work --- packages/ui/stories/Checkbox.stories.tsx | 40 +++++++++++++++++++++- packages/ui/stories/Hooks.stories.tsx | 40 ++++++++++++++++++++++ packages/ui/stories/Layout.stories.tsx | 12 +++++++ packages/ui/stories/Select.stories.tsx | 42 +++++++++++++++++++++++- packages/ui/stories/Skeleton.stories.tsx | 22 ++++++++++++- 5 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/ui/stories/Checkbox.stories.tsx b/packages/ui/stories/Checkbox.stories.tsx index 9f5697f..f7c4b1b 100644 --- a/packages/ui/stories/Checkbox.stories.tsx +++ b/packages/ui/stories/Checkbox.stories.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: MIT */ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Checkbox, Stack } from "../src"; +import { RiCheckLine } from "react-icons/ri"; +import { Checkbox, HStack, Icon, Stack, Text } from "../src"; const meta = { title: "Forms/Checkbox", @@ -45,3 +46,40 @@ export const Sizes: Story = { ), }; + +/** + * `control={false}` drops the box (and its label wrapper): the children draw + * the selected state themselves — a selectable tile here. The box carried the + * focus ring, so the root has to restate one; render-prop children receive + * the state to draw with. ListBox's "custom selected state" story shows the + * same idea on an avatar. + */ +export const WithoutTheBox: Story = { + render: () => ( + + {["Blocks", "Python"].map((name) => ( + + {({ isSelected }) => ( + + {name} + {isSelected && } + + )} + + ))} + + ), +}; diff --git a/packages/ui/stories/Hooks.stories.tsx b/packages/ui/stories/Hooks.stories.tsx index cb6ee9b..33f7863 100644 --- a/packages/ui/stories/Hooks.stories.tsx +++ b/packages/ui/stories/Hooks.stories.tsx @@ -10,10 +10,16 @@ import { Code, HStack, Input, + Modal, + ModalBody, + ModalCloseButton, + ModalFooter, + ModalHeader, Stack, Text, useBreakpointValue, useClipboard, + useDisclosure, useMediaQuery, usePrevious, } from "../src"; @@ -74,6 +80,40 @@ export const UseBreakpointValue: Story = { }, }; +/** + * The controlled dialog shape every Chakra call site ports to, and the one a + * dialog with more than one opener needs. A dialog with a single trigger + * beside it can skip the hook entirely — see Overlays/Modal's "With Trigger". + */ +export const UseDisclosure: Story = { + name: "useDisclosure", + render: () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + return ( + <> + + + Driven by useDisclosure + + + + The hook holds the open state; the dialog closes through{" "} + onClose however it is dismissed. + + + + + + + + ); + }, +}; + export const UsePrevious: Story = { name: "usePrevious", render: () => { diff --git a/packages/ui/stories/Layout.stories.tsx b/packages/ui/stories/Layout.stories.tsx index 3eea47c..75296a2 100644 --- a/packages/ui/stories/Layout.stories.tsx +++ b/packages/ui/stories/Layout.stories.tsx @@ -8,6 +8,7 @@ import { ReactNode } from "react"; import { AspectRatio, Center, + Container, Grid, GridItem, HStack, @@ -75,6 +76,17 @@ export const WrapLayout: Story = { ), }; +/** Container centres itself and caps its width (Chakra's Container). */ +export const ContainerLayout: Story = { + render: () => ( + + + A centred column capped at maxW. + + + ), +}; + export const AspectRatioLayout: Story = { render: () => ( diff --git a/packages/ui/stories/Select.stories.tsx b/packages/ui/stories/Select.stories.tsx index cc84ebe..b2ee389 100644 --- a/packages/ui/stories/Select.stories.tsx +++ b/packages/ui/stories/Select.stories.tsx @@ -10,7 +10,19 @@ import { ComboBox, Icon, Select, SelectOption, Stack } from "../src"; const meta = { title: "Forms/Select", -} satisfies Meta; + component: Select, + // Children come from the render functions; `null` just satisfies the type. + args: { label: "Fruit", placeholder: "Select…", children: null }, + argTypes: { + isDisabled: { control: "boolean" }, + isInvalid: { control: "boolean" }, + maxHeight: { control: "number" }, + placement: { + control: "select", + options: ["bottom start", "bottom end", "top start", "top end"], + }, + }, +} satisfies Meta; export default meta; type Story = StoryObj; @@ -23,6 +35,14 @@ const options = FRUIT.map((f) => ( )); +export const Playground: Story = { + render: (args) => ( + + + + ), +}; + /** A listbox behind a button: pick one of a known set, no typing. */ export const Basic: Story = { render: () => ( @@ -151,6 +171,26 @@ export const LongListWithACappedHeight: Story = { ), }; +/** + * Per-instance overrides: `css` styles the control, `contentCss` the dropdown + * card. An app-wide restyle belongs in an app-preset recipe variant instead + * (classroom's rounded `classroom` variant is one). + */ +export const Overridden: Story = { + render: () => ( + + + + ), +}; + /** Invalid state, as a form would set it. */ export const Invalid: Story = { render: () => ( diff --git a/packages/ui/stories/Skeleton.stories.tsx b/packages/ui/stories/Skeleton.stories.tsx index a7fe1a3..6dc2542 100644 --- a/packages/ui/stories/Skeleton.stories.tsx +++ b/packages/ui/stories/Skeleton.stories.tsx @@ -9,7 +9,10 @@ import { Skeleton, SkeletonText, Stack, Text } from "../src"; const meta = { title: "Feedback/Skeleton", component: Skeleton, - argTypes: { isLoaded: { control: "boolean" } }, + argTypes: { + isLoaded: { control: "boolean" }, + speed: { control: { type: "number", step: 0.1 } }, + }, } satisfies Meta; export default meta; @@ -32,6 +35,23 @@ export const Text_: Story = { ), }; +/** + * Retinted through the custom-property pair the keyframe animates over — + * a call site changes the colours without knowing how the animation works. + */ +export const Retinted: Story = { + render: () => ( + + ), +}; + /** A skeleton sized by the content it is standing in for. */ export const SizedByContent: Story = { render: () => (