@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/dist/components/ai/AgentRequest/AgentRequest.theme.js +1 -1
  3. package/dist/components/core/Button/Button.theme.js +3 -3
  4. package/dist/components/core/Pill/Pill.js +32 -17
  5. package/dist/components/core/Pill/Pill.types.d.ts +14 -1
  6. package/dist/components/core/SlidingNumber/SlidingNumber.d.ts +8 -0
  7. package/dist/components/core/SlidingNumber/SlidingNumber.js +12 -3
  8. package/dist/components/forms/ColorPicker/ColorPicker.js +45 -3
  9. package/dist/components/forms/ColorPicker/ColorPickerBody.js +8 -4
  10. package/dist/components/forms/Combobox/Combobox.js +8 -4
  11. package/dist/components/forms/Combobox/Combobox.theme.js +9 -0
  12. package/dist/components/forms/Combobox/Combobox.types.d.ts +13 -0
  13. package/dist/components/forms/DatePicker/DatePicker.js +19 -12
  14. package/dist/components/forms/DatePicker/DatePicker.theme.js +5 -0
  15. package/dist/components/forms/DatePicker/DatePicker.types.d.ts +8 -0
  16. package/dist/components/forms/DatePicker/DatePickerContext.d.ts +0 -1
  17. package/dist/components/forms/DatePicker/DatePickerTrigger.js +5 -3
  18. package/dist/components/forms/DateRangePicker/DateRangePicker.theme.js +5 -0
  19. package/dist/components/forms/DateRangePicker/DateRangePicker.types.d.ts +8 -0
  20. package/dist/components/forms/DateRangePicker/DateRangePickerTrigger.js +5 -3
  21. package/dist/components/forms/Input/Input.js +17 -12
  22. package/dist/components/forms/Input/Input.theme.js +10 -0
  23. package/dist/components/forms/Input/Input.types.d.ts +14 -0
  24. package/dist/components/forms/InputAddress/InputAddress.js +5 -5
  25. package/dist/components/forms/InputAddress/InputAddress.types.d.ts +7 -0
  26. package/dist/components/forms/InputCounter/InputCounter.js +194 -17
  27. package/dist/components/forms/InputCounter/InputCounter.theme.d.ts +12 -0
  28. package/dist/components/forms/InputCounter/InputCounter.theme.js +92 -2
  29. package/dist/components/forms/InputCounter/InputCounter.types.d.ts +47 -4
  30. package/dist/components/forms/InputCreditCard/InputCreditCard.js +4 -4
  31. package/dist/components/forms/InputCreditCard/InputCreditCard.types.d.ts +7 -0
  32. package/dist/components/forms/Mention/Mention.js +13 -11
  33. package/dist/components/forms/RichTextEditor/RichTextEditor.js +12 -3
  34. package/dist/components/forms/Select/Select.js +15 -8
  35. package/dist/components/forms/Select/Select.theme.js +9 -1
  36. package/dist/components/forms/Select/Select.types.d.ts +16 -1
  37. package/dist/components/forms/Textarea/Textarea.js +14 -25
  38. package/dist/components/forms/TimePicker/TimePicker.theme.js +9 -0
  39. package/dist/components/forms/TimePicker/TimePicker.types.d.ts +11 -0
  40. package/dist/components/forms/TimePicker/TimePickerTrigger.js +7 -5
  41. package/dist/components/forms/forms.theme.d.ts +57 -0
  42. package/dist/components/forms/forms.theme.js +83 -8
  43. package/dist/components/generative/registry.js +1 -0
  44. package/dist/components/generative/schema.generated.js +21 -1
  45. package/dist/generative.schema.json +21 -1
  46. package/dist/registry.json +108 -17
  47. package/dist/styles.css +1 -1
  48. package/dist/tools.json +21 -1
  49. package/dist/utils/composeEventHandlers.d.ts +19 -0
  50. package/dist/utils/composeEventHandlers.js +26 -0
  51. package/dist/utils/composeEventHandlers.test.d.ts +1 -0
  52. package/dist/utils/index.d.ts +1 -0
  53. package/package.json +1 -1
  54. package/src/styles/theme.css +33 -9
@@ -1,14 +1,16 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from 'react/jsx-runtime';
3
- import React__default, { useId, useMemo, useEffect, useCallback } from 'react';
3
+ import React__default, { useId, useState, useRef, useCallback, useMemo, useEffect } from 'react';
4
4
  import { useReducedMotion, motion } from 'motion/react';
5
5
  import { inputCounterTheme } from './InputCounter.theme.js';
6
6
  import { Button } from '../../core/Button/Button.js';
7
- import { SlidingNumber } from '../../core/SlidingNumber/SlidingNumber.js';
7
+ import { SLIDING_NUMBER_CHAR_WIDTH_EM, SlidingNumber } from '../../core/SlidingNumber/SlidingNumber.js';
8
8
  import { cn } from '../../../utils/cn.js';
9
9
  import { Minus, Plus } from '@phosphor-icons/react';
10
10
  import { FormLabel } from '../FormLabel/FormLabel.js';
11
11
 
12
+ /** Lands a typed value in place instead of rolling the digits to it. */
13
+ const INSTANT_TRANSITION = { duration: 0 };
12
14
  /**
13
15
  * InputCounter component with animated number display
14
16
  *
@@ -35,12 +37,44 @@ import { FormLabel } from '../FormLabel/FormLabel.js';
35
37
  * );
36
38
  * ```
37
39
  */
