@flamingo-stack/openframe-frontend-core 0.0.659-2347.6820.1 → 0.0.659

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 (116) hide show
  1. package/dist/{chunk-D4Z7TYWU.js → chunk-2DLM3NXE.js} +4 -4
  2. package/dist/{chunk-NIKCNKD2.js → chunk-6DVSTVUU.js} +6 -6
  3. package/dist/{chunk-VAXW73AH.js → chunk-6KR74SOJ.js} +2 -2
  4. package/dist/{chunk-VZLPXDYN.cjs → chunk-AOOORZI5.cjs} +3 -3
  5. package/dist/{chunk-VZLPXDYN.cjs.map → chunk-AOOORZI5.cjs.map} +1 -1
  6. package/dist/{chunk-DMJ636H5.js → chunk-APWQTZCR.js} +2 -2
  7. package/dist/chunk-BXWXBBAC.js +228 -0
  8. package/dist/chunk-BXWXBBAC.js.map +1 -0
  9. package/dist/{chunk-JBL365GX.js → chunk-E7C3F2SK.js} +237 -223
  10. package/dist/chunk-E7C3F2SK.js.map +1 -0
  11. package/dist/{chunk-VXTTNIRO.cjs → chunk-EOGXNLZC.cjs} +17 -17
  12. package/dist/{chunk-VXTTNIRO.cjs.map → chunk-EOGXNLZC.cjs.map} +1 -1
  13. package/dist/{chunk-BOZSCBHS.cjs → chunk-GDHV5W5C.cjs} +33 -33
  14. package/dist/{chunk-BOZSCBHS.cjs.map → chunk-GDHV5W5C.cjs.map} +1 -1
  15. package/dist/{chunk-FLWYFXNK.cjs → chunk-ITVLOEGF.cjs} +55 -55
  16. package/dist/{chunk-FLWYFXNK.cjs.map → chunk-ITVLOEGF.cjs.map} +1 -1
  17. package/dist/{chunk-K5HPBVUH.cjs → chunk-K4W3II7M.cjs} +3 -3
  18. package/dist/{chunk-K5HPBVUH.cjs.map → chunk-K4W3II7M.cjs.map} +1 -1
  19. package/dist/{chunk-J7OY7QBA.js → chunk-KP4K6I5O.js} +3 -3
  20. package/dist/{chunk-XBPTM4IT.cjs → chunk-L4YJU3AG.cjs} +16 -16
  21. package/dist/{chunk-XBPTM4IT.cjs.map → chunk-L4YJU3AG.cjs.map} +1 -1
  22. package/dist/{chunk-FXUFGHVU.js → chunk-MEFCA6VD.js} +2 -2
  23. package/dist/{chunk-S5OQILYF.js → chunk-MW7LEMFG.js} +2 -2
  24. package/dist/{chunk-PDLHEQ6C.cjs → chunk-NXAXZPEO.cjs} +10 -10
  25. package/dist/{chunk-PDLHEQ6C.cjs.map → chunk-NXAXZPEO.cjs.map} +1 -1
  26. package/dist/{chunk-I6Q5RA5M.js → chunk-P2ROGTMF.js} +2 -2
  27. package/dist/{chunk-ZUOXB5YX.cjs → chunk-PUE4XMZK.cjs} +279 -265
  28. package/dist/chunk-PUE4XMZK.cjs.map +1 -0
  29. package/dist/chunk-QVSVRG7I.cjs +228 -0
  30. package/dist/chunk-QVSVRG7I.cjs.map +1 -0
  31. package/dist/{chunk-XNR7DJP3.js → chunk-QVZD2LLU.js} +4 -4
  32. package/dist/{chunk-3CTLV2BR.cjs → chunk-RRGTBH3L.cjs} +3 -3
  33. package/dist/{chunk-3CTLV2BR.cjs.map → chunk-RRGTBH3L.cjs.map} +1 -1
  34. package/dist/{chunk-7Q4UWN5X.js → chunk-T6LNAX4R.js} +3 -3
  35. package/dist/{chunk-TUXQWRF7.cjs → chunk-UIAXQJPV.cjs} +24 -24
  36. package/dist/{chunk-TUXQWRF7.cjs.map → chunk-UIAXQJPV.cjs.map} +1 -1
  37. package/dist/{chunk-ALMCR73J.js → chunk-VFGBRGFB.js} +3 -3
  38. package/dist/{chunk-5IXTYDUH.cjs → chunk-VJMPYY5O.cjs} +18 -18
  39. package/dist/{chunk-5IXTYDUH.cjs.map → chunk-VJMPYY5O.cjs.map} +1 -1
  40. package/dist/{chunk-XGJUNSUY.js → chunk-VVOI2OC3.js} +2 -2
  41. package/dist/{chunk-MCGLT2GN.cjs → chunk-VW7NXM2O.cjs} +36 -36
  42. package/dist/{chunk-MCGLT2GN.cjs.map → chunk-VW7NXM2O.cjs.map} +1 -1
  43. package/dist/{chunk-WK4464NX.cjs → chunk-VWGFJJD6.cjs} +4 -4
  44. package/dist/{chunk-WK4464NX.cjs.map → chunk-VWGFJJD6.cjs.map} +1 -1
  45. package/dist/{chunk-NP2XVKA6.cjs → chunk-WMIB6JSA.cjs} +4 -4
  46. package/dist/{chunk-NP2XVKA6.cjs.map → chunk-WMIB6JSA.cjs.map} +1 -1
  47. package/dist/{chunk-OTDNJTDJ.js → chunk-WWEQT2WX.js} +5 -5
  48. package/dist/{chunk-XRD5FFHK.cjs → chunk-Z7NI2TQD.cjs} +3 -3
  49. package/dist/{chunk-XRD5FFHK.cjs.map → chunk-Z7NI2TQD.cjs.map} +1 -1
  50. package/dist/{chunk-CCGP4PRB.js → chunk-ZT3CZCDS.js} +2 -2
  51. package/dist/components/case-studies/index.cjs +12 -12
  52. package/dist/components/case-studies/index.js +3 -3
  53. package/dist/components/contact/index.cjs +4 -4
  54. package/dist/components/contact/index.js +3 -3
  55. package/dist/components/docs/index.cjs +7 -7
  56. package/dist/components/docs/index.js +6 -6
  57. package/dist/components/embeds/index.cjs +4 -4
  58. package/dist/components/embeds/index.js +3 -3
  59. package/dist/components/faq/index.cjs +4 -4
  60. package/dist/components/faq/index.js +3 -3
  61. package/dist/components/features/index.cjs +3 -3
  62. package/dist/components/features/index.js +2 -2
  63. package/dist/components/help-center-pages/index.cjs +23 -23
  64. package/dist/components/help-center-pages/index.js +14 -14
  65. package/dist/components/index.cjs +33 -33
  66. package/dist/components/index.js +13 -13
  67. package/dist/components/meeting-scheduler/index.cjs +27 -27
  68. package/dist/components/meeting-scheduler/index.js +2 -2
  69. package/dist/components/navigation/index.cjs +6 -6
  70. package/dist/components/navigation/index.js +5 -5
  71. package/dist/components/onboarding-guides/index.cjs +6 -6
  72. package/dist/components/onboarding-guides/index.js +5 -5
  73. package/dist/components/tickets/index.cjs +7 -7
  74. package/dist/components/tickets/index.js +6 -6
  75. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  76. package/dist/components/ui/date-picker.d.ts +2 -2
  77. package/dist/components/ui/date-picker.d.ts.map +1 -1
  78. package/dist/components/ui/field-wrapper.d.ts +15 -1
  79. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  80. package/dist/components/ui/file-manager/index.cjs +4 -4
  81. package/dist/components/ui/file-manager/index.js +1 -1
  82. package/dist/components/ui/index.cjs +3 -3
  83. package/dist/components/ui/index.js +2 -2
  84. package/dist/components/ui/input.d.ts.map +1 -1
  85. package/dist/components/ui/select.d.ts.map +1 -1
  86. package/dist/components/ui/textarea.d.ts.map +1 -1
  87. package/dist/index.cjs +7 -7
  88. package/dist/index.js +6 -6
  89. package/package.json +1 -1
  90. package/src/components/ui/__tests__/field-wrapper-error-slot.test.tsx +82 -0
  91. package/src/components/ui/autocomplete.tsx +7 -1
  92. package/src/components/ui/date-picker.tsx +40 -38
  93. package/src/components/ui/field-wrapper.tsx +27 -3
  94. package/src/components/ui/input.tsx +131 -121
  95. package/src/components/ui/select.tsx +5 -2
  96. package/src/components/ui/textarea.tsx +173 -175
  97. package/dist/chunk-IWISVG4P.js +0 -198
  98. package/dist/chunk-IWISVG4P.js.map +0 -1
  99. package/dist/chunk-JBL365GX.js.map +0 -1
  100. package/dist/chunk-YWUZL3RT.cjs +0 -198
  101. package/dist/chunk-YWUZL3RT.cjs.map +0 -1
  102. package/dist/chunk-ZUOXB5YX.cjs.map +0 -1
  103. /package/dist/{chunk-D4Z7TYWU.js.map → chunk-2DLM3NXE.js.map} +0 -0
  104. /package/dist/{chunk-NIKCNKD2.js.map → chunk-6DVSTVUU.js.map} +0 -0
  105. /package/dist/{chunk-VAXW73AH.js.map → chunk-6KR74SOJ.js.map} +0 -0
  106. /package/dist/{chunk-DMJ636H5.js.map → chunk-APWQTZCR.js.map} +0 -0
  107. /package/dist/{chunk-J7OY7QBA.js.map → chunk-KP4K6I5O.js.map} +0 -0
  108. /package/dist/{chunk-FXUFGHVU.js.map → chunk-MEFCA6VD.js.map} +0 -0
  109. /package/dist/{chunk-S5OQILYF.js.map → chunk-MW7LEMFG.js.map} +0 -0
  110. /package/dist/{chunk-I6Q5RA5M.js.map → chunk-P2ROGTMF.js.map} +0 -0
  111. /package/dist/{chunk-XNR7DJP3.js.map → chunk-QVZD2LLU.js.map} +0 -0
  112. /package/dist/{chunk-7Q4UWN5X.js.map → chunk-T6LNAX4R.js.map} +0 -0
  113. /package/dist/{chunk-ALMCR73J.js.map → chunk-VFGBRGFB.js.map} +0 -0
  114. /package/dist/{chunk-XGJUNSUY.js.map → chunk-VVOI2OC3.js.map} +0 -0
  115. /package/dist/{chunk-OTDNJTDJ.js.map → chunk-WWEQT2WX.js.map} +0 -0
  116. /package/dist/{chunk-CCGP4PRB.js.map → chunk-ZT3CZCDS.js.map} +0 -0
