@flikk/ui 1.0.0-beta.32 → 1.0.0-beta.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/dist/components/ai/AgentRequest/AgentRequest.theme.js +1 -1
- package/dist/components/core/Button/Button.theme.js +3 -3
- package/dist/components/core/Pill/Pill.js +32 -17
- package/dist/components/core/Pill/Pill.types.d.ts +14 -1
- package/dist/components/core/SlidingNumber/SlidingNumber.d.ts +8 -0
- package/dist/components/core/SlidingNumber/SlidingNumber.js +12 -3
- package/dist/components/forms/ColorPicker/ColorPicker.js +45 -3
- package/dist/components/forms/ColorPicker/ColorPickerBody.js +8 -4
- package/dist/components/forms/Combobox/Combobox.js +8 -4
- package/dist/components/forms/Combobox/Combobox.theme.js +9 -0
- package/dist/components/forms/Combobox/Combobox.types.d.ts +13 -0
- package/dist/components/forms/DatePicker/DatePicker.js +19 -12
- package/dist/components/forms/DatePicker/DatePicker.theme.js +5 -0
- package/dist/components/forms/DatePicker/DatePicker.types.d.ts +8 -0
- package/dist/components/forms/DatePicker/DatePickerContext.d.ts +0 -1
- package/dist/components/forms/DatePicker/DatePickerTrigger.js +5 -3
- package/dist/components/forms/DateRangePicker/DateRangePicker.theme.js +5 -0
- package/dist/components/forms/DateRangePicker/DateRangePicker.types.d.ts +8 -0
- package/dist/components/forms/DateRangePicker/DateRangePickerTrigger.js +5 -3
- package/dist/components/forms/Input/Input.js +17 -12
- package/dist/components/forms/Input/Input.theme.js +10 -0
- package/dist/components/forms/Input/Input.types.d.ts +14 -0
- package/dist/components/forms/InputAddress/InputAddress.js +5 -5
- package/dist/components/forms/InputAddress/InputAddress.types.d.ts +7 -0
- package/dist/components/forms/InputCounter/InputCounter.js +194 -17
- package/dist/components/forms/InputCounter/InputCounter.theme.d.ts +12 -0
- package/dist/components/forms/InputCounter/InputCounter.theme.js +92 -2
- package/dist/components/forms/InputCounter/InputCounter.types.d.ts +47 -4
- package/dist/components/forms/InputCreditCard/InputCreditCard.js +4 -4
- package/dist/components/forms/InputCreditCard/InputCreditCard.types.d.ts +7 -0
- package/dist/components/forms/Mention/Mention.js +13 -11
- package/dist/components/forms/RichTextEditor/RichTextEditor.js +12 -3
- package/dist/components/forms/Select/Select.js +15 -8
- package/dist/components/forms/Select/Select.theme.js +9 -1
- package/dist/components/forms/Select/Select.types.d.ts +16 -1
- package/dist/components/forms/Textarea/Textarea.js +14 -25
- package/dist/components/forms/TimePicker/TimePicker.theme.js +9 -0
- package/dist/components/forms/TimePicker/TimePicker.types.d.ts +11 -0
- package/dist/components/forms/TimePicker/TimePickerTrigger.js +7 -5
- package/dist/components/forms/forms.theme.d.ts +57 -0
- package/dist/components/forms/forms.theme.js +83 -8
- package/dist/components/generative/registry.js +1 -0
- package/dist/components/generative/schema.generated.js +21 -1
- package/dist/generative.schema.json +21 -1
- package/dist/registry.json +108 -17
- package/dist/styles.css +1 -1
- package/dist/tools.json +21 -1
- package/dist/utils/composeEventHandlers.d.ts +19 -0
- package/dist/utils/composeEventHandlers.js +26 -0
- package/dist/utils/composeEventHandlers.test.d.ts +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/styles/theme.css +33 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,207 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.0-beta.33] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
|
|
7
|
+
- 07713f5: **Fixed `ColorPicker` emitting `onChange` on mount, and echoing values the parent pushed in.**
|
|
8
|
+
|
|
9
|
+
`prevOutputRef` started as `undefined`, so the very first pass of the notify effect always "differed" and fired `onChange` — before any interaction. Worse, ColorPicker re-derives its output through hex → HSL (integer rounding) → hex, so the emitted string often isn't the one that was passed in (`#3B82F6` round-trips to `#3C83F6`, casing normalizes). A consumer cannot tell that apart from a real user edit:
|
|
10
|
+
|
|
11
|
+
- Any app tracking "has the user modified this?" recorded a **phantom override** — merely rendering the picker (e.g. selecting a layer in an inspector) wrote a colour identical to the one already in force.
|
|
12
|
+
- A parent that stores what it receives and feeds it back could ping-pong into `Maximum update depth exceeded`, since each render handed back a value that re-entered as a new `value` prop.
|
|
13
|
+
|
|
14
|
+
Two changes:
|
|
15
|
+
|
|
16
|
+
1. **The first effect pass seeds the baseline and emits nothing.** Mounting is not a user edit.
|
|
17
|
+
2. **A value arriving via the controlled `value` prop is recorded as already in force**, using a new pure `formatColorAs(color, format)` that mirrors the display pipeline. The parent's own value is therefore never echoed back.
|
|
18
|
+
|
|
19
|
+
Real user changes (preset click, hex entry, slider drag, format switch) emit exactly as before — now exactly once.
|
|
20
|
+
|
|
21
|
+
`InputCounter` was audited as the only other component emitting from an effect. Its emission is deliberate and stays: it fires only when the incoming `value` is outside `min`/`max`, which is a genuine correction the parent must know about — suppressing it would leave the parent disagreeing with what's displayed.
|
|
22
|
+
|
|
23
|
+
CLAUDE.md §9 now states the rule: a controlled component never emits a change for a value the user didn't produce.
|
|
24
|
+
|
|
25
|
+
- 07713f5: **Fixed `ColorPicker` panel overflowing the viewport instead of clamping to it.**
|
|
26
|
+
|
|
27
|
+
`ColorPicker.Body` positions through `useSelectPortal`, which measures the panel via the `contentRef` it **returns** and shifts the panel left when its right edge would cross the viewport edge. Body declared its _own_ `contentRef` for click-outside and attached that one to the panel, so the hook's ref stayed `null` for the panel's whole lifetime.
|
|
28
|
+
|
|
29
|
+
With nothing to measure, `measureDropdownWidth` fell through to its last-resort fallback — the **trigger's** width. Collision detection then fitted a ~32px box, concluded there was plenty of room, and left the ~320px panel anchored at `bottom-start` running off the right edge. The same null ref also disabled the vertical clamp and left the panel's `ResizeObserver` observing nothing.
|
|
30
|
+
|
|
31
|
+
Body now takes `contentRef` from the hook and attaches it to the panel, which is what every other `useSelectPortal` consumer already does. Click-outside uses the same ref — one element, one ref, no merging needed.
|
|
32
|
+
|
|
33
|
+
Audited the other nine consumers (Select, Combobox, Dropdown, Tooltip, Popover, TimePicker, InputTag, DatePicker, DateRangePicker): all wire the hook's ref correctly. ColorPicker was the only one.
|
|
34
|
+
|
|
35
|
+
- 07713f5: **Fixed a family of components that looked controlled but weren't.** Two distinct root causes, both of which made a component silently disagree with the props it was given.
|
|
36
|
+
|
|
37
|
+
### Controlled props are now authoritative (`Pill`, `DatePicker`)
|
|
38
|
+
|
|
39
|
+
Both mirrored their controlled prop into `useState` and re-synced from a `useEffect` keyed on that prop — the pattern §9 forbids ("derive per render, never store in state"). The effect only fires when the prop _changes_, so a parent that **rejects or normalizes** a change left the component holding a value the parent never accepted:
|
|
40
|
+
|
|
41
|
+
- **`Pill`** — in a single-choice group, re-clicking the option already in force calls `onSelect(false)`, the parent keeps `selected={true}`, the prop is unchanged, the effect never runs, and the chip reports `aria-pressed="false"` while still being the selected option. A control lying about the state it controls. Consumers were working around this by remounting the pill with a `key` to force the truth back — that hack can now be deleted.
|
|
42
|
+
- **`DatePicker`** — same desync, plus its sync effect was unconditional, so on mount it ran with `value === undefined` and wiped the `useState(value || defaultValue)` seed. **`defaultValue` — a documented public prop — never survived the first paint.** It works now.
|
|
43
|
+
|
|
44
|
+
Both components now derive `isControlled` per render and write internal state only when uncontrolled.
|
|
45
|
+
|
|
46
|
+
**`Pill` gains `defaultSelected`** to complete the conventional pair, so an uncontrolled row of filter chips can start with some applied. Passing both `selected` and `defaultSelected` dev-warns; `selected` wins.
|
|
47
|
+
|
|
48
|
+
⚠️ **Behavior change:** `<Pill selected={true}>` passed once as a _one-time initial value_ no longer toggles — that now means "controlled, permanently selected", which is what the prop always claimed. Use `defaultSelected` for the initial-value case.
|
|
49
|
+
|
|
50
|
+
`DatePicker`'s internal context no longer exposes `setSelectedValue` (it was unused and could not be made controlled-safe), and its single-mode dedupe now compares dates **by time rather than identity** — every calendar click builds a new `Date`, so the old `newDate !== selectedValue` guard was always true and never fired.
|
|
51
|
+
|
|
52
|
+
### Handlers are composed, not clobbered (`RichTextEditor`, `Mention`)
|
|
53
|
+
|
|
54
|
+
`{...rest}` spread before an element's own attributes means every later attribute silently wins. The prop stays in the public type, so TypeScript accepts it and it does nothing at runtime — invisible on both sides. `RichTextEditor` dropped `onInput`, `onPaste`, `onDrop`, `onKeyUp`, `onMouseUp` and `onBlur`; `Mention` dropped seven of its nine.
|
|
55
|
+
|
|
56
|
+
Spreading last isn't the fix either: a consumer's `onKeyUp` would then _replace_ `RichTextEditor`'s selection tracking and silently disable the toolbar's active state. Internal handlers are load-bearing machinery, not defaults. Both components now compose via a new internal `composeEventHandlers(ours, theirs)`:
|
|
57
|
+
|
|
58
|
+
- **Ours runs first**, so consumer code observes settled internal state.
|
|
59
|
+
- **Both always run.** A `preventDefault()` in either does not skip the other — a consumer that cares reads `event.defaultPrevented`. Conditionally dropping the second handler would reintroduce the same invisible-drop bug in a new costume.
|
|
60
|
+
|
|
61
|
+
`Pill`'s `onKeyDown` had the _mirrored_ version of this bug — `{...restProps}` spreads last there, so a consumer's handler replaced Pill's own and silently killed Enter/Space selection. Now composed.
|
|
62
|
+
|
|
63
|
+
`ScrollArea` and `Signature` were audited and are correct as-is (ScrollArea already calls through to `props.onWheel`; Signature's spread targets the wrapper while the drawing handlers sit on the canvas, so consumer handlers still fire via bubbling). Both gained tests pinning that contract.
|
|
64
|
+
|
|
65
|
+
CLAUDE.md §9 documents the merge rules for `className`, `style`, `id` and handlers.
|
|
66
|
+
|
|
67
|
+
- f4ac111: **Icons inside form controls are now themeable and size-aware — `--form-icon-size-sm/md/lg`.**
|
|
68
|
+
|
|
69
|
+
A field had its box tokenised (`--form-min-h-*`, `--form-px/py-*`, `--form-radius`) and its type tokenised (`--form-text-size-*`), but every icon inside it was a hardcoded `size-4` / `size-5` / `h-4 w-4` at the call site. Icon size was the one axis of a control's geometry a theme could not reach: scale a preset up and the chevron, clock and search glyph stayed exactly where they were.
|
|
70
|
+
|
|
71
|
+
`--form-icon-size-sm/md/lg` (12 / 14 / 16px) closes that, consumed as `size-[var(--form-icon-size-…)]` through `formsBaseTheme.iconStyles.sizes`. The ramp is 12/14/16, matching the control type ramp. (It shipped as the gentler of the two, against a type ramp of 14/14/16, on the argument that an icon carries more optical mass per px than a text glyph; the `sm` type rung dropped to 12px in the same release and the two converged.)
|
|
72
|
+
|
|
73
|
+
**Slot geometry ramps with it**, derived from the control's own padding rather than flat literals:
|
|
74
|
+
|
|
75
|
+
| | leading slot | trailing slot |
|
|
76
|
+
| ----------- | -------------------------------- | ----------------------------- |
|
|
77
|
+
| inset | `--form-px-*` exactly (12/14/16) | `--form-px-* − 4px` (8/10/12) |
|
|
78
|
+
| text runway | `+ 20/22/24` (32/36/40) | `+ 16/18/20` (28/32/36) |
|
|
79
|
+
|
|
80
|
+
The two differ on purpose: a leading icon stands where the text would have started, so matching `--form-px-*` keeps a field's left edge constant with or without one, while a trailing affordance is chrome — a chevron is mostly whitespace and reads further from the edge than a glyph at the same metric distance. New keys on `formsBaseTheme.iconStyles`: `leftSizes`, `rightSizes`, `paddingLeftSizes`, `paddingRightSizes`, `sizes`. The size-blind `left` / `right` / `padding` remain as the fallback.
|
|
81
|
+
|
|
82
|
+
**Adopted by** `Input` (built-in type glyphs, number-stepper glyphs, both slots), `Select`, `Combobox`, `DatePicker`, `DateRangePicker`, `TimePicker`. Each component's theme gained optional per-size keys (`iconStartStyles`, `iconEndStyles`, `iconSizes`, `iconPaddings` / `iconEndPaddings`) and falls back to its flat keys, so an existing theme override keeps applying.
|
|
83
|
+
|
|
84
|
+
⚠️ **Visible change.** `Input`'s built-in lock and search glyphs were `size-5` (20px) at every size and are now 14px at `md`; the picker/clock/calendar carets were `h-4 w-4` and now ramp. **Consumer-supplied slot icons are untouched** — passing `className` on a slot icon replaces the auto size (§7), so only our own built-ins moved.
|
|
85
|
+
|
|
86
|
+
**Not adopted by** listbox check marks or panel chrome (ColorPicker internals, FileUpload dropzone): a dropdown row's type doesn't track its trigger's size, so its icons shouldn't either.
|
|
87
|
+
|
|
88
|
+
### Breaking: `--button-icon-size-*` → `--button-icon-square-*`
|
|
89
|
+
|
|
90
|
+
The icon-only Button's **box** (aliased to `--form-min-h-*`) was called `--button-icon-size-*`. Shipping a glyph token called `--form-icon-size-*` alongside it would have put two tokens named "icon size" meaning different things into the system, so the box token was renamed to say what it is. Update any theme overriding it.
|
|
91
|
+
|
|
92
|
+
The Figma exporter's variable NAME is deliberately unchanged (`size/button/icon-size-*`): it matches existing kit variables by name, so emitting a new name would create a second variable and strand every component bound to the first. Renaming it in the kit is a deliberate pass, not a side effect. `--form-icon-size-*` exports as `size/form/icon-*`.
|
|
93
|
+
|
|
94
|
+
- 967f18b: **`InputCounter` gains `size` and manual entry; `InputAddress` and `InputCreditCard` gain `size`.**
|
|
95
|
+
|
|
96
|
+
### `size` — the counter can finally line up with the fields around it
|
|
97
|
+
|
|
98
|
+
`InputCounter` was the only stepper-style control with no `size` prop, and its geometry was hardcoded three ways: an implicit height from `p-1`, `<Button size="sm">` on both steppers, and a `text-sm` number. Nothing about it responded to a size, so a counter sitting under a `Select` in an inspector column was visibly taller and chunkier than the fields above it, with no way to fix it from the outside.
|
|
99
|
+
|
|
100
|
+
It now takes `size?: 'sm' | 'md' | 'lg'` (default `'md'`), drawn from the shared `formsBaseTheme.sizes` scale — the same `--form-min-h-*` / `--form-text-size-*` tokens `Input`, `Select` and `Combobox` use, which is what keeps a row of fields on one baseline. A `buttonSizes` map in the theme picks the stepper Button size per counter size, so it is tunable in one place.
|
|
101
|
+
|
|
102
|
+
⚠️ **Visual change at the default.** Today's hardcoded look is roughly `md` height with `sm` steppers and `sm` text; at `size="md"` the number and steppers now grow to match a real `md` field. That is the point of the change, but existing counters will render slightly larger.
|
|
103
|
+
|
|
104
|
+
`InputAddress` and `InputCreditCard` take the same `size`, forwarded to every field they render (a per-field `*InputProps.size` still wins). `InputDate` already inherited and forwarded it via `InputProps`.
|
|
105
|
+
|
|
106
|
+
### The steppers are now derived from the counter, and the edge is the shared one
|
|
107
|
+
|
|
108
|
+
Two geometry bugs fell out of the size work, both visible at `sm`:
|
|
109
|
+
|
|
110
|
+
- **The steppers were mapped onto `Button`'s own size scale**, which has only three rungs — so `sm` and `md` both got the same 32px square, and at `sm` that square was **taller than the counter's content box**: the ghost hover fill punched straight through the rounded border, and its 6px radius didn't match the container's 8px. They are now squares derived from the counter's own height (`--form-min-h-*` minus the container's 4px inset → 24 / 28 / 32) with a concentric radius (`--form-radius` − 4px), so they scale with the control instead of landing on whichever `Button` size is closest. Stepper icons track the type ramp too (16 / 16 / 20px). A `buttonProps.className` still wins.
|
|
111
|
+
- **The container drew a real `border`.** That is a §9 violation on its own — the edge of every form control is the shared inset `box-shadow` — but it was also doing structural damage: a real border consumes 2px of the fixed `--form-min-h-*` height, which is what left the `sm` steppers with only 30px to fit into. It now composes `formsBaseTheme.inputGroupBorderStyle`, so the counter reads identically to the `Select` beside it, picks up the hover edge, gains a **focus ring it never had** (it was the only tabbable form control without one), and takes the shared `states.invalid` instead of a bespoke `ring-1`.
|
|
112
|
+
|
|
113
|
+
`formsBaseTheme` gains `inputGroupBorderStyle` — the field edge alone, without `inputGroupBaseStyle`'s `flex`, fill and `transition-all`. The counter's root is a motion element carrying `layout`, and `transition-all` there covers `transform`, re-interpolating motion's per-frame writes every frame (§12); it lists `transition-[box-shadow,background-color]` instead. `inputGroupBaseStyle` is now literally that constant plus the three extras, so there is still exactly one definition of the edge.
|
|
114
|
+
|
|
115
|
+
The number also drops to normal weight, matching the text in the fields it sits beside, and disabled counters now take the shared `states.disabled` treatment instead of looking identical to an enabled one.
|
|
116
|
+
|
|
117
|
+
**The odometer no longer rolls for values that never travelled.** The sliding animation is feedback for _stepping_ — it shows the value moving by a step. A typed value didn't move through the digits in between, so rolling `4 → 9` past 5, 6, 7 and 8 read as the control second-guessing the entry. Manual entry now lands in place, and so does any return from an edit session: the display is mounted fresh when the field unmounts, and a motion element without `initial` animates on mount, so even **Escape** used to roll the untouched number up from zero. Stepping still animates.
|
|
118
|
+
|
|
119
|
+
### Manual entry
|
|
120
|
+
|
|
121
|
+
Clicking the number — or pressing Enter while the counter has focus, so it is reachable without a mouse — swaps `SlidingNumber` for a real text field, focused with its contents selected. Typing is free-form: no clamping and no `onChange` while the user is mid-edit, so typing `5` on the way to `50` under `min={10}` is no longer eaten. The value is parsed, rounded to the counter's decimal places, clamped to `min`/`max` and emitted on **Enter or blur**; **Escape** reverts and returns focus to the counter. Empty or unparseable input reverts. Per §9, a typed value equal to the current one emits nothing.
|
|
122
|
+
|
|
123
|
+
**Clicking the number does not resize the control.** The field used to size itself with the `size` attribute — the browser's average character width — which never matched `SlidingNumber`'s fixed per-character em box, so the counter grew 1–3px on every click, animated into a visible wobble by the root's `layout`. Both states now lay into one slot whose width is reserved from the character count in the same `em` metric `SlidingNumber` uses (exported as `SLIDING_NUMBER_CHAR_WIDTH_EM`, internal). A bounded counter reserves room for its widest legal value up front, so stepping 9 → 10 doesn't resize it either.
|
|
124
|
+
|
|
125
|
+
**An out-of-range draft is flagged while you type.** Typing stays free-form and clamping still happens on commit — clamping mid-edit would eat the `5` on the way to `50` under `min={10}` — but a draft that parses outside `min`/`max` now paints the shared invalid edge and sets `aria-invalid` on the field, so `34` under `max={10}` reads as rejected _before_ you blur rather than silently becoming `10`. Unparseable input isn't flagged; it simply reverts.
|
|
126
|
+
|
|
127
|
+
The field is mounted only while editing, so there is exactly one tab stop at any moment and no `tabIndex` juggling. The root keeps `role="spinbutton"` — a spinbutton containing a textbox is the canonical composite pattern — and its arrow/PageUp/PageDown handling is suppressed while editing so those keys move the caret instead of stepping the value.
|
|
128
|
+
|
|
129
|
+
Opt out with `editable={false}`.
|
|
130
|
+
|
|
131
|
+
The field is a bare transparent `<input>` inside the counter's existing surface rather than a nested `<Input>`, which would have meant suppressing that component's border, background, height and padding. CLAUDE.md §2 records this as the one sanctioned exception to the reuse rule, with the test for when it applies.
|
|
132
|
+
|
|
133
|
+
### Removed: `InputCounter`'s `formatNumber`
|
|
134
|
+
|
|
135
|
+
**Breaking.** The prop was destructured and never used — it never reached the rendered `SlidingNumber`, so passing it did nothing. It has been removed rather than wired up, because its documented default (`(n) => n.toString()`) would render `1` where the display shows `1.00`, making "fixing" it a behavior change rather than a fix.
|
|
136
|
+
|
|
137
|
+
The counter displays a number; render units (`%`, `px`, `$`) as adjacent text, and use `decimalPlaces` (auto-detected from `step`) for precision. It was already excluded from the generative schema, so the curated surface is unchanged.
|
|
138
|
+
|
|
139
|
+
- 07713f5: **Fixed `RichTextEditor` silently discarding a consumer's `style` prop, and `RichTextEditor`/`Mention` discarding a consumer's `id`.**
|
|
140
|
+
|
|
141
|
+
Both components spread `{...rest}` onto their contentEditable surface _before_ setting their own attributes on the same element, so any prop the component also sets was overwritten by the later JSX attribute. `style` and `id` are both part of the public typed surface — the props extend `React.HTMLAttributes<HTMLDivElement>` — so TypeScript accepted them and they silently did nothing. The reported symptom was a consumer routing typography overrides through the forwarded ref because `style` never landed.
|
|
142
|
+
|
|
143
|
+
`style` was the outlier: `Mention` already destructured and merged it (`{ minHeight, ...styleProp }`), and every other component in the library that sets `style` alongside a spread merges the incoming value (Skeleton, AspectRatio, Masonry, AppShell's sidebars, the effects family). `RichTextEditor` is now consistent with them:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
style={{ minHeight, maxHeight, ...styleProp }}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A passed `style` therefore wins over the `minHeight`/`maxHeight` props for the keys it sets, and leaves the rest in place.
|
|
150
|
+
|
|
151
|
+
`id` was missing from both. Every other form control resolves `id || generatedId` (Input, Textarea, Select, Switch, Slider, DatePicker, …); these two ignored the prop and kept their `useId()` value, which also left `FormLabel`'s `htmlFor` pointing at an id the consumer could not predict. Both now resolve `idProp ?? autoId`, and every derived id (`-description`, `-listbox`) hangs off the resolved value, so a consumer-supplied id makes the whole ARIA wiring addressable.
|
|
152
|
+
|
|
153
|
+
`className` was already merged correctly on both components and is unchanged.
|
|
154
|
+
|
|
155
|
+
- f4ac111: **`Select`'s chevron scales with the control.**
|
|
156
|
+
|
|
157
|
+
The trailing chevron was hardcoded three ways at every size — a `size-4` glyph, a `pr-3` slot, and a `pr-10` text runway — while the field's own horizontal padding ramps 12 / 14 / 16 (`--form-px-*`). At `sm` that left a 16px icon out-weighing the 14px text beside it, and at every size the icon's distance from the edge was unrelated to the control it sat in.
|
|
158
|
+
|
|
159
|
+
All three now derive from the size:
|
|
160
|
+
|
|
161
|
+
| | sm | md | lg |
|
|
162
|
+
| --------------------- | -------------------------- | ------------------------- | ------------------------- |
|
|
163
|
+
| glyph | 12px | 14px | 16px |
|
|
164
|
+
| inset from right edge | `--form-px-sm − 4px` (8) | `--form-px-md − 4px` (10) | `--form-px-lg − 4px` (12) |
|
|
165
|
+
| text runway | `--form-px-sm + 16px` (28) | `+ 18px` (32) | `+ 20px` (36) |
|
|
166
|
+
|
|
167
|
+
The slot insets one step tighter than the text on purpose: a chevron is mostly whitespace, so at the same metric distance it reads as further from the edge than a glyph does. Both are expressed against `--form-px-*` rather than literals, so retuning a theme's control padding moves the icon with it.
|
|
168
|
+
|
|
169
|
+
The values live in `formsBaseTheme.iconStyles` as `rightSizes` / `paddingRightSizes` / `sizes`, alongside the size-blind `right` / `padding` they replace. **`Select` is the only adopter so far** — `Input`, `Combobox`, `DatePicker`, `DateRangePicker` and `TimePicker` still render trailing icons through the flat keys and are unchanged.
|
|
170
|
+
|
|
171
|
+
`SelectThemeOverrides` gains `iconEndStyles`, `iconEndSizes` and `iconEndPaddings` (all `Partial<Record<SelectSize, string>>`). `iconEndStyle` and `iconPadding.right` still work and are used as the fallback when a size has no entry, so an existing theme override keeps applying.
|
|
172
|
+
|
|
173
|
+
- 8819689: **The `sm` control rung is now a 28px box with 12px type.**
|
|
174
|
+
|
|
175
|
+
`--form-text-size-sm` moves from `--text-sm` (14px) to `--text-xs` (12px) with its line-height partner, and `--form-min-h-sm` from 32px to 28px. Everything that aliases them follows: `--button-text-size-sm`, `--segmented-text-size-sm`, `--button-min-h-sm`, `--segmented-min-h-sm`, `--otp-slot-size-sm`, `--button-icon-square-sm`. So `sm` finally reads as a genuinely denser rung rather than an `md` in a slightly shorter box.
|
|
176
|
+
|
|
177
|
+
`--form-py-sm` drops to 6px alongside. A 28px box around a 12px/16px line has exactly 12px of padding to spend; at 8px the padding claimed 16px of a 12px content box — invisible wherever a fixed `h-*` wins, but wrong for anything sized by padding.
|
|
178
|
+
|
|
179
|
+
This retires the "deliberately two rungs across three sizes" note in `docs/THEMING.md`, which argued that 12px on a 32px-tall action was a legibility liability and that the repeat shouldn't be "fixed" into 12/14/16 without a design pass. That pass is this change: shadcn ships `text-xs` on its `sm` button at `h-8`, and the objection was really about 12px in a control too tall for it — so the box came down to 28px in the same move. The type ramp is now 12/14/16 and matches the control icon ramp exactly.
|
|
180
|
+
|
|
181
|
+
### `InputCounter` at `sm` and WCAG 2.2 target size
|
|
182
|
+
|
|
183
|
+
A 28px box cannot hold a 24px stepper at any sensible inset, so the `sm` steppers (20×20) are undersized for **SC 2.5.8 Target Size (Minimum), Level AA** and clear it through the spacing exception instead: a 24px circle centred on each target must not intersect the next one's. The neighbouring target is the click-to-edit value, so `sm` widens its gap to 8px, putting those centres **25.2px** apart. `md` (26px) and `lg` (30px) pass on size outright.
|
|
184
|
+
|
|
185
|
+
Two details are load-bearing there, both commented in the source:
|
|
186
|
+
|
|
187
|
+
- The value slot now reserves a **two-character minimum**. A single-character slot pulls the centres back to 21.6px and fails.
|
|
188
|
+
- `sm` uses a 4px inset (20×20 square) where `md`/`lg` use 5px. The uniform 5px inset it briefly had produced a 22×22 square at 32px whose centres sat 22.2px apart — which also failed, on the previous box height.
|
|
189
|
+
|
|
190
|
+
### Fixed
|
|
191
|
+
|
|
192
|
+
- 91f4939: **Fix: `Textarea` with `unstyled` silently ignored `onValueChange`, making a controlled textarea read-only.**
|
|
193
|
+
|
|
194
|
+
The `unstyled` early return destructured `onValueChange` out of props and then never wired it — only the styled path below it did. So `value` + `onValueChange` + `unstyled` rendered a textarea React considered controlled with no `onChange`: it logged the controlled-without-onChange warning and then discarded every keystroke. Nothing threw, and the field simply refused to accept input. `onChange` worked on both paths, which is why `PromptInput` (the only in-library consumer of `unstyled`) never hit it.
|
|
195
|
+
|
|
196
|
+
Both paths now share one `handleChange`, wired above the branch so a future path cannot drop it again.
|
|
197
|
+
|
|
198
|
+
Two related defects in the same branch, found while fixing it:
|
|
199
|
+
|
|
200
|
+
- **`{...props}` was spread AFTER the internal `onFocus`/`onBlur`**, so a consumer's handlers silently replaced the component's own — §9's clobber rule, in the one place it still applied.
|
|
201
|
+
- **Those internal handlers only fed an `isFocused` state that nothing read.** Rather than compose dead code, the state and both wrappers are removed; consumer `onFocus`/`onBlur` now pass straight through. No visual behaviour changes — the focus ring is CSS `focus-within`, never that state.
|
|
202
|
+
|
|
203
|
+
Regression tests cover all three, and were confirmed to fail against the pre-fix component.
|
|
204
|
+
|
|
3
205
|
## [1.0.0-beta.32] - 2026-08-14
|
|
4
206
|
|
|
5
207
|
### Breaking Changes
|
|
@@ -22,7 +22,7 @@ const agentRequestTheme = {
|
|
|
22
22
|
// `bottom-5` centres the 24px nav against the 32px `size="sm"` button sitting
|
|
23
23
|
// on the panel's 16px bottom inset.
|
|
24
24
|
navStyle: "absolute bottom-5 left-3 z-[2] flex shrink-0 items-center gap-0.5",
|
|
25
|
-
// Overrides Button's `iconOnly` sizing (--button-icon-
|
|
25
|
+
// Overrides Button's `iconOnly` sizing (--button-icon-square-sm is the FORM
|
|
26
26
|
// control height — too heavy for a quiet header affordance). Plain utilities,
|
|
27
27
|
// not `!important`: Button puts `className` last in its own `cn(...)`, so
|
|
28
28
|
// twMerge already resolves these against the size/padding it set.
|
|
@@ -87,15 +87,15 @@ const buttonTheme = {
|
|
|
87
87
|
// Tighten the corner radius at sm so a short button doesn't read as a pill
|
|
88
88
|
// (overrides baseStyle's rounded-[var(--button-radius)] via twMerge).
|
|
89
89
|
default: "font-medium min-h-[var(--button-min-h-sm)] px-[var(--button-px-sm)] py-[var(--button-py-sm)] text-[length:var(--button-text-size-sm)]/[var(--button-line-height-sm)] rounded-[calc(var(--button-radius)*0.75)]",
|
|
90
|
-
iconOnly: "size-[var(--button-icon-
|
|
90
|
+
iconOnly: "size-[var(--button-icon-square-sm)] p-[var(--button-icon-p-sm)] rounded-[calc(var(--button-radius)*0.75)]",
|
|
91
91
|
},
|
|
92
92
|
md: {
|
|
93
93
|
default: "font-medium min-h-[var(--button-min-h-md)] px-[var(--button-px-md)] py-[var(--button-py-md)] text-[length:var(--button-text-size-md)]/[var(--button-line-height-md)]",
|
|
94
|
-
iconOnly: "size-[var(--button-icon-
|
|
94
|
+
iconOnly: "size-[var(--button-icon-square-md)] p-[var(--button-icon-p-md)]",
|
|
95
95
|
},
|
|
96
96
|
lg: {
|
|
97
97
|
default: "font-medium min-h-[var(--button-min-h-lg)] px-[var(--button-px-lg)] py-[var(--button-py-lg)] text-[length:var(--button-text-size-lg)]/[var(--button-line-height-lg)]",
|
|
98
|
-
iconOnly: "size-[var(--button-icon-
|
|
98
|
+
iconOnly: "size-[var(--button-icon-square-lg)] p-[var(--button-icon-p-lg)]",
|
|
99
99
|
},
|
|
100
100
|
},
|
|
101
101
|
// Icon slot sizing. `[&>svg]:size-*` sizes the icon itself, so a caller passing a bare
|
|
@@ -6,6 +6,7 @@ import { pillTheme } from './Pill.theme.js';
|
|
|
6
6
|
import { createPressVariants } from '../shared/interaction.animations.js';
|
|
7
7
|
import { Ripple } from '../shared/Ripple.js';
|
|
8
8
|
import { cn } from '../../../utils/cn.js';
|
|
9
|
+
import { composeEventHandlers } from '../../../utils/composeEventHandlers.js';
|
|
9
10
|
|
|
10
11
|
// Shared press physics + subtle hover lift (module-level constant)
|
|
11
12
|
const PILL_PRESS_VARIANTS = createPressVariants(0.97, 1.02);
|
|
@@ -46,19 +47,21 @@ const PILL_PRESS_VARIANTS = createPressVariants(0.97, 1.02);
|
|
|
46
47
|
*/
|
|
47
48
|
const Pill = React__default.forwardRef((props, ref) => {
|
|
48
49
|
var _a, _b;
|
|
49
|
-
const { children, size = "md", iconStart, selected = false, onSelect, readOnly = false, className, theme = {}, onClick, ...restProps } = props;
|
|
50
|
+
const { children, size = "md", iconStart, selected, defaultSelected = false, onSelect, readOnly = false, className, theme = {}, onClick, onKeyDown, ...restProps } = props;
|
|
50
51
|
// Detect whether the consumer *explicitly* passed `selected` (even
|
|
51
|
-
// `selected={undefined}` counts as not-passed)
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// "set".
|
|
56
|
-
const hasExplicitSelected = "selected" in props && props.selected !== undefined;
|
|
52
|
+
// `selected={undefined}` counts as not-passed), so a plain `<Pill>` with no
|
|
53
|
+
// `selected` prop never trips the dev-warning below.
|
|
54
|
+
const hasExplicitSelected = selected !== undefined;
|
|
55
|
+
const hasExplicitDefaultSelected = "defaultSelected" in props && props.defaultSelected !== undefined;
|
|
57
56
|
// ========== State Management ==========
|
|
58
|
-
|
|
59
|
-
useEffect(
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// 🔴 §9: mode is DERIVED per render, never stored. `selected` used to be
|
|
58
|
+
// mirrored into state and re-synced from a `useEffect(…, [selected])`. A
|
|
59
|
+
// single-choice group that rejects a deselect leaves the prop identical, so
|
|
60
|
+
// the effect never fired and the chip reported `aria-pressed="false"` while
|
|
61
|
+
// still being the option in force — the control lying about its own state.
|
|
62
|
+
const isControlled = hasExplicitSelected;
|
|
63
|
+
const [uncontrolledSelected, setUncontrolledSelected] = useState(defaultSelected);
|
|
64
|
+
const isSelected = isControlled ? selected : uncontrolledSelected;
|
|
62
65
|
const shouldReduceMotion = useReducedMotion();
|
|
63
66
|
// ========== Derived State ==========
|
|
64
67
|
const isSelectable = Boolean(onSelect);
|
|
@@ -71,8 +74,12 @@ const Pill = React__default.forwardRef((props, ref) => {
|
|
|
71
74
|
// eslint-disable-next-line no-console
|
|
72
75
|
console.warn('[Pill] Component has "selected" prop but missing "onSelect" handler. The pill will not be interactive.');
|
|
73
76
|
}
|
|
77
|
+
if (hasExplicitSelected && hasExplicitDefaultSelected) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.warn("[Pill] Received both `selected` and `defaultSelected`. `selected` wins and the pill is controlled; drop `defaultSelected`.");
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
|
-
}, [hasExplicitSelected, onSelect]);
|
|
82
|
+
}, [hasExplicitSelected, hasExplicitDefaultSelected, onSelect]);
|
|
76
83
|
// ========== Event Handlers ==========
|
|
77
84
|
const handleClick = useCallback((e) => {
|
|
78
85
|
if (readOnly) {
|
|
@@ -81,21 +88,29 @@ const Pill = React__default.forwardRef((props, ref) => {
|
|
|
81
88
|
}
|
|
82
89
|
if (onSelect) {
|
|
83
90
|
const newSelected = !isSelected;
|
|
84
|
-
|
|
91
|
+
// Controlled: the parent owns the value — it may accept, reject or
|
|
92
|
+
// normalize this. Writing a local copy is what desynced the chip.
|
|
93
|
+
if (!isControlled)
|
|
94
|
+
setUncontrolledSelected(newSelected);
|
|
85
95
|
onSelect(newSelected);
|
|
86
96
|
}
|
|
87
97
|
onClick === null || onClick === void 0 ? void 0 : onClick(e);
|
|
88
|
-
}, [readOnly, onSelect, isSelected, onClick]);
|
|
98
|
+
}, [readOnly, onSelect, isSelected, isControlled, onClick]);
|
|
89
99
|
const handleKeyDown = useCallback((e) => {
|
|
90
100
|
if (readOnly || !isSelectable)
|
|
91
101
|
return;
|
|
92
102
|
if (e.key === "Enter" || e.key === " ") {
|
|
93
103
|
e.preventDefault();
|
|
94
104
|
const newSelected = !isSelected;
|
|
95
|
-
|
|
105
|
+
if (!isControlled)
|
|
106
|
+
setUncontrolledSelected(newSelected);
|
|
96
107
|
onSelect === null || onSelect === void 0 ? void 0 : onSelect(newSelected);
|
|
97
108
|
}
|
|
98
|
-
}, [readOnly, isSelectable, isSelected, onSelect]);
|
|
109
|
+
}, [readOnly, isSelectable, isSelected, isControlled, onSelect]);
|
|
110
|
+
// 🔴 §9: `{...restProps}` spreads AFTER this element's own attributes, so a
|
|
111
|
+
// consumer's onKeyDown used to REPLACE ours and silently kill Enter/Space
|
|
112
|
+
// selection. Compose instead — ours first, then theirs.
|
|
113
|
+
const composedKeyDown = composeEventHandlers(handleKeyDown, onKeyDown);
|
|
99
114
|
// ========== Theme Merging ==========
|
|
100
115
|
const mergedTheme = {
|
|
101
116
|
baseStyle: theme.baseStyle || pillTheme.baseStyle,
|
|
@@ -119,7 +134,7 @@ const Pill = React__default.forwardRef((props, ref) => {
|
|
|
119
134
|
isSelected && mergedTheme.selectedStyle,
|
|
120
135
|
// Cursor styling
|
|
121
136
|
isInteractive ? "cursor-pointer" : "cursor-default", className // User overrides (highest priority)
|
|
122
|
-
), onClick: handleClick, onKeyDown:
|
|
137
|
+
), onClick: handleClick, onKeyDown: composedKeyDown, "data-size": size, "data-selected": isSelected ? "true" : "false", "data-readonly": !isInteractive ? "true" : "false", "data-selectable": isSelectable ? "true" : "false",
|
|
123
138
|
// Accessibility
|
|
124
139
|
role: isSelectable && !readOnly ? "button" : undefined, tabIndex: isInteractive ? 0 : undefined, "aria-pressed": isSelectable && !readOnly ? isSelected : undefined, "aria-readonly": readOnly || undefined,
|
|
125
140
|
// Animation
|
|
@@ -30,8 +30,21 @@ export interface PillProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "
|
|
|
30
30
|
* Note: Replaced by CheckIcon when pill is selected
|
|
31
31
|
*/
|
|
32
32
|
iconStart?: React.ReactNode;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Whether the pill is selected — CONTROLLED. Passing it makes the prop
|
|
35
|
+
* authoritative: the pill renders from it on every render and keeps no copy,
|
|
36
|
+
* so a parent that rejects or normalizes a change (a single-choice group
|
|
37
|
+
* refusing to deselect the active option) stays truthfully reflected.
|
|
38
|
+
*
|
|
39
|
+
* Mutually exclusive with `defaultSelected` (dev-warns if both are passed).
|
|
40
|
+
*/
|
|
34
41
|
selected?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Initial selection for an UNCONTROLLED pill, which then owns its own state.
|
|
44
|
+
* Ignored when `selected` is provided.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
defaultSelected?: boolean;
|
|
35
48
|
/**
|
|
36
49
|
* Callback when selection state changes
|
|
37
50
|
* @param selected - The new selected state
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { SlidingNumberProps } from "./SlidingNumber.types";
|
|
3
|
+
/**
|
|
4
|
+
* Every rendered character — an animated digit or a static one (`-`, `.`, `,`) —
|
|
5
|
+
* occupies exactly this many `em`s. Exported so a host can reserve an exact width
|
|
6
|
+
* for a given character count WITHOUT measuring the DOM: `InputCounter` sizes its
|
|
7
|
+
* value slot from it, which is what keeps the box identical whether the animated
|
|
8
|
+
* number or the click-to-edit field is mounted. Changing it changes that geometry.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SLIDING_NUMBER_CHAR_WIDTH_EM = 0.6;
|
|
3
11
|
/**
|
|
4
12
|
* SlidingNumber component with odometer-style digit animation
|
|
5
13
|
*
|
|
@@ -4,11 +4,20 @@ import React__default, { useMemo } from 'react';
|
|
|
4
4
|
import { motion, useReducedMotion } from 'motion/react';
|
|
5
5
|
import { cn } from '../../../utils/cn.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Every rendered character — an animated digit or a static one (`-`, `.`, `,`) —
|
|
9
|
+
* occupies exactly this many `em`s. Exported so a host can reserve an exact width
|
|
10
|
+
* for a given character count WITHOUT measuring the DOM: `InputCounter` sizes its
|
|
11
|
+
* value slot from it, which is what keeps the box identical whether the animated
|
|
12
|
+
* number or the click-to-edit field is mounted. Changing it changes that geometry.
|
|
13
|
+
*/
|
|
14
|
+
const SLIDING_NUMBER_CHAR_WIDTH_EM = 0.6;
|
|
15
|
+
const CHAR_WIDTH = `${SLIDING_NUMBER_CHAR_WIDTH_EM}em`;
|
|
16
|
+
const DIGIT_CONTAINER_STYLE = { height: "1em", width: CHAR_WIDTH, lineHeight: "1em" };
|
|
8
17
|
const DIGIT_ITEM_STYLE = { height: "1em", lineHeight: "1em" };
|
|
9
18
|
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
10
19
|
const DEFAULT_TRANSITION = { type: "spring", stiffness: 300, damping: 30, bounce: 0 };
|
|
11
|
-
const STATIC_CHAR_STYLE = { width:
|
|
20
|
+
const STATIC_CHAR_STYLE = { width: CHAR_WIDTH };
|
|
12
21
|
const ROOT_STYLE = { lineHeight: "1" };
|
|
13
22
|
/**
|
|
14
23
|
* Individual digit component that handles the sliding animation
|
|
@@ -72,4 +81,4 @@ const SlidingNumber = React__default.forwardRef(({ value, decimalPlaces = 0, pad
|
|
|
72
81
|
});
|
|
73
82
|
SlidingNumber.displayName = "SlidingNumber";
|
|
74
83
|
|
|
75
|
-
export { SlidingNumber };
|
|
84
|
+
export { SLIDING_NUMBER_CHAR_WIDTH_EM, SlidingNumber };
|
|
@@ -34,6 +34,24 @@ const DEFAULT_GRADIENT = {
|
|
|
34
34
|
* and return null). Skip parsing for gradient values.
|
|
35
35
|
*/
|
|
36
36
|
const isGradientString = (v) => /^(?:repeating-)?(?:linear|radial|conic)-gradient\(/.test(v.trim());
|
|
37
|
+
/**
|
|
38
|
+
* The solid-color output pipeline, as a pure function.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors `getFormattedValue` below, but callable from an effect that needs to
|
|
41
|
+
* know what a color WILL serialize to before that color reaches state — used to
|
|
42
|
+
* mark a parent-supplied value as already in force so it is never echoed back
|
|
43
|
+
* through `onChange`.
|
|
44
|
+
*/
|
|
45
|
+
const formatColorAs = (c, fmt) => {
|
|
46
|
+
switch (fmt) {
|
|
47
|
+
case 'rgb':
|
|
48
|
+
return formatRgb(c.h, c.s, c.l, c.a);
|
|
49
|
+
case 'hsl':
|
|
50
|
+
return formatHsl(c.h, c.s, c.l, c.a);
|
|
51
|
+
default:
|
|
52
|
+
return formatHex(c.h, c.s, c.l);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
37
55
|
/**
|
|
38
56
|
* ColorPicker Root Component
|
|
39
57
|
* Follows Flikkui compound component pattern (similar to Accordion, Tabs, Popover)
|
|
@@ -55,6 +73,11 @@ const ColorPickerRoot = React__default.forwardRef(({ value, defaultValue = '#000
|
|
|
55
73
|
const [color, setColor] = useState(initialColor);
|
|
56
74
|
const [format, setFormat] = useState(initialFormat);
|
|
57
75
|
const [isOpen, setIsOpen] = useState(false);
|
|
76
|
+
// Last value handed to `onChange` (or accepted from the parent). Declared
|
|
77
|
+
// here because BOTH the controlled-value sync and the emit effect below
|
|
78
|
+
// write it — see the emit effect for the full rationale.
|
|
79
|
+
const prevOutputRef = useRef(undefined);
|
|
80
|
+
const hasSeededOutputRef = useRef(false);
|
|
58
81
|
// Mode state (controlled/uncontrolled)
|
|
59
82
|
const [internalMode, setInternalMode] = useState(defaultMode);
|
|
60
83
|
const mode = modeProp !== null && modeProp !== void 0 ? modeProp : internalMode;
|
|
@@ -130,9 +153,14 @@ const ColorPickerRoot = React__default.forwardRef(({ value, defaultValue = '#000
|
|
|
130
153
|
const parsed = parseColor(value);
|
|
131
154
|
if (parsed) {
|
|
132
155
|
setColor(parsed);
|
|
156
|
+
// 🔴 Record what this externally-supplied value normalizes to, so the
|
|
157
|
+
// emit effect below treats the resulting render as "already in force"
|
|
158
|
+
// and does not echo the parent's own value back as an onChange. Without
|
|
159
|
+
// this, a parent that writes what it receives ping-pongs forever.
|
|
160
|
+
prevOutputRef.current = formatColorAs(parsed, format);
|
|
133
161
|
}
|
|
134
162
|
}
|
|
135
|
-
}, [value]);
|
|
163
|
+
}, [value, format]);
|
|
136
164
|
// Individual channel setters
|
|
137
165
|
const setHue = useCallback((h) => {
|
|
138
166
|
setColor((prev) => ({ ...prev, h }));
|
|
@@ -147,6 +175,7 @@ const ColorPickerRoot = React__default.forwardRef(({ value, defaultValue = '#000
|
|
|
147
175
|
setColor((prev) => ({ ...prev, a }));
|
|
148
176
|
}, []);
|
|
149
177
|
// Utility functions to get color in different formats
|
|
178
|
+
// (module-level `formatColorAs` is the same pipeline, callable outside render)
|
|
150
179
|
const getHexValue = useCallback(() => {
|
|
151
180
|
return formatHex(color.h, color.s, color.l);
|
|
152
181
|
}, [color]);
|
|
@@ -180,10 +209,23 @@ const ColorPickerRoot = React__default.forwardRef(({ value, defaultValue = '#000
|
|
|
180
209
|
}
|
|
181
210
|
return getFormattedValue();
|
|
182
211
|
}, [mode, gradient, getFormattedValue]);
|
|
183
|
-
// Notify parent of color changes
|
|
184
|
-
|
|
212
|
+
// Notify parent of color changes.
|
|
213
|
+
//
|
|
214
|
+
// 🔴 A controlled component NEVER emits for a value it wasn't given by the
|
|
215
|
+
// user. This used to fire on mount — `prevOutputRef` started `undefined`, so
|
|
216
|
+
// the first pass always "differed" — handing the consumer a value nobody
|
|
217
|
+
// chose. Because the output is re-derived through hex -> HSL (integer
|
|
218
|
+
// rounding) -> hex, that value often wasn't even the one passed in, so a
|
|
219
|
+
// parent storing what it receives recorded a phantom user edit, and a parent
|
|
220
|
+
// that fed it back re-rendered into an infinite emit loop.
|
|
185
221
|
useEffect(() => {
|
|
186
222
|
const output = getOutputValue();
|
|
223
|
+
// First pass: seed the baseline, emit nothing. Mounting is not a user edit.
|
|
224
|
+
if (!hasSeededOutputRef.current) {
|
|
225
|
+
hasSeededOutputRef.current = true;
|
|
226
|
+
prevOutputRef.current = output;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
187
229
|
if (onChange && !disabled && output !== prevOutputRef.current) {
|
|
188
230
|
prevOutputRef.current = output;
|
|
189
231
|
onChange(output);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from 'react/jsx-runtime';
|
|
3
3
|
import { useReducedMotion, AnimatePresence, motion } from 'motion/react';
|
|
4
|
-
import { useContext,
|
|
4
|
+
import { useContext, useEffect } from 'react';
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
6
|
import { useSelectPortal } from '../../../hooks/useSelectPortal.js';
|
|
7
7
|
import { cn } from '../../../utils/cn.js';
|
|
@@ -16,15 +16,19 @@ import { useIsClient } from '../../../hooks/useIsClient.js';
|
|
|
16
16
|
*/
|
|
17
17
|
const ColorPickerBody = ({ children, className, portal = true, animation = true, ...rest }) => {
|
|
18
18
|
const context = useContext(ColorPickerContext);
|
|
19
|
-
const contentRef = useRef(null);
|
|
20
19
|
const shouldReduceMotion = useReducedMotion();
|
|
21
20
|
const isClient = useIsClient();
|
|
22
21
|
if (!context) {
|
|
23
22
|
throw new Error('ColorPicker.Body must be used within a ColorPicker');
|
|
24
23
|
}
|
|
25
24
|
const { isOpen, setIsOpen, theme, triggerRef, placement, offset, dialogId } = context;
|
|
26
|
-
// Use positioning hook (same as Dropdown/Select)
|
|
27
|
-
|
|
25
|
+
// Use positioning hook (same as Dropdown/Select).
|
|
26
|
+
// 🔴 `contentRef` MUST come from the hook and be attached to the panel below:
|
|
27
|
+
// the hook measures the panel through it to clamp against the viewport. Body
|
|
28
|
+
// used to declare its own ref, leaving the hook's forever null — so
|
|
29
|
+
// `measureDropdownWidth` fell through to the TRIGGER's width, clamping fitted
|
|
30
|
+
// a ~32px box, and the ~320px panel ran straight off the right edge.
|
|
31
|
+
const { position, cssVariables, contentRef } = useSelectPortal({
|
|
28
32
|
triggerRef,
|
|
29
33
|
isOpen: isOpen || false,
|
|
30
34
|
placement,
|
|
@@ -134,7 +134,7 @@ const ComboboxOptions = ({ isOpen, onClose, triggerRef, portal, placement, offse
|
|
|
134
134
|
* ```
|
|
135
135
|
*/
|
|
136
136
|
const ComboboxInner = ({ options, value, onChange, placeholder = "Search...", label, helperText, size = "md", state = "default", clearable = true, iconStart, iconEnd, openOnFocus = true, filterFn = defaultFilterFn, emptyMessage = "No results found", creatable = false, onCreateOption, createLabel, required = false, name, className, wrapperClassName, inputClassName, dropdownClassName, theme: themeOverrides, portal = true, placement = "bottom-start", offset = 8, elevation, lift, id, ...props }, ref) => {
|
|
137
|
-
var _a;
|
|
137
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
138
138
|
// Merge theme
|
|
139
139
|
const theme = React__default.useMemo(() => ({ ...comboboxTheme, ...(themeOverrides || {}) }), [themeOverrides]);
|
|
140
140
|
// Generate unique ID
|
|
@@ -405,8 +405,12 @@ const ComboboxInner = ({ options, value, onChange, placeholder = "Search...", la
|
|
|
405
405
|
// Icon padding
|
|
406
406
|
const hasLeftIcon = Boolean(iconStart);
|
|
407
407
|
const hasRightIcon = Boolean(iconEnd);
|
|
408
|
-
const iconStartPadding = hasLeftIcon
|
|
409
|
-
|
|
408
|
+
const iconStartPadding = hasLeftIcon
|
|
409
|
+
? ((_c = (_b = theme.iconPaddings) === null || _b === void 0 ? void 0 : _b.left) === null || _c === void 0 ? void 0 : _c[size]) || theme.iconPadding.left
|
|
410
|
+
: "";
|
|
411
|
+
const iconEndPadding = hasRightIcon
|
|
412
|
+
? ((_e = (_d = theme.iconPaddings) === null || _d === void 0 ? void 0 : _d.right) === null || _e === void 0 ? void 0 : _e[size]) || theme.iconPadding.right
|
|
413
|
+
: "";
|
|
410
414
|
// Create a modified selectState for the provider with our custom filtered options
|
|
411
415
|
const modifiedSelectState = React__default.useMemo(() => ({
|
|
412
416
|
...selectState,
|
|
@@ -416,7 +420,7 @@ const ComboboxInner = ({ options, value, onChange, placeholder = "Search...", la
|
|
|
416
420
|
return (jsx(SelectProvider, { selectState: modifiedSelectState, size: size, componentState: state, theme: theme, searchable: true, enableTypeahead: false, children: jsxs("div", { ref: ref, className: cn("relative", containerClasses, stateClass, wrapperClassName), ...props, children: [label && (typeof label === 'string' ? (jsx(FormLabel, { htmlFor: comboboxId, state: state, required: required, children: label })) : (label)), jsxs("div", { ref: wrapperRef, className: theme.wrapperStyle, children: [jsx("div", { className: cn(inputGroupClasses, stateClasses, className), children: jsxs("div", { className: "relative flex-1", children: [jsx("input", { ref: inputRef, id: comboboxId, type: "text", role: "combobox", "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-controls": listboxId, "aria-activedescendant": highlightedIndex >= 0 &&
|
|
417
421
|
customFilteredOptions[highlightedIndex]
|
|
418
422
|
? `${comboboxId}-option-${customFilteredOptions[highlightedIndex].id}`
|
|
419
|
-
: undefined, "aria-autocomplete": "list", "aria-invalid": isInvalid, "aria-describedby": helperText ? `${comboboxId}-helper` : undefined, autoComplete: "off", className: cn(inputClasses, sizeClasses, iconStartPadding, (hasRightIcon || showClearButton) && iconEndPadding, showClearButton && "pr-8", inputClassName), value: searchValue, onChange: handleInputChange, onFocus: handleInputFocus, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: isDisabled, required: required }), hasLeftIcon && (jsx("div", { className: theme.iconStartStyle, children: iconStart })), showClearButton && (jsx("button", { type: "button", className: theme.clearButtonStyle, onClick: handleClear, "aria-label": "Clear selection", tabIndex: -1, children: jsx(X, { className: theme.clearIconStyle }) })), hasRightIcon && !showClearButton && (jsx("div", { className: theme.iconEndStyle, children: iconEnd }))] }) }), jsx(ComboboxOptions, { isOpen: isOpen, onClose: () => setIsOpen(false), triggerRef: wrapperRef, portal: portal, placement: placement, offset: offset, emptyMessage: emptyMessage, className: dropdownClassName, listboxId: listboxId, comboboxId: comboboxId, elevation: elevation, lift: lift })] }), helperText && (typeof helperText === 'string' ? (jsx("p", { id: `${comboboxId}-helper`, className: cn(helperTextClasses, helperTextStateClasses), children: helperText })) : (helperText)), name && (jsx("input", { type: "hidden", name: name, value: value !== undefined ? String(value) : "" }))] }) }));
|
|
423
|
+
: undefined, "aria-autocomplete": "list", "aria-invalid": isInvalid, "aria-describedby": helperText ? `${comboboxId}-helper` : undefined, autoComplete: "off", className: cn(inputClasses, sizeClasses, iconStartPadding, (hasRightIcon || showClearButton) && iconEndPadding, showClearButton && "pr-8", inputClassName), value: searchValue, onChange: handleInputChange, onFocus: handleInputFocus, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: isDisabled, required: required }), hasLeftIcon && (jsx("div", { className: ((_f = theme.iconStartStyles) === null || _f === void 0 ? void 0 : _f[size]) || theme.iconStartStyle, children: iconStart })), showClearButton && (jsx("button", { type: "button", className: theme.clearButtonStyle, onClick: handleClear, "aria-label": "Clear selection", tabIndex: -1, children: jsx(X, { className: theme.clearIconStyle }) })), hasRightIcon && !showClearButton && (jsx("div", { className: ((_g = theme.iconEndStyles) === null || _g === void 0 ? void 0 : _g[size]) || theme.iconEndStyle, children: iconEnd }))] }) }), jsx(ComboboxOptions, { isOpen: isOpen, onClose: () => setIsOpen(false), triggerRef: wrapperRef, portal: portal, placement: placement, offset: offset, emptyMessage: emptyMessage, className: dropdownClassName, listboxId: listboxId, comboboxId: comboboxId, elevation: elevation, lift: lift })] }), helperText && (typeof helperText === 'string' ? (jsx("p", { id: `${comboboxId}-helper`, className: cn(helperTextClasses, helperTextStateClasses), children: helperText })) : (helperText)), name && (jsx("input", { type: "hidden", name: name, value: value !== undefined ? String(value) : "" }))] }) }));
|
|
420
424
|
};
|
|
421
425
|
const ComboboxForwarded = React__default.forwardRef(ComboboxInner);
|
|
422
426
|
ComboboxForwarded.displayName = "Combobox";
|
|
@@ -31,6 +31,15 @@ const comboboxTheme = {
|
|
|
31
31
|
iconEndStyle: "absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none text-[var(--color-text-muted)]",
|
|
32
32
|
// Icon padding configuration
|
|
33
33
|
iconPadding: formsBaseTheme.iconStyles.padding,
|
|
34
|
+
// Size-aware icon geometry: slot inset from --form-px-*, glyph from
|
|
35
|
+
// --form-icon-size-*. Flat keys above stay as the fallback.
|
|
36
|
+
iconStartStyles: formsBaseTheme.iconStyles.leftSizes,
|
|
37
|
+
iconEndStyles: formsBaseTheme.iconStyles.rightSizes,
|
|
38
|
+
iconSizes: formsBaseTheme.iconStyles.sizes,
|
|
39
|
+
iconPaddings: {
|
|
40
|
+
left: formsBaseTheme.iconStyles.paddingLeftSizes,
|
|
41
|
+
right: formsBaseTheme.iconStyles.paddingRightSizes,
|
|
42
|
+
},
|
|
34
43
|
// Clear button styles
|
|
35
44
|
clearButtonStyle: "absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer " +
|
|
36
45
|
"text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors z-10",
|
|
@@ -97,8 +97,21 @@ export interface ComboboxTheme {
|
|
|
97
97
|
sizes: Record<ComboboxSize, string>;
|
|
98
98
|
states: Record<ComboboxState, string>;
|
|
99
99
|
focusStates: Record<ComboboxState, string>;
|
|
100
|
+
/** @deprecated size-blind; superseded by `iconStartStyles` */
|
|
100
101
|
iconStartStyle: string;
|
|
102
|
+
/** @deprecated size-blind; superseded by `iconEndStyles` */
|
|
101
103
|
iconEndStyle: string;
|
|
104
|
+
/** Per-size leading icon slot (position + inset) */
|
|
105
|
+
iconStartStyles?: Partial<Record<ComboboxSize, string>>;
|
|
106
|
+
/** Per-size trailing icon slot (position + inset) */
|
|
107
|
+
iconEndStyles?: Partial<Record<ComboboxSize, string>>;
|
|
108
|
+
/** Per-size sizing for built-in icons */
|
|
109
|
+
iconSizes?: Record<ComboboxSize, string>;
|
|
110
|
+
/** Per-size text runway reserved for each icon slot */
|
|
111
|
+
iconPaddings?: {
|
|
112
|
+
left?: Partial<Record<ComboboxSize, string>>;
|
|
113
|
+
right?: Partial<Record<ComboboxSize, string>>;
|
|
114
|
+
};
|
|
102
115
|
iconPadding: {
|
|
103
116
|
left: string;
|
|
104
117
|
right: string;
|