38
- const InputCounter = React__default.forwardRef(({ value, onChange, state = "default", label, helperText, errorMessage, min, max, step = 1, disabled = false, decimalPlaces, formatNumber = (n) => n.toString(), className, buttonProps, theme, transition = { type: "spring", bounce: 0, stiffness: 300, damping: 30 }, "aria-label": ariaLabel = "Counter", onAnimationStart, onAnimationEnd, onDrag, onDragStart, onDragEnd, ...props }, ref) => {
40
+ const InputCounter = React__default.forwardRef(({ value, onChange, size = "md", editable = true, state = "default", label, helperText, errorMessage, min, max, step = 1, disabled = false, decimalPlaces, className, buttonProps, theme, transition = { type: "spring", bounce: 0, stiffness: 300, damping: 30 }, "aria-label": ariaLabel = "Counter", onAnimationStart, onAnimationEnd, onDrag, onDragStart, onDragEnd, ...props }, ref) => {
39
41
  const shouldReduceMotion = useReducedMotion();
40
42
  const shouldDisableAnimations = !!shouldReduceMotion;
41
43
  const generatedId = useId();
42
44
  const isDisabled = state === "disabled" || disabled;
43
45
  const isInvalid = state === "invalid";
46
+ // --- Manual entry ---------------------------------------------------
47
+ // Clicking the number swaps SlidingNumber for a real field. Typing is
48
+ // free-form (no clamping, no onChange) so "5" on the way to "50" under
49
+ // min=10 isn't eaten; the value is parsed, clamped and emitted only on
50
+ // commit. `editSession` guards the commit path: Enter and Escape both
51
+ // unmount the field, which fires a blur that must not commit a second
52
+ // time (or re-commit a value Escape just discarded).
53
+ const [isEditing, setIsEditing] = useState(false);
54
+ const [draft, setDraft] = useState("");
55
+ /**
56
+ * True when the value on screen got there by being TYPED. The odometer roll
57
+ * is feedback for stepping — it shows the value travelling by one step. A
58
+ * number entered outright never travelled, so rolling 4 → 9 through 5, 6, 7,
59
+ * 8 reads as the control second-guessing the entry; it should simply appear.
60
+ *
61
+ * Carried as state rather than a remount: remounting SlidingNumber makes it
62
+ * animate from zero instead (a motion element without `initial` animates ON
63
+ * MOUNT), which is the same roll in a worse costume. Every emit declares
64
+ * which kind it is via `emitValue`, so an async parent that applies the value
65
+ * a tick later still lands it instantly, and the next step still animates.
66
+ */
67
+ const [landsInstantly, setLandsInstantly] = useState(false);
68
+ const fieldRef = useRef(null);
69
+ const containerRef = useRef(null);
70
+ const editSessionRef = useRef("idle");
71
+ const setContainerRef = useCallback((node) => {
72
+ containerRef.current = node;
73
+ if (typeof ref === "function")
74
+ ref(node);
75
+ else if (ref)
76
+ ref.current = node;
77
+ }, [ref]);
44
78
  // Auto-detect decimal places from step if not explicitly provided
45
79
  const autoDecimalPlaces = useMemo(() => {
46
80
  if (decimalPlaces !== undefined)
@@ -58,6 +92,16 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
58
92
  ...inputCounterTheme,
59
93
  ...theme,
60
94
  }), [theme]);
95
+ /**
96
+ * The single emit path. Every value change declares whether it was typed
97
+ * (lands instantly) or stepped (rolls), so the flag can never drift out of
98
+ * sync with what is on screen.
99
+ */
100
+ const emitValue = useCallback((next, options) => {
101
+ var _a;
102
+ setLandsInstantly((_a = options === null || options === void 0 ? void 0 : options.typed) !== null && _a !== void 0 ? _a : false);
103
+ onChange === null || onChange === void 0 ? void 0 : onChange(next);
104
+ }, [onChange]);
61
105
  // Auto-clamp value to min/max bounds when they change
62
106
  useEffect(() => {
63
107
  let clampedValue = value;
@@ -73,9 +117,9 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
73
117
  needsUpdate = true;
74
118
  }
75
119
  if (needsUpdate) {
76
- onChange === null || onChange === void 0 ? void 0 : onChange(clampedValue);
120
+ emitValue(clampedValue);
77
121
  }
78
- }, [min, max, value, onChange]);
122
+ }, [min, max, value, emitValue]);
79
123
  // Handle increment
80
124
  const handleIncrement = useCallback(() => {
81
125
  if (isDisabled)
@@ -91,8 +135,8 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
91
135
  }
92
136
  return;
93
137
  }
94
- onChange === null || onChange === void 0 ? void 0 : onChange(roundedValue);
95
- }, [value, step, max, isDisabled, onChange]);
138
+ emitValue(roundedValue);
139
+ }, [value, step, max, isDisabled, emitValue]);
96
140
  // Handle decrement
97
141
  const handleDecrement = useCallback(() => {
98
142
  if (isDisabled)
@@ -108,8 +152,113 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
108
152
  }
109
153
  return;
110
154
  }
