Skip to content

fix(react): menus size to their labels, and a disabled item looks disabled - #573

Merged
blove merged 3 commits into
mainfrom
blove/pinned-columns-dropdown-style-17541a
Sep 4, 2026
Merged

fix(react): menus size to their labels, and a disabled item looks disabled#573
blove merged 3 commits into
mainfrom
blove/pinned-columns-dropdown-style-17541a

Conversation

@blove

@blove blove commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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

popoverStyle stamps an inline width: 240px on every popover. That is right for FilterMenu — 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") and AddGroupMenu shared 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.
  • menuPopoverStylewidth: max-content between 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 :disabled rule anywhere, so the item kept --pretable-text-cell, cursor: pointer, and the :hover highlight (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 menuitemcheckbox mode bit (which stays open) read as one flat list of four. ColumnRowMenu now renders a role="separator" between them, styled off [data-pretable-menu-separator]. useMenuKeyboard roves over [data-pretable-menu-item] only, so it is not a focus stop by construction.

Verification

pnpm lint, pnpm typecheck, pnpm api:check and pnpm 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:

before after
pin menu width 240px 160px
disabled item color / cursor rgb(26,26,31) / pointer rgb(82,82,92) / default
disabled item under a real hover rgba(37,84,207,0.1) transparent
enabled sibling under a real hover highlight highlight (unchanged)
separator 1px --pretable-rule
filter dialog 240px 240px (untouched)

Behavior 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: menuPopoverStyle sizing/placement/clamp/flip in popover-position.test.ts, and the separator's position and non-item-ness in tool-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] 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. 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 missing color.

Its two stacked fields were different heights

The operator <select> and the value <input> 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 — what the tool pane's identical controls already do.

Those fields wore the browser's focus ring, not the product's

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: measured rgb(229,151,0) auto 1px @ 0 in the dialog against rgb(37,84,207) solid 2px @ -2px in 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

DateCellEditor disables prev/next at the calendar's min/max month, and [data-pretable-date-header] button:hover had no :not(:disabled) and no :disabled rule 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 checked font-family alone, which is exactly the half the filter dialog had. It now checks size and colour too and rejects the font: inherit shorthand, 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.css in turn, each guard confirmed to fail, then restored:

FAILS (good)  filter-menu loses font-size
FAILS (good)  ghost takes the cell size
FAILS (good)  date hover loses its guard
FAILS (good)  menu item loses its :disabled rule
FAILS (good)  filter dialog loses its focus ring

Verification (second pass)

pnpm lint, pnpm typecheck and pnpm test pass. Measured in a browser on a rebuilt production site:

before after
filter dialog font-size / color 16px / page color 14px / --pretable-text-cell
dialog select vs input height 28px vs 33px 28px vs 28px
dialog field focus ring rgb(229,151,0) auto 1px @ 0 rgb(37,84,207) solid 2px @ -2px
reorder ghost 16px / 400 / rgb(26,26,31) 12.5px / 500 / rgb(94,94,106)
header cell (the ghost's target) 12.5px / 500 / rgb(94,94,106) unchanged

Behavior intact: typing NVDA into the rebuilt dialog still filters the grid to one row, and 66 Playwright specs across grid-header-popover-scroll, grid-header-keyboard and smoke pass — column-reorder drags included.

…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
blove enabled auto-merge (squash) September 4, 2026 00:23
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 4, 2026 2:38am UTC

Request Review

blove and others added 2 commits September 3, 2026 18:35
…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>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-7e2bq9e1j-cacheplane.vercel.app
Commit: 549c8980e61a424db841d5bda77877e41beabc04

Updated automatically by the deploy-preview job.

@blove
blove merged commit 1ad5602 into main Sep 4, 2026
21 checks passed
@blove
blove deleted the blove/pinned-columns-dropdown-style-17541a branch September 4, 2026 03:12
@blove blove mentioned this pull request Sep 4, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant