fix(react): menus size to their labels, and a disabled item looks disabled - #573
Merged
Merged
Conversation
…abled
Every popover took `popoverStyle`'s fixed 240px width. That is the dialog
width — `FilterMenu`'s operator select over its value input, whose controls
stretch to their container — and it made the tool panel's pin menu ("Pin
left" / "Pin right" / "Unpin" / "Auto width") a mostly empty 240px box
anchored at a 20px kebab, spilling past the grid and over the page beside it.
The header's ⋮ ("Group by this column") and `AddGroupMenu` had the same box.
Split the module's one export in two over a shared `placement()`:
`popoverStyle` keeps the fixed column for the dialog and the cell editors,
and `menuPopoverStyle` sizes a menu to its content between a 160px floor and
the dialog's 240px. The horizontal clamp stays bound to 240 for both — a
content-sized menu can only be narrower, so the right edge is safe with no
measure-then-reposition pass.
Two things the pin menu's own semantics needed and the stylesheet never gave
it. `[data-pretable-menu-item]` had no `:disabled` rule, so the placement the
column is ALREADY in — the menu's way of saying where the column is — kept
the enabled color, a pointer cursor and the `:hover` highlight, reading as
the one item to click. It now takes the tool pane's standard disabled
treatment and the hover rule carries `:not(:disabled)`. And a `role=
"separator"` now divides the one-shot placement commands from the auto-width
mode bit, which stays open when toggled; `useMenuKeyboard` roves over
`[data-pretable-menu-item]` only, so it is not a focus stop.
Verified in a browser on a production build, not just in jsdom: the pin menu
measures 160px (was 240), the disabled item computes `--pretable-text-dim`
with `cursor: default` and stays transparent under a real pointer hover while
an enabled sibling still highlights, the separator paints a 1px rule, the
filter dialog is untouched at 240px, and Pin right / Pin left still move the
column between the pinned sections.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove
enabled auto-merge (squash)
September 4, 2026 00:23
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…led state The pin menu's sizing fix has four siblings, all the same shape: a surface portaled to <body> cannot inherit anything the grid declares, and the ones that got it wrong took the CONSUMER's page instead. - [data-pretable-filter-menu] declared `font: inherit`. The shorthand reads as "keep the surrounding type" and does the opposite here — it pulls the host page's size and line-height in, so the dialog drew at 16px on this site while every other popover sat at --pretable-font-size-cell, and would draw at whatever any other consumer's body font is. It now declares family, size and colour outright; [data-pretable-column-menu], which pinned family and size, gained the colour. - That dialog's operator <select> and value <input> are a stacked pair and came out 28px and 33px: Chrome forces `line-height: normal` on a select and ignores what it is given, so equal padding does not mean equal height. Both now take one explicit block-size, which is what the tool pane's identical controls already do. - Those same fields had no focus-visible rule, so they kept the UA ring — which takes the consumer's accent-color. Tabbing between the pane's copy of these controls and the dialog's changed the ring's colour, width and offset. They now take --pretable-focus-ring at the shared -2px offset. - [data-pretable-reorder-ghost] draws a copy of a header cell but declared no size or weight and took --pretable-text-cell: 16px/400 in the cell colour against a 12.5px/500 header. The label under the cursor did not match the column it came from. It now mirrors the header's trio. - The date editor's month steppers disable at the calendar's min/max month, and `[data-pretable-date-header] button:hover` had no :not(:disabled) and no :disabled rule — the menu-item defect in a second place. The reason only half of this was caught already is the guard: "portaled popovers declare the sans font themselves" checked font-family alone, which is exactly the half the filter dialog had. It now checks size and colour and rejects the shorthand, and four more guards join it — the ghost's header type, the disabled treatment, the hover guards, and the dialog's ring. All five were mutation-tested: each fails when its declaration is removed. Verified in a browser on a production build: the dialog computes 14px with the cell colour, its select and input are both 28px, both take the blue 2px/-2px ring instead of the page's orange UA ring, and the ghost computes 12.5px/500/rgb(94,94,106) — identical to the header cell beside it. Behavior intact: typing NVDA into the dialog still filters to one row, and 66 Playwright specs over the popover, header-keyboard and smoke suites pass, column-reorder drags included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `format` check caught what my own verification missed: I ran lint and typecheck but never `pnpm format`, so three files went up with long lines Prettier wraps. No assertion changed — the suites pass identically after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Vercel preview readyPreview: https://pretable-7e2bq9e1j-cacheplane.vercel.app Updated automatically by the |
Merged
blove
added a commit
that referenced
this pull request
Sep 5, 2026
…lot (#580) * docs: spec for components SP1 — the contract, and Button / IconButton Opens the component-kit program: every control in the grid rebuilt on a pre-built, accessible, styleable kit a consumer can also replace per component type. Four sub-projects by component type; this spec is the first and settles the contract the rest inherit — placement in @pretable/react, one `components` slot per type with a `role` in the props, the house styling channel (attributes + tokens + className passthrough), and context as the plumbing because popovers are portalled. Proven against the grid's twelve plain push-buttons, which keep their existing data attributes so nothing that identifies a button today stops identifying it. Menu items, checkboxes, tabs and the twisty are roles that happen to use <button>, and wait for their own components. Also records the decision that the Excel theme stays light-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(spec): components SP1 — ghost/link variants, and `site` not `role` Two corrections from reading the code the plan has to match. The four labelled actions are `border: 0; background: transparent` with a hover tint — a ghost button, not an outlined one; the earlier survey read `border: 0` as a border. And `role` cannot be the prop: it is the ARIA attribute on every button, so a replacement spreading `role="filter-clear"` onto a <button> would emit invalid ARIA. The prop is `site`, which is what the spec already defined it to mean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: implementation plan for components SP1 Fifteen tasks, TDD throughout: the pixel baseline first, the two components, the type contract, the context, the surface wiring with the override tests landing red, the component rules and a site-rule guard that stays red until the twelve sites migrate and their CSS collapses, the public API, the pixel re-measure, docs with registered tables, the browser proof through the portalled dialog, and a verification step that runs the bench suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(react): PretableButton and PretableIconButton The kit's first two components. Always type="button"; className and style pass through; the contract attributes follow the spread so no prop displaces them; IconButton's accessible name is a required prop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(react): IconButton warns on an empty accessible name; pin the contract attributes "aria-label": string makes omission a compile error but lets "" through, which is the failure the prop exists to prevent; warnOnce closes it in development the way PretableStatus's missing-children warning does. The hostile-spread test now pins all four contract attributes, and `site` is asserted never to reach the DOM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(spec): the two push-buttons share one file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(react): the empty-name guard tolerates a missing aria-label The type requires it; the runtime cannot, so the guard that warns about an empty name must not itself throw on an absent one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(react): pin the Button/IconButton type contract aria-label required on IconButton, no `type` prop, an open `site`, a closed variant, and native attributes flowing through with the ref. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(react): the components context One slot per type, resolved over the defaults and memoised on each slot's identity; context rather than props because the popovers are portalled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(react): the site union is pinned open AND built-in; anchor the hostile cast `Extract<PretableButtonSite, "filter-clear">` was the same whether or not the union was open, so the alias could not fail for the reason its name claimed; `Equal` against the full union can, and was mutation-checked. The source-relative import is marked as the temporary state it is, and the missing-name test casts to the component's own props type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(react): pin the resolved-components memo with a changed-slot case An empty dependency array passed every test, because every test fed the memo unchanged values. The negative twin now fails it. Also: the React 18 forwardRef obligation is stated where the type cannot enforce it, both branches of the resolution are frozen, and the context has a DevTools name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(react): the components prop on the surface and the preset Resolved once, memoised on slot identity, provided on both of the surface's return paths so the portalled popovers inherit it. The override tests land red on the site assertions, which the migrations turn green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(ui): the kit button rules The shared push-button box on the component attributes, first in the file so site rules still win by order; ghost and link looks; the standard ring, disabled treatment and forced-colours ink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(ui): a push-button site rule declares only what is its own Red until the site rules collapse onto the component rules (Task 10). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(react): the five ghost actions render the kit Button + filter, + group, Add group, Expand all, Collapse all. Each keeps its site attribute, so nothing that identified it stops identifying it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(react): Clear and Reset columns render the kit Button, link variant The Clear button is inside the portalled filter dialog, which is the case the components context exists for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(react): the ref test proves the replacement rendered; the memo test says what it pins The kebab-menu test opened the menu on the built-in as readily as on a replacement, so it could not fail; it now asserts the node is the replacement first. The remount test's comment claimed the memo, which a same-type reconcile hides from it; the memo is pinned at the hook level, and the comment now points there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(ui): the button guards check declarations, not selector counts The site-rule guard demanded zero :focus-visible rules per site, which would have forced the funnel's and the column menu's keyboard reveal (opacity: 1) out of the file — a focus-visibility regression arriving through a test whose message said the opposite. It now forbids the ring's DECLARATION at a site, keeps a positive check of what each site genuinely owns so a deleted rule cannot pass, forbids the whole shared box rather than five of its lines, and its list is mirrored into the kit guard so the two read the same list from both ends. A third guard pins the source order the design rests on. Two stay red by design until the site rules collapse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(react): the six icon buttons render the kit IconButton Funnel, column menu, row kebab, chip remove, filter-row remove and group remove. Every site keeps its attribute, its ref, its tabindex and its handlers; the override suite goes fully green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(ui): the push-button site rules collapse onto the kit rules Twelve sites keep a size, a reveal, an alignment; the box, ring and disabled ink are the component's. Kit state rules move to the end of the layer so a site's own state colour cannot outrank the disabled ink; position: relative leaves the kit icon rule and stays only where a ::after hit area needs it (funnel, column menu, row kebab) — on the two remove buttons, which sit in flow among later siblings, it would have reordered painting; the two shared focus lists are split, not deleted, so the five non-button controls on them keep their ring. Deliberate changes, from the review, not regressions: filter-clear and tool-reset gain the kit's border-radius and the product focus ring (tool-reset wore the UA ring); for a consumer without a CSS reset filter-clear also gains the dialog's font, which Tailwind preflight already gave it on our own site; the funnel and the column-menu button gain flex: none. Four older guards read declarations that have moved onto the kit rules (a radius, two padding: 0s, box-sizing, the ghost box and its disabled state, and the existence of a [data-pretable-filter-add] rule). Each is re-pointed at the kit rule that now carries it rather than dropped, so the same claim is still made from the one place it is true. Guards mutation-checked: border: 0 added back to the funnel's base rule fails "a push-button site rule declares only what is its own" ("redeclares /border:\s*0/ — the kit button rule owns it"); the kit :disabled rule moved back to the top section fails "the kit rules bracket the site rules in source order" ("expected 2545 to be greater than 36192"). Both reverted; packages/ui 109/109, packages/react override suite 7/7, typecheck and lint clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(react): export the kit buttons and the components slot types Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(react): every migrated site is pinned to its kit component; review minors All twelve sites are now asserted in jsdom to carry the kit attribute and their site name — the bridge that makes the CSS guards load-bearing, since a site quietly back on a raw <button> would have left them green. Also: JSDoc on the two components slots (their report markers were "undocumented"), a key-omissibility pin in the type test, the components hook read at the top of every component body, and a stale "Task 8/9" docblock in ColumnsSection removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): chip-remove's dead type declarations go; site hovers guard :disabled The ✕ is a CloseIcon sized by --pretable-icon-size, so font-size and line-height sized nothing and the comment that justified them was untrue. Every icon site's hover now carries :not(:disabled), and the site guard keeps it — latent today, since no icon site is disabled, but the rule is #573's. The bracket guard's comment no longer describes a red state that has since gone green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: the Components page, its live example, and the registered tables Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: the portal read settles like its sibling; the hover guard checks per comma-part Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): the components slot in a real browser A replacement lands inside the portalled filter dialog, and the grid still anchors its menu on and returns focus to a replaced icon button. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: the Components page says what the code does An icon button carries no variant; the site attribute is written only when a site is given; the example replaces labelled buttons only, and its Clear lives in a column funnel's dialog, not the Filters section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): the ref claim is the focus-return line, and says so Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ui): the two portaled surfaces that host kit controls declare line-height font: inherit on the kit button pulled the host page's line-height into the filter dialog's Clear and every menu item; the surfaces declared family, size and colour but not the fourth member. 1.5 is what the grid's own body computes and what the pixel baseline measured. The portaled-trio guard now requires it on these two blocks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: changeset for components SP1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): the sitemap has 51 entries with the Components page Task 13 moved the three unit-test count constants and missed this e2e one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.
The pin menu on the home page's tool panel (PINNED LEFT → Symbol → ⋮) was a mostly empty 240px box, with its "already pinned here" item looking like the obvious thing to click. Three causes, all fixed here.
1. Menus borrowed the dialog's width
popoverStylestamps an inlinewidth: 240pxon every popover. That is right forFilterMenu— a dialog whose operator<select>and value<input>stretch to their container — and wrong for a menu of four ~60px labels, which got drawn as a 240×142 rectangle anchored at a 20px kebab, spilling out of the grid card and over the sidebar beside it. The header's⋮("Group by this column") andAddGroupMenushared the box.The module's one export is now two over a shared
placement():popoverStyle— unchanged fixed column, for the dialog and the cell editors.menuPopoverStyle—width: max-contentbetween a 160px floor and the dialog's 240px cap.The horizontal clamp stays bound to 240 for both. A content-sized menu can only be narrower, so clamping against the widest it could be keeps the right edge safe with no measure-then-reposition pass; near the right edge it just sits a little further in than it strictly needed to.
2. A disabled item was styled exactly like an enabled one
The pin menu disables the placement the column is already in — that is how it says where the column is. But
[data-pretable-menu-item]had no:disabledrule anywhere, so the item kept--pretable-text-cell,cursor: pointer, and the:hoverhighlight (the hover rule had no:not(:disabled)). It now takes the tool pane's standard disabled treatment —--pretable-text-dim,cursor: default— and the hover rule skips it.3. No line between the two kinds of item
Three one-shot placement commands (which close the menu) and one
menuitemcheckboxmode bit (which stays open) read as one flat list of four.ColumnRowMenunow renders arole="separator"between them, styled off[data-pretable-menu-separator].useMenuKeyboardroves over[data-pretable-menu-item]only, so it is not a focus stop by construction.Verification
pnpm lint,pnpm typecheck,pnpm api:checkandpnpm test(all packages + website) pass.Because none of this is visible to jsdom, it was also checked in a browser against a production build of the website:
rgb(26,26,31)/pointerrgb(82,82,92)/defaultrgba(37,84,207,0.1)--pretable-ruleBehavior checked too, not just paint: Pin right moves Symbol into PINNED RIGHT and re-pins the grid column on the right, Pin left moves it back, and the newly-disabled item follows the current placement.
New tests:
menuPopoverStylesizing/placement/clamp/flip inpopover-position.test.ts, and the separator's position and non-item-ness intool-panel.test.tsx.🤖 Generated with Claude Code
Second pass: the rest of the portaled surfaces
A deeper sweep of the same family — a surface portaled to
<body>inherits nothing from the grid, so whatever it fails to declare comes from the consumer's page. Four more defects, plus the menu-item disabled bug in a second place.The filter dialog drew at the host page's font size
[data-pretable-filter-menu]declaredfont: inherit. The shorthand reads as "keep the surrounding type" and does the opposite here: it pulls the host page's size and line-height in. The dialog rendered at 16px on this site while every other popover sat at--pretable-font-size-cell(14px) — and would render at whatever any other consumer's body font is. It now declares family, size and colour outright, the way the enum listbox and date popover already did.[data-pretable-column-menu], which pinned family and size, gained the missingcolor.Its two stacked fields were different heights
The operator
<select>and the value<input>came out 28px and 33px: Chrome forcesline-height: normalon a select and ignores what it is given, so equal padding does not mean equal height. Both now take one explicitblock-size— what the tool pane's identical controls already do.Those fields wore the browser's focus ring, not the product's
No
:focus-visiblerule, so they kept the UA ring — which takes the consumer'saccent-color. Tabbing between the pane's copy of these controls and the dialog's changed the ring's colour, width and offset: measuredrgb(229,151,0) auto 1px @ 0in the dialog againstrgb(37,84,207) solid 2px @ -2pxin the pane. Now both are the latter.The column-reorder ghost didn't match the column it came from
[data-pretable-reorder-ghost]draws a copy of a header cell, but declared no size or weight and took--pretable-text-cell: 16px/400 in the cell colour against a 12.5px/500 header. It now mirrors the header's trio.The calendar's month steppers had the menu-item bug
DateCellEditordisables prev/next at the calendar's min/max month, and[data-pretable-date-header] button:hoverhad no:not(:disabled)and no:disabledrule at all. Fixed the same way as the menu items.Why only half of this was already caught
There was a guard —
"portaled popovers declare the sans font themselves"— and it checkedfont-familyalone, which is exactly the half the filter dialog had. It now checks size and colour too and rejects thefont: inheritshorthand, joined by four more: the ghost's header type, the disabled treatment, the hover guards, and the dialog's ring.All five new guards were mutation-tested — each declaration removed from
grid.cssin turn, each guard confirmed to fail, then restored:Verification (second pass)
pnpm lint,pnpm typecheckandpnpm testpass. Measured in a browser on a rebuilt production site:--pretable-text-cellrgb(229,151,0) auto 1px @ 0rgb(37,84,207) solid 2px @ -2pxrgb(26,26,31)rgb(94,94,106)rgb(94,94,106)Behavior intact: typing
NVDAinto the rebuilt dialog still filters the grid to one row, and 66 Playwright specs acrossgrid-header-popover-scroll,grid-header-keyboardandsmokepass — column-reorder drags included.