111
- onChange === null || onChange === void 0 ? void 0 : onChange(roundedValue);
112
- }, [value, step, min, isDisabled, onChange]);
155
+ emitValue(roundedValue);
156
+ }, [value, step, min, isDisabled, emitValue]);
157
+ const canEdit = editable && !isDisabled;
158
+ // --- Value slot width -----------------------------------------------
159
+ // SlidingNumber gives every character (digit, `-`, `.`) the same fixed em
160
+ // box, so an exact width can be reserved from a character COUNT without
161
+ // measuring. Both states are laid into that one box, which is what keeps
162
+ // clicking the number from resizing the control: the edit field used to
163
+ // size itself via the `size` attribute — the browser's average character
164
+ // width — and landed 1–3px wider than the number it replaced, a gap the
165
+ // root's `layout` animation then made visible as a wobble.
166
+ const formatChars = useCallback((n) => (autoDecimalPlaces > 0
167
+ ? Number(n).toFixed(autoDecimalPlaces)
168
+ : String(Math.round(n))).length, [autoDecimalPlaces]);
169
+ // Bounded counters reserve room for their widest legal value up front, so
170
+ // stepping 9 → 10 doesn't resize the box either.
171
+ //
172
+ // 🔴 The floor is TWO characters, not one, and it is load-bearing at `sm`:
173
+ // the steppers there are undersized for WCAG 2.2 SC 2.5.8 and clear it via
174
+ // the spacing exception, which is measured to this slot's centre (it is a
175
+ // click-to-edit target). A 1-character slot pulls those centres to 21.6px
176
+ // and fails; two characters keeps them at 25.2px. See InputCounter.theme.ts.
177
+ const slotChars = useMemo(() => {
178
+ let chars = Math.max(formatChars(value), 2);
179
+ if (min !== undefined)
180
+ chars = Math.max(chars, formatChars(min));
181
+ if (max !== undefined)
182
+ chars = Math.max(chars, formatChars(max));
183
+ if (isEditing)
184
+ chars = Math.max(chars, draft.length);
185
+ return chars;
186
+ }, [formatChars, value, min, max, isEditing, draft]);
187
+ /**
188
+ * A draft that parses to a number outside min/max. Typing stays free-form —
189
+ * clamping mid-edit would eat the `5` of `50` under `min={10}` — so the
190
+ * counter instead shows the shared invalid edge the moment the value is out
191
+ * of range, and clamps on commit. Unparseable input isn't "out of range";
192
+ * it simply reverts.
193
+ */
194
+ const draftOutOfRange = useMemo(() => {
195
+ if (!isEditing)
196
+ return false;
197
+ const trimmed = draft.trim();
198
+ if (trimmed === "")
199
+ return false;
200
+ const parsed = Number(trimmed);
201
+ if (!Number.isFinite(parsed))
202
+ return false;
203
+ return ((min !== undefined && parsed < min) || (max !== undefined && parsed > max));
204
+ }, [isEditing, draft, min, max]);
205
+ const beginEdit = useCallback(() => {
206
+ if (!canEdit)
207
+ return;
208
+ editSessionRef.current = "active";
209
+ setDraft(autoDecimalPlaces > 0 ? value.toFixed(autoDecimalPlaces) : String(value));
210
+ setIsEditing(true);
211
+ }, [canEdit, value, autoDecimalPlaces]);
212
+ /** Focus and select the field once it exists, so typing replaces. */
213
+ useEffect(() => {
214
+ if (!isEditing)
215
+ return;
216
+ const field = fieldRef.current;
217
+ if (!field)
218
+ return;
219
+ field.focus();
220
+ field.select();
221
+ }, [isEditing]);
222
+ const endEdit = useCallback((commit, refocusContainer) => {
223
+ var _a;
224
+ if (editSessionRef.current !== "active")
225
+ return;
226
+ editSessionRef.current = "idle";
227
+ // Every exit from an edit session lands instantly, not just a changed
228
+ // value: the display is MOUNTED FRESH when the field unmounts, and a
229
+ // motion element without `initial` animates on mount — so without this,
230
+ // even Escape would roll the untouched number up from zero.
231
+ setLandsInstantly(true);
232
+ setIsEditing(false);
233
+ if (refocusContainer)
234
+ (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
235
+ if (!commit)
236
+ return;
237
+ const trimmed = draft.trim();
238
+ if (trimmed === "")
239
+ return; // empty is a revert, not a zero
240
+ const parsed = Number(trimmed);
241
+ if (!Number.isFinite(parsed))
242
+ return; // unparseable reverts
243
+ let next = Number(parsed.toFixed(autoDecimalPlaces));
244
+ if (min !== undefined && next < min)
245
+ next = min;
246
+ if (max !== undefined && next > max)
247
+ next = max;
248
+ // §9: never emit a change the user didn't produce.
249
+ if (next !== value)
250
+ emitValue(next, { typed: true });
251
+ }, [draft, autoDecimalPlaces, min, max, value, emitValue]);
252
+ const handleFieldKeyDown = useCallback((event) => {
253
+ if (event.key === "Enter") {
254
+ event.preventDefault();
255
+ endEdit(true, true);
256
+ }
257
+ else if (event.key === "Escape") {
258
+ event.preventDefault();
259
+ endEdit(false, true);
260
+ }
261
+ }, [endEdit]);
113
262
  // Keyboard support — scoped to THIS counter instance via onKeyDown on the
114
263
  // focusable root. Because the root is focusable (tabIndex 0) and its child
115
264
  // buttons bubble keydown to it, this fires only when focus is within THIS
@@ -117,7 +266,20 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
117
266
  const handleKeyDown = useCallback((event) => {
118
267
  if (isDisabled)
119
268
  return;
269
+ // While the edit field is open its keys belong to it — arrows move the
270
+ // caret, they must not step the counter.
271
+ if (isEditing)
272
+ return;
120
273
  switch (event.key) {
274
+ // Enter opens the edit field, so manual entry is reachable without a
275
+ // mouse. Field-level keys are handled by the field itself; this
276
+ // handler bails while editing (see the isEditing guard above).
277
+ case "Enter":
278
+ if (canEdit) {
279
+ event.preventDefault();
280
+ beginEdit();
281
+ }
282
+ break;
121
283
  case "ArrowUp":
122
284
  event.preventDefault();
123
285
  handleIncrement();
@@ -130,10 +292,10 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
130
292
  event.preventDefault();
131
293
  const pageUpValue = value + step * 10;
132
294
  if (max === undefined || pageUpValue <= max) {
133
- onChange === null || onChange === void 0 ? void 0 : onChange(pageUpValue);
295
+ emitValue(pageUpValue);
134
296
  }
135
297
  else if (max !== undefined) {
136
- onChange === null || onChange === void 0 ? void 0 : onChange(max);
298
+ emitValue(max);
137
299
  }
138
300
  break;
139
301
  }
@@ -141,21 +303,24 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
141
303
  event.preventDefault();
142
304
  const pageDownValue = value - step * 10;
143
305
  if (min === undefined || pageDownValue >= min) {
144
- onChange === null || onChange === void 0 ? void 0 : onChange(pageDownValue);
306
+ emitValue(pageDownValue);
145
307
  }
146
308
  else if (min !== undefined) {
147
- onChange === null || onChange === void 0 ? void 0 : onChange(min);
309
+ emitValue(min);
148
310
  }
149
311
  break;
150
312
  }
151
313
  }
152
314
  }, [
315
+ isEditing,
316
+ canEdit,
317
+ beginEdit,
153
318
  value,
154
319
  step,
155
320
  min,
156
321
  max,
157
322
  isDisabled,
158
- onChange,
323
+ emitValue,
159
324
  handleIncrement,
160
325
  handleDecrement,
161
326
  ]);
