@thangnqdev/ui 0.1.2 → 0.2.1

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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/bento/styles.css +2 -46
  7. package/dist/buttons/index.cjs +111 -18
  8. package/dist/buttons/index.cjs.map +1 -1
  9. package/dist/buttons/index.d.cts +1 -1
  10. package/dist/buttons/index.d.ts +1 -1
  11. package/dist/buttons/index.js +3 -3
  12. package/dist/buttons/styles.css +2 -6
  13. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  14. package/dist/chunk-2YJBW4E2.js.map +1 -0
  15. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  16. package/dist/chunk-4CRFNALF.js.map +1 -0
  17. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  18. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  19. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  20. package/dist/chunk-77KTYOMQ.js.map +1 -0
  21. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  22. package/dist/chunk-ADO3SVUA.js.map +1 -0
  23. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  24. package/dist/chunk-D7FB3TCA.js.map +1 -0
  25. package/dist/chunk-DSEL7ELZ.js +314 -0
  26. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  27. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  28. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  29. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  30. package/dist/chunk-GXWB6BSY.js.map +1 -0
  31. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  32. package/dist/chunk-I2FQBMVW.js.map +1 -0
  33. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  34. package/dist/chunk-I5O2WCGF.js.map +1 -0
  35. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  36. package/dist/chunk-ME4XILG5.js.map +1 -0
  37. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  38. package/dist/chunk-PC5SZFZC.js.map +1 -0
  39. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  40. package/dist/chunk-S6XJMYME.js.map +1 -0
  41. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  42. package/dist/chunk-V52JOSDF.js.map +1 -0
  43. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  44. package/dist/chunk-W5WNWTQC.js.map +1 -0
  45. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  46. package/dist/chunk-YJ64RI7J.js.map +1 -0
  47. package/dist/chunk-YOCGBORV.js +155 -0
  48. package/dist/chunk-YOCGBORV.js.map +1 -0
  49. package/dist/chunk-ZEDXARXW.js +96 -0
  50. package/dist/chunk-ZEDXARXW.js.map +1 -0
  51. package/dist/components.css +2 -0
  52. package/dist/core/index.cjs +33 -24
  53. package/dist/core/index.cjs.map +1 -1
  54. package/dist/core/index.js +2 -2
  55. package/dist/core/styles.css +2 -134
  56. package/dist/data-display/index.cjs +770 -247
  57. package/dist/data-display/index.cjs.map +1 -1
  58. package/dist/data-display/index.d.cts +1 -1
  59. package/dist/data-display/index.d.ts +1 -1
  60. package/dist/data-display/index.js +5 -4
  61. package/dist/data-display/styles.css +2 -4
  62. package/dist/feedback/index.cjs.map +1 -1
  63. package/dist/feedback/index.js +2 -2
  64. package/dist/feedback/styles.css +2 -4
  65. package/dist/forms/index.cjs +1211 -459
  66. package/dist/forms/index.cjs.map +1 -1
  67. package/dist/forms/index.d.cts +1 -1
  68. package/dist/forms/index.d.ts +1 -1
  69. package/dist/forms/index.js +8 -8
  70. package/dist/forms/styles.css +2 -6
  71. package/dist/glass/index.cjs.map +1 -1
  72. package/dist/glass/index.js +2 -2
  73. package/dist/glass/styles.css +2 -81
  74. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  75. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  76. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  77. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  78. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  79. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  80. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  81. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  82. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  83. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  84. package/dist/index.cjs +2481 -917
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +5 -5
  87. package/dist/index.d.ts +5 -5
  88. package/dist/index.js +19 -19
  89. package/dist/layout/index.cjs.map +1 -1
  90. package/dist/layout/index.js +2 -2
  91. package/dist/layout/styles.css +2 -8
  92. package/dist/navigation/index.cjs +288 -48
  93. package/dist/navigation/index.cjs.map +1 -1
  94. package/dist/navigation/index.d.cts +1 -1
  95. package/dist/navigation/index.d.ts +1 -1
  96. package/dist/navigation/index.js +6 -6
  97. package/dist/navigation/styles.css +2 -4
  98. package/dist/overlays/index.cjs +566 -229
  99. package/dist/overlays/index.cjs.map +1 -1
  100. package/dist/overlays/index.d.cts +1 -1
  101. package/dist/overlays/index.d.ts +1 -1
  102. package/dist/overlays/index.js +7 -7
  103. package/dist/overlays/styles.css +2 -8
  104. package/dist/reset.css +45 -0
  105. package/dist/styles.css +1 -1
  106. package/dist/tokens.css +11 -0
  107. package/dist/typography/index.cjs.map +1 -1
  108. package/dist/typography/index.js +4 -4
  109. package/dist/typography/styles.css +2 -5
  110. package/llms.txt +211 -113
  111. package/package.json +167 -116
  112. package/scripts/postinstall.js +125 -0
  113. package/src/bento/BentoCard.tsx +140 -140
  114. package/src/bento/BentoGrid.tsx +50 -50
  115. package/src/bento/index.ts +2 -2
  116. package/src/bento/tokens.css +46 -46
  117. package/src/buttons/Button.tsx +104 -104
  118. package/src/buttons/ButtonGroup.tsx +43 -43
  119. package/src/buttons/IconButton.tsx +79 -79
  120. package/src/buttons/SplitButton.tsx +186 -86
  121. package/src/buttons/ToggleButton.tsx +157 -157
  122. package/src/buttons/index.ts +5 -5
  123. package/src/buttons/tokens.css +6 -6
  124. package/src/core/ThemeProvider.tsx +135 -123
  125. package/src/core/index.ts +2 -2
  126. package/src/core/tokens.css +134 -134
  127. package/src/data-display/Accordion.tsx +147 -147
  128. package/src/data-display/Avatar.tsx +134 -134
  129. package/src/data-display/Card.tsx +112 -112
  130. package/src/data-display/Chip.tsx +74 -74
  131. package/src/data-display/DataTablePro.tsx +299 -121
  132. package/src/data-display/StatCard.tsx +60 -60
  133. package/src/data-display/Table.tsx +74 -74
  134. package/src/data-display/Timeline.tsx +62 -62
  135. package/src/data-display/TreeView.tsx +243 -104
  136. package/src/data-display/tokens.css +4 -4
  137. package/src/feedback/Alert.tsx +80 -80
  138. package/src/feedback/EmptyState.tsx +45 -45
  139. package/src/feedback/Skeleton.tsx +66 -66
  140. package/src/feedback/Spinner.tsx +65 -65
  141. package/src/feedback/index.ts +5 -5
  142. package/src/feedback/tokens.css +4 -4
  143. package/src/forms/Checkbox.tsx +149 -69
  144. package/src/forms/CheckboxGroup.tsx +76 -29
  145. package/src/forms/Combobox.tsx +172 -28
  146. package/src/forms/DatePicker.tsx +385 -81
  147. package/src/forms/FileUpload.tsx +51 -7
  148. package/src/forms/FormField.tsx +115 -115
  149. package/src/forms/Input.tsx +151 -151
  150. package/src/forms/NumberInput.tsx +34 -20
  151. package/src/forms/PasswordInput.tsx +35 -35
  152. package/src/forms/PinInput.tsx +139 -125
  153. package/src/forms/Radio.tsx +142 -142
  154. package/src/forms/RangeSlider.tsx +237 -111
  155. package/src/forms/SearchInput.tsx +34 -34
  156. package/src/forms/Select.tsx +103 -103
  157. package/src/forms/Slider.tsx +85 -85
  158. package/src/forms/Textarea.tsx +114 -114
  159. package/src/forms/tokens.css +6 -6
  160. package/src/glass/GlassButton.tsx +59 -59
  161. package/src/glass/GlassCard.tsx +71 -71
  162. package/src/glass/index.ts +3 -3
  163. package/src/glass/tokens.css +81 -81
  164. package/src/index.ts +26 -26
  165. package/src/layout/AspectRatio.tsx +39 -39
  166. package/src/layout/Container.tsx +53 -53
  167. package/src/layout/Divider.tsx +61 -61
  168. package/src/layout/Flex.tsx +79 -79
  169. package/src/layout/Grid.tsx +124 -124
  170. package/src/layout/PageLayout.tsx +59 -59
  171. package/src/layout/Section.tsx +47 -47
  172. package/src/layout/Spacer.tsx +30 -30
  173. package/src/layout/Stack.tsx +88 -88
  174. package/src/layout/tokens.css +8 -8
  175. package/src/navigation/Breadcrumb.tsx +73 -73
  176. package/src/navigation/CommandPalette.tsx +142 -142
  177. package/src/navigation/DropdownMenu.tsx +244 -160
  178. package/src/navigation/Pagination.tsx +114 -114
  179. package/src/navigation/Stepper.tsx +93 -93
  180. package/src/navigation/Tabs.tsx +265 -133
  181. package/src/navigation/index.ts +6 -6
  182. package/src/navigation/tokens.css +4 -4
  183. package/src/overlays/AlertDialog.tsx +63 -54
  184. package/src/overlays/BottomSheet.tsx +30 -10
  185. package/src/overlays/ContextMenu.tsx +51 -9
  186. package/src/overlays/Drawer.tsx +160 -145
  187. package/src/overlays/FocusTrap.tsx +122 -49
  188. package/src/overlays/Modal.tsx +296 -219
  189. package/src/overlays/Popover.tsx +55 -25
  190. package/src/overlays/Portal.tsx +23 -23
  191. package/src/overlays/Toast.tsx +175 -137
  192. package/src/overlays/Tooltip.tsx +125 -100
  193. package/src/overlays/tokens.css +8 -8
  194. package/src/registry/components.ts +1368 -0
  195. package/src/styles/components.css +39 -0
  196. package/src/styles/index.css +2 -93
  197. package/src/styles/reset.css +45 -0
  198. package/src/styles/tokens.css +11 -2
  199. package/src/typography/Blockquote.tsx +32 -32
  200. package/src/typography/Code.tsx +89 -89
  201. package/src/typography/Heading.tsx +56 -56
  202. package/src/typography/Kbd.tsx +25 -25
  203. package/src/typography/Label.tsx +47 -47
  204. package/src/typography/Link.tsx +56 -56
  205. package/src/typography/Text.tsx +62 -62
  206. package/src/typography/index.ts +7 -7
  207. package/src/typography/tokens.css +5 -5
  208. package/src/utils/cn.ts +6 -6
  209. package/src/utils/scroll-lock.ts +19 -0
  210. package/src/utils/useControllableState.ts +54 -0
  211. package/ui-contract.json +192 -35
  212. package/dist/chunk-2OXZK6IP.js.map +0 -1
  213. package/dist/chunk-4S6L5V7X.js.map +0 -1
  214. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  215. package/dist/chunk-66VWUJEI.js.map +0 -1
  216. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  217. package/dist/chunk-ATYGDTMK.js.map +0 -1
  218. package/dist/chunk-C4SUXMI3.js.map +0 -1
  219. package/dist/chunk-EU2LPUYP.js.map +0 -1
  220. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  221. package/dist/chunk-GXI2H5XA.js.map +0 -1
  222. package/dist/chunk-H3LCNPMC.js +0 -101
  223. package/dist/chunk-H3LCNPMC.js.map +0 -1
  224. package/dist/chunk-IABPUCAQ.js.map +0 -1
  225. package/dist/chunk-KJRBRTQE.js.map +0 -1
  226. package/dist/chunk-LBUBXUYQ.js +0 -40
  227. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  228. package/dist/chunk-LWFRUJRU.js.map +0 -1
  229. package/dist/chunk-NFVONJUK.js.map +0 -1
  230. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  231. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  232. package/dist/chunk-X76KBH4Y.js +0 -71
  233. 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';