feat(forms): add the form-control-group primitive and move every field component onto it - #675
Merged
Conversation
…d component onto it Six components draw a frame that has to read as a single control — autocomplete, chip input, multi select, password input and the four picker shells — and each carried its own copy of the styling. That duplication is what produced three v2 bugs already (a double icon, a double frame that broke sizing, one picker reading another's properties) and it is what the styling doctrine set out to end. The primitive ------------- - `.form-control-group` is the frame. It shares one canonical definition with `.form-control` through the new `form-control-frame()` mixin, lays its children out as a flex row, and answers focus on `:focus-within` because a div never fires `:focus`. `form-control-group-focus()` lets a component replay that treatment while its popup holds the focus. - `.form-control-group > .form-control`: the control inside gives up its own frame. One deliberate rule replaces the per-family copies, and it outranks a theme's `.form-control` border so a second frame can never reappear inside a group. There is no "ghost" class — the editable keeps the class it already had. - `.form-control-icon` is an icon and nothing more: passive, and usable either on its own or inside an action. `.form-control-action` is an interactive adornment (a real button) that may hold one; `.form-control-cleaner` is the value-clearing action. - Sizing rides the standard `.form-control-sm`/`-lg` on the frame, so the per-family sizing classes are gone. The group answers those classes itself, since it declares the control tokens and would otherwise win the cascade against them. Adoption -------- - Pickers: frame, indicator and the range separator move to the shared classes; the sizing classes and the indicator tokens disappear. - Password input drops the overlay model entirely — the toggle was an absolutely positioned button over the input, with padding carved out and its size recalculated per size class. As a flex child none of that is needed. Its icon paints in `currentcolor`, so it follows the action through hover and validation instead of needing its own colour rules. - Chip input keeps wrapping, chip spacing and chip disabling; the frame, focus and disabled treatment come from the group. - Autocomplete and multi select take the frame. Their validation, open and disabled states are now written onto the frame rather than inherited from the component root, which also retires their `!important` overrides. Their adornments keep family styling for now: those icons are CSS masks, and converting them to the inline-SVG convention the primitive's actions expect belongs with their Popup pass. - Validation generalizes to one `.form-control-group` selector, replacing the per-family pair added in #673. Compiled CSS drops by 1892 lines. The primitive gets its own docs page and the migration guide carries the class map for the markup users write by hand. Design and rationale: architecture/v6-form-controls-styling.md in the workspace repo.
Coverage Report for CI Build 30898775070Coverage increased (+0.08%) to 93.105%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
Both families pad the parts inside the frame, not the frame itself — a wrapped tag has to be able to reach its edge — so the padding the primitive applies came on top of theirs and the control grew from 38px to 50px. Their frames now carry none, and their parts stretch to the full height as they did before.
…s disabled A control that isn't a real input — the pickers' section field — carries `.disabled` as a class, which `:disabled` never matches. So the frame never learned it was disabled while the field painted the disabled background on itself: a grey block inside a white frame. Both spellings now reach the frame, and the control inside gives up its disabled background the same way it gives up the rest of its chrome. This restores what v1 did through `.date-picker.disabled`, without each family having to wire its root state to the frame.
…is disabled The indicator button was never actually disabled — the shells only had the click handler return early — so a disabled picker still offered a focusable button that silently did nothing, and it kept its enabled colours next to a greyed-out frame. The shells set the attribute now, so it drops out of the tab order and the primitive's :disabled styling applies. Adornments inside a group marked .disabled are muted too, for groups assembled by hand where the author disabled the control but not every button. Direct children only, so an icon inside an action doesn't fade twice.
…rimitive v1 offered a button inside the field that cleared the value; the v2 rewrite dropped it, which is how its styling tokens ended up dead. The primitive already had a place for it, so it comes back properly: - a real `<button>` — v1 used a bare div, so the control it operated could not be reached from the keyboard — labelled, disabled together with the picker, and clearing without opening the popup. - it appears only once the field holds a value. The section field already reports that with `.form-date-time-filled`, so the rule belongs to that family and one selector covers all four shells; no component has to track the state itself. - `cleaner` (on by default, as in v1), `cleanerIcon` and `ariaCleanerLabel` on all four shells. The cross icon was duplicated byte for byte as a data URI in autocomplete and multi select; it becomes one inline SVG in `util/icons`, painted in currentColor so it follows the action through hover, validation and disabled. Building an adornment moves to `util/form-control-group` — the JavaScript half of the primitive's markup contract, and the same consolidation the SCSS just went through. Budgets: the JS ones move up for the restored feature, and the CSS ones come down, so the 1892 lines this branch removed stay removed.
The cross came from a 16-unit grid, where the same shape carries roughly twice the stroke weight of the 512-grid icons the indicators use — at a shared 16px it read noticeably heavier than the calendar and clock next to it.
The cross was drawn three different ways: a data URI duplicated byte for byte in autocomplete and multi select, painted through a CSS mask on a ::before, and a third copy about to appear in the pickers. Both families now use `.form-control-cleaner` with the shared inline SVG, so every cleaner in the library is the same mark at the same size, and follows its button's colour through hover, validation and disabled instead of needing tokens of its own. The primitive gives a cleaner its own icon size (.625rem, the value both families already used): the cross is drawn on a 16-unit grid, so it carries more weight than the 512-grid glyph icons and has to render smaller to read as their equal. 32 declarations go — both families' cleaner width, height, padding, icon, icon colours and icon size. Their indicator keeps its mask-based styling until the Popup pass; merging what was left of the shared base rule into it removes a duplicate selector.
The cleaner had its own smaller icon size, inherited from autocomplete, because the cross it used was drawn on a 16-unit grid and carried about twice the stroke weight of the icons beside it. That put a per-role exception into a primitive whose whole point is that these are the same kind of button. The cross is cil-x from the CoreUI set now — drawn on the same 512 grid as the icons it stands next to — so both roles share one rule and one icon size, and the exception goes away with the token behind it.
cil-x carries 85 units of built-in margin where the calendar and clock carry 16, so on the raw 512 box it painted a third less ink than they did at the same rendered size — the cross looked inset rather than equal. Cropping it to their exact coverage overshoots the other way: a cross that reaches the corners reads larger than an orthogonal glyph of the same box, its diagonal being 1.41x the side. The viewBox sits between the two, which lets every adornment keep one icon size.
The guard exists so no public class disappears without a migration note. Ten did: the per-family frames' indicators, separators and sizing classes, and the three cleaners.
Their frames and cleaners already came across; the indicator was the last part still styled by the family, drawn as a CSS mask through a ::before. It is a `.form-control-action` now with the shared inline SVG, so every adornment in the library is the same mark at the same size and takes its colour from the button. The chevron was the second icon duplicated byte for byte between these two — it joins the cross in `util/icons`. Both indicators also become genuinely disabled buttons rather than ones merely dropped from the tab order with tabindex, which is what a disabled control should offer. What is left of these two families' styling is what they actually own: the options listbox, tags, search, and the caret turning when the popup opens.
…he group's own Two things were still hand-written inside the group. The editable carried `.autocomplete-input`, a copy of the typography, colours, padding and reset that `.form-control` plus the group's neutralization already give it — it is a plain `.form-control` now. And both families wrapped their adornments in a `-buttons` div that existed to give an unpadded frame its height; the group lays its children out itself, so the buttons are its children and the wrapper is gone. With that, the frame pads both families like every other control instead of delegating it inwards: the multi select tag row keeps only its own spacing, and the autocomplete markup is now the same shape as a picker's — control, cleaner, indicator. The neutralization reaches nested editables too, since everything inside a group is part of the control, and the picker's cleaner-visibility rule is scoped to groups actually built around a section field. It was written when their cleaner sat inside a wrapper; once every cleaner became a direct child it would have hidden the ones the other components manage themselves.
…ks for it `selectionType: 'tags'` and `'chips'` were the same idea built twice, and the default was the hand-rolled one: a div with a delete button, styled by its own set of variables, without the roles a Chip carries since #658. Both values render the Chip component now, so a selection looks and behaves the same everywhere in the library and is themed through the chip properties. `'tags'` stays accepted as a synonym. That removes the second implementation entirely — its markup builder, its update pass, its delete handler (chips route their own removal through the selection model) and eighteen variables.
… panel The pickers' dropdowns and the combobox listbox drew the same box three times: three sets of background, border, radius, shadow and z-index tokens, two of them byte-for-byte copies of each other. The panel now carries `.popup`, the floating-surface primitive of the field components, next to its structural class — which keeps its own shape (min-content for pickers, toggler-width for the listbox) and the parent-driven open state, and keeps carrying the family tokens for the teleported case. Nineteen chrome declarations collapse into six `$popup-*` variables and their custom properties: theme the popup once and every field component's panel follows. Defaults match what the panels drew before, so nothing changes visually. The name is deliberately presentation-neutral — this is the anchored mode; the fullscreen/modal mode for mobile (the v1 pickers had one, the v2 rewrite lost it) is designed at this primitive and lands separately. The popup borrows the look of a menu, never its interaction semantics: the body inside owns those, and Menu itself stays the nav primitive.
… typing The combobox family plan makes chip-input the selection surface of the multi select: chips there come from the listbox selection, and typing filters options rather than minting chips. `create: false` is that mode: Enter, separators, paste-splitting and create-on-blur stop producing chips, no hidden form input is rendered (the host owns the value), while `add()` and the whole chip keyboard model keep working. Groundwork for embedding: chip-input already adopts an existing <input> instead of creating its own, so the host can hand it a field carrying its own wiring.
|
|
||
| it('should not render a hidden form input', () => { | ||
| fixtureEl.innerHTML = '<div id="ci"></div>' | ||
| const chipInput = new ChipInput(fixtureEl.querySelector('#ci'), { create: false }) // eslint-disable-line no-unused-vars |
Every regression of the primitives cycle — the doubled padding, the white disabled frame, the vanishing cleaner, the oversized cross — was caught by eye, none by the 2950-test unit suite, because a shared rule reaches six components and no unit test sees a pixel. This is the safety net for exactly that class of change. Vitest 4's browser mode ships toMatchScreenshot, so the suite is the same Chromium-through-Playwright the unit specs run in — no new dependency. 21 screenshots cover the form-control-group and .popup surfaces: the pickers (states, sizes, open popups, dark mode), autocomplete, multi select (chips, dark), chip input and password input. Determinism: fixed dates only, transitions and animations off, caret hidden, fixed viewport, one screenshot per test (creating a missing baseline aborts the test, so a second one would never get its baseline on the first pass). Baselines are per platform — the matcher suffixes them with the browser and OS — and macOS ones are committed here; the Visual workflow generates the Linux set as an artifact on its first run and gates once it is committed. Runs via `npm run js-test-visual`; deliberately outside the local pre-push gate, since macOS font rendering would fight the CI baselines.
…ation across chips The chips were individually focusable but not navigable: no roving focus, no keyboard model across the row. The selection area is a role-less ChipSet now (the ChipInput precedent — the container mixes chips with the search input), which brings arrow-key navigation, Home/End, keyboard removal on a focused chip, and a role=status region announcing add and remove — replacing the cruder aria-live on the container, which announced every re-render. The selection model stays the component's own: Chip.getOrCreateInstance makes both sides meet on one instance.
The frame declares the --cui-control-* properties on itself, so a family's own copy of them - declared on the component root and shadowed on every element inside the group - never had an effect. Each family keeps only what is genuinely its own: placeholder colours, the pickers' footers, the multi select's tag row. Gone with the layer: the frame-mirroring Sass variables, the family-named sizing classes (.autocomplete-sm/-lg, .form-multi-select-sm/-lg - sizing is .form-control-sm/-lg on the group), the multi select's tag-delete tokens orphaned by the move to chips, and the @use lines none of it needed. The visual suite pins the proof: all 21 screenshots unchanged.
…s in two chip input examples Calendar and the date-time field partial each carried a box-shadow @use nothing consumed. The chip input Sizes examples lost their .form-control-group during the frame migration - the component does not add the class itself, so the small and default variants rendered without a frame.
…the chevron snapped The shared $input-btn-transition moves color, background, border and shadow; the indicator flip on an open combobox rotates. v5 gave the indicator its own transform transition - the action token carries it now, for every family at once.
Nothing in the harness ever loaded the stylesheet, so all 21 baselines were pictures of bare markup: no frames, no chips, and the dark-mode shots identical to the light ones. It would have passed any CSS regression, which is the only thing it exists to catch. The spec imports scss/coreui.scss now - the source, so a change shows up on the next run with no build step - and every baseline is regenerated. Three cases join it, one per way a validation state reaches the frame: the component root (autocomplete, multi select) and native constraint validation on the control inside the group.
… rule Autocomplete and Multi Select carried their own copy of the frame's validation state, and it had drifted: it set the border but not the adornment colours, so an invalid multi select kept a grey chevron where an invalid picker got a red one, and its focus ring was mixed from a different source than every other control in the library. Both copies are gone; the shared mixin now names their root classes alongside the group's own state, and the four $*-invalid/valid-border-color variables that shadowed --cui-form-*-border-color go with them. Native constraint validation reaches the frame too, which it never did: a required control inside a group under .was-validated only recoloured itself, and the control inside a group draws no border. The autocomplete's was-validated selector had been looking for a sibling element the component never builds. Locked by three visual cases - one per route the state takes to the frame. Catching them needed the comparator to count antialiased pixels: a 16px icon's stroke is almost all antialiasing, and pixelmatch discounts those by default, so grey-to-red scored as no difference.
…lready does Autocomplete is down to four rules and no variables of its own. What went: a disabled state the group already reads off the disabled control inside it (and reads more completely - it also sets the cursor and mutes the adornments); a text colour the control inherits from the focused frame; a placeholder colour identical to --cui-control-placeholder-color, which it was shadowing rather than extending; and a selector feeding tokens to a teleported .autocomplete-dropdown, a class the component stopped emitting when the options menu became the shared combobox surface. Multi Select loses the same placeholder mirror and dropdown selector, plus a position: relative on the selection with nothing absolute inside it. Its disabled state moves where the doctrine puts it - on the frame - so the one shared rule covers it: the class lands on the group in JS now, since the search input the group would otherwise read is absent when search is off. The visual suite is what makes this safe to do: the two states without a screenshot got one first, so every removal had to prove itself against a baseline taken before it.
…ilities API The dark: variants were generated for the colour utilities only, never documented, and duplicated every value under a [data-coreui-theme] selector - 40 classes and ~10 kB of CSS to say what light-dark() already says in the palette. The rtl key had nothing left to do: direction is logical properties now, so rtl: false only emitted RTLCSS /* rtl:remove */ directives and rtl: true carried ltr/rtl value maps for a single utility. Both branches doubled the generator, which is 44 lines lighter for it. translate-middle follows Bootstrap v6-dev and stops flipping under dir="rtl" - transform is not direction-aware, and the flag was the only thing left asking the generator to care about direction. Aligns the API with Bootstrap's v6-dev branch, which we track; their side adds selector types and child-selector on top, which is the next step.
… rtl key transform is not direction-aware: in an RTL document .start-50 resolves to right: 50%, so an element centred on that line has to move the other way. Dropping the generator's rtl key took the flip with it; it comes back as the plain [dir="rtl"] rule it always should have been - one rule for the one utility that needs it, instead of an option every utility carries.
The Sass suite covers the generator directly, and two specs still described the old API: one asserted the RTLCSS removal directives that the dropped rtl key used to emit, and the api spec caught the translate-middle override leaking into a run whose $utilities map never asked for that utility. The override is tied to the utility now, so disabling it takes the override with it - which is what the spec was really reporting.
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.
Implements the frame layer of the v6 styling doctrine (
architecture/v6-form-controls-styling.mdin the workspace repo), designed with @mrholek on 2026-08-03.The problem
Six components draw a frame that has to read as a single control — autocomplete, chip input, multi select, password input and the four picker shells — and each carried its own copy of the styling. Per family, 26–34 Sass variables literally aliased
$input-*. That duplication produced three v2 bugs already: a double icon, a double frame that broke sizing, and one picker reading another family's properties.The primitive
.form-control-groupis the frame. It shares one canonical definition with.form-controlthrough the newform-control-frame()mixin, lays children out as a flex row, and answers focus on:focus-within(a div never fires:focus).form-control-group-focus()lets a component replay that treatment while its popup holds the focus..form-control-group > .form-controlgives up its own frame — one deliberate rule replacing the per-family copies, and it outranks a theme's.form-controlborder so a second frame can't reappear inside a group. No "ghost" class, unlike upstream'sform-adorn/form-ghost..form-control-iconis an icon and nothing more — passive, usable on its own or inside an action..form-control-actionis an interactive adornment (a real button) that may hold one;.form-control-cleaneris the value-clearing action. Upstream's adornments are passive only; every real adornment in our components is a button, which is where the consolidation actually pays..form-control-sm/-lgon the frame, so per-family sizing classes are gone. The group answers those classes itself, because it declares the control tokens and would otherwise win the cascade against them.Adoption — all six families
currentcolorso it follows the action through hover and validation!importantoverrides. Their frames carry no padding — both pad the parts inside instead, because a wrapped tag has to reach its edge — and their parts stretch rather than centre. Adornments keep family styling for now: those icons are CSS masks, and converting them to the inline-SVG convention belongs with their Popup passValidation generalizes to one
.form-control-groupselector, replacing the per-family pair added in #673.Result
Compiled CSS drops by 1892 lines (17569 → 15677).
fusvis green with zero unused variables after removing everything the migration orphaned.Verification
Full gate: eslint, tsc, stylelint, 44 SCSS tests, 2939 unit tests, DefaultType check, dist, bundlewatch PASS, and the docs site builds (139 pages). Beyond that, every family was checked in a real browser — frame, focus, disabled, invalid/valid and the
sm/lgvariants — because none of that is covered by unit tests: picker heights come back at exactly the pre-migration 31/38/48 px, password renders correctly in all four states (including the red eye icon on invalid, which is thecurrentcolorpayoff), chips render and wrap in all three sizes, and autocomplete/multi-select keep their frames in every state.Docs
The primitive gets its own page — parts, sizing, validation, disabled, and the boundary against
.input-group(separate controls side by side vs one control made of parts). The migration guide carries the class map for markup users write by hand (the password wrapper and chip input).Follow-up in this branch: the pickers get their cleaner back
v1 offered a button inside the field that cleared the value; the v2 rewrite dropped it — which is how its styling tokens ended up dead and got removed earlier. The primitive already had a place for it, so it returns properly:
<button>. v1 used a bare<div>, so the control that cleared the field could not be reached from the keyboard at all. It is labelled, disabled together with the picker, and clears without opening the popup..form-date-time-filled, so the rule belongs to that family and one selector covers all four shells — no component tracks the state itself.cleaner(on by default, as in v1),cleanerIcon,ariaCleanerLabel.util/icons, painted incurrentColorso it follows the action through hover, validation and disabled.util/form-control-group— the JavaScript half of the primitive's markup contract, and the same consolidation the SCSS went through.Bundle budgets
The restored feature adds JS, so
coreui.js,coreui.esm.jsandcoreui.bundle.jsmove up by ~1 kB each. In the same commit the CSS budgets come down (56.5 → 55 kB, 47 → 45.5 kB) so the 1892 lines this branch removed stay removed rather than becoming slack.Verified in the browser across all four pickers: visible with a value, hidden while empty, disabled with the picker, absent under
cleaner: false, and clearing without opening the popup. 2946 unit tests green (7 new), docs site builds.One cleaner across the library
The cross was drawn three different ways: a data URI duplicated byte for byte in autocomplete and multi select, painted through a CSS mask on a
::before, and a third copy about to appear in the pickers. All of them are.form-control-cleanerwith the shared inline SVG now, so every cleaner is the same mark at the same size and follows its button's colour through hover, validation and disabled rather than carrying tokens of its own. 32 declarations go — both families' cleaner width, height, padding, icon, icon colours and icon size.The primitive gives a cleaner its own icon size (
.625rem— the value both families already used): the cross is drawn on a 16-unit grid, so it carries more weight than the 512-grid glyph icons and has to render smaller to read as their equal. That is also why the first attempt looked oversized next to the calendar.Autocomplete and multi select finish the move
Their indicator was the last part still styled by the family, drawn as a CSS mask through a
::before. It is a.form-control-actionwith the shared inline SVG now, so every adornment in the library is the same mark at the same size and takes its colour from the button.The chevron turned out to be the second icon duplicated byte for byte between those two (after the cross) — both live in
util/iconsnow. Their indicators also became genuinely disabled buttons instead of ones merely dropped from the tab order withtabindex="-1", which is what a disabled control should offer.What remains family-styled in those two is what they actually own: the options listbox, tags, search, and the caret turning when the popup opens.
CI guard worth knowing about
check-class-apiblocks any public class disappearing without a declared removal. Twelve did across this branch — the per-family frames, indicators, separators, sizing classes and cleaners — each now registered inbuild/class-api-removals.jsonwith the reason a user needs, and mapped in the migration guide.Layer 2: the
.popupprimitiveDecided with @mrholek in review: the floating panel is its own primitive, and deliberately not named after the menu — the same surface will grow a fullscreen/modal presentation for mobile (the v1 pickers had one; the v2 rewrite lost it), so the name must not bake the anchored look in.
.popupnext to its structural class (date-picker-dropdown/time-picker-dropdown/combobox-popup), which keeps only its own shape, the parent-driven open state, and the family tokens for the teleported case.$popup-*variables — theme the popup once and every field component's panel follows. Defaults match the previous values, so nothing changes visually; verified in the browser on an open picker, an open combobox and a picker teleported viacontainer..menuon its panel. Ours cannot — the modal mode is a real requirement here — and the popup borrows the menu's look, never its interaction semantics.The doctrine's three layers are now:
form-control-group(one frame) — implemented;.popup(one floating surface, two presentations) — anchored mode implemented, fullscreen mode designed, lands separately; component-owned bodies — untouched by design.Running CSS total for the branch: 17569 → 15442 lines (−2127).