@@ -166,7 +331,7 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
166
331
  // errorMessage takes precedence over helperText while invalid; helperText
167
332
  // behavior is unchanged otherwise.
168
333
  const displayedMessage = isInvalid && errorMessage ? errorMessage : helperText;
169
- return (jsxs("div", { className: "flex flex-col", children: [label && (typeof label === "string" ? (jsx(FormLabel, { htmlFor: `${generatedId}-counter`, state: isDisabled ? "disabled" : isInvalid ? "invalid" : "default", className: "mb-1.5", children: label })) : (jsx("div", { className: "mb-1.5", children: label }))), jsxs(motion.div, { ref: ref, id: `${generatedId}-counter`, "data-counter": "true", layout: true, transition: shouldDisableAnimations ? undefined : transition, className: cn(mergedTheme.baseStyle, isInvalid && "ring-1 ring-[var(--color-danger)]", className), role: "spinbutton", "aria-label": ariaLabel, "aria-valuenow": value, "aria-valuemin": min, "aria-valuemax": max, "aria-disabled": isDisabled, "aria-invalid": isInvalid || undefined, "aria-describedby": displayedMessage ? helperId : undefined, tabIndex: isDisabled ? -1 : 0, onKeyDown: handleKeyDown, ...props, children: [jsx(motion.div, { className: mergedTheme.buttonWrapperStyle,
334
+ return (jsxs("div", { className: "flex flex-col", children: [label && (typeof label === "string" ? (jsx(FormLabel, { htmlFor: `${generatedId}-counter`, state: isDisabled ? "disabled" : isInvalid ? "invalid" : "default", className: "mb-1.5", children: label })) : (jsx("div", { className: "mb-1.5", children: label }))), jsxs(motion.div, { ref: setContainerRef, id: `${generatedId}-counter`, "data-counter": "true", "data-size": size, layout: true, transition: shouldDisableAnimations ? undefined : transition, className: cn(mergedTheme.baseStyle, mergedTheme.sizes[size], isDisabled && mergedTheme.disabledStyle, (isInvalid || draftOutOfRange) && mergedTheme.invalidStyle, className), role: "spinbutton", "aria-label": ariaLabel, "aria-valuenow": value, "aria-valuemin": min, "aria-valuemax": max, "aria-disabled": isDisabled, "aria-invalid": isInvalid || undefined, "aria-describedby": displayedMessage ? helperId : undefined, tabIndex: isDisabled ? -1 : 0, onKeyDown: handleKeyDown, ...props, children: [jsx(motion.div, { className: mergedTheme.buttonWrapperStyle,
170
335
  // Framer Motion's press gesture (triggered by `whileTap`) adds
171
336
  // `tabIndex=0` to any non-natively-focusable element that
172
337
  // doesn't already declare a `tabindex`, to make tap gestures
@@ -175,7 +340,16 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
175
340
  // focusable <Button>, so an explicit tabIndex={-1} keeps it out
176
341
  // of the tab order (motion.js only adds its own when the
177
342
  // attribute is absent).
178
- tabIndex: -1, whileHover: !isDisabled && !shouldDisableAnimations ? { scale: 1.05 } : undefined, whileTap: !isDisabled && !shouldDisableAnimations ? { scale: 0.95 } : undefined, children: jsx(Button, { size: "sm", variant: "ghost", iconOnly: true, ...buttonProps, onClick: handleDecrement, disabled: !canDecrement, "aria-label": "Decrement", children: jsx(Minus, { className: "size-4 text-[var(--color-text-primary)]", weight: "bold" }) }) }), jsx(SlidingNumber, { value: value, decimalPlaces: autoDecimalPlaces, className: mergedTheme.numberStyle, transition: transition }), jsx(motion.div, { className: mergedTheme.buttonWrapperStyle,
343
+ tabIndex: -1, whileHover: !isDisabled && !shouldDisableAnimations ? { scale: 1.05 } : undefined, whileTap: !isDisabled && !shouldDisableAnimations ? { scale: 0.95 } : undefined, children: jsx(Button, { size: mergedTheme.buttonSizes[size], variant: "ghost", iconOnly: true, ...buttonProps,
344
+ // The square comes from the COUNTER's height, not Button's size
345
+ // scale; a consumer className still wins (shadcn, §4).
346
+ className: cn(mergedTheme.stepperSizes[size], buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.className), onClick: handleDecrement, disabled: !canDecrement, "aria-label": "Decrement", children: jsx(Minus, { className: cn(mergedTheme.stepperIconSizes[size], "text-[var(--color-text-primary)]"), weight: "bold" }) }) }), jsx("span", { "data-testid": "input-counter-slot", className: cn(mergedTheme.valueSlotStyle, mergedTheme.textSizes[size]),
347
+ // The one box both states occupy, sized in the same `em` metric
348
+ // SlidingNumber lays its characters out on — so the field that
349
+ // replaces the number is exactly as wide as the number was.
350
+ style: {
351
+ width: `calc(${slotChars} * ${SLIDING_NUMBER_CHAR_WIDTH_EM}em)`,
352
+ }, children: isEditing ? (jsx("input", { ref: fieldRef, type: "text", inputMode: "decimal", "aria-label": ariaLabel, "aria-invalid": draftOutOfRange || isInvalid || undefined, value: draft, className: cn(mergedTheme.editFieldStyle, mergedTheme.textSizes[size]), onChange: (event) => setDraft(event.target.value), onKeyDown: handleFieldKeyDown, onBlur: () => endEdit(true, false) })) : (jsx("span", { "data-testid": "input-counter-value", onClick: beginEdit, className: cn("flex w-full items-center justify-center", canEdit && "cursor-text"), children: jsx(SlidingNumber, { value: value, decimalPlaces: autoDecimalPlaces, className: cn(mergedTheme.numberStyle, mergedTheme.textSizes[size], isDisabled && mergedTheme.numberDisabledStyle), transition: landsInstantly ? INSTANT_TRANSITION : transition }) })) }), jsx(motion.div, { className: mergedTheme.buttonWrapperStyle,
179
353
  // Framer Motion's press gesture (triggered by `whileTap`) adds
180
354
  // `tabIndex=0` to any non-natively-focusable element that
181
355
  // doesn't already declare a `tabindex`, to make tap gestures
@@ -184,7 +358,10 @@ const InputCounter = React__default.forwardRef(({ value, onChange, state = "defa
184
358
  // focusable <Button>, so an explicit tabIndex={-1} keeps it out
185
359
  // of the tab order (motion.js only adds its own when the
186
360
  // attribute is absent).
187
- tabIndex: -1, whileHover: !isDisabled && !shouldDisableAnimations ? { scale: 1.05 } : undefined, whileTap: !isDisabled && !shouldDisableAnimations ? { scale: 0.95 } : undefined, children: jsx(Button, { size: "sm", variant: "ghost", iconOnly: true, ...buttonProps, onClick: handleIncrement, disabled: !canIncrement, "aria-label": "Increment", children: jsx(Plus, { className: "size-4 text-[var(--color-text-primary)]", weight: "bold" }) }) })] }), displayedMessage && (jsx("div", { id: helperId, role: isInvalid ? "alert" : undefined, className: cn("text-sm mt-1", isInvalid
361
+ tabIndex: -1, whileHover: !isDisabled && !shouldDisableAnimations ? { scale: 1.05 } : undefined, whileTap: !isDisabled && !shouldDisableAnimations ? { scale: 0.95 } : undefined, children: jsx(Button, { size: mergedTheme.buttonSizes[size], variant: "ghost", iconOnly: true, ...buttonProps,
362
+ // The square comes from the COUNTER's height, not Button's size
363
+ // scale; a consumer className still wins (shadcn, §4).
364
+ className: cn(mergedTheme.stepperSizes[size], buttonProps === null || buttonProps === void 0 ? void 0 : buttonProps.className), onClick: handleIncrement, disabled: !canIncrement, "aria-label": "Increment", children: jsx(Plus, { className: cn(mergedTheme.stepperIconSizes[size], "text-[var(--color-text-primary)]"), weight: "bold" }) }) })] }), displayedMessage && (jsx("div", { id: helperId, role: isInvalid ? "alert" : undefined, className: cn("text-sm mt-1", isInvalid
188
365
  ? "text-[var(--color-danger)]"
189
366
  : "text-[var(--color-text-muted)]"), children: displayedMessage }))] }));
190
367
  });
@@ -2,5 +2,17 @@ import { InputCounterTheme } from "./InputCounter.types";
2
2
  /**
3
3
  * InputCounter theme configuration
4
4
  * Follows Flikkui design system with CSS variables
5
+ *
6
+ * 🔴 Sizing comes from the SHARED `--form-*` scale (`formsBaseTheme.sizes`), not
7
+ * from literals — a counter sits in the same column as an Input/Select and has
8
+ * to land on the same baseline. Before this, height was implicit (`p-1` plus a
9
+ * hardcoded `size="sm"` Button) and the number was locked to `text-sm`, so a
10
+ * counter could not be made to match the fields around it.
11
+ *
12
+ * 🔴 The edge is the shared inset box-shadow (`formsBaseTheme.inputGroupBorderStyle`),
13
+ * NOT a real `border` — CLAUDE.md §9. A real border was doing three kinds of damage:
14
+ * it read differently from the Select beside it, it consumed 2px of the fixed
15
+ * `--form-min-h-*` height (which is what made the `sm` steppers overflow the box),
16
+ * and it left the counter as the only tabbable form control with no focus ring.
5
17
  */
6
18
  export declare const inputCounterTheme: InputCounterTheme;
@@ -1,11 +1,101 @@
1
+ import { formsBaseTheme } from '../forms.theme.js';
2
+
1
3
  /**
2
4
  * InputCounter theme configuration
3
5
  * Follows Flikkui design system with CSS variables
6
+ *
7
+ * 🔴 Sizing comes from the SHARED `--form-*` scale (`formsBaseTheme.sizes`), not
8
+ * from literals — a counter sits in the same column as an Input/Select and has
9
+ * to land on the same baseline. Before this, height was implicit (`p-1` plus a
10
+ * hardcoded `size="sm"` Button) and the number was locked to `text-sm`, so a
11
+ * counter could not be made to match the fields around it.
12
+ *
13
+ * 🔴 The edge is the shared inset box-shadow (`formsBaseTheme.inputGroupBorderStyle`),
14
+ * NOT a real `border` — CLAUDE.md §9. A real border was doing three kinds of damage:
15
+ * it read differently from the Select beside it, it consumed 2px of the fixed
16
+ * `--form-min-h-*` height (which is what made the `sm` steppers overflow the box),
17
+ * and it left the counter as the only tabbable form control with no focus ring.
4
18
  */
5
19
  const inputCounterTheme = {
6
- baseStyle: "flex items-center gap-3 transition-all p-1 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--form-radius)] w-fit",
20
+ baseStyle: "flex items-center gap-1 w-fit bg-[var(--field-fill)] " +
21
+ // 🔴 Explicitly listed properties, never `transition-all`: this root is a
22
+ // motion element carrying `layout`, and a CSS transition covering
23
+ // `transform` re-interpolates motion's per-frame writes (§12).
24
+ "transition-[box-shadow,background-color] duration-[var(--motion-duration-fast)] " +
25
+ formsBaseTheme.inputGroupBorderStyle,
26
+ invalidStyle: formsBaseTheme.states.invalid,
27
+ // The same disabled treatment every other field gets (disabled fill, resting
28
+ // border held, no hover edge, not-allowed cursor on the steppers). The number
29
+ // is a <span>, not an <input>, so `states.disabled`'s `[&_input]` text rule
30
+ // can't reach it — `numberDisabledStyle` greys it explicitly.
31
+ disabledStyle: formsBaseTheme.states.disabled,
32
+ numberDisabledStyle: "text-[var(--color-text-disabled)]",
7
33
  buttonWrapperStyle: "flex-shrink-0",
8
- numberStyle: "min-w-fit text-center text-sm font-medium text-[var(--color-text-primary)] tabular-nums select-none",
34
+ numberStyle: "min-w-fit text-center text-[var(--color-text-primary)] tabular-nums select-none",
35
+ // The value slot is a fixed box both states share — the animated number at rest
36
+ // and the edit field while typing. Its width is set inline from the character
37
+ // count (see InputCounter.tsx), so clicking the number cannot resize the
38
+ // control: the `size` attribute the field used to carry sized it by the
39
+ // browser's average character width, which is NOT SlidingNumber's fixed
40
+ // per-character em box, so the box grew ~1–3px on every click.
41
+ valueSlotStyle: "relative flex items-center justify-center flex-shrink-0",
42
+ // Inset all round, so the stepper squares come out 20 / 26 / 30 against the
43
+ // 28 / 36 / 40 heights. md/lg take 5px — the button lands at roughly 0.7× the
44
+ // control height, and at 4px the square read as filling its box.
45
+ //
46
+ // 🔴 `sm` is the one rung where WCAG 2.2 SC 2.5.8 (Target Size, AA) binds. A
47
+ // 28px box cannot hold a 24px square at any sensible inset, so the steppers
48
+ // are undersized and have to clear the spacing exception instead: a 24px
49
+ // circle centred on each target must not intersect the next one's. The
50
+ // neighbour is the click-to-edit value, so `sm` widens the gap to 8px, which
51
+ // puts the centres 25.2px apart (`gap-1` gave 21.2px and failed). The value
52
+ // slot's 2-character floor (see InputCounter.tsx) is what keeps that true for
53
+ // a single-digit counter — a 1-char slot would pull the centres back to 21.6px.
54
+ sizes: {
55
+ sm: `${formsBaseTheme.sizes.sm.height} p-1 gap-2`,
56
+ md: `${formsBaseTheme.sizes.md.height} p-[5px]`,
57
+ lg: `${formsBaseTheme.sizes.lg.height} p-[5px]`,
58
+ },
59
+ textSizes: {
60
+ sm: formsBaseTheme.sizes.sm.text,
61
+ md: formsBaseTheme.sizes.md.text,
62
+ lg: formsBaseTheme.sizes.lg.text,
63
+ },
64
+ // The steppers are squares derived from the COUNTER's height — `--form-min-h-*`
65
+ // minus the container's inset on each side (20 / 26 / 30 at the default
66
+ // theme) — rather than from Button's own size scale. The old `buttonSizes` map
67
+ // landed on whichever Button size was closest, which gave `sm` and `md` the
68
+ // SAME 32px stepper and, at `sm`, a square TALLER than the counter's content
69
+ // box: the ghost hover fill punched straight through the border.
70
+ // Radius is concentric with the container's (`--form-radius` − the 5px inset),
71
+ // the same construction `--form-dropdown-inner-radius` uses for dropdown rows.
72
+ // 🔴 Written out per size, never assembled from a template — Tailwind scans
73
+ // source text, so an interpolated class name emits no CSS at all (§3). The
74
+ // `_-_` is the codebase's arbitrary-value spelling for the whitespace CSS
75
+ // requires around a calc `-`.
76
+ stepperSizes: {
77
+ sm: "size-[calc(var(--form-min-h-sm)_-_8px)] p-0 rounded-[calc(var(--form-radius)_-_4px)]",
78
+ md: "size-[calc(var(--form-min-h-md)_-_10px)] p-0 rounded-[calc(var(--form-radius)_-_5px)]",
79
+ lg: "size-[calc(var(--form-min-h-lg)_-_10px)] p-0 rounded-[calc(var(--form-radius)_-_5px)]",
80
+ },
81
+ // Icons ramp with the square they sit in (12 / 14 / 16px inside 22 / 26 / 30),
82
+ // holding roughly the same optical breathing room at every size. They were a
83
+ // flat size-4 at all three, which is what made the `sm` stepper look packed.
84
+ stepperIconSizes: {
85
+ sm: "size-3",
86
+ md: "size-3.5",
87
+ lg: "size-4",
88
+ },
89
+ buttonSizes: {
90
+ sm: "sm",
91
+ md: "sm",
92
+ lg: "md",
93
+ },
94
+ // The counter's own container paints the border, background and radius — this
95
+ // field is the text layer inside it, so it stays transparent and chrome-free
96
+ // (the same split Input uses between its group wrapper and inner <input>).
97
+ // `w-full` fills the shared value slot; it must NOT size itself.
98
+ editFieldStyle: "w-full min-w-0 bg-transparent border-0 outline-none text-center text-[var(--color-text-primary)] tabular-nums p-0 focus:outline-none",
9
99
  };
10
100
 
11
101
  export { inputCounterTheme };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import type { Transition } from 'motion/react';
3
3
  import { ButtonProps } from '../../core/Button/Button.types';
4
4
  export type InputCounterState = 'default' | 'disabled' | 'invalid';
5
+ export type InputCounterSize = 'sm' | 'md' | 'lg';
5
6
  /**
6
7
  * InputCounter component props
7
8
  * @property {number} value - Current counter value (controlled)
@@ -10,7 +11,6 @@ export type InputCounterState = 'default' | 'disabled' | 'invalid';
10
11
  * @property {number} max - Maximum allowed value (optional)
11
12
  * @property {number} step - Increment/decrement step size (default: 1)
12
13
  * @property {boolean} disabled - Whether the counter is disabled
13
- * @property {function} formatNumber - Custom number formatter
14
14
  * @property {object} buttonProps - Props to pass to increment/decrement buttons
15
15
  * @property {object} theme - Theme overrides for styling
16
16
  * @property {object} transition - Custom Framer Motion transition
@@ -20,6 +20,18 @@ export interface InputCounterProps extends Omit<React.HTMLAttributes<HTMLDivElem
20
20
  value: number;
21
21
  /** Callback when value changes */
22
22
  onChange?: (value: number) => void;
23
+ /**
24
+ * Control size, drawn from the shared `--form-*` scale so a counter lines up
25
+ * with the Input/Select/Combobox it sits beside.
26
+ * @default 'md'
27
+ */
28
+ size?: InputCounterSize;
29
+ /**
30
+ * Whether clicking the number swaps it for a text field so the value can be
31
+ * typed directly. Committed on Enter or blur; Escape reverts.
32
+ * @default true
33
+ */
34
+ editable?: boolean;
23
35
  /** Validation/interaction state */
24
36
  state?: InputCounterState;
25
37
  /** Field label rendered above the counter */
@@ -42,8 +54,6 @@ export interface InputCounterProps extends Omit<React.HTMLAttributes<HTMLDivElem
42
54
  disabled?: boolean;
43
55
  /** Number of decimal places to display (auto-detected from step if not specified) */
44
56
  decimalPlaces?: number;
45
- /** Custom number formatter (e.g., for decimals or localization) */
46
- formatNumber?: (value: number) => string;
47
57
  /** Additional class names */
48
58
  className?: string;
49
59
  /** Props to pass to increment/decrement buttons (omitting onClick) */
@@ -59,10 +69,43 @@ export interface InputCounterProps extends Omit<React.HTMLAttributes<HTMLDivElem
59
69
  * Theme configuration for InputCounter component
60
70
  */
61
71
  export interface InputCounterTheme {
62
- /** Base container style */
72
+ /** Base container style — includes the shared inset-shadow field edge */
63
73
  baseStyle: string;
74
+ /** Invalid edge, from the shared `formsBaseTheme.states.invalid` */
75
+ invalidStyle: string;
76
+ /** Disabled treatment, from the shared `formsBaseTheme.states.disabled` */
77
+ disabledStyle: string;
78
+ /** Number colour while disabled (the span `states.disabled` can't reach) */
79
+ numberDisabledStyle: string;
64
80
  /** Wrapper style for button animations */
65
81
  buttonWrapperStyle: string;
66
82
  /** Number display style */
67
83
  numberStyle: string;
84
+ /**
85
+ * The fixed box the animated number and the edit field share, so swapping one
86
+ * for the other cannot change the control's width. Its WIDTH is set inline
87
+ * from the character count, not by this class.
88
+ */
89
+ valueSlotStyle: string;
90
+ /** Per-size container height/padding, from the shared `--form-*` scale */
91
+ sizes: Record<InputCounterSize, string>;
92
+ /** Per-size text sizing, shared by the number display and the edit field */
93
+ textSizes: Record<InputCounterSize, string>;
94
+ /**
95
+ * Per-size stepper geometry — a square derived from the counter's own height
96
+ * (`--form-min-h-*` minus the container inset) with a concentric radius, applied
97
+ * over whatever `buttonSizes` picks. This is what makes the steppers scale with
98
+ * the counter rather than with Button's size scale.
99
+ */
100
+ stepperSizes: Record<InputCounterSize, string>;
101
+ /** Per-size stepper icon sizing, tracking the type ramp */
102
+ stepperIconSizes: Record<InputCounterSize, string>;
103
+ /**
104
+ * Which Button size the steppers take at each counter size. Mapped rather
105
+ * than derived: Button has only three sizes and both must nest inside the
106
+ * counter's own height. `stepperSizes` then sets the exact square.
107
+ */
108
+ buttonSizes: Record<InputCounterSize, ButtonProps['size']>;
109
+ /** The transparent, centered edit field swapped in on click */
110
+ editFieldStyle: string;
68
111
  }
@@ -21,7 +21,7 @@ import { formsBaseTheme } from '../forms.theme.js';
21
21
  * />
22
22
  * ```
23
23
  */
24
- const InputCreditCard = React__default.forwardRef(({ state = 'default', errorMessage, error, onCardNumberChange, onExpiryDateChange, onCvcChange, expiryDateLabel = "MM / YY", cvcLabel = "CVC", legendText = "Payment Information", cardNumberInputProps = {}, expiryDateInputProps = {}, cvcInputProps = {}, className = "", theme = {}, }, ref) => {
24
+ const InputCreditCard = React__default.forwardRef(({ state = 'default', size = 'md', errorMessage, error, onCardNumberChange, onExpiryDateChange, onCvcChange, expiryDateLabel = "MM / YY", cvcLabel = "CVC", legendText = "Payment Information", cardNumberInputProps = {}, expiryDateInputProps = {}, cvcInputProps = {}, className = "", theme = {}, }, ref) => {
25
25
  var _a;
26
26
  const isDisabled = state === 'disabled';
27
27
  // Clear the error visually as soon as the user starts re-entering after an error.
@@ -90,9 +90,9 @@ const InputCreditCard = React__default.forwardRef(({ state = 'default', errorMes
90
90
  const fieldState = isDisabled ? 'disabled' : 'default';
91
91
  // Merge top-level state into each field's props; strip per-field helperText
92
92
  // so only the consolidated error message is shown at the bottom.
93
- const { helperText: _cn, ...cardProps } = { state: fieldState, ...cardNumberInputProps };
94
- const { helperText: _ex, ...expiryProps } = { state: fieldState, ...expiryDateInputProps };
95
- const { helperText: _cv, ...cvcProps } = { state: fieldState, ...cvcInputProps };
93
+ const { helperText: _cn, ...cardProps } = { state: fieldState, size, ...cardNumberInputProps };
94
+ const { helperText: _ex, ...expiryProps } = { state: fieldState, size, ...expiryDateInputProps };
95
+ const { helperText: _cv, ...cvcProps } = { state: fieldState, size, ...cvcInputProps };
96
96
  return (jsxs("fieldset", { ref: ref, className: `w-full border-0 m-0 p-0 ${isInvalid ? 'state-invalid' : ''} ${className}`, "data-testid": "input-credit-card-fieldset", "aria-invalid": isInvalid || undefined, "aria-errormessage": isInvalid && resolvedError ? errorId : undefined, children: [jsx("legend", { className: `block text-sm/6 font-medium mb-2 text-[var(--color-text-primary)] ${(_a = mergedTheme.legendStyles) !== null && _a !== void 0 ? _a : ""}`, children: legendText }), jsxs("div", { className: `relative grid gap-0 ${mergedTheme.contentStyles}`, "data-testid": "input-credit-card-content", children: [jsx("div", { className: "w-full relative focus-within:z-10 hover:z-10", "data-testid": "input-card-number", children: jsx(Input, { id: cardNumberId, type: "creditCard", value: cardNumber, onChange: handleCardNumberChange, placeholder: "Card Number", "aria-label": "card number", onFocus: () => handleFocus("cardNumber"), onBlur: handleBlur, inputGroupClassName: "rounded-b-none", ...cardProps }) }), jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 w-full -mt-px relative focus-within:z-10 hover:z-10", children: [jsx("div", { className: `${mergedTheme.expiryDateWrapperStyles || ""} w-full sm:pr-0 relative focus-within:z-10 hover:z-10`, "data-testid": "input-expiry-date", children: jsx(Input, { id: expiryDateId, type: "expiryDate", value: expiryDate, onChange: handleExpiryDateChange, placeholder: expiryDateLabel, "aria-label": expiryDateLabel, onFocus: () => handleFocus("expiryDate"), onBlur: handleBlur, inputGroupClassName: "sm:rounded-bl-[var(--form-radius)] rounded-t-none sm:rounded-tr-none sm:rounded-br-none", ...expiryProps }) }), jsx("div", { className: `${mergedTheme.cvcWrapperStyles || ""} w-full -mt-px sm:mt-0 sm:-ml-px sm:w-[calc(100%+1px)] relative focus-within:z-10 hover:z-10`, "data-testid": "input-cvc", children: jsx(Input, { id: cvcId, type: "tel", maxLength: 3, pattern: "[0-9]*", inputMode: "numeric", value: cvc, onChange: handleCvcChange, placeholder: cvcLabel, "aria-label": cvcLabel, onFocus: () => handleFocus("cvc"), onBlur: handleBlur, inputGroupClassName: " sm:rounded-br-[var(--form-radius)] rounded-t-none rounded-bl-[var(--form-radius)] sm:rounded-bl-none", ...cvcProps }) })] }), isInvalid && jsx("div", { "aria-hidden": "true", className: formsBaseTheme.groupInvalidOverlayStyle })] }), isInvalid && resolvedError && (jsx("p", { id: errorId, role: "alert", className: formsBaseTheme.helperText, children: resolvedError }))] }));
97
97
  });
98
98
  InputCreditCard.displayName = "InputCreditCard";
@@ -13,6 +13,13 @@ import { InputProps, InputTheme } from '../Input/Input.types';
13
13
  export interface InputCreditCardProps {
14
14
  /** Visual/validation state of the entire card group */
15
15
  state?: 'default' | 'disabled' | 'invalid';
16
+ /**
17
+ * Size of every field in the group, from the shared `--form-*` scale, so the
18
+ * card block lines up with the Inputs and Selects around it. A per-field
19
+ * `*InputProps.size` still wins.
20
+ * @default 'md'
21
+ */
22
+ size?: InputProps['size'];
16
23
  /** Consolidated error message shown below all fields when `state="invalid"` */
17
24
  errorMessage?: React.ReactNode;
18
25
  /**