Skip to content

fix: [Select/MultiSelect/FieldSelect] refinements (ENG-46730, 46731, 46732, 46739) - #782

Merged
robsongajunior merged 17 commits into
mainfrom
fix/select-refinements-eng-46730-31-32-39
Jul 28, 2026
Merged

fix: [Select/MultiSelect/FieldSelect] refinements (ENG-46730, 46731, 46732, 46739)#782
robsongajunior merged 17 commits into
mainfrom
fix/select-refinements-eng-46730-31-32-39

Conversation

@isaquebock

@isaquebock isaquebock commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Refinamentos ao Select V4 cobrindo 4 bugs do épico.

  • ENG-46730 — Focus ring já estava no SelectOption; adicionados testes de regressão para navegação via ArrowDown (foco visível e wrap).
  • ENG-46731Resolvido via fix: [Inputs] normalize z-index across all inputs (ENG-46735) #783. O select-content.vue usa o novo token --z-input-overlay: 1100 desse PR (não modificado aqui para evitar conflito).
  • ENG-46732 — Criado componente FieldSelect (Label + Select + HelperText), v-model via defineModel, seguindo padrão FieldText.
  • ENG-46739 — Root cause: onDocumentPointerDown (capture phase) fechava o painel ao pointerdown no próprio trigger, e o @click do trigger reabria. Bailout adicionado quando triggerRef.contains(target).

How to test (mapeado 1:1 com Jira)

ENG-46730 — "Focus state por teclado não funciona nas opções"

  • Storybook → Components/Inputs/Select → abrir o painel.
  • Pressionar ArrowDown → primeira opção exibe o focus ring visível (destaque visual da opção em foco por teclado).
  • ArrowDown sucessivos movem o ring; do último volta para o primeiro (wrap).
  • Escape fecha e devolve o foco para o trigger.

ENG-46731 — "Dropdown renderiza atrás de Drawer/Dialog (z-index)"

  • Depois do PR fix: [Inputs] normalize z-index across all inputs (ENG-46735) #783 mergear: montar um <Select> ou <MultiSelect> dentro de um <Drawer> ou <Dialog> aberto.
  • Abrir o Select → painel de opções renderiza acima do overlay do modal (antes ficava atrás/invisível).
  • Repetir com MultiSelect (mesmo fix, aplicado no multi-select-content).

ENG-46732 — "Criar componente FieldSelect (faltando)"

  • Storybook → Components/Inputs/FieldSelect existe (era o gap).
  • Story Default — Label acima, Select no meio, HelperText abaixo — análogo aos demais Field*.
  • Stories Sizes, Required, Invalid, Disabled, Multiple renderizam corretamente e v-model funciona.

ENG-46739 — "Clique duplicado ao selecionar opção"

  • Storybook → Components/Inputs/Select → abrir com clique no trigger.
  • Clique novamente no trigger → painel fecha limpo (antes: fechava e reabria imediatamente).
  • DevTools log de update:openapenas 1 evento por clique no trigger.
  • Clique em uma opção → update:modelValue dispara 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:

  • Menu abrindo fora de lugar com zoom no Storybook — o painel é position: fixed dentro do Teleport target (document.body), e o controle de zoom do Storybook aplica transform: 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: 351px renderizava em 548px = 351 × 1.5625). Novo helper interno getFixedFrame() (src/utils/containing-block.ts) sobe até o ancestral que cria o containing block e devolve seu offset + escala; select-content e multi-select-content convertem 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 de 0.2px no left e na width.
  • Bordas das opções do MultiSelect "por baixo" — o focus ring era pintado fora da box da opção, invadindo a área das vizinhas; vizinhas pintam depois na ordem do DOM e uma vizinha selecionada carrega background, cobrindo o topo e a base do anel. Passou a ser desenhado inset, dentro da própria box — nenhum irmão pinta por cima e o overflow do ScrollArea não corta.
  • FieldSelect mudando de largura conforme a seleção — não era o componente: o trigger é w-full, mas as stories de campo único o renderizavam solto sob layout: '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 story Sizes já 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.ts e multi-select.test.ts: escala o document.body e 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 de 27.5px / 34.9px).

Não há teste para o anel inset: asserção de box-shadow é style assertion, proibida por .claude/rules/testing.md. Verificado visualmente com foco real de teclado (:focus-visible não ativa com focus() 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 mesmo getFixedFrame() na conversão final de top/left — vale um PR próprio.

Bundle budget do field-select

A entrada field-select do .size-limit.json foi 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 — o pnpm run size estava vermelho desde aquele commit, não por causa dos fixes deste PR.

O estouro é estrutural. O check-size.mjs compila cada entrada externalizando apenas vue, então o grafo inteiro entra na conta. No grafo do FieldSelect só o ScrollArea importa cn (clsx + tailwind-merge), e o tailwind-merge sozinho tem 17.6 KB gzip não-minificado. Os próprios números do gate mostram o piso:

Entrada Medido Puxa cn?
input-text 1.36 KB não
button 1.86 KB não
calendar-root 15.49 KB sim
table-root 42.98 KB sim

Entradas sem cn ficam em 1-2 KB; a menor com cn é 15.49 KB. São 64 componentes do pacote importando cn.

Cobertura de budget é deliberadamente discricionária — 5 de 179 component export paths, e nenhum outro Field* tem entrada; o check-size.mjs e o registry em standards.mjs afirmam 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 o field-select sob review como os outros 174 exports.

O fix de verdade fica para um PR próprio: externalizar clsx/tailwind-merge no 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 toca bundle-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-overlay que resolve ENG-46731.

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
@isaquebock
isaquebock marked this pull request as ready for review July 23, 2026 17:33
@isaquebock
isaquebock requested a review from a team as a code owner July 23, 2026 17:33
@isaquebock
isaquebock marked this pull request as draft July 23, 2026 17:34
@isaquebock
isaquebock marked this pull request as ready for review July 23, 2026 21:02
@gdsantana

Copy link
Copy Markdown
Contributor
image

menu do Select esta abrindo errado quando é feito zoom no story

@gdsantana

Copy link
Copy Markdown
Contributor
image image MultiSelect ta ficando com as bordas por baixo para as primeiras opçoes

@gdsantana

Copy link
Copy Markdown
Contributor

O FieldSelect fica alterando a largura conforme selecionamos mais itens.
image
image
image

robsongajunior and others added 11 commits July 26, 2026 00:54
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.
@robsongajunior
robsongajunior merged commit 344f77c into main Jul 28, 2026
22 checks passed
@robsongajunior
robsongajunior deleted the fix/select-refinements-eng-46730-31-32-39 branch July 28, 2026 22:12
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants