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/docs/migration-playbook.md b/docs/migration-playbook.md index 4545f08..fcc7bac 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -261,78 +261,104 @@ 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). 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. + 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 @@ -360,6 +386,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 @@ -463,6 +498,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"`; @@ -528,6 +568,236 @@ 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. + +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. + +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. + +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. + +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`. + +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. + +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). 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. + +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. + +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. + +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 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']` + 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 @@ -546,10 +816,33 @@ 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` 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 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 @@ -578,6 +871,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 @@ -585,27 +892,41 @@ 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. -- **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`. +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**~~ — **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**~~, ~~**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. +- `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 +958,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 +1003,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 1710caf..3f4d113 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 @@ -162,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/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" + } +} 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/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..59339c4 --- /dev/null +++ b/packages/ui/src/Avatar.tsx @@ -0,0 +1,276 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + cloneElement, + CSSProperties, + HTMLAttributes, + isValidElement, + ReactElement, + ReactNode, + SVGProps, + useEffect, + 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. 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] : ""; + 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) => ( + + + + +); + +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 { + /** + * The person. Shown as initials, and hashed into the background colour, so + * two people are unlikely to share one. + */ + name?: string; + /** + * 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. */ + 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 = avatarInitials, + showBorder, + size, + children, + css: cssProp, + className, + style, + ...rest +}: AvatarProps) => { + 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; + return ( + + {isLoaded ? ( + {name + ) : 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/Button.recipe.ts b/packages/ui/src/Button.recipe.ts index 890a9c3..2e857f4 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,14 +125,28 @@ 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 // 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", diff --git a/packages/ui/src/Checkbox.tsx b/packages/ui/src/Checkbox.tsx index 426bc23..4027407 100644 --- a/packages/ui/src/Checkbox.tsx +++ b/packages/ui/src/Checkbox.tsx @@ -12,13 +12,33 @@ 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); + /** + * 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?: boolean; } /** @@ -31,6 +51,7 @@ export const Checkbox = ({ css: cssProp, className, children, + control, ...rest }: CheckboxProps) => { const slots = checkbox({ size }); @@ -39,38 +60,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/ComboBox.tsx b/packages/ui/src/ComboBox.tsx new file mode 100644 index 0000000..3349540 --- /dev/null +++ b/packages/ui/src/ComboBox.tsx @@ -0,0 +1,192 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + ForwardedRef, + forwardRef, + ReactNode, + useLayoutEffect, + useRef, + useState, +} 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; + /** + * 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; + /** + * 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"]; + /** + * 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 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; + 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. + */ +const ComboBoxInner = ( + { + label, + placeholder, + startContent, + children, + indicator, + emptyState, + isPopoverHidden, + placement = "bottom start", + maxHeight, + 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); + // 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 ( + + )} + className={cx(slots.root, className)} + > + {label != null && {label}} +
+ {startContent} + + {indicator !== null && ( + + {indicator ?? } + + )} +
+ {!isPopoverHidden && ( + +
{emptyState}
+ : undefined + } + > + {children} +
+
+ )} +
+
+ ); +}; + +/** + * 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; 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/Icon.tsx b/packages/ui/src/Icon.tsx index 7f49b2c..6e48424 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; @@ -43,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, 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 (
{children} @@ -217,10 +309,12 @@ export const ModalFooter = ({ children, css: cssProp, className, + ...rest }: SlotProps) => { const { slots } = useDialog(); return (
{ const intl = useIntl(); const { slots, onClose } = useDialog(); return ( ; + +// 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; `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"]; + /** + * 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. */ + 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", + maxHeight, + 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 !== null && ( + + {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/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/Spinner.tsx b/packages/ui/src/Spinner.tsx index c5bde24..ea6fd62 100644 --- a/packages/ui/src/Spinner.tsx +++ b/packages/ui/src/Spinner.tsx @@ -5,6 +5,7 @@ */ import { CSSProperties } from "react"; import { css, cx } from "styled-system/css"; +import { dataAttrs } from "./data-attrs"; import { SystemStyleObject } from "styled-system/types"; export interface SpinnerProps { @@ -23,6 +24,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 +38,10 @@ export const Spinner = ({ css: cssProp, className, "aria-label": ariaLabel, + ...rest }: SpinnerProps) => ( ( 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/src/Tooltip.recipe.ts b/packages/ui/src/Tooltip.recipe.ts new file mode 100644 index 0000000..2e59346 --- /dev/null +++ b/packages/ui/src/Tooltip.recipe.ts @@ -0,0 +1,37 @@ +/** + * (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. + * + * 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: "whiteAlpha.900", + px: "2", + py: "0.5", + borderRadius: "sm", + 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 b0fccda..feb42d4 100644 --- a/packages/ui/src/base-preset.ts +++ b/packages/ui/src/base-preset.ts @@ -23,19 +23,24 @@ 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 { listBox } from "./ListBox.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"; import { text } from "./Text.recipe"; +import { tooltip } from "./Tooltip.recipe"; import { field } from "./TextField.recipe"; import { toast } from "./Toast.recipe"; @@ -65,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: { @@ -158,6 +175,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}" }, @@ -174,16 +212,21 @@ export const basePreset = definePreset({ heading, input, text, + tooltip, }, slotRecipes: { + avatar, card, checkbox, dialog, drawer, field, + gridList, + listBox, menu, numberField, radio, + select, slider, switchRecipe, toast, @@ -240,7 +283,12 @@ 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. + avatar: ["*"], + button: [{ size: ["*"], responsive: true }, { variant: ["*"] }], checkbox: ["*"], heading: ["*"], card: ["*"], @@ -248,10 +296,14 @@ export const basePreset = definePreset({ // as a runtime prop, so generate the breakpoint-prefixed variants too. dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }], drawer: ["*"], + gridList: ["*"], + listBox: ["*"], input: ["*"], radio: ["*"], + select: ["*"], switchRecipe: ["*"], text: ["*"], + tooltip: ["*"], // Toast status is chosen at runtime from the toast content. toast: ["*"], }, 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/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; 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 e27dc46..5c13291 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"; @@ -23,8 +24,10 @@ export * from "./NativeSelect"; export * from "./NumberField"; export * from "./ProgressBar"; export * from "./Radio"; +export * from "./Skeleton"; export * from "./Slide"; export * from "./Slider"; +export * from "./Select"; export * from "./Spinner"; export * from "./Svg"; export * from "./Switch"; @@ -40,8 +43,11 @@ export * from "./Collapse"; export * from "./Fade"; export * from "./Kbd"; export * from "./Divider"; +export * from "./GridList"; export * from "./Drawer"; export * from "./List"; +export * from "./ListBox"; +export * from "./ComboBox"; export * from "./Menu"; export * from "./Modal"; export * from "./PopoverArrow"; @@ -51,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/src/system.ts b/packages/ui/src/system.ts index 0475e4a..ff81022 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, @@ -21,9 +23,16 @@ 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, + Container, Flex, Stack, HStack, diff --git a/packages/ui/stories/Avatar.stories.tsx b/packages/ui/stories/Avatar.stories.tsx new file mode 100644 index 0000000..6b5708e --- /dev/null +++ b/packages/ui/stories/Avatar.stories.tsx @@ -0,0 +1,125 @@ +/** + * (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"; + +// 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, + 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. 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" /> + + + + ), +}; + +/** + * 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/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/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/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/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) => ( + + + +); 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/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/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/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/stories/Select.stories.tsx b/packages/ui/stories/Select.stories.tsx new file mode 100644 index 0000000..b2ee389 --- /dev/null +++ b/packages/ui/stories/Select.stories.tsx @@ -0,0 +1,206 @@ +/** + * (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 { RiCloudLine, RiFireLine, RiSnowyLine } from "react-icons/ri"; +import { ComboBox, Icon, Select, SelectOption, Stack } from "../src"; + +const meta = { + title: "Forms/Select", + 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; + +const FRUIT = ["Apple", "Banana", "Cherry", "Damson", "Elderberry"]; + +const options = FRUIT.map((f) => ( + + {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: () => ( + + + + + ), +}; + +/** + * 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: () => ( + + + {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} + + + ); + }, +}; + +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: () => ( + + + + ), +}; + +/** + * 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: () => ( + + + + {options} + + + ), +}; diff --git a/packages/ui/stories/Skeleton.stories.tsx b/packages/ui/stories/Skeleton.stories.tsx new file mode 100644 index 0000000..6dc2542 --- /dev/null +++ b/packages/ui/stories/Skeleton.stories.tsx @@ -0,0 +1,67 @@ +/** + * (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" }, + speed: { control: { type: "number", step: 0.1 } }, + }, +} 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: () => ( + + + + + ), +}; + +/** + * 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: () => ( + + + Something the width of this sentence. + + + Something the width of this sentence. + + + ), +}; diff --git a/packages/ui/tests/Avatar.test.tsx b/packages/ui/tests/Avatar.test.tsx new file mode 100644 index 0000000..c40c390 --- /dev/null +++ b/packages/ui/tests/Avatar.test.tsx @@ -0,0 +1,187 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { Avatar, AvatarBadge, avatarInitials, 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", + ); +}); + +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(); + } +}); 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); +}); 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(); +}); 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(); +}); 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/Modal.test.tsx b/packages/ui/tests/Modal.test.tsx new file mode 100644 index 0000000..572393f --- /dev/null +++ b/packages/ui/tests/Modal.test.tsx @@ -0,0 +1,103 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import { afterEach, expect, it, vi } from "vitest"; +import { + Button, + DialogTrigger, + Modal, + ModalBody, + ModalCloseButton, + 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"); +}); + +// 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); +}); 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]); +}); diff --git a/packages/ui/tests/Select.test.tsx b/packages/ui/tests/Select.test.tsx new file mode 100644 index 0000000..c4b3ea2 --- /dev/null +++ b/packages/ui/tests/Select.test.tsx @@ -0,0 +1,128 @@ +/** + * (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(); +}); + +it("drops the chevron when asked, rather than silently keeping it", () => { + const { container } = render( + , + ); + expect(container.querySelector('[class*="select__indicator"]')).toBeNull(); +}); 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); +}); 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"], }, });