@thangnqdev/ui 0.1.2 → 0.2.0

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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/dist/bento/index.cjs.map +1 -1
  4. package/dist/bento/index.js +2 -2
  5. package/dist/bento/styles.css +2 -46
  6. package/dist/buttons/index.cjs +111 -18
  7. package/dist/buttons/index.cjs.map +1 -1
  8. package/dist/buttons/index.d.cts +1 -1
  9. package/dist/buttons/index.d.ts +1 -1
  10. package/dist/buttons/index.js +3 -3
  11. package/dist/buttons/styles.css +2 -6
  12. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  13. package/dist/chunk-2YJBW4E2.js.map +1 -0
  14. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  15. package/dist/chunk-4CRFNALF.js.map +1 -0
  16. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  17. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  18. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  19. package/dist/chunk-77KTYOMQ.js.map +1 -0
  20. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  21. package/dist/chunk-ADO3SVUA.js.map +1 -0
  22. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  23. package/dist/chunk-D7FB3TCA.js.map +1 -0
  24. package/dist/chunk-DSEL7ELZ.js +314 -0
  25. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  26. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  27. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  28. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  29. package/dist/chunk-GXWB6BSY.js.map +1 -0
  30. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  31. package/dist/chunk-I2FQBMVW.js.map +1 -0
  32. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  33. package/dist/chunk-I5O2WCGF.js.map +1 -0
  34. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  35. package/dist/chunk-ME4XILG5.js.map +1 -0
  36. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  37. package/dist/chunk-PC5SZFZC.js.map +1 -0
  38. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  39. package/dist/chunk-S6XJMYME.js.map +1 -0
  40. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  41. package/dist/chunk-V52JOSDF.js.map +1 -0
  42. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  43. package/dist/chunk-W5WNWTQC.js.map +1 -0
  44. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  45. package/dist/chunk-YJ64RI7J.js.map +1 -0
  46. package/dist/chunk-YOCGBORV.js +155 -0
  47. package/dist/chunk-YOCGBORV.js.map +1 -0
  48. package/dist/chunk-ZEDXARXW.js +96 -0
  49. package/dist/chunk-ZEDXARXW.js.map +1 -0
  50. package/dist/components.css +2 -0
  51. package/dist/core/index.cjs +33 -24
  52. package/dist/core/index.cjs.map +1 -1
  53. package/dist/core/index.js +2 -2
  54. package/dist/core/styles.css +2 -134
  55. package/dist/data-display/index.cjs +770 -247
  56. package/dist/data-display/index.cjs.map +1 -1
  57. package/dist/data-display/index.d.cts +1 -1
  58. package/dist/data-display/index.d.ts +1 -1
  59. package/dist/data-display/index.js +5 -4
  60. package/dist/data-display/styles.css +2 -4
  61. package/dist/feedback/index.cjs.map +1 -1
  62. package/dist/feedback/index.js +2 -2
  63. package/dist/feedback/styles.css +2 -4
  64. package/dist/forms/index.cjs +1211 -459
  65. package/dist/forms/index.cjs.map +1 -1
  66. package/dist/forms/index.d.cts +1 -1
  67. package/dist/forms/index.d.ts +1 -1
  68. package/dist/forms/index.js +8 -8
  69. package/dist/forms/styles.css +2 -6
  70. package/dist/glass/index.cjs.map +1 -1
  71. package/dist/glass/index.js +2 -2
  72. package/dist/glass/styles.css +2 -81
  73. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  74. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  75. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  76. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  77. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  78. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  79. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  80. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  81. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  82. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  83. package/dist/index.cjs +2481 -917
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +5 -5
  86. package/dist/index.d.ts +5 -5
  87. package/dist/index.js +19 -19
  88. package/dist/layout/index.cjs.map +1 -1
  89. package/dist/layout/index.js +2 -2
  90. package/dist/layout/styles.css +2 -8
  91. package/dist/navigation/index.cjs +288 -48
  92. package/dist/navigation/index.cjs.map +1 -1
  93. package/dist/navigation/index.d.cts +1 -1
  94. package/dist/navigation/index.d.ts +1 -1
  95. package/dist/navigation/index.js +6 -6
  96. package/dist/navigation/styles.css +2 -4
  97. package/dist/overlays/index.cjs +566 -229
  98. package/dist/overlays/index.cjs.map +1 -1
  99. package/dist/overlays/index.d.cts +1 -1
  100. package/dist/overlays/index.d.ts +1 -1
  101. package/dist/overlays/index.js +7 -7
  102. package/dist/overlays/styles.css +2 -8
  103. package/dist/reset.css +45 -0
  104. package/dist/styles.css +1 -1
  105. package/dist/tokens.css +11 -0
  106. package/dist/typography/index.cjs.map +1 -1
  107. package/dist/typography/index.js +4 -4
  108. package/dist/typography/styles.css +2 -5
  109. package/llms.txt +211 -113
  110. package/package.json +160 -116
  111. package/src/bento/BentoCard.tsx +140 -140
  112. package/src/bento/BentoGrid.tsx +50 -50
  113. package/src/bento/index.ts +2 -2
  114. package/src/bento/tokens.css +46 -46
  115. package/src/buttons/Button.tsx +104 -104
  116. package/src/buttons/ButtonGroup.tsx +43 -43
  117. package/src/buttons/IconButton.tsx +79 -79
  118. package/src/buttons/SplitButton.tsx +186 -86
  119. package/src/buttons/ToggleButton.tsx +157 -157
  120. package/src/buttons/index.ts +5 -5
  121. package/src/buttons/tokens.css +6 -6
  122. package/src/core/ThemeProvider.tsx +135 -123
  123. package/src/core/index.ts +2 -2
  124. package/src/core/tokens.css +134 -134
  125. package/src/data-display/Accordion.tsx +147 -147
  126. package/src/data-display/Avatar.tsx +134 -134
  127. package/src/data-display/Card.tsx +112 -112
  128. package/src/data-display/Chip.tsx +74 -74
  129. package/src/data-display/DataTablePro.tsx +299 -121
  130. package/src/data-display/StatCard.tsx +60 -60
  131. package/src/data-display/Table.tsx +74 -74
  132. package/src/data-display/Timeline.tsx +62 -62
  133. package/src/data-display/TreeView.tsx +243 -104
  134. package/src/data-display/tokens.css +4 -4
  135. package/src/feedback/Alert.tsx +80 -80
  136. package/src/feedback/EmptyState.tsx +45 -45
  137. package/src/feedback/Skeleton.tsx +66 -66
  138. package/src/feedback/Spinner.tsx +65 -65
  139. package/src/feedback/index.ts +5 -5
  140. package/src/feedback/tokens.css +4 -4
  141. package/src/forms/Checkbox.tsx +149 -69
  142. package/src/forms/CheckboxGroup.tsx +76 -29
  143. package/src/forms/Combobox.tsx +172 -28
  144. package/src/forms/DatePicker.tsx +385 -81
  145. package/src/forms/FileUpload.tsx +51 -7
  146. package/src/forms/FormField.tsx +115 -115
  147. package/src/forms/Input.tsx +151 -151
  148. package/src/forms/NumberInput.tsx +34 -20
  149. package/src/forms/PasswordInput.tsx +35 -35
  150. package/src/forms/PinInput.tsx +139 -125
  151. package/src/forms/Radio.tsx +142 -142
  152. package/src/forms/RangeSlider.tsx +237 -111
  153. package/src/forms/SearchInput.tsx +34 -34
  154. package/src/forms/Select.tsx +103 -103
  155. package/src/forms/Slider.tsx +85 -85
  156. package/src/forms/Textarea.tsx +114 -114
  157. package/src/forms/tokens.css +6 -6
  158. package/src/glass/GlassButton.tsx +59 -59
  159. package/src/glass/GlassCard.tsx +71 -71
  160. package/src/glass/index.ts +3 -3
  161. package/src/glass/tokens.css +81 -81
  162. package/src/index.ts +26 -26
  163. package/src/layout/AspectRatio.tsx +39 -39
  164. package/src/layout/Container.tsx +53 -53
  165. package/src/layout/Divider.tsx +61 -61
  166. package/src/layout/Flex.tsx +79 -79
  167. package/src/layout/Grid.tsx +124 -124
  168. package/src/layout/PageLayout.tsx +59 -59
  169. package/src/layout/Section.tsx +47 -47
  170. package/src/layout/Spacer.tsx +30 -30
  171. package/src/layout/Stack.tsx +88 -88
  172. package/src/layout/tokens.css +8 -8
  173. package/src/navigation/Breadcrumb.tsx +73 -73
  174. package/src/navigation/CommandPalette.tsx +142 -142
  175. package/src/navigation/DropdownMenu.tsx +244 -160
  176. package/src/navigation/Pagination.tsx +114 -114
  177. package/src/navigation/Stepper.tsx +93 -93
  178. package/src/navigation/Tabs.tsx +265 -133
  179. package/src/navigation/index.ts +6 -6
  180. package/src/navigation/tokens.css +4 -4
  181. package/src/overlays/AlertDialog.tsx +63 -54
  182. package/src/overlays/BottomSheet.tsx +30 -10
  183. package/src/overlays/ContextMenu.tsx +51 -9
  184. package/src/overlays/Drawer.tsx +160 -145
  185. package/src/overlays/FocusTrap.tsx +122 -49
  186. package/src/overlays/Modal.tsx +296 -219
  187. package/src/overlays/Popover.tsx +55 -25
  188. package/src/overlays/Portal.tsx +23 -23
  189. package/src/overlays/Toast.tsx +175 -137
  190. package/src/overlays/Tooltip.tsx +125 -100
  191. package/src/overlays/tokens.css +8 -8
  192. package/src/registry/components.ts +1368 -0
  193. package/src/styles/components.css +39 -0
  194. package/src/styles/index.css +2 -93
  195. package/src/styles/reset.css +45 -0
  196. package/src/styles/tokens.css +11 -2
  197. package/src/typography/Blockquote.tsx +32 -32
  198. package/src/typography/Code.tsx +89 -89
  199. package/src/typography/Heading.tsx +56 -56
  200. package/src/typography/Kbd.tsx +25 -25
  201. package/src/typography/Label.tsx +47 -47
  202. package/src/typography/Link.tsx +56 -56
  203. package/src/typography/Text.tsx +62 -62
  204. package/src/typography/index.ts +7 -7
  205. package/src/typography/tokens.css +5 -5
  206. package/src/utils/cn.ts +6 -6
  207. package/src/utils/scroll-lock.ts +19 -0
  208. package/src/utils/useControllableState.ts +54 -0
  209. package/ui-contract.json +192 -35
  210. package/dist/chunk-2OXZK6IP.js.map +0 -1
  211. package/dist/chunk-4S6L5V7X.js.map +0 -1
  212. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  213. package/dist/chunk-66VWUJEI.js.map +0 -1
  214. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  215. package/dist/chunk-ATYGDTMK.js.map +0 -1
  216. package/dist/chunk-C4SUXMI3.js.map +0 -1
  217. package/dist/chunk-EU2LPUYP.js.map +0 -1
  218. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  219. package/dist/chunk-GXI2H5XA.js.map +0 -1
  220. package/dist/chunk-H3LCNPMC.js +0 -101
  221. package/dist/chunk-H3LCNPMC.js.map +0 -1
  222. package/dist/chunk-IABPUCAQ.js.map +0 -1
  223. package/dist/chunk-KJRBRTQE.js.map +0 -1
  224. package/dist/chunk-LBUBXUYQ.js +0 -40
  225. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  226. package/dist/chunk-LWFRUJRU.js.map +0 -1
  227. package/dist/chunk-NFVONJUK.js.map +0 -1
  228. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  229. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  230. package/dist/chunk-X76KBH4Y.js +0 -71
  231. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -1,142 +1,142 @@
