Skip to content

feat(DatePicker,DateRangePicker,DateInput): add week and quarter section masks - #673

Merged
mrholek merged 8 commits into
v6-devfrom
feat/week-quarter-pickers
Aug 3, 2026
Merged

feat(DatePicker,DateRangePicker,DateInput): add week and quarter section masks#673
mrholek merged 8 commits into
v6-devfrom
feat/week-quarter-pickers

Conversation

@mrholek

@mrholek mrholek commented Aug 2, 2026

Copy link
Copy Markdown
Member

Closes the last selection-type gap in the pickers rewrite (gate condition 3): selectionType="week" and selectionType="quarter" now render dedicated masked sections — the equivalents of the native <input type="week"> and of the <input type="quarter"> HTML never shipped — instead of falling back to the underlying day mask.

Format engine (util/date-sections)

  • New section types with tokens: ww (ISO week number, 1–53) and q / QQQ (numeric quarter or Q1Q4 names). Uppercase W deliberately stays a literal so masks can spell the ISO prefix: yyyy-Www renders 2026-W29.
  • Quoted literals, date-fns style: text in single quotes is always a fixed, non-editable literal even when it contains token letters ('Week' ww renders Week 29); '' escapes the quote character.
  • In a week layout the year section holds the ISO week-numbering year (which differs from the calendar year around January 1st — 2027-01-01 edits as week 53 of 2026), and the value resolves to the Monday of the selected week. A quarter layout resolves to the first day of the quarter.
  • The week bound follows the selected year (52 vs 53 ISO weeks) via new getISOWeeksInYear / getDateOfISOWeek / getWeekSectionMax helpers, mirroring the day-in-month clamping.

Shells

  • selectionType="week" defaults to the native week input's presentation: a fixed localized label plus week and year sections — Week 29, 2026, Tydzień 29, 2026, Woche 29, 2026 (getWeekLabel via Intl.DisplayNames dateTimeField: weekOfYear, capitalized, Week fallback). An explicit format still wins — format="yyyy-Www" gives the ISO-style field.
  • selectionType="quarter" defaults to QQQ yyyy (Q4 2026) — in both DatePicker and DateRangePicker.
  • SectionInput gains week/quarter placeholders, spinbutton labels, and year-dependent week clamping.

Docs

  • date-picker / date-range-picker pages: the "underlying date" warning callouts are gone; the Weeks/Quarters sections document the native-style presentation and the ISO-format opt-in.
  • date-input: quoted-literal syntax and week/quarter mask examples; the "isn't supported" note removed.
  • Migration guide entry updated in the same cycle (living document rule).

Tests

40 new cases: token parsing incl. quoted literals, ISO week math (round trips, 52/53-week years, week-year edges), localized week label, build/fill from sections, serialization, paste, and picker integration for both shells (incl. pl-PL localization and the explicit-format override). Full gate green: lint, typecheck, 2922 unit tests, dist, bundlewatch PASS.

Programmatic dates validate like typed ones

SectionInput.update() rebuilt the field without running validation, so a programmatic date outside min/max (setDate, the projected today action, the initial date config) rendered as valid and reached the form value — while typing the same date flagged the field invalid and nulled the value. The validation pass is now extracted into _applyValidationState() and runs in the constructor and at the end of update() (preserving the previous date so dateChange/errorChange reflect the actual transition), and the shells emit dateChange — and update the calendar/time selection — with the validated value.

Projected footer buttons stay userland by design (the picker never disables them); the footer docs and the migration guide document that contract.

Slot context: isDateSelectable(date)

A button opting into the today action (now on the time picker) opts into the library's click semantics — so it now opts into the state those semantics imply: it is disabled while the footer template is cloned when its target falls outside minDate/maxDate/disabledDates, matching the v1 built-in button. One-way only — the picker never re-enables a projected button, so a disabled set in the template stays.

For buttons with custom actions, the field gains isDateSelectable(date) — it expresses the date in the field's mask and answers whether it passes minDate/maxDate/disabledDates validation. The granularity follows the mask: a day mask checks the calendar day (so new Date() passes with a maxDate of today — no midnight trap), a week mask the week's Monday, a date-time mask the exact time. Every shell exposes it through getContext() (isTimeSelectable on the time picker), and the date picker docs gained a runnable example: a projected Today button disabled via the context when today is out of range.

Validation border on the picker frame

Reported on the branch: date-input painted a red border on is-invalid, the pickers did not. The picker's input group is the frame and the field inside it draws no border of its own, so the state never reached anything visible — the validation mixin sets --cui-control-border-color on the field, and custom properties only inherit downwards.

The group now picks the state up with :has(.form-date-time.is-{valid,invalid}), reusing the same tokens as .form-control, so the color is identical to the standalone field. Per the styling doctrine's "validation icon wrinkle": the icon would land under the indicator button, so it is suppressed inside pickers and the inline-SVG indicator carries the color through currentColor instead — the same shape as the existing .form-password block in the mixin.

