fix: [Select/MultiSelect/FieldSelect] refinements (ENG-46730, 46731, 46732, 46739) - #782
Merged
Merged
Conversation
…30-32, ENG-46739)
The document-level pointerdown handler in select-content closed the panel whenever the pointer landed outside the content root — including on the trigger itself. The trigger's @click then re-opened it, so closing the open panel via the trigger appeared to require a second click and could emit update:open twice per press. Skip the outside-close path when the pointerdown lands on the trigger; the trigger's own handler drives the toggle. Adds two regression tests exercising a real pointerdown+click sequence: one asserts the panel closes after a trigger press while open, the other asserts one open transition per press. Also lands focus-visible tests for ENG-46730 asserting ArrowDown moves DOM focus onto the options. Refs: ENG-46730, ENG-46739
FieldSelect composes Label + Select (with Trigger + Content + Option
sub-components) + HelperText into a single vertical stack, mirroring
FieldText's role for text inputs. Options are supplied via a data prop
(`options: { value, label }[]`) so the field owns the rendering;
consumers who need slot-based composition can drop to the underlying
Select compound directly.
Includes the spec (.specs/field-select.md, monolithic structure), the
flat exports entry, a size-limit budget (6 KB), a minimal Storybook
story with Default/Sizes/Required/Invalid/Disabled/Multiple, and a
functional test suite covering data-testid derivation, label/for/id
wiring, aria-describedby / aria-required / aria-invalid, disabled
suppression, size variants, option rendering + selection, and axe
runs on the default and disabled states.
Refs: ENG-46732
5 tasks
isaquebock
marked this pull request as ready for review
July 23, 2026 17:33
isaquebock
marked this pull request as draft
July 23, 2026 17:34
isaquebock
marked this pull request as ready for review
July 23, 2026 21:02
Contributor
Contributor
Contributor
The dropdown panel is `position: fixed` inside its Teleport target (document.body). When an ancestor of that target carries a transform, it becomes the panel's containing block, so the viewport coordinates written to top/left are scaled a second time and the panel lands away from the trigger. Storybook's zoom control does exactly this: it applies `transform: scale()` to the preview iframe's body. Add `getFixedFrame()`, which walks up to the ancestor that establishes the containing block and reports its offset and scale, and convert the trigger's viewport rect into that frame before writing the panel's top/left/width. The 4px gap is applied after the conversion so it stays 4 layout pixels and scales with the frame. Both panels are covered by a regression test that scales document.body and asserts the panel keeps tracking the trigger's left edge and width.
The option's focus ring was painted outside its own box, so it extended into the neighbouring options' area. Neighbours paint after it in DOM order and a selected neighbour carries a background, which covered the ring's top and bottom edges — the ring looked cut off on the options around a selected one. Draw the ring inset instead: it stays inside the option's own box, so no sibling can paint over it and the ScrollArea's overflow cannot clip it.
FieldSelect fills its container, and the single-field stories rendered it bare under `layout: 'centered'`, whose container is shrink-to-fit. The width therefore came from the content, so the field resized every time the selected value changed length. Wrap those stories in a fixed-width container, the way the Sizes story already does, and carry the same wrapper into each "Show code" snippet so the panel keeps matching the canvas one-to-one.
The field-select entry was added together with the component in this same branch, with a 6 KB limit written by guess — the component-creation flow has no measurement step, and no gate could catch the guess beforehand because it is the entry itself that creates the gate. Measured, the entry is 16.02 KB. The gap is structural, not a regression. check-size.mjs compiles each entry with only `vue` externalized, so the whole graph counts. In FieldSelect's graph only ScrollArea imports `cn` (clsx + tailwind-merge), and tailwind-merge alone is 17.6 KB gzipped unminified. The gate's own numbers show the floor: entries without `cn` land at 1-2 KB (input-text 1.36, button 1.86), while the smallest entry with `cn` is calendar-root at 15.49 KB. 64 components in the package import `cn`. Budget coverage is deliberately discretionary — 5 of 179 component export paths, and no other Field* component has an entry; check-size.mjs and the standards registry both state that unbudgeted paths stay gated by review. So drop the entry rather than invent a second number, and leave field-select review-gated like the other 174 exports. Measuring component code instead of the shared dependency (externalizing clsx/tailwind-merge in the gate, then ratcheting the remaining budgets down) is the real fix, but it changes the gate's semantics and touches bundle-budget.md — that belongs in its own PR.
Generated by the Storybook Regenerate Baseline workflow (run 30385627137) on this branch, full matrix. The artifact carried 1945 snapshots against the 1909 committed: no deletions, and every pre-existing baseline came back byte-identical, so this commit only adds the 36 missing FieldSelect ones (6 stories x 6 visual modes). The component's own fixes do not move any existing snapshot: the option focus ring only paints under keyboard focus, which the visual run does not capture, and the panel mis-anchoring only reproduces under Storybook's zoom control, which the run does not exercise.
gdsantana
approved these changes
Jul 28, 2026
robsongajunior
approved these changes
Jul 28, 2026
robsongajunior
approved these changes
Jul 28, 2026
gabriel-lisboa-azion
added a commit
that referenced
this pull request
Jul 29, 2026
Brings in #782 (Select/MultiSelect/FieldSelect refinements) and #779 (InputText clickable area). Both regenerated linux visual baselines, which collided with the baselines this branch regenerated for the typography token changes: 60 binary PNGs that git cannot auto-merge (32 inputtext, 24 fieldtext, 4 logview). Neither side is correct for the merged tree, since main's baselines predate the token changes and ours predate the component changes. All 60 are resolved to main's version as the more recent component truth, and the full 1909-baseline matrix is regenerated from this merged tree in the following commit, which is what makes the set consistent. No source conflicts; catalog.json auto-merged. main did not touch packages/theme, so the rem letter-spacing scale and its dist output carry over unchanged.
gabriel-lisboa-azion
added a commit
that referenced
this pull request
Jul 29, 2026
The merge resolved 60 conflicting baselines to main's version, which predates this branch's token changes, and #782 added 36 more for FieldSelect. Regenerated the full matrix from the merged tree so every capture reflects both the landed component fixes and the label-weight and letter-spacing token changes. 92 of 1945 baselines changed: 32 inputtext, 24 fieldtext, 36 fieldselect. All carry a label or overline text surface, so all shift under this branch's tokens. The other 4 conflicted files (logview) regenerate byte-identical to main's version, so the token changes do not reach those captures.
robsongajunior
added a commit
that referenced
this pull request
Jul 29, 2026
* fix(theme): reference typography primitives in text tokens
Replace hardcoded literals in texts.data.js with the primitive tokens
they duplicate, so the text bundles can no longer drift from the scales:
- font sizes -> fontSize.xs/sm/base (text-label-code-*)
- line heights -> leading.none (new primitive, = 1)
- font weights -> fontWeight.normal (text-label-code-*)
- letter spacing -> tracking.normal / tracking.widest
- text-link transition -> duration['fast-02'] + curve['productive-entrance']
Adds leading.none to the leading primitive scale (--leading-none: 1),
the value the code label tokens were already hardcoding.
Emitted CSS is unchanged except for overline letter-spacing, which snaps
from an orphan 0.08em literal onto tracking.widest (0.1em); 0 -> 0em and
the text-link transition are byte-equivalent.
* fix(theme): set label text tokens to normal font weight
text-label-lg / -md / -sm drop from fontWeight.medium (500) to
fontWeight.normal (400), aligning label text with the rest of the
typography scale, which is uniformly 400.
Visual change: any consumer of .text-label-lg / .text-label-md /
.text-label-sm renders one weight step lighter.
* test(storybook): regenerate linux visual baselines for the typography token changes
The label weight (500 -> 400) and overline letter-spacing (0.08em -> 0.1em)
token changes shift rendering in every component that composes
text-label-{lg,md,sm} or text-overline-*, so all four visual shards failed
against the previous baselines.
Regenerated on ubuntu via the app-storybook-generate-baseline workflow (the
committed baselines are linux-only; process.platform keys the snapshot dir).
725 of 1909 baselines changed, all traceable to a label or overline user.
* chore(webkit): regenerate catalog for the new --leading-none token
build-catalog.mjs enumerates the theme tokens into catalog.json, so adding
--leading-none to the leading scale drifts the catalog. The catalog:check
gate lives in the toolkit job, whose paths filter covers packages/webkit and
apps/storybook but not packages/theme -- so a theme-only change skips the
gate and the drift went unreported until this branch touched apps/storybook.
* fix(theme): emit the letter-spacing scale in rem
Every value in dist/v4/globals.css was rem except the tracking scale, which
was em, so the emitted CSS mixed measure units: --text-label-md-font-size
0.875rem sat next to --text-overline-xs-letter-spacing 0.1em.
The six tracking steps move to rem, keeping their numbers. The scale reaches
consumers almost entirely through the eight derived --text-*-letter-spacing
vars (raw tracking-* utilities are banned by the tracking-raw check in
token-checks.js), and five of those are 0 and inert. The three text-overline-*
tokens carry the only visual change: 0.1em on a 0.75rem overline was 1.2px, a
fixed 0.1rem is 1.6px.
Note that letter-spacing in rem no longer scales with the type size, so
text-overline-md keeps the same absolute tracking at its sm breakpoint
(0.875rem) as at its base size.
No token was added, removed or renamed, and catalog.json stores names only, so
it needs no regeneration. build:tokens stays idempotent.
* test(storybook): regenerate linux baselines for the rem letter-spacing
The tracking scale moving from em to rem takes text-overline-* letter-spacing
from 0.1em (1.2px at a 0.75rem size) to a fixed 0.1rem (1.6px), so stories that
render overline text shift.
22 of 1909 baselines changed, across 4 stories: the three Overline stories in
all six modes, plus the two Dropdown stories that render a group label, on the
mobile modes where the extra tracking reflows the text. The remaining
text-overline-* users (divider, code-block, menu-item, select-group,
multi-select-group) captured no visible change.
Overline's own baselines changing confirms that its tracking-tightest class
emits nothing: there is no tightest step on the scale, so the component was
already rendering the token's letter-spacing rather than overriding it.
Regenerated on ubuntu via the app-storybook-generate-baseline workflow, since
the committed baselines are linux-only (process.platform keys the snapshot dir).
* test(storybook): regenerate linux baselines on the merged tree
The merge resolved 60 conflicting baselines to main's version, which predates
this branch's token changes, and #782 added 36 more for FieldSelect. Regenerated
the full matrix from the merged tree so every capture reflects both the landed
component fixes and the label-weight and letter-spacing token changes.
92 of 1945 baselines changed: 32 inputtext, 24 fieldtext, 36 fieldselect. All
carry a label or overline text surface, so all shift under this branch's tokens.
The other 4 conflicted files (logview) regenerate byte-identical to main's
version, so the token changes do not reach those captures.
* fix(webkit): drop overline's dead and wrong typography overrides
overline.vue restated three things the text-overline-md token already owns, and
two of them were silently wrong:
- `leading-1` is not line-height 1. In Tailwind v4 `leading-<n>` resolves to
calc(var(--spacing) * n), so it emitted line-height: 0.25rem — and since the
custom @Utility for text-overline-md is emitted before core utilities, it won
the cascade. Overline text has been rendering a 4px line box instead of the
token's 1.375.
- `tracking-tightest` is not a step on the tracking scale, so Tailwind emitted
nothing for it. It overrode nothing, and the token's letter-spacing always
applied.
- `font-proto-mono` duplicates the token's own font-family (fontFamily.display
is Proto Mono) and is banned by the font-family-raw check.
Both wrong names slipped past token-checks.js because its alternations did not
cover them: leading-raw listed only 3-12, and tracking-raw omitted tightest.
Widened both so this class of mistake is reported instead of read as working
code. leading-none stays out of leading-raw, which allows it on icons.
Measured with the Tailwind v4 compile API rather than inferred: leading-1 emits
line-height: var(--spacing), leading-none emits var(--leading-none),
tracking-tightest emits nothing, and .leading-1 sorts after .text-overline-md.
overline.vue was the repo's only user of either name, so the widened rules add
no other violations. ESLint clean on webkit src; 16 overline tests pass.
* test(storybook): regenerate overline baselines for the line-height fix
Dropping `leading-1` from overline.vue lets the token's line-height (1.375)
apply where a 0.25rem line box was winning, so every Overline capture changes:
18 of 1945 baselines, the three Overline stories across all six modes.
Nothing else moved. The components that compose `text-overline-*` directly
(divider, dropdown-group, menu-item, code-block, select-group,
multi-select-group) never carried the override, so their captures are unchanged
— the defect was local to overline.vue.
---------
Co-authored-by: ROBSON.JUNIOR <robsongajunior@users.noreply.github.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.