1
- import React, { forwardRef, createContext, useContext } from 'react';
2
- import { cva, type VariantProps } from 'class-variance-authority';
3
- import { cn } from '../utils/cn';
4
-
5
- interface RadioGroupContextType {
6
- name?: string;
7
- value?: string;
8
- onChange?: (val: string) => void;
9
- disabled?: boolean;
10
- }
11
-
12
- const RadioGroupContext = createContext<RadioGroupContextType | null>(null);
13
-
14
- export const radioVariants = cva(
15
- 'peer shrink-0 rounded-full border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] disabled:cursor-not-allowed disabled:opacity-50 transition-all cursor-pointer flex items-center justify-center',
16
- {
17
- variants: {
18
- size: {
19
- sm: 'w-4 h-4',
20
- md: 'w-5 h-5',
21
- lg: 'w-6 h-6',
22
- },
23
- },
24
- defaultVariants: {
25
- size: 'md',
26
- },
27
- }
28
- );
29
-
30
- export interface RadioProps
31
- extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
32
- VariantProps<typeof radioVariants> {
33
- label?: React.ReactNode;
34
- value: string;
35
- }
36
-
37
- export const Radio = forwardRef<HTMLInputElement, RadioProps>(
38
- ({ className, size, label, value, checked, disabled, id, onChange, ...props }, ref) => {
39
- const group = useContext(RadioGroupContext);
40
- const isChecked = checked ?? (group?.value !== undefined ? group.value === value : false);
41
- const isDisabled = disabled ?? group?.disabled ?? false;
42
- const name = props.name || group?.name;
43
-
44
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
- onChange?.(e);
46
- if (group?.onChange) {
47
- group.onChange(value);
48
- }
49
- };
50
-
51
- return (
52
- <label className={cn('inline-flex items-center gap-2 select-none cursor-pointer', isDisabled && 'cursor-not-allowed opacity-50')}>
53
- <div className="relative flex items-center justify-center">
54
- <input
55
- ref={ref}
56
- type="radio"
57
- name={name}
58
- value={value}
59
- id={id}
60
- disabled={isDisabled}
61
- checked={isChecked}
62
- onChange={handleChange}
63
- className="sr-only peer"
64
- {...props}
65
- />
66
- <div
67
- className={cn(
68
- radioVariants({ size }),
69
- isChecked && 'border-[var(--ui-primary)]',
70
- className
71
- )}
72
- >
73
- {isChecked && (
74
- <span className="w-2.5 h-2.5 rounded-full bg-[var(--ui-primary)] animate-in zoom-in-75 duration-150" />
75
- )}
76
- </div>
77
- </div>
78
- {label && <span className="text-sm font-medium text-[var(--ui-text-primary)]">{label}</span>}
79
- </label>
80
- );
81
- }
82
- );
83
- Radio.displayName = 'Radio';
84
-
85
- export interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
86
- name?: string;
87
- value?: string;
88
- defaultValue?: string;
89
- onValueChange?: (value: string) => void;
90
- orientation?: 'horizontal' | 'vertical';
91
- disabled?: boolean;
92
- }
93
-
94
- export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
95
- (
96
- {
97
- className,
98
- name,
99
- value: controlledValue,
100
- defaultValue,
101
- onValueChange,
102
- orientation = 'vertical',
103
- disabled,
104
- children,
105
- ...props
106
- },
107
- ref
108
- ) => {
109
- const [val, setVal] = React.useState(defaultValue || '');
110
- const currentValue = controlledValue !== undefined ? controlledValue : val;
111
-
112
- const handleChange = (newValue: string) => {
113
- setVal(newValue);
114
- onValueChange?.(newValue);
115
- };
116
-
117
- return (
118
- <RadioGroupContext.Provider
119
- value={{
120
- name,
121
- value: currentValue,
122
- onChange: handleChange,
123
- disabled,
124
- }}
125
- >
126
- <div
127
- ref={ref}
128
- role="radiogroup"
129
- className={cn(
130
- 'flex gap-2',
131
- orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap items-center',
132
- className
133
- )}
134
- {...props}
135
- >
136
- {children}
137
- </div>
138
- </RadioGroupContext.Provider>
139
- );
140
- }
141
- );
142
- RadioGroup.displayName = 'RadioGroup';
1
+ import React, { forwardRef, createContext, useContext } from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { cn } from '../utils/cn';
4
+
5
+ interface RadioGroupContextType {
6
+ name?: string;
7
+ value?: string;
8
+ onChange?: (val: string) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ const RadioGroupContext = createContext<RadioGroupContextType | null>(null);
13
+
14
+ export const radioVariants = cva(
15
+ 'peer shrink-0 rounded-full border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] disabled:cursor-not-allowed disabled:opacity-50 transition-all cursor-pointer flex items-center justify-center',
16
+ {
17
+ variants: {
18
+ size: {
19
+ sm: 'w-4 h-4',
20
+ md: 'w-5 h-5',
21
+ lg: 'w-6 h-6',
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ size: 'md',
26
+ },
27
+ }
28
+ );
29
+
30
+ export interface RadioProps
31
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
32
+ VariantProps<typeof radioVariants> {
33
+ label?: React.ReactNode;
34
+ value: string;
35
+ }
36
+
37
+ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
38
+ ({ className, size, label, value, checked, disabled, id, onChange, ...props }, ref) => {
39
+ const group = useContext(RadioGroupContext);
40
+ const isChecked = checked ?? (group?.value !== undefined ? group.value === value : false);
41
+ const isDisabled = disabled ?? group?.disabled ?? false;
42
+ const name = props.name || group?.name;
43
+
44
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ onChange?.(e);
46
+ if (group?.onChange) {
47
+ group.onChange(value);
48
+ }
49
+ };
50
+
51
+ return (
52
+ <label className={cn('inline-flex items-center gap-2 select-none cursor-pointer', isDisabled && 'cursor-not-allowed opacity-50')}>
53
+ <div className="relative flex items-center justify-center">
54
+ <input
55
+ ref={ref}
56
+ type="radio"
57
+ name={name}
58
+ value={value}
59
+ id={id}
60
+ disabled={isDisabled}
61
+ checked={isChecked}
62
+ onChange={handleChange}
63
+ className="sr-only peer"
64
+ {...props}
65
+ />
66
+ <div
67
+ className={cn(
68
+ radioVariants({ size }),
69
+ isChecked && 'border-[var(--ui-primary)]',
70
+ className
71
+ )}
72
+ >
73
+ {isChecked && (
74
+ <span className="w-2.5 h-2.5 rounded-full bg-[var(--ui-primary)] animate-in zoom-in-75 duration-150" />
75
+ )}
76
+ </div>
77
+ </div>
78
+ {label && <span className="text-sm font-medium text-[var(--ui-text-primary)]">{label}</span>}
79
+ </label>
80
+ );
81
+ }
82
+ );
83
+ Radio.displayName = 'Radio';
84
+
85
+ export interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
86
+ name?: string;
87
+ value?: string;
88
+ defaultValue?: string;
89
+ onValueChange?: (value: string) => void;
90
+ orientation?: 'horizontal' | 'vertical';
91
+ disabled?: boolean;
92
+ }
93
+
94
+ export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
95
+ (
96
+ {
97
+ className,
98
+ name,
99
+ value: controlledValue,
100
+ defaultValue,
101
+ onValueChange,
102
+ orientation = 'vertical',
103
+ disabled,
104
+ children,
105
+ ...props
106
+ },
107
+ ref
108
+ ) => {
109
+ const [val, setVal] = React.useState(defaultValue || '');
110
+ const currentValue = controlledValue !== undefined ? controlledValue : val;
111
+
112
+ const handleChange = (newValue: string) => {
113
+ setVal(newValue);
114
+ onValueChange?.(newValue);
115
+ };
116
+
117
+ return (
118
+ <RadioGroupContext.Provider
119
+ value={{
120
+ name,
121
+ value: currentValue,
122
+ onChange: handleChange,
123
+ disabled,
124
+ }}
125
+ >
126
+ <div
127
+ ref={ref}
128
+ role="radiogroup"
129
+ className={cn(
130
+ 'flex gap-2',
131
+ orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap items-center',
132
+ className
133
+ )}
134
+ {...props}
135
+ >
136
+ {children}
137
+ </div>
138
+ </RadioGroupContext.Provider>
139
+ );
140
+ }
141
+ );
142
+ RadioGroup.displayName = 'RadioGroup';
@@ -1,111 +1,237 @@
1
- import React, { forwardRef, useState } from 'react';
2
- import { cn } from '../utils/cn';
3
-
4
- export interface RangeSliderProps {
5
- min?: number;
6
- max?: number;
7
- step?: number;
8
- value?: [number, number];
9
- defaultValue?: [number, number];
10
- onChange?: (val: [number, number]) => void;
11
- showValue?: boolean;
12
- formatValue?: (val: number) => string;
13
- className?: string;
14
- disabled?: boolean;
15
- }
16
-
17
- export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
18
- (
19
- {
20
- min = 0,
21
- max = 100,
22
- step = 1,
23
- value: controlledValue,
24
- defaultValue = [20, 80],
25
- onChange,
26
- showValue = false,
27
- formatValue = (v) => `${v}`,
28
- className,
29
- disabled = false,
30
- },
31
- ref
32
- ) => {
33
- const [stateValue, setStateValue] = useState<[number, number]>(defaultValue);
34
- const [minVal, maxVal] = controlledValue || stateValue;
35
-
36
- const minPercentage = Math.min(100, Math.max(0, ((minVal - min) / (max - min)) * 100));
37
- const maxPercentage = Math.min(100, Math.max(0, ((maxVal - min) / (max - min)) * 100));
38
-
39
- const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
40
- const val = Math.min(Number(e.target.value), maxVal - step);
41
- setStateValue([val, maxVal]);
42
- onChange?.([val, maxVal]);
43
- };
44
-
45
- const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
46
- const val = Math.max(Number(e.target.value), minVal + step);
47
- setStateValue([minVal, val]);
48
- onChange?.([minVal, val]);
49
- };
50
-
51
- return (
52
- <div ref={ref} className={cn('w-full flex items-center gap-4', className)}>
53
- <div className="relative w-full flex items-center h-6 select-none touch-none">
54
- {/* Base rail */}
55
- <div className="w-full h-2 rounded-full bg-[var(--ui-border-default)] overflow-hidden relative">
56
- {/* Active Range Fill */}
57
- <div
58
- className="absolute h-full bg-[var(--ui-primary)]"
59
- style={{
60
- left: `${minPercentage}%`,
61
- width: `${maxPercentage - minPercentage}%`,
62
- }}
63
- />
64
- </div>
65
-
66
- {/* Min thumb input */}
67
- <input
68
- type="range"
69
- min={min}
70
- max={max}
71
- step={step}
72
- value={minVal}
73
- disabled={disabled}
74
- onChange={handleMinChange}
75
- className="absolute inset-0 w-full h-full opacity-0 pointer-events-auto cursor-pointer z-20"
76
- />
77
-
78
- {/* Max thumb input */}
79
- <input
80
- type="range"
81
- min={min}
82
- max={max}
83
- step={step}
84
- value={maxVal}
85
- disabled={disabled}
86
- onChange={handleMaxChange}
87
- className="absolute inset-0 w-full h-full opacity-0 pointer-events-auto cursor-pointer z-30"
88
- />
89
-
90
- {/* Visual Thumbs */}
91
- <div
92
- className="absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md pointer-events-none -translate-x-1/2 z-10"
93
- style={{ left: `${minPercentage}%` }}
94
- />
95
- <div
96
- className="absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md pointer-events-none -translate-x-1/2 z-10"
97
- style={{ left: `${maxPercentage}%` }}
98
- />
99
- </div>
100
-
101
- {showValue && (
102
- <span className="text-xs font-semibold font-mono text-[var(--ui-text-secondary)] min-w-[5rem] text-right">
103
- {formatValue(minVal)} - {formatValue(maxVal)}
104
- </span>
105
- )}
106
- </div>
107
- );
108
- }
109
- );
110
-
111
- RangeSlider.displayName = 'RangeSlider';
1
+ import React, { forwardRef, useState, useRef, useCallback, useEffect } from 'react';
2
+ import { cn } from '../utils/cn';
3
+
4
+ export interface RangeSliderProps {
5
+ min?: number;
6
+ max?: number;
7
+ step?: number;
8
+ value?: [number, number];
9
+ defaultValue?: [number, number];
10
+ onChange?: (val: [number, number]) => void;
11
+ showValue?: boolean;
12
+ formatValue?: (val: number) => string;
13
+ className?: string;
14
+ disabled?: boolean;
15
+ name?: string;
16
+ 'aria-label'?: string;
17
+ }
18
+
19
+ export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
20
+ (
21
+ {
22
+ min = 0,
23
+ max = 100,
24
+ step = 1,
25
+ value: controlledValue,
26
+ defaultValue = [20, 80],
27
+ onChange,
28
+ showValue = false,
29
+ formatValue = (v) => `${v}`,
30
+ className,
31
+ disabled = false,
32
+ name,
33
+ 'aria-label': ariaLabel = 'Range slider',
34
+ },
35
+ ref
36
+ ) => {
37
+ const isControlled = controlledValue !== undefined;
38
+ const [internalValue, setInternalValue] = useState<[number, number]>(defaultValue);
39
+ const currentValue = isControlled ? controlledValue : internalValue;
40
+
41
+ const [minVal, maxVal] = [
42
+ Math.max(min, Math.min(currentValue[0], currentValue[1])),
43
+ Math.min(max, Math.max(currentValue[0], currentValue[1])),
44
+ ];
45
+
46
+ const trackRef = useRef<HTMLDivElement>(null);
47
+ const draggingThumbRef = useRef<'min' | 'max' | null>(null);
48
+
49
+ const updateValues = useCallback(
50
+ (newMin: number, newMax: number) => {
51
+ const clampedMin = Math.max(min, Math.min(newMin, newMax));
52
+ const clampedMax = Math.min(max, Math.max(clampedMin, newMax));
53
+ const next: [number, number] = [clampedMin, clampedMax];
54
+
55
+ if (!isControlled) {
56
+ setInternalValue(next);
57
+ }
58
+ onChange?.(next);
59
+ },
60
+ [min, max, isControlled, onChange]
61
+ );
62
+
63
+ const getValueFromPointer = useCallback(
64
+ (clientX: number): number => {
65
+ if (!trackRef.current) return min;
66
+ const rect = trackRef.current.getBoundingClientRect();
67
+ const percent = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
68
+ const rawValue = min + percent * (max - min);
69
+ const steppedValue = Math.round((rawValue - min) / step) * step + min;
70
+ return Math.min(max, Math.max(min, Number(steppedValue.toFixed(4))));
71
+ },
72
+ [min, max, step]
73
+ );
74
+
75
+ const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
76
+ if (disabled) return;
77
+ const clickedVal = getValueFromPointer(e.clientX);
78
+ const distToMin = Math.abs(clickedVal - minVal);
79
+ const distToMax = Math.abs(clickedVal - maxVal);
80
+
81
+ const targetThumb = distToMin <= distToMax ? 'min' : 'max';
82
+ draggingThumbRef.current = targetThumb;
83
+
84
+ if (targetThumb === 'min') {
85
+ updateValues(Math.min(clickedVal, maxVal), maxVal);
86
+ } else {
87
+ updateValues(minVal, Math.max(clickedVal, minVal));
88
+ }
89
+
90
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
91
+ };
92
+
93
+ const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
94
+ if (disabled || !draggingThumbRef.current) return;
95
+ const moveVal = getValueFromPointer(e.clientX);
96
+
97
+ if (draggingThumbRef.current === 'min') {
98
+ updateValues(Math.min(moveVal, maxVal), maxVal);
99
+ } else {
100
+ updateValues(minVal, Math.max(moveVal, minVal));
101
+ }
102
+ };
103
+
104
+ const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
105
+ if (draggingThumbRef.current) {
106
+ draggingThumbRef.current = null;
107
+ try {
108
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
109
+ } catch {
110
+ // ignore
111
+ }
112
+ }
113
+ };
114
+
115
+ const handleKeyDownMin = (e: React.KeyboardEvent) => {
116
+ if (disabled) return;
117
+ let nextMin = minVal;
118
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
119
+ e.preventDefault();
120
+ nextMin = Math.min(minVal + step, maxVal);
121
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
122
+ e.preventDefault();
123
+ nextMin = Math.max(minVal - step, min);
124
+ } else if (e.key === 'Home') {
125
+ e.preventDefault();
126
+ nextMin = min;
127
+ } else if (e.key === 'End') {
128
+ e.preventDefault();
129
+ nextMin = maxVal;
130
+ }
131
+ if (nextMin !== minVal) {
132
+ updateValues(nextMin, maxVal);
133
+ }
134
+ };
135
+
136
+ const handleKeyDownMax = (e: React.KeyboardEvent) => {
137
+ if (disabled) return;
138
+ let nextMax = maxVal;
139
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
140
+ e.preventDefault();
141
+ nextMax = Math.min(maxVal + step, max);
142
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
143
+ e.preventDefault();
144
+ nextMax = Math.max(maxVal - step, minVal);
145
+ } else if (e.key === 'Home') {
146
+ e.preventDefault();
147
+ nextMax = minVal;
148
+ } else if (e.key === 'End') {
149
+ e.preventDefault();
150
+ nextMax = max;
151
+ }
152
+ if (nextMax !== maxVal) {
153
+ updateValues(minVal, nextMax);
154
+ }
155
+ };
156
+
157
+ const minPercentage = Math.min(100, Math.max(0, ((minVal - min) / (max - min || 1)) * 100));
158
+ const maxPercentage = Math.min(100, Math.max(0, ((maxVal - min) / (max - min || 1)) * 100));
159
+
160
+ return (
161
+ <div
162
+ ref={ref}
163
+ className={cn('w-full flex items-center gap-4', disabled && 'opacity-50 cursor-not-allowed', className)}
164
+ >
165
+ <div
166
+ ref={trackRef}
167
+ onPointerDown={handlePointerDown}
168
+ onPointerMove={handlePointerMove}
169
+ onPointerUp={handlePointerUp}
170
+ className="relative w-full flex items-center h-7 select-none touch-none cursor-pointer"
171
+ >
172
+ {/* Base rail */}
173
+ <div className="w-full h-2 rounded-full bg-[var(--ui-border-default)] overflow-hidden relative">
174
+ {/* Active Range Fill */}
175
+ <div
176
+ className="absolute h-full bg-[var(--ui-primary)]"
177
+ style={{
178
+ left: `${minPercentage}%`,
179
+ width: `${Math.max(0, maxPercentage - minPercentage)}%`,
180
+ }}
181
+ />
182
+ </div>
183
+
184
+ {/* Hidden inputs for form submission if name provided */}
185
+ {name && (
186
+ <>
187
+ <input type="hidden" name={`${name}_min`} value={minVal} />
188
+ <input type="hidden" name={`${name}_max`} value={maxVal} />
189
+ </>
190
+ )}
191
+
192
+ {/* Min thumb */}
193
+ <div
194
+ role="slider"
195
+ tabIndex={disabled ? -1 : 0}
196
+ aria-label={`${ariaLabel} minimum`}
197
+ aria-valuemin={min}
198
+ aria-valuemax={maxVal}
199
+ aria-valuenow={minVal}
200
+ aria-disabled={disabled}
201
+ onKeyDown={handleKeyDownMin}
202
+ className={cn(
203
+ 'absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md -translate-x-1/2 z-20 cursor-grab active:cursor-grabbing transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] focus-visible:ring-offset-2',
204
+ disabled && 'pointer-events-none'
205
+ )}
206
+ style={{ left: `${minPercentage}%` }}
207
+ />
208
+
209
+ {/* Max thumb */}
210
+ <div
211
+ role="slider"
212
+ tabIndex={disabled ? -1 : 0}
213
+ aria-label={`${ariaLabel} maximum`}
214
+ aria-valuemin={minVal}
215
+ aria-valuemax={max}
216
+ aria-valuenow={maxVal}
217
+ aria-disabled={disabled}
218
+ onKeyDown={handleKeyDownMax}
219
+ className={cn(
220
+ 'absolute w-5 h-5 rounded-full bg-white border-2 border-[var(--ui-primary)] shadow-md -translate-x-1/2 z-20 cursor-grab active:cursor-grabbing transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)] focus-visible:ring-offset-2',
221
+ disabled && 'pointer-events-none'
222
+ )}
223
+ style={{ left: `${maxPercentage}%` }}
224
+ />
225
+ </div>
226
+
227
+ {showValue && (
228
+ <span className="text-xs font-semibold font-mono text-[var(--ui-text-secondary)] min-w-[5rem] text-right">
229
+ {formatValue(minVal)} - {formatValue(maxVal)}
230
+ </span>
231
+ )}
232
+ </div>
233
+ );
234
+ }
235
+ );
236
+
237
+ RangeSlider.displayName = 'RangeSlider';