Verified in a real browser across all four shells plus the valid state: identical rgb(229, 83, 83) on the standalone field and every picker frame, green on valid, indicator recolored, background icon suppressed inside pickers only. :has() is already inside the declared browser floor (#667), and the migration guide's :has() rationale now lists the pickers.

Time picker: v1 styling leftovers removed

Removing the frame bug uncovered dead code in the same family, so it goes with it. The rewritten shell has no cleaner button and draws its indicator as an inline SVG on currentColor, and validation now lives on the field rather than the root, so nothing read any of this:

  • .time-picker.is-invalid / .is-valid — recoloured the frame from the root with !important overrides. v2 never puts that class on the root.
  • --cui-time-picker-cleaner-* (5 tokens), -indicator-icon, -indicator-icon-size, -gap, -placeholder-color and their Sass variables, plus the sm/lg blocks that set them. $time-picker-cleaner-icon-size-sm/-lg were declared twice — both copies go.
  • $input-padding-x-lg, orphaned once the dead gap-lg went (.form-control-lg reads --cui-btn-input-lg-padding-x).
  • Two imports the file no longer needs: sass:math, and sass:color which was already unused.

--cui-time-picker-indicator-icon-color stays and now carries the validation color too. fusv is green with zero unused variables, and the browser check confirms invalid/valid borders, indicator color and sm/lg sizing (31/38/48 px) are unchanged. Documented under "Design-token maps" in the migration guide.

…ion masks

Close the last selection-type gap in the pickers rewrite: week and quarter
selection now render dedicated masked sections instead of falling back to
the underlying day mask.

- new week and quarter section types in the format engine: ww (ISO week,
  1-53, clamped to the 52/53-week length of the selected year) and
  q/QQQ (numeric quarter or Q1-Q4 names); uppercase W stays a literal so
  yyyy-Www can spell the ISO prefix
- in week layouts the year section holds the ISO week-numbering year, and
  the value resolves to the Monday of the selected week; quarter layouts
  resolve to the first day of the quarter
- selectionType week/quarter default masks: yyyy-Www (the native
  input type=week wire format) and QQQ yyyy in both pickers
- week/quarter placeholders, spinbutton labels, and year-dependent week
  clamping in SectionInput
@coveralls

coveralls commented Aug 2, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 30772085684

Coverage increased (+0.06%) to 93.026%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 4 uncovered changes across 4 files (128 of 132 lines covered, 96.97%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
js/src/date-range-picker.ts 8 7 87.5%
js/src/date-time-picker.ts 14 13 92.86%
js/src/time-picker.ts 8 7 87.5%
js/src/util/date-sections.ts 63 62 98.41%
Total (7 files) 132 128 96.97%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
js/src/date-range-picker.ts 1 88.03%

Coverage Stats

Coverage Status
Relevant Lines: 8524
Covered Lines: 8139
Line Coverage: 95.48%
Relevant Branches: 4768
Covered Branches: 4226
Branch Coverage: 88.63%
Branches in Coverage %: Yes
Coverage Strength: 343.71 hits per line

💛 - Coveralls

mrholek added 7 commits August 2, 2026 22:58
…e the native week input

The native <input type="week"> presents a fixed, non-editable label before
the editable parts (Week 29, 2026), not the ISO wire format.

- quoted literals in the format parser, date-fns style: text in single
  quotes is always a literal even when it contains token letters
  ('Week' ww renders "Week 29"), '' escapes the quote character
- getWeekLabel resolves the localized week-of-year label via
  Intl.DisplayNames (Week / Tydzien / Woche), capitalized, with a Week
  fallback; getWeekSectionsFromLocale builds the native-style layout
- selectionType week now defaults to that presentation in both pickers;
  an explicit format (e.g. yyyy-Www for the ISO style) still wins
new Date(year, 0, 4) maps years 0-99 to 19xx, so getISOWeekNumberAndYear
returned week numbers around ±99000 while a week mask's year section was
still being typed (the clamp then wrote the garbage into the week section:
"Week -99085, 0022"). Build the January 4th reference by copying the date
and calling setMonth(0, 4), which keeps the full year.
… programmatic dates like typed ones

update() rebuilt the field without running validation, so a programmatic
date outside min/max (setDate, the projected today action, the initial
date config) rendered as valid and reached the form value — while typing
the same date flagged the field invalid and nulled the value.

- extract the validation pass into _applyValidationState() and run it in
  the constructor and at the end of update(), preserving the previous
  date so dateChange/errorChange reflect the actual transition
- the shells emit dateChange (and update the calendar/time selection)
  with the validated value, not the raw argument
- the validity toggle keeps the config-driven invalid class

Projected footer buttons stay userland (the picker never disables them) —
documented with the validation contract in the footer docs and the
migration guide.
…r): expose isDateSelectable in the slot context

Projected footer buttons are userland, so the picker never disables them —
but the slot context gave userland no way to compute that state. The field
gains isDateSelectable(date): it expresses the date in the field's mask and
answers whether it passes min/max/disabledDates validation, so the
granularity follows the mask (a day mask checks the calendar day — "now"
passes with a maxDate of today; a date-time mask checks the exact time).
Every shell exposes it through getContext() (isTimeSelectable on the time
picker), and the date picker docs show the pattern: disabling a projected
Today button when today is out of range.
…/now action when its target is not selectable

A button opting into the today action (now on the time picker) opts into
the library's click semantics — so it opts into the state those semantics
imply. When today (or the current time) falls outside
minDate/maxDate/disabledDates, the button is disabled while the footer
template is cloned, matching the v1 built-in button. One-way only: the
picker never re-enables a projected button, so a disabled set in the
template stays, and buttons with custom actions remain fully userland
(isDateSelectable stays in the slot context for those).
…alidation border on the picker frame

In a picker the input group is the frame and the field inside it draws no
border of its own, so the validation state never reached anything visible:
the mixin sets --cui-control-border-color on the field, and custom
properties only inherit downwards. The group now picks the state up with
:has(), matching the standalone date input exactly (same tokens, same
color).

Following the styling doctrine for the indicator: the validation icon
would land under the indicator button, so it is suppressed inside pickers
and the inline-SVG indicator carries the color through currentColor
instead (the .form-password block does the same for its action icon).
The rewritten shell has no cleaner button and renders its indicator as an
inline SVG on currentColor, and validation state now lives on the field
inside the group rather than on the root element — so a set of tokens and
variables had no reader left:

- .time-picker.is-invalid / .is-valid blocks (frame recoloured from the
  root with !important overrides; v2 reads the state from the field)
- --cui-time-picker-cleaner-* (5), -indicator-icon, -indicator-icon-size,
  -gap and -placeholder-color, plus their Sass variables; the sm/lg blocks
  no longer set them either
- $input-padding-x-lg, whose last consumer was the dead gap-lg
  (.form-control-lg reads --cui-btn-input-lg-padding-x)

$time-picker-cleaner-icon-size-sm/-lg were declared twice; both copies go.
--cui-time-picker-indicator-icon-color stays and now carries the validation
color as well. Also drops two imports the file no longer needs (sass:math,
sass:color — the latter was already unused).

Verified in a browser: invalid/valid borders, indicator color and the sm/lg
sizing are unchanged.
@mrholek
mrholek merged commit 0ebd35f into v6-dev Aug 3, 2026
8 checks passed
mrholek added a commit that referenced this pull request Aug 3, 2026
…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.
mrholek added a commit that referenced this pull request Aug 4, 2026
…d component onto it (#675)

* feat(forms): add the form-control-group primitive and move every field 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.

* fix(Autocomplete,MultiSelect): don't pad the frame twice

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.

* fix(FormControlGroup): grey the whole frame when the control inside is 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.

* fix(pickers,FormControlGroup): disable the indicator when the picker 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.

* feat(pickers): restore the inline cleaner on the form-control-group primitive

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.

* fix(pickers): draw the cleaner cross on the same grid as the other icons

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.

* feat(Autocomplete,MultiSelect): 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. 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.

* refactor(FormControlGroup): an action and a cleaner are one style

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.

* fix(icons): crop the cleaner cross so it matches the icons beside 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.

* build(class-api): register the classes the primitive replaced

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.

* feat(Autocomplete,MultiSelect): finish the move onto the primitive

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.

* feat(Autocomplete,MultiSelect): the editable and the adornments are the 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.

* feat(MultiSelect): a selection is a Chip, whichever selection type asks 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.

* feat(forms): add the .popup primitive — one chrome for every floating 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.

* feat(ChipInput): controlled mode — chips come from the host, not from 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.

* test(visual): state-matrix screenshot suite for the field components

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.

* feat(MultiSelect): the selection area rides ChipSet — arrow-key navigation 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.

* refactor(forms): drop the inert per-family frame layer

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.

* refactor(forms): finish the dead-import sweep; restore the frame class 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.

* fix(forms): the action's transition list never covered transform, so 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.

* fix(tests): the visual suite screenshotted unstyled DOM

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.

* fix(forms): give every family the same validation treatment, from one 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.

* refactor(Autocomplete,MultiSelect): delete what the frame primitive already 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.

* refactor(utilities)!: drop the theme-variant and rtl keys from the utilities 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.

* fix(utilities): keep the translate-middle RTL flip after dropping the 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.

* test(Sass): follow the utilities API through its own suite

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.
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.

2 participants