@@ -0,0 +1,82 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { FieldWrapper } from '../field-wrapper';
5
+ import { Input } from '../input';
6
+ import { Select, SelectTrigger, SelectValue } from '../select';
7
+ import { Textarea } from '../textarea';
8
+
9
+ /**
10
+ * A label-less FieldWrapper is `display: contents` until `error` is set and a
11
+ * box afterwards, and the parent's spacing lands on the box but not on
12
+ * `contents` — so the control jumped by that spacing whenever a message came
13
+ * or went. `errorSlot` renders the box from the first render, and the controls
14
+ * derive it from whether the caller wrote an `error` attribute at all. These
15
+ * pin both halves: the flag, and each control's wiring of it.
16
+ *
17
+ * The wrapper is a role-less <div> and its display mode IS the contract, so
18
+ * the tests walk up from the control (this file is listed in eslint.config.mjs).
19
+ */
20
+
21
+ const BOX = 'relative';
22
+ const TRANSPARENT = 'contents';
23
+
24
+ const wrapperOf = (control: Element | null) => control?.parentElement ?? null;
25
+ const inputWrapper = () => wrapperOf(screen.getByLabelText('Name').closest('label'));
26
+
27
+ describe('FieldWrapper errorSlot', () => {
28
+ it('stays layout-transparent without a label, a message or a slot', () => {
29
+ render(
30
+ <FieldWrapper>
31
+ <input aria-label="n" />
32
+ </FieldWrapper>,
33
+ );
34
+ expect(wrapperOf(screen.getByLabelText('n'))).toHaveClass(TRANSPARENT);
35
+ });
36
+
37
+ it('is a box from the first render when the slot is reserved, and stays one', () => {
38
+ const { rerender } = render(
39
+ <FieldWrapper errorSlot>
40
+ <input aria-label="n" />
41
+ </FieldWrapper>,
42
+ );
43
+ expect(wrapperOf(screen.getByLabelText('n'))).toHaveClass(BOX);
44
+ rerender(
45
+ <FieldWrapper errorSlot error="Required">
46
+ <input aria-label="n" />
47
+ </FieldWrapper>,
48
+ );
49
+ expect(wrapperOf(screen.getByLabelText('n'))).toHaveClass(BOX);
50
+ expect(screen.getByText('Required')).toBeInTheDocument();
51
+ });
52
+ });
53
+
54
+ describe('the controls reserve the slot when an error attribute is written', () => {
55
+ it('Input without the attribute stays transparent', () => {
56
+ render(<Input aria-label="Name" />);
57
+ expect(inputWrapper()).toHaveClass(TRANSPARENT);
58
+ });
59
+
60
+ it('Input with error={undefined} is a box before any message, and stays one', () => {
61
+ const { rerender } = render(<Input aria-label="Name" error={undefined} />);
62
+ expect(inputWrapper()).toHaveClass(BOX);
63
+ rerender(<Input aria-label="Name" error="Required" />);
64
+ expect(inputWrapper()).toHaveClass(BOX);
65
+ });
66
+
67
+ it('SelectTrigger with error={undefined} is a box before any message', () => {
68
+ render(
69
+ <Select>
70
+ <SelectTrigger aria-label="Shell" error={undefined}>
71
+ <SelectValue placeholder="Pick" />
72
+ </SelectTrigger>
73
+ </Select>,
74
+ );
75
+ expect(wrapperOf(screen.getByRole('combobox'))).toHaveClass(BOX);
76
+ });
77
+
78
+ it('Textarea with error={undefined} is a box before any message', () => {
79
+ render(<Textarea aria-label="Notes" error={undefined} />);
80
+ expect(wrapperOf(screen.getByLabelText('Notes'))).toHaveClass(BOX);
81
+ });
82
+ });
@@ -742,7 +742,13 @@ function AutocompleteInner<T = string>(props: AutocompleteProps<T>, ref: Forward
742
742
  );
