Repository navigation
fix(ui): harden registry components against worst-case data - #52
Merged
Merged
Conversation
Adds a dataset switch (demo, worst, empty, one, 1,000 rows), a direction toggle wired to Base UI's DirectionProvider, and worst-case stories for every component in the Storybook. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Fixes the break-ui findings for the primitives group (rows from the group A report, by number): - Progress (1): finite values clamp into the range; null, NaN, Infinity and unusable bounds are indeterminate with a sweeping band; data-overflow above max - Item (2-4, 20, 40): content floor so actions wrap before the text is crushed, a title clamp that works, wrapping actions, min-w-0 header and footer, rem text - PageHeader and Card (5-7, 21): wrapping title and actions, minmax(0, 1fr) header grid, wrapping footer, leading-tight titles - Stepper (8, 30): shrinking items with truncating labels, aligned vertical steps - Button (9): opt-in wrap prop. Tooltip (10, 33): focusable wrapper for disabled triggers, wrapping text, no empty bubble - Empty, Field, Label (11-13, 34-36): overflow-wrap, min-w-0, a flex-row separator, an inline required star, logical text alignment - Alert (14, 15, 22, 41): AlertAction slot, wrapping code, empty title, full colour descriptions - Avatar (16, 25): object-cover, backing for transparent images, fallback text that scales with the avatar; initials() helper with a registry item - Toaster and toasts (17, 39): theme follows the page; one shared card with a three-line clamp and details dialog for every variant - Breadcrumb and Pagination (18, 27, 29, 41): wrapping crumbs, page links that grow with their label, mirrored chevrons, full colour links - Badge (23, 24): truncating text, min-width for single characters - ButtonGroup, Kbd, UnlinkButton, Separator, Spinner (19, 37, 38, 42, 44, 45): logical radii, nowrap keys, 24px target, visible vertical rule, decorative spinner option - Dock (31, 32, 43): wrapping row, context injection, clientX, keyboard operable icons - StackHeader (46): title attributes on truncated text Left as host concerns or deferred (rows 7, 19, 21, 24, 26, 28, 38, 47, 48), and primary colour contrast skipped by decision (row 41). Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Fixes the group-B break-ui findings for the input and picker components. Selection and labels - Select shows the selected item's label instead of its raw value, read from the declared items (and items that mount later); `items` still wins (row 2) - Select popup shows an empty message when it holds no option, keeps the selected row clear of the scroll arrows, and its trigger never exceeds a narrow container; truncated values get a title (rows 18, 19, 20) - MultiSelect `+N more` is plain text and clearing lives in the popover, so a stray click cannot drop the selection (row 1) - MultiSelect scrolls the keyboard-highlighted option into view, keeps the highlight pill on the hovered row, wraps option descriptions, announces the count plus the first labels, and uses singular copy for one option (rows 4, 17, 32, 35, 46) - RecordPicker always searches the visible label (row 37) Long and unbreakable text - Chips (MultiSelect, Combobox, Tags) and option rows (Select, Combobox, Autocomplete, Command) wrap or truncate instead of pushing the control or page wide; chip fields cap their height and scroll (rows 5, 9, 10, 12, 39) - Command rows keep their icon and shortcut; CommandEmpty is centered (rows 8, 29) - FormControl and Form wrap long labels, descriptions and errors; floating labels stay on one line and also float for Textarea; FormMessage falls back to its children when an error has no message (rows 7, 11, 28, 42, 43) - InputGroup addons shrink and truncate, icons never shrink, the input keeps a floor and the row cannot overflow its border (rows 6, 16) - Input ellipsizes long values and placeholders; Textarea and JsonInput cap their height and scroll; JsonInput keeps its format button off the text, follows the page theme and reports line and column (rows 13, 14, 30, 31) - ToggleGroup and Toggle wrap and truncate, and the attached group applies its corner radii and collapsed borders (rows 15, 33) Direction, scripts and ranges - Dial codes, shortcuts and counts are isolated left to right; InputOtp stays left to right and groups logically; email, url and tel inputs default to `dir="ltr"` (rows 23, 24) - Phone country search matches calling codes and the number is capped at the longest valid length; InputOtp accepts digits from other numeral systems and its gaps no longer scale with the root font size (rows 25, 41, 44) - Calendar nav buttons show their disabled state, chevrons mirror in RTL and the today dot stays centered (row 27) - Switch is the size of its track with a pseudo-element touch target; a Slider with no range stays visible and disabled (rows 21, 40) Not changed: rows 22 (host provides DirectionProvider), 26, 45 (below the control's content minimum), 34, 38 (host paginates, host data), 36 (cmdk identifies items by value; now documented). Row 3 is fixed by ScrollArea. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
FieldRow centred its checkbox or switch against the whole label, so a label that wrapped left the control floating beside the middle line, and the description sat under the control instead of under the label text. The control now sits at the top of the row (beside the first line) and the label, description and error share one column. Handed off from the inputs group; the label overflow-wrap change they asked for was already in the primitives commit. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Fixes the group D rows from the break-ui report for billing-kit, billing-account, billing-console, workspace-kit, agents-builder, data-rooms and ai. Layout and overflow (rows 1-4, 6-7, 9-15, 17-19, 26, 31-32, 34, 36, 46-47) - base grid-cols-1 track at 26 implicit-grid sites; Usage no longer scrolls sideways on phones - composer Send, room header sections, ViewHeader, Panel header, rail items, ledger, tables (name column floors, nowrap dates and money, scroll fade), approval, recommendation, draft and connect cards wrap instead of overflow - account and Move-to lists scroll inside their menus - canvas fit anchors wide content at the start edge, canvases and the day timeline stay LTR Data handling (rows 5, 8, 16, 20-25, 28, 30, 33, 42-45) - plans with no active price are hidden from comparisons and refused in the change dialog (isPlanAvailable), PriceTag shows Unavailable - markdown keeps literal asterisks, line breaks and h4-h6; inline diff is a real LCS diff with context - initials from lib/initials, plural forms via Intl.PluralRules with optional unitSingular, exact plan limits, year-aware short dates, calendar-day relative dates, percent never rounds up to 100%, compact numbers and durations roll over, zero timestamps render as a dash Resilience and polish (rows 27, 29, 35, 37-39, 48 deferred) - title on truncated strings, avatar and favicon fallbacks, empty sections left out, banner and attention name lists capped - 367 px type sizes converted to rem, physical direction utilities moved to logical ones, directional icons mirror, arrow keys follow useDirection - rows 40 and 41 are left to hosts (pagination); row 48 (kit copy i18n) is deferred Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
…s on one row - the Switch is now its own 18px track (the touch target is a pseudo-element), so the -my-3 that cancelled its old 40px box in catalog and gift code tables pushed the toggles below the row text; centre them with align-middle instead - the room header wraps its sections to a second row from 52rem of container width instead of 56rem, so the common 1100px desktop width keeps one row Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
…able added row - the inbox working line (agent name and activity) wrapped nowhere, so an 80-character agent name was cut at the thread edge; it now wraps anywhere - the schedule card history row wraps its "Last run" toggle below the run cells instead of overflowing the card at phone widths - DiffTable added rows wrap long cells instead of clipping them - TableSurface scroller is positioned so screen-reader-only text inside a scrolled table stays inside it - declare @base-ui/react on the workspace-kit and data-rooms registry items that now import useDirection directly Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
…se data Fixes break-ui report rows 1-34 and 37-42 for group C (overlays, data containers, workspace kit), found with the worst-case Storybook harness at 320px, 1440px, 200% text, dark and RTL. Overlays (rows 1-9, 16, 18, 32) - Sheet, Drawer, Dialog, AlertDialog, Popover and DropdownMenu cap to the room they have and scroll inside; long titles, descriptions and labels wrap and stay clear of the close button; AlertDialog actions wrap; a right drawer gets the same width as a left one. - Dialog, AlertDialog and Sheet open at their top: when the first tabbable element is below the fold they focus the popup (new lib/initial-focus). Layout and workspace kit (rows 10-14, 17, 28, 30, 31, 34, 39, 40, 41) - Tabs scroll with edge fades, Accordion and Collapsible wrap, Sidebar icon mode scrolls, menu badges and group labels get room, side accepts start and end, ScrollArea honours max-height, Resizable gets a bigger hit area. - OrgChart scales with the root font size and stacks more than eight reports without a team in columns (routed lines, arrow-key movement); ResponsiveDiagram floors its zoom; FlowZoomPanel fits wide graphs and takes labels; the decorative grids and blur are hardened. Storage kit (rows 15, 19-27, 33, 37, 38, 42) - MiddleTruncate for names and keys with the full key in the detail sheet, upload errors clamp and can be dismissed, humanizeBytes carries, counts take a locale, the name column has a floor, previews fall back, text is rem. - TableCell gets an opt-in truncate prop. Rows 29, 35, 36 and 43 are won't fix (platform scrollbar convention, host paginates, consumer-owned empty states). Builds on Button wrap and lib/initials from the primitives commit. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
MiddleTruncate split names across two flex items, so their accessible name read with a break at the cut. Screen readers now get an sr-only copy. Also drops the dotAll regex flag from initials so ES2017 consumers type-check. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
|
Review complete. No issues found — approved ✅. The diff for this PR is empty (0-line patch), so there are no changes to review. Reviewed commit: 20ee426 |
Fixes rows 1-10 of the group B react and logic audit.
Behaviour
- MultiSelect: Enter and Backspace inside the popover footer belong to its
buttons, so the clear button works from the keyboard instead of toggling
the highlighted option or dropping the last value (row 1)
- Select labels: the registry is a small external store rather than refs
read and written during render, so Select compiles under React Compiler
(rows 2, 5, 6)
- with `items` the children are not walked and nothing registers (row 3)
- an entry leaves when its last item unmounts; the selection keeps its
label, so the trigger stays labelled when the list closes (row 6)
- an item that moves from the declared children into a wrapper keeps its
label (row 5)
- SelectValue keeps a `title` the host sets (row 7)
- InputGroupText, Toggle and ToggleGroupItem wrap each run of adjacent text
once, so `{n}/{max}` stays one flex item instead of opening a gap between
children; long labels still truncate (row 4)
Smaller fixes
- JSON error position: numbers and \u escapes are matched with sticky
regexes, so a number longer than 64 characters no longer misplaces the
reported line and column (row 8)
- RecordPicker: an inline `getRecordLabel` no longer re-runs the search on
every parent render; the labels are compared by content (row 9)
- JsonInput: one shared theme observer and one resize listener for every
editor, and none when the host sets the theme (row 10)
Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Audit rows fixed, High and Medium first: - 1 PlanChangeDialog, PlanComparison and PriceTag read one resolvePrice(); the dialog names the period from the price it charges and onSelect gets the price the card shows - 2 plural() and unitFor() pick English forms by English rules; the billing call sites stop passing the formatting locale - 3 the unlimited sentinel (-1) never reaches the plural rules - 4 lineDiff follows the edits with a bounded Myers search before it falls back to removed-then-added, so distant edits in a long file stay small hunks - 5 the documents list is memoized and sorted with one Intl.Collator per room locale - 6 avatar and favicon fallbacks also check complete/naturalWidth on mount, so an image that failed before hydration still falls back - 7 useScrollFade watches children added or removed later - 8 Intl objects in plural, nameList, unitFor and the data rooms formatters are built once - 9 singularize exceptions, and only the last word of a phrase changes - 10 duration and byte rounding at unit boundaries - 11 MiddleTruncate splits at grapheme boundaries; tail 0 draws a plain line - 12 markdown strong patterns stop at the next marker, so long lines take linear time - 13 ContextRing label keeps a percent past 100 - 14 PlanComparison currentPrice for a retired price; Plan.fallback documented - 15 unset or invalid dates are a dash (and "Undated") instead of a RangeError Adds ai/line-diff.ts and ai/use-image-failed.ts, registered in the ai item. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Fixes audit rows 1-7 for group C. Overlays (rows 1, 2, 4) - Dialog, AlertDialog and Sheet focus the popup again when opened by touch, so the soft keyboard stays closed: scrollSafeInitialFocus now receives Base UI's interaction type and returns the popup for touch, which the default it replaces did. A consumer's initialFocus still wins, and an explicit undefined counts as omitted. - TabsList, DialogPopup and AlertDialogContent merge their own ref with the consumer's through the new lib/compose-refs helper (mergeRefs, useMergedRefs), so React 19 ref cleanups run, a cleanup ref is never called with null, and the merged callback keeps its identity between renders. Registered as the compose-refs lib item and aliased in the registry compiler; dialog, alert-dialog and tabs depend on it. Data and layout (rows 3, 5, 6, 7) - SidebarMenuBadge shares one ResizeObserver that reads every badge width before writing any item variable, and reads no layout at mount, so a long list of badges no longer forces a layout per row. - MiddleTruncate draws text that fits in its tail as one selectable line. Split text keeps an sr-only copy and makes the two visible halves unselectable, so copying a name pastes the original string once instead of name, newline, tail. The Intl.Segmenter is built once and the split is memoized per text. - MiddleTruncate takes a title prop (false leaves it off). BucketRail passes false so the row's "name, details" tooltip is no longer hidden by a nested title. Each High and Medium row has a regression test that fails without the fix. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
Audit rows fixed (a-audit.md):
1, 2. toast: the "View details" dialog opens in a root of its own instead of
inside the toast. Sonner's timer, toast.dismiss and swipe-out can no longer
unmount it, a drag inside it is not read as a swipe, it does not hold the
toaster in its hover state, and mouse text selection works.
3. badge: each run of string and number children is wrapped once, so
interpolated text stays one flex item (no gap inside "{n}%" or "+{n}") and
icon plus text still truncates. Whitespace between elements adds no item.
4, 5. tooltip, button: a disabled Button trigger is no longer wrapped. It stays
the same element in the same box, disabled with aria-disabled (focusable,
hoverable, ignores clicks, presses and submits), so layout is unchanged and
nothing remounts when disabled flips. Other disabled controls are left
natively disabled; the docs say to supply a wrapper for them.
6, 11. dock: Enter and Space press a DockIcon only when the key came from the
icon itself, and the consumer's onKeyDown runs first and can cancel it.
7. separator: the vertical rule no longer sets align-self.
8. stepper: the horizontal scroll container keeps room for the focus ring and
the hit-area overhang (vertical padding with a matching negative margin).
9. initials: casing no longer depends on the runtime locale; opt in with the
new locale option.
10. toast: one copy-feedback timer, cleared and extended, never left running.
12. field: a labelled FieldSeparator is one separator to assistive tech.
Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
…e-group InputGroupText, Toggle and ToggleGroupItem now use the same helper as Badge: each contiguous run of text becomes one truncating element, elements are left alone, and a run of only whitespace between elements is dropped instead of being left behind as a text node. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
…commit Two orderings could drop the label of a value that was just picked from wrapper-rendered items: - the selection was recorded in a passive effect, which runs after the cleanups of items that unmount in the same commit; it is now a layout effect, which runs before them - Base UI reports `null` when it resets a value that has no mounted item, and the Select recorded that as the last pick; a controlled Select now ignores it, as its host never receives it Both surfaced as a test that failed about one run in forty; two deterministic cases now cover them. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
The details dialog opened in a React root of its own (cdab56f). That fixed the toast timer and Sonner's swipe and hover handlers, but a new root does not get the app's providers (Base UI DirectionProvider, motion settings, the portal context, theme or i18n), and nothing gave focus back when the toast and its "View details" button were already gone. - openToastDetails publishes the request to a small store (on globalThis, so a package copy and a registry copy still find each other). - Toaster renders ToastDetailsHost next to Sonner, not inside it: Sonner never sees the dialog's events, and the app's providers reach the dialog. The first of several toasters shows it; a host that unmounts drops its pending request. - Without a Toaster of ours (Sonner's own Toaster) it still opens in a root of its own, as before; documented in the code. - Focus goes back to the control that opened it (the toast passes its button, so it also works where a click does not focus a button, as in Safari) while that control is on the page, else to the toaster's region, via Base UI's finalFocus. - Registry: toast-details.tsx moves to the sonner item (which now depends on button and dialog); the toast item depends on sonner. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
line-diff.ts and use-image-failed.ts joined the ai registry item, so the expected source closure has to name them. Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
- Select reads labels from declared items only; the wrapper-item registry, its layout effect and picked-value state are gone (pass `items` instead) - Shared lib modules replace parallel copies: text-runs (badge, toggle, toggle-group, input-group, sidebar badge), use-scroll-fade (tabs, kits), use-document-theme (sonner, json-input), intl (cached formatters, formatCount, plural, nameList, isUnsetDate, graphemes) and json-error-position - One MiddleTruncate in workspace-kit replaces the storage and kit copies - PlanChangeDialog renders an unavailable plan through its own body instead of guarding six branches - TooltipTrigger sets a typed `focusableWhenDisabled` on Button, which uses Base UI's Button for it, replacing the data-attribute protocol - Sidebar menu badge is capped in CSS instead of measured by an observer - Toast variants share one ToastProps type Claude-Session: https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardens every
@constructive-io/uicomponent in Storybook against realistic worst-case data: long, unbreakable names and emails, translated labels, large counts, empty and single-item lists, RTL text and 200% font size. Adds a Storybook harness so these cases stay reproducible.Harness
&globals=dataset:worst;direction:rtl).DirectionProvider.src/stories/break-ui/holds about 90 stress stories with shared worst-case fixtures (worst-case.ts).Fixes (186 findings: 163 fixed, 6 partly fixed, 15 won't fix, 2 deferred)
--available-height/--available-widthand scroll inside. A newinitial-focushelper keeps a scrolling overlay opening at its top.min-w-0andoverflow-wrap: anywherewhere text sits next to actions (Item, Card, PageHeader, Field, Form errors, InputGroup, Command, Accordion, Sidebar). Tabs and Stepper scroll instead of widening the page.grid-cols-1on 26 kit grids that only set columns at breakpoints, and kit body text in rem instead of px.NaN/nullas indeterminate instead of drawing a full bar.items. Items rendered by a wrapper component still needitems.+N moreno longer clears selections. Clearing moved to the popover.humanizeBytesrolls over at 1,048,576 bytes.initials()(grapheme-safe, first + last word) replaces seven copies that broke on emoji and CJK names.intl(cached formatters,formatCount,plural,nameList, graphemes),text-runs(one truncating span per text run),use-scroll-fade,use-document-theme(one<html>observer for Sonner and JsonInput) andjson-error-position. Storage and data rooms share oneMiddleTruncate.start/end),dir="ltr"on dial codes, OTP digits and shortcuts, and mirrored Calendar chevrons.New optional API
<Button wrap>,<Button focusableWhenDisabled>,AlertAction, Spinnerlabel/decorative, SelectemptyMessage, JsonInputmaxLines, Sidebarside="start" | "end",initials(name, { locale }),MiddleTruncatetitle,PlanComparisoncurrentPrice, registry lib itemsinitials,initial-focus,compose-refs(mergeRefs/useMergedRefs, which keeps React 19 ref cleanups),intl,text-runs,use-scroll-fade,use-document-themeandjson-error-position, and kit helpersformatCount,plural,nameList,MiddleTruncate,isPlanAvailableandresolvePrice.Behaviour changes for hosts
darkclass instead ofsystem, and every toast variant clamps to 3 lines with "View details". The details dialog renders from<Toaster>beside Sonner rather than inside the toast, so it keeps the app's providers, survives the toast's timeout and swipe, and returns focus on close.Buttonunder aTooltipstays the same element instead of being wrapped in a span:TooltipTriggersetsfocusableWhenDisabled, and Button renders through Base UI'sButton(aria-disabled, focusable, inert to clicks). Other disabled controls need a wrapper passed throughrenderto get a tooltip, as onmain.Christopher Alexander Montgomery IIIbecomesCM.Deliberately not handled here
messagesprop) is deferred.Before / after (same story, worst-case data)
+N moretextmaxHeightis ignored and rows spill out, and the list now scrolls at 300pxgrid-cols-1fixes it🤖 Generated with Claude Code
https://claude.ai/code/session_014YjBrkY2FYbr72Cc6DpRKJ