Summary
Refinamentos ao Select V4 cobrindo 4 bugs do épico.
SelectOption; adicionados testes de regressão para navegação viaArrowDown(foco visível e wrap).select-content.vueusa o novo token--z-input-overlay: 1100desse PR (não modificado aqui para evitar conflito).FieldSelect(Label + Select + HelperText),v-modelviadefineModel, seguindo padrãoFieldText.onDocumentPointerDown(capture phase) fechava o painel ao pointerdown no próprio trigger, e o@clickdo trigger reabria. Bailout adicionado quandotriggerRef.contains(target).How to test (mapeado 1:1 com Jira)
ENG-46730 — "Focus state por teclado não funciona nas opções"
ArrowDown→ primeira opção exibe o focus ring visível (destaque visual da opção em foco por teclado).ArrowDownsucessivos movem o ring; do último volta para o primeiro (wrap).Escapefecha e devolve o foco para o trigger.ENG-46731 — "Dropdown renderiza atrás de Drawer/Dialog (z-index)"
<Select>ou<MultiSelect>dentro de um<Drawer>ou<Dialog>aberto.MultiSelect(mesmo fix, aplicado nomulti-select-content).ENG-46732 — "Criar componente FieldSelect (faltando)"
Default— Label acima, Select no meio, HelperText abaixo — análogo aos demais Field*.Sizes,Required,Invalid,Disabled,Multiplerenderizam corretamente ev-modelfunciona.ENG-46739 — "Clique duplicado ao selecionar opção"
update:open→ apenas 1 evento por clique no trigger.update:modelValuedispara apenas uma vez (não duas).Review follow-ups (comentários do PR)
Três bugs visuais reportados por @gdsantana na review, cada um com causa raiz confirmada em Chromium real:
position: fixeddentro do Teleport target (document.body), e o controle de zoom do Storybook aplicatransform: scale()nesse body. O body transformado passa a ser o containing block do painel, então as coordenadas de viewport lidas do trigger eram escaladas uma segunda vez (medido:left: 351pxrenderizava em548px= 351 × 1.5625). Novo helper internogetFixedFrame()(src/utils/containing-block.ts) sobe até o ancestral que cria o containing block e devolve seu offset + escala;select-contentemulti-select-contentconvertem o rect do trigger para esse espaço. O gap de 4px é somado depois da conversão, para permanecer 4px de layout e escalar junto. Com zoom: desvio de0.2pxnolefte nawidth.inset, dentro da própria box — nenhum irmão pinta por cima e ooverflowdoScrollAreanão corta.w-full, mas as stories de campo único o renderizavam solto soblayout: 'centered', cujo container é shrink-to-fit, então a largura vinha do conteúdo. As stories agora usam um container de largura definida, como a storySizesjá fazia, e o mesmo wrapper foi levado para cada snippet de "Show code" para o painel continuar batendo 1:1 com o canvas. Largura do trigger após cada seleção:320, 320, 320.Teste de regressão para o posicionamento em
select.test.tsemulti-select.test.ts: escala odocument.bodye verifica que o painel continua acompanhando a borda esquerda e a largura do trigger. Verificado que não é vacuoso — revertendo o fix, ambos falham (desvio de27.5px/34.9px).Não há teste para o anel
inset: asserção debox-shadowé style assertion, proibida por.claude/rules/testing.md. Verificado visualmente com foco real de teclado (:focus-visiblenão ativa comfocus()programático).Fora do escopo deste PR: o mesmo defeito de zoom existe em
usePlacement(src/composables/use-placement/index.js), que serve Dropdown, Popover, Tooltip, Calendar e os filtros da Table. A correção é adotar o mesmogetFixedFrame()na conversão final detop/left— vale um PR próprio.Bundle budget do field-select
A entrada
field-selectdo.size-limit.jsonfoi criada nesta mesma branch, junto com o componente, com limite de 6 KB escrito a palpite: o fluxo de criação de componente não tem etapa de medição, e nenhum gate poderia ter pegado o chute antes, porque é a própria entrada que cria o gate. Medida, a entrada dá 16.02 KB — opnpm run sizeestava vermelho desde aquele commit, não por causa dos fixes deste PR.O estouro é estrutural. O
check-size.mjscompila cada entrada externalizando apenasvue, então o grafo inteiro entra na conta. No grafo do FieldSelect só oScrollAreaimportacn(clsx+tailwind-merge), e otailwind-mergesozinho tem 17.6 KB gzip não-minificado. Os próprios números do gate mostram o piso:cn?input-textbuttoncalendar-roottable-rootEntradas sem
cnficam em 1-2 KB; a menor comcné 15.49 KB. São 64 componentes do pacote importandocn.Cobertura de budget é deliberadamente discricionária — 5 de 179 component export paths, e nenhum outro
Field*tem entrada; ocheck-size.mjse o registry emstandards.mjsafirmam que quem não está na lista fica gated by review. Então a entrada foi removida em vez de trocar um número inventado por outro, deixando ofield-selectsob review como os outros 174 exports.O fix de verdade fica para um PR próprio: externalizar
clsx/tailwind-mergeno gate, medindo o código do componente em vez da dep compartilhada (que o consumidor baixa uma vez, não por componente), e então ratchetar os budgets restantes para baixo. Muda a semântica do gate e tocabundle-budget.md, por isso não entra aqui.Baselines visuais
Regenerados pelo workflow Storybook Regenerate Baseline (run
30385627137) nesta branch, matriz completa. O artifact trouxe 1945 snapshots contra os 1909 commitados: nenhuma deleção, e todo baseline pré-existente voltou byte-idêntico — o commit só acrescenta os 36 do FieldSelect que faltavam (6 stories × 6 modos).Nenhum snapshot existente se move por causa dos fixes, e isso é esperado: o focus ring das opções só pinta sob foco de teclado, que o run visual não captura, e o desalinhamento do painel só reproduz sob o controle de zoom do Storybook, que o run não exercita.
Dependencies
Requer merge de #783 (ENG-46735) primeiro — traz o token
--z-input-overlayque resolve ENG-46731.