743
743
 
744
744
  return (
745
- <FieldWrapper label={label} labelVariant={labelVariant} error={error} className={className}>
745
+ <FieldWrapper
746
+ label={label}
747
+ labelVariant={labelVariant}
748
+ error={error}
749
+ errorSlot={'error' in props}
750
+ className={className}
751
+ >
746
752
  <div className="relative" ref={containerRef}>
747
753
  {popover}
748
754
 
@@ -757,7 +757,7 @@ export function DatePicker(props: DatePickerProps) {
757
757
  );
758
758
 
759
759
  return (
760
- <FieldWrapper label={label} error={error}>
760
+ <FieldWrapper label={label} error={error} errorSlot={'error' in props}>
761
761
  {picker}
762
762
  </FieldWrapper>
763
763
  );
@@ -790,23 +790,24 @@ const generateMinuteOptions = (): string[] => {
790
790
  return Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
791
791
  };
792
792
 
793
- export function DatePickerInput({
794
- placeholder = 'Select date',
795
- formatDate = defaultFormatDate,
796
- disabled = false,
797
- className,
798
- numberOfMonths = 1,
799
- fromDate,
800
- toDate,
801
- locale,
802
- value,
803
- onChange,
804
- showTime = false,
805
- use24HourFormat = false,
806
- label,
807
- error,
808
- invalid = false,
809
- }: DatePickerInputProps) {
793
+ export function DatePickerInput(props: DatePickerInputProps) {
794
+ const {
795
+ placeholder = 'Select date',
796
+ formatDate = defaultFormatDate,
797
+ disabled = false,
798
+ className,
799
+ numberOfMonths = 1,
800
+ fromDate,
801
+ toDate,
802
+ locale,
803
+ value,
804
+ onChange,
805
+ showTime = false,
806
+ use24HourFormat = false,
807
+ label,
808
+ error,
809
+ invalid = false,
810
+ } = props;
810
811
  const [open, setOpen] = useState(false);
811
812
  const isInvalid = invalid || !!error;
812
813
 
@@ -988,7 +989,7 @@ export function DatePickerInput({
988
989
  );
989
990
 
990
991
  return (
991
- <FieldWrapper label={label} error={error} className={className}>
992
+ <FieldWrapper label={label} error={error} errorSlot={'error' in props} className={className}>
992
993
  {content}
993
994
  </FieldWrapper>
994
995
  );
@@ -1036,24 +1037,25 @@ const generateTimeOptions = (intervalMinutes: number, use24Hour: boolean): { val
1036
1037
  return options;
1037
1038
  };
1038
1039
 
1039
- export function DatePickerInputSimple({
1040
- placeholder = 'Select date',
1041
- formatDate = defaultFormatDate,
1042
- disabled = false,
1043
- className,
1044
- numberOfMonths = 1,
1045
- fromDate,
1046
- toDate,
1047
- locale,
1048
- value,
1049
- onChange,
1050
- showTime = false,
1051
- timeInterval = 30,
1052
- use24HourFormat = false,
1053
- label,
1054
- error,
1055
- invalid = false,
1056
- }: DatePickerInputSimpleProps) {
1040
+ export function DatePickerInputSimple(props: DatePickerInputSimpleProps) {
1041
+ const {
1042
+ placeholder = 'Select date',
1043
+ formatDate = defaultFormatDate,
1044
+ disabled = false,
1045
+ className,
1046
+ numberOfMonths = 1,
1047
+ fromDate,
1048
+ toDate,
1049
+ locale,
1050
+ value,
1051
+ onChange,
1052
+ showTime = false,
1053
+ timeInterval = 30,
1054
+ use24HourFormat = false,
1055
+ label,
1056
+ error,
1057
+ invalid = false,
1058
+ } = props;
1057
1059
  const [open, setOpen] = useState(false);
1058
1060
  const isInvalid = invalid || !!error;
1059
1061
 
@@ -1181,7 +1183,7 @@ export function DatePickerInputSimple({
1181
1183
  );
1182
1184
 
1183
1185
  return (
1184
- <FieldWrapper label={label} error={error} className={className}>
1186
+ <FieldWrapper label={label} error={error} errorSlot={'error' in props} className={className}>
1185
1187
  {content}
1186
1188
  </FieldWrapper>
1187
1189
  );
@@ -21,13 +21,27 @@ export interface FieldWrapperProps {
21
21
  *
22
22
  * It is positioned OUT OF FLOW (absolute, hanging below the wrapper), so it
23
23
  * never changes the field's height when it appears — the same treatment
24
- * `CheckboxBlock` uses. The trade is that it overlaps whatever sits directly
24
+ * `CheckboxBlock` uses. That holds only while the wrapper is already a box
25
+ * when the message arrives — see `errorSlot`. The trade is that it overlaps whatever sits directly
25
26
  * below: a form stacking fields needs at least ~20px of vertical gap (e.g.
26
27
  * `gap-[var(--spacing-system-lf)]`) for the message to land in clear space.
27
28
  */
28
29
  error?: string;
29
30
  /** Color variant for the message: "error" (red), "warning" (yellow), "success" (green) or "muted" (grey) */
30
31
  errorVariant?: 'error' | 'warning' | 'success' | 'muted';
32
+ /**
33
+ * The field is wired for a message: `error` may arrive later, so render the
34
+ * positioned box from the FIRST render instead of only once it does.
35
+ *
36
+ * Without a label the wrapper is `display: contents` while `error` is empty
37
+ * and a box once it is set — and the parent's spacing lands on a box but not
38
+ * on `contents`: a `space-y-*` margin, a flex row's sizing. So the control
39
+ * moved by that spacing the moment a message came, and back when it went.
40
+ * The controls pass this whenever the caller wrote an `error` attribute at
41
+ * all (`error={undefined}` included): those are exactly the call sites whose
42
+ * layout could flip, and a box there is the layout they already got on error.
43
+ */
44
+ errorSlot?: boolean;
31
45
  /**
32
46
  * Label scale. Default is the standard form-label scale (text-h6).
33
47
  * 'large' (text-h4) is for screens whose design specifies body-scale field
@@ -56,10 +70,20 @@ const errorVariantClasses = {
56
70
 
57
71
  const FieldWrapper = forwardRef<HTMLDivElement, FieldWrapperProps>(
58
72
  (
59
- { label, htmlFor, error, errorVariant = 'error', labelVariant = 'default', required = false, className, children },
73
+ {
74
+ label,
75
+ htmlFor,
76
+ error,
77
+ errorVariant = 'error',
78
+ errorSlot = false,
79
+ labelVariant = 'default',
80
+ required = false,
81
+ className,
82
+ children,
83
+ },
60
84
  ref,
61
85
  ) => {
62
- const hasChrome = label != null || error != null;
86
+ const hasChrome = label != null || error != null || errorSlot;
63
87
 
64
88
  return (
65
89
  <div ref={ref} className={cn(hasChrome ? 'relative flex w-full flex-col' : 'contents', className)}>
@@ -30,134 +30,144 @@ const invalidBorderClasses = {
30
30
  warning: '!border-ods-warning hover:!border-ods-warning has-[:focus]:!border-ods-warning',
31
31
  } as const;
32
32
 
33
- const Input = forwardRef<HTMLInputElement, InputProps>(
34
- (
35
- {
36
- className,
37
- type,
38
- invalid = false,
39
- startAdornment,
40
- endAdornment,
41
- label,
42
- labelVariant,
43
- error,
44
- errorVariant = 'error',
45
- loading = false,
46
- ...props
47
- },
48
- ref,
49
- ) => {
50
- // success/muted are informational — they never paint the invalid border
51
- const variantIsInvalid = errorVariant === 'error' || errorVariant === 'warning';
52
- const isInvalid = invalid || (!!error && variantIsInvalid);
33
+ const Input = forwardRef<HTMLInputElement, InputProps>((allProps, ref) => {
34
+ const {
35
+ className,
36
+ type,
37
+ invalid = false,
38
+ startAdornment,
39
+ endAdornment,
40
+ label,
41
+ labelVariant,
42
+ error,
43
+ errorVariant = 'error',
44
+ loading = false,
45
+ ...props
46
+ } = allProps;
47
+ // Wired for a message even while `error` is undefined — see `FieldWrapper.errorSlot`.
48
+ const errorSlot = 'error' in allProps;
49
+ // success/muted are informational — they never paint the invalid border
50
+ const variantIsInvalid = errorVariant === 'error' || errorVariant === 'warning';
51
+ const isInvalid = invalid || (!!error && variantIsInvalid);
53
52
 
54
- // Range inputs get a clean slider rendering — no label wrapper, borders, or adornments
55
- if (type === 'range') {
56
- const rangeInput = (
57
- <input
58
- type="range"
59
- className={cn(
60
- 'h-1 w-full cursor-pointer appearance-none rounded-full bg-white/30',
61
- // Webkit (Chrome/Safari) thumb
62
- '[&::-webkit-slider-thumb]:appearance-none',
63
- '[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3',
64
- '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white',
65
- '[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm',
66
- // Firefox thumb
67
- '[&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3',
68
- '[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white',
69
- '[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-0',
70
- // Firefox track
71
- '[&::-moz-range-track]:bg-transparent',
72
- // Disabled
73
- 'disabled:cursor-not-allowed disabled:opacity-50',
74
- className,
75
- )}
76
- ref={ref}
77
- {...props}
78
- />
79
- );
80
- return label ? (
81
- <FieldWrapper label={label} labelVariant={labelVariant} error={error} errorVariant={errorVariant}>
82
- {rangeInput}
83
- </FieldWrapper>
84
- ) : (
85
- rangeInput
86
- );
87
- }
88
-
89
- const content = (
90
- <label
91
- data-invalid={isInvalid || undefined}
53
+ // Range inputs get a clean slider rendering — no label wrapper, borders, or adornments
54
+ if (type === 'range') {
55
+ const rangeInput = (
56
+ <input
57
+ type="range"
92
58
  className={cn(
93
- // Layout & spacing
94
- 'flex h-11 w-full cursor-text items-center gap-2 rounded-[6px] border px-3 md:h-12',
95
- // Focus-within states
96
- 'has-[:focus-visible]:outline-none',
97
- 'group',
98
- // Animations & touch UX
99
- 'transition-colors duration-200',
100
- // Theme palette
101
- 'border-ods-border bg-ods-card has-[:focus]:border-ods-accent',
102
- // Hover & active (not disabled)
103
- !props.disabled &&
104
- 'hover:border-ods-border-hover hover:bg-ods-bg-hover active:border-ods-border-active active:bg-ods-bg-active',
105
- // Disabled. The adornments (and any icon inside them) carry their own
106
- // `text-ods-text-secondary`, so grey them from here — a disabled field
107
- // must read as one flat colour, not grey text next to a live icon.
108
- // Scoped to the DIRECT span children (the two adornment wrappers): a
109
- // descendant rule would also repaint whatever the caller renders INSIDE
110
- // an adornment, which is passed verbatim precisely so it can own its
111
- // colour (a `Tag` there went invisible against its own fill).
112
- props.disabled && '!cursor-not-allowed bg-ods-bg',
113
- 'has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled',
114
- // Invalid
115
- isInvalid && invalidBorderClasses[variantIsInvalid ? errorVariant : 'error'],
59
+ 'h-1 w-full cursor-pointer appearance-none rounded-full bg-white/30',
60
+ // Webkit (Chrome/Safari) thumb
61
+ '[&::-webkit-slider-thumb]:appearance-none',
62
+ '[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3',
63
+ '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white',
64
+ '[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm',
65
+ // Firefox thumb
66
+ '[&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3',
67
+ '[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white',
68
+ '[&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-0',
69
+ // Firefox track
70
+ '[&::-moz-range-track]:bg-transparent',
71
+ // Disabled
72
+ 'disabled:cursor-not-allowed disabled:opacity-50',
116
73
  className,
117
74
  )}
118
- >
119
- {startAdornment && (
120
- <span className="flex-shrink-0 text-ods-text-secondary transition-colors duration-200 text-h6 group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error [&_svg]:size-4 md:[&_svg]:size-6">
121
- {startAdornment}
122
- </span>
123
- )}
124
- <input
125
- type={type}
126
- className={cn(
127
- // Layout
128
- 'min-w-0 flex-1 border-none bg-transparent outline-none',
129
- // Typography
130
- 'text-h4',
131
- // Colors
132
- 'text-ods-text-primary placeholder:text-ods-text-secondary',
133
- // File input adjustments
134
- 'file:border-0 file:bg-transparent',
135
- // Disabled
136
- 'disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border',
137
- // Touch
138
- 'touch-manipulation',
139
- // Autofill override
140
- '[&:-webkit-autofill]:[-webkit-box-shadow:0_0_0_9999px_transparent_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:var(--color-text-primary)] [&:-webkit-autofill]:[caret-color:var(--color-text-primary)] [&:-webkit-autofill]:[transition:background-color_9999s_ease-in-out_0s]',
141
- )}
142
- ref={ref}
143
- {...props}
144
- />
145
- {loading && <Loader2 className="size-4 flex-shrink-0 animate-spin text-ods-text-secondary md:size-6" />}
146
- {!loading && endAdornment && (
147
- <span className="flex-shrink-0 text-ods-text-secondary transition-colors duration-200 text-h6 group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error [&_svg]:size-4 md:[&_svg]:size-6">
148
- {endAdornment}
149
- </span>
150
- )}
151
- </label>
75
+ ref={ref}
76
+ {...props}
77
+ />
152
78
  );
153
-
154
- return (
155
- <FieldWrapper label={label} labelVariant={labelVariant} error={error} errorVariant={errorVariant}>
156
- {content}
79
+ return label ? (
80
+ <FieldWrapper
81
+ label={label}
82
+ labelVariant={labelVariant}
83
+ error={error}
84
+ errorVariant={errorVariant}
85
+ errorSlot={errorSlot}
86
+ >
87
+ {rangeInput}
157
88
  </FieldWrapper>
89
+ ) : (
90
+ rangeInput
158
91
  );
159
- },
160
- );
92
+ }
93
+
94
+ const content = (
95
+ <label
96
+ data-invalid={isInvalid || undefined}
97
+ className={cn(
98
+ // Layout & spacing
99
+ 'flex h-11 w-full cursor-text items-center gap-2 rounded-[6px] border px-3 md:h-12',
100
+ // Focus-within states
101
+ 'has-[:focus-visible]:outline-none',
102
+ 'group',
103
+ // Animations & touch UX
104
+ 'transition-colors duration-200',
105
+ // Theme palette
106
+ 'border-ods-border bg-ods-card has-[:focus]:border-ods-accent',
107
+ // Hover & active (not disabled)
108
+ !props.disabled &&
109
+ 'hover:border-ods-border-hover hover:bg-ods-bg-hover active:border-ods-border-active active:bg-ods-bg-active',
110
+ // Disabled. The adornments (and any icon inside them) carry their own
111
+ // `text-ods-text-secondary`, so grey them from here — a disabled field
112
+ // must read as one flat colour, not grey text next to a live icon.
113
+ // Scoped to the DIRECT span children (the two adornment wrappers): a
114
+ // descendant rule would also repaint whatever the caller renders INSIDE
115
+ // an adornment, which is passed verbatim precisely so it can own its
116
+ // colour (a `Tag` there went invisible against its own fill).
117
+ props.disabled && '!cursor-not-allowed bg-ods-bg',
118
+ 'has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled',
119
+ // Invalid
120
+ isInvalid && invalidBorderClasses[variantIsInvalid ? errorVariant : 'error'],
121
+ className,
122
+ )}
123
+ >
124
+ {startAdornment && (
125
+ <span className="flex-shrink-0 text-ods-text-secondary transition-colors duration-200 text-h6 group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error [&_svg]:size-4 md:[&_svg]:size-6">
126
+ {startAdornment}
127
+ </span>
128
+ )}
129
+ <input
130
+ type={type}
131
+ className={cn(
132
+ // Layout
133
+ 'min-w-0 flex-1 border-none bg-transparent outline-none',
134
+ // Typography
135
+ 'text-h4',
136
+ // Colors
137
+ 'text-ods-text-primary placeholder:text-ods-text-secondary',
138
+ // File input adjustments
139
+ 'file:border-0 file:bg-transparent',
140
+ // Disabled
141
+ 'disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border',
142
+ // Touch
143
+ 'touch-manipulation',
144
+ // Autofill override
145
+ '[&:-webkit-autofill]:[-webkit-box-shadow:0_0_0_9999px_transparent_inset] [&:-webkit-autofill]:[-webkit-text-fill-color:var(--color-text-primary)] [&:-webkit-autofill]:[caret-color:var(--color-text-primary)] [&:-webkit-autofill]:[transition:background-color_9999s_ease-in-out_0s]',
146
+ )}
147
+ ref={ref}
148
+ {...props}
149
+ />
150
+ {loading && <Loader2 className="size-4 flex-shrink-0 animate-spin text-ods-text-secondary md:size-6" />}
151
+ {!loading && endAdornment && (
152
+ <span className="flex-shrink-0 text-ods-text-secondary transition-colors duration-200 text-h6 group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error [&_svg]:size-4 md:[&_svg]:size-6">
153
+ {endAdornment}
154
+ </span>
155
+ )}
156
+ </label>
157
+ );
158
+
159
+ return (
160
+ <FieldWrapper
161
+ label={label}
162
+ labelVariant={labelVariant}
163
+ error={error}
164
+ errorVariant={errorVariant}
165
+ errorSlot={errorSlot}
166
+ >
167
+ {content}
168
+ </FieldWrapper>
169
+ );
170
+ });
161
171
  Input.displayName = 'Input';
162
172
 
163
173
  export { Input };
@@ -23,7 +23,10 @@ const SelectTrigger = forwardRef<
23
23
  labelVariant?: 'default' | 'large';
24
24
  error?: string;
25
25
  }
26
- >(({ className, children, invalid, label, labelVariant, error, ...props }, ref) => {
26
+ >((allProps, ref) => {
27
+ const { className, children, invalid, label, labelVariant, error, ...props } = allProps;
28
+ // Wired for a message even while `error` is undefined — see `FieldWrapper.errorSlot`.
29
+ const errorSlot = 'error' in allProps;
27
30
  const isInvalid = invalid || !!error;
28
31
 
29
32
  const trigger = (
@@ -73,7 +76,7 @@ const SelectTrigger = forwardRef<
73
76
  );
74
77
 
75
78
  return (
76
- <FieldWrapper label={label} labelVariant={labelVariant} error={error}>
79
+ <FieldWrapper label={label} labelVariant={labelVariant} error={error} errorSlot={errorSlot}>
77
80
  {trigger}
78
81
  </FieldWrapper>
79
82
  );