@xyhp915/slack-base-ui 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -4
- package/agents/slack-base-ui/SKILL.md +137 -0
- package/agents/slack-base-ui/checklists/style-review.md +56 -0
- package/agents/slack-base-ui/templates/consumer-setup.md +109 -0
- package/agents/slack-base-ui/templates/slack-theme.css +152 -0
- package/libs/Dialog.d.ts +73 -0
- package/libs/Dialog.d.ts.map +1 -1
- package/libs/Popover.d.ts +69 -0
- package/libs/Popover.d.ts.map +1 -1
- package/libs/index.d.ts +4 -4
- package/libs/index.d.ts.map +1 -1
- package/libs/index.js +2885 -2718
- package/package.json +1 -1
- package/src/App.css +7 -0
- package/src/App.tsx +18 -0
- package/src/assets/react.svg +1 -0
- package/src/components/AlertDialog.tsx +185 -0
- package/src/components/AutoComplete.tsx +311 -0
- package/src/components/Avatar.tsx +70 -0
- package/src/components/Badge.tsx +48 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/Checkbox.tsx +109 -0
- package/src/components/ContextMenu.tsx +393 -0
- package/src/components/Dialog.tsx +371 -0
- package/src/components/Form.tsx +409 -0
- package/src/components/IconButton.tsx +49 -0
- package/src/components/Input.tsx +56 -0
- package/src/components/Loading.tsx +123 -0
- package/src/components/Menu.tsx +368 -0
- package/src/components/Popover.tsx +367 -0
- package/src/components/Progress.tsx +89 -0
- package/src/components/Radio.tsx +137 -0
- package/src/components/Select.tsx +177 -0
- package/src/components/Switch.tsx +116 -0
- package/src/components/Tabs.tsx +128 -0
- package/src/components/Toast.tsx +149 -0
- package/src/components/Tooltip.tsx +46 -0
- package/src/components/index.ts +186 -0
- package/src/context/ThemeContext.tsx +53 -0
- package/src/context/useTheme.ts +11 -0
- package/src/examples/slack-clone/SlackApp.tsx +94 -0
- package/src/examples/slack-clone/components/ChannelHeader.tsx +34 -0
- package/src/examples/slack-clone/components/Composer.tsx +42 -0
- package/src/examples/slack-clone/components/Message.tsx +97 -0
- package/src/examples/slack-clone/components/UserProfile.tsx +78 -0
- package/src/examples/slack-clone/layout/Layout.tsx +27 -0
- package/src/examples/slack-clone/layout/Sidebar.tsx +67 -0
- package/src/examples/slack-clone/layout/SidebarItem.tsx +57 -0
- package/src/examples/slack-clone/layout/TopBar.tsx +30 -0
- package/src/index.css +240 -0
- package/src/main.tsx +22 -0
- package/src/pages/ComponentShowcase.tsx +1964 -0
- package/src/pages/Dashboard.tsx +87 -0
- package/src/pages/QuickStartDemo.tsx +262 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
|
2
|
+
import { Popover as BasePopover } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
|
|
5
|
+
export interface PopoverProps {
|
|
6
|
+
children: React.ReactNode
|
|
7
|
+
open?: boolean
|
|
8
|
+
defaultOpen?: boolean
|
|
9
|
+
onOpenChange?: (open: boolean) => void
|
|
10
|
+
modal?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PopoverTriggerProps {
|
|
14
|
+
children?: React.ReactNode
|
|
15
|
+
className?: string
|
|
16
|
+
render?: React.ReactElement
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PopoverContentProps {
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
23
|
+
align?: 'start' | 'center' | 'end'
|
|
24
|
+
sideOffset?: number
|
|
25
|
+
alignOffset?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PopoverCloseProps {
|
|
29
|
+
children?: React.ReactNode
|
|
30
|
+
className?: string
|
|
31
|
+
render?: React.ReactElement
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Popover: React.FC<PopoverProps> = ({
|
|
35
|
+
children,
|
|
36
|
+
open,
|
|
37
|
+
defaultOpen,
|
|
38
|
+
onOpenChange,
|
|
39
|
+
modal = false,
|
|
40
|
+
}) => {
|
|
41
|
+
return (
|
|
42
|
+
<BasePopover.Root
|
|
43
|
+
open={open}
|
|
44
|
+
defaultOpen={defaultOpen}
|
|
45
|
+
onOpenChange={onOpenChange}
|
|
46
|
+
modal={modal}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</BasePopover.Root>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const PopoverTrigger = React.forwardRef<
|
|
54
|
+
HTMLButtonElement,
|
|
55
|
+
PopoverTriggerProps
|
|
56
|
+
>(({ children, className, render }, ref) => {
|
|
57
|
+
// Wrap with `contents` span to isolate the hidden accessibility <span> nodes
|
|
58
|
+
// that @base-ui/react injects as siblings of the trigger. Without this wrapper,
|
|
59
|
+
// those fixed-position spans become siblings in the parent container and break
|
|
60
|
+
// Tailwind's `space-y-*` selectors (`:not(:last-child)`) causing layout shifts.
|
|
61
|
+
if (render) {
|
|
62
|
+
return (
|
|
63
|
+
<span className="contents">
|
|
64
|
+
<BasePopover.Trigger
|
|
65
|
+
ref={ref}
|
|
66
|
+
className={clsx('outline-none', className)}
|
|
67
|
+
render={render}
|
|
68
|
+
/>
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<span className="contents">
|
|
75
|
+
<BasePopover.Trigger
|
|
76
|
+
ref={ref}
|
|
77
|
+
className={clsx('outline-none', className)}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</BasePopover.Trigger>
|
|
81
|
+
</span>
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
PopoverTrigger.displayName = 'PopoverTrigger'
|
|
86
|
+
|
|
87
|
+
export const PopoverContent: React.FC<PopoverContentProps> = ({
|
|
88
|
+
children,
|
|
89
|
+
className,
|
|
90
|
+
side = 'bottom',
|
|
91
|
+
align = 'center',
|
|
92
|
+
sideOffset = 8,
|
|
93
|
+
alignOffset = 0,
|
|
94
|
+
}) => {
|
|
95
|
+
return (
|
|
96
|
+
<BasePopover.Portal>
|
|
97
|
+
<BasePopover.Positioner
|
|
98
|
+
side={side}
|
|
99
|
+
align={align}
|
|
100
|
+
sideOffset={sideOffset}
|
|
101
|
+
alignOffset={alignOffset}
|
|
102
|
+
>
|
|
103
|
+
<BasePopover.Popup
|
|
104
|
+
className={clsx(
|
|
105
|
+
'z-50 min-w-50 max-w-90 rounded-lg border border-(--border-light) bg-(--bg-primary) shadow-lg',
|
|
106
|
+
className
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
<BasePopover.Arrow className="fill-(--bg-primary) stroke-(--border-light)" />
|
|
111
|
+
</BasePopover.Popup>
|
|
112
|
+
</BasePopover.Positioner>
|
|
113
|
+
</BasePopover.Portal>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
PopoverContent.displayName = 'PopoverContent'
|
|
118
|
+
|
|
119
|
+
export const PopoverClose = React.forwardRef<
|
|
120
|
+
HTMLButtonElement,
|
|
121
|
+
PopoverCloseProps
|
|
122
|
+
>(({ children, className, render }, ref) => {
|
|
123
|
+
if (render) {
|
|
124
|
+
return (
|
|
125
|
+
<BasePopover.Close
|
|
126
|
+
ref={ref}
|
|
127
|
+
className={clsx('outline-none', className)}
|
|
128
|
+
render={render}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<BasePopover.Close
|
|
135
|
+
ref={ref}
|
|
136
|
+
className={clsx('outline-none', className)}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</BasePopover.Close>
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
PopoverClose.displayName = 'PopoverClose'
|
|
144
|
+
|
|
145
|
+
// Convenience components for common Popover content patterns
|
|
146
|
+
export const PopoverHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
|
147
|
+
children,
|
|
148
|
+
className,
|
|
149
|
+
}) => {
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className={clsx(
|
|
153
|
+
'px-4 py-3 border-b border-(--border-light)',
|
|
154
|
+
'font-bold text-[15px] text-(--text-primary)',
|
|
155
|
+
className
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const PopoverBody: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
|
164
|
+
children,
|
|
165
|
+
className,
|
|
166
|
+
}) => {
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={clsx(
|
|
170
|
+
'px-4 py-3',
|
|
171
|
+
'text-[15px] text-(--text-primary)',
|
|
172
|
+
className
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const PopoverFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
|
181
|
+
children,
|
|
182
|
+
className,
|
|
183
|
+
}) => {
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
className={clsx(
|
|
187
|
+
'px-4 py-3 border-t border-(--border-light)',
|
|
188
|
+
'flex items-center justify-end gap-2',
|
|
189
|
+
className
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
{children}
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Imperative Popover API ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface ImperativePopoverOptions {
|
|
200
|
+
/** Content rendered inside the popover */
|
|
201
|
+
content: React.ReactNode
|
|
202
|
+
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
203
|
+
align?: 'start' | 'center' | 'end'
|
|
204
|
+
sideOffset?: number
|
|
205
|
+
alignOffset?: number
|
|
206
|
+
className?: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface UseImperativePopoverReturn {
|
|
210
|
+
/**
|
|
211
|
+
* Show the popover anchored to the given element.
|
|
212
|
+
* If already open on the same anchor, calling `show` again replaces the content.
|
|
213
|
+
*/
|
|
214
|
+
show: (anchor: HTMLElement | null, options: ImperativePopoverOptions) => void
|
|
215
|
+
/** Hide the currently open popover. */
|
|
216
|
+
hide: () => void
|
|
217
|
+
/**
|
|
218
|
+
* Toggle the popover for the given anchor.
|
|
219
|
+
* Closes it if the same anchor is already open; otherwise opens on the new anchor.
|
|
220
|
+
*/
|
|
221
|
+
toggle: (anchor: HTMLElement | null, options: ImperativePopoverOptions) => void
|
|
222
|
+
/** Whether the imperative popover is currently open. */
|
|
223
|
+
isOpen: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ImperativePopoverState {
|
|
227
|
+
open: boolean
|
|
228
|
+
anchor: HTMLElement | null
|
|
229
|
+
options: ImperativePopoverOptions
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ImperativePopoverContext = createContext<UseImperativePopoverReturn | null>(null)
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Provides the imperative popover API to all descendant components.
|
|
236
|
+
* Only one popover is shown at a time (singleton).
|
|
237
|
+
* Must wrap any component that calls `useImperativePopover()`.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```tsx
|
|
241
|
+
* // main.tsx
|
|
242
|
+
* <ImperativePopoverProvider>
|
|
243
|
+
* <App />
|
|
244
|
+
* </ImperativePopoverProvider>
|
|
245
|
+
*
|
|
246
|
+
* // Any component
|
|
247
|
+
* const popover = useImperativePopover()
|
|
248
|
+
*
|
|
249
|
+
* <button onClick={e => popover.show(e.currentTarget, { content: <MyPanel /> })}>
|
|
250
|
+
* Open
|
|
251
|
+
* </button>
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
export const ImperativePopoverProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
255
|
+
children,
|
|
256
|
+
}) => {
|
|
257
|
+
const [state, setState] = useState<ImperativePopoverState>({
|
|
258
|
+
open: false,
|
|
259
|
+
anchor: null,
|
|
260
|
+
options: { content: null },
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const show = useCallback(
|
|
264
|
+
(anchor: HTMLElement | null, options: ImperativePopoverOptions) => {
|
|
265
|
+
setState({ open: true, anchor, options })
|
|
266
|
+
},
|
|
267
|
+
[],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const hide = useCallback(() => {
|
|
271
|
+
setState(prev => ({ ...prev, open: false }))
|
|
272
|
+
}, [])
|
|
273
|
+
|
|
274
|
+
const toggle = useCallback(
|
|
275
|
+
(anchor: HTMLElement | null, options: ImperativePopoverOptions) => {
|
|
276
|
+
setState(prev => {
|
|
277
|
+
if (prev.open && prev.anchor === anchor) {
|
|
278
|
+
return { ...prev, open: false }
|
|
279
|
+
}
|
|
280
|
+
return { open: true, anchor, options }
|
|
281
|
+
})
|
|
282
|
+
},
|
|
283
|
+
[],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const value = useMemo<UseImperativePopoverReturn>(
|
|
287
|
+
() => ({ show, hide, toggle, isOpen: state.open }),
|
|
288
|
+
[show, hide, toggle, state.open],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<ImperativePopoverContext.Provider value={value}>
|
|
293
|
+
{children}
|
|
294
|
+
<ImperativePopoverPortal state={state} onClose={hide} />
|
|
295
|
+
</ImperativePopoverContext.Provider>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
ImperativePopoverProvider.displayName = 'ImperativePopoverProvider'
|
|
300
|
+
|
|
301
|
+
function ImperativePopoverPortal({
|
|
302
|
+
state,
|
|
303
|
+
onClose,
|
|
304
|
+
}: {
|
|
305
|
+
state: ImperativePopoverState
|
|
306
|
+
onClose: () => void
|
|
307
|
+
}) {
|
|
308
|
+
if (!state.open || !state.anchor) return null
|
|
309
|
+
|
|
310
|
+
const { options } = state
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<BasePopover.Root open={state.open} onOpenChange={open => !open && onClose()}>
|
|
314
|
+
<BasePopover.Portal>
|
|
315
|
+
<BasePopover.Positioner
|
|
316
|
+
anchor={state.anchor}
|
|
317
|
+
side={options.side ?? 'bottom'}
|
|
318
|
+
align={options.align ?? 'center'}
|
|
319
|
+
sideOffset={options.sideOffset ?? 8}
|
|
320
|
+
alignOffset={options.alignOffset ?? 0}
|
|
321
|
+
>
|
|
322
|
+
<BasePopover.Popup
|
|
323
|
+
className={clsx(
|
|
324
|
+
'z-50 min-w-50 max-w-90 rounded-lg border border-(--border-light) bg-(--bg-primary) shadow-lg',
|
|
325
|
+
options.className,
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{options.content}
|
|
329
|
+
<BasePopover.Arrow className="fill-(--bg-primary) stroke-(--border-light)" />
|
|
330
|
+
</BasePopover.Popup>
|
|
331
|
+
</BasePopover.Positioner>
|
|
332
|
+
</BasePopover.Portal>
|
|
333
|
+
</BasePopover.Root>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Returns imperative methods for showing a popover anchored to any element.
|
|
339
|
+
*
|
|
340
|
+
* Must be used inside `<ImperativePopoverProvider>`.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```tsx
|
|
344
|
+
* const popover = useImperativePopover()
|
|
345
|
+
*
|
|
346
|
+
* // Show on button click
|
|
347
|
+
* <button onClick={e => popover.show(e.currentTarget, { content: <ProfileCard />, side: 'bottom' })}>
|
|
348
|
+
* Profile
|
|
349
|
+
* </button>
|
|
350
|
+
*
|
|
351
|
+
* // Toggle
|
|
352
|
+
* <button onClick={e => popover.toggle(e.currentTarget, { content: <Settings /> })}>
|
|
353
|
+
* Settings
|
|
354
|
+
* </button>
|
|
355
|
+
* ```
|
|
356
|
+
*/
|
|
357
|
+
export function useImperativePopover(): UseImperativePopoverReturn {
|
|
358
|
+
const ctx = useContext(ImperativePopoverContext)
|
|
359
|
+
if (!ctx) throw new Error('useImperativePopover must be used within <ImperativePopoverProvider>')
|
|
360
|
+
return ctx
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Progress as BaseProgress } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
|
|
5
|
+
export type ProgressVariant = 'default' | 'success' | 'warning' | 'danger'
|
|
6
|
+
export type ProgressSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
|
|
8
|
+
export interface ProgressProps {
|
|
9
|
+
/** Current value (0–max). Omit for indeterminate state. */
|
|
10
|
+
value?: number
|
|
11
|
+
/** Maximum value. Defaults to 100. */
|
|
12
|
+
max?: number
|
|
13
|
+
variant?: ProgressVariant
|
|
14
|
+
size?: ProgressSize
|
|
15
|
+
/** Label shown above the bar */
|
|
16
|
+
label?: string
|
|
17
|
+
/** Show percentage text on the right */
|
|
18
|
+
showValue?: boolean
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const variantColor: Record<ProgressVariant, string> = {
|
|
23
|
+
default: 'bg-(--accent)',
|
|
24
|
+
success: 'bg-(--slack-green)',
|
|
25
|
+
warning: 'bg-amber-400',
|
|
26
|
+
danger: 'bg-(--danger)',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const trackSizes: Record<ProgressSize, string> = {
|
|
30
|
+
sm: 'h-1',
|
|
31
|
+
md: 'h-2',
|
|
32
|
+
lg: 'h-3',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|
36
|
+
(
|
|
37
|
+
{
|
|
38
|
+
value,
|
|
39
|
+
max = 100,
|
|
40
|
+
variant = 'default',
|
|
41
|
+
size = 'md',
|
|
42
|
+
label,
|
|
43
|
+
showValue,
|
|
44
|
+
className,
|
|
45
|
+
},
|
|
46
|
+
ref,
|
|
47
|
+
) => {
|
|
48
|
+
return (
|
|
49
|
+
<div className={clsx('flex flex-col gap-1.5', className)}>
|
|
50
|
+
{(label || showValue) && (
|
|
51
|
+
<div className="flex items-center justify-between gap-2">
|
|
52
|
+
{label && (
|
|
53
|
+
<span className="text-[13px] font-medium text-(--text-secondary)">{label}</span>
|
|
54
|
+
)}
|
|
55
|
+
{showValue && value !== undefined && (
|
|
56
|
+
<span className="text-[12px] text-(--text-muted)">
|
|
57
|
+
{Math.round((value / max) * 100)}%
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
<BaseProgress.Root
|
|
64
|
+
ref={ref}
|
|
65
|
+
value={value ?? null}
|
|
66
|
+
max={max}
|
|
67
|
+
className={clsx(
|
|
68
|
+
'w-full overflow-hidden rounded-full bg-(--bg-hover)',
|
|
69
|
+
trackSizes[size],
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<BaseProgress.Track className="relative h-full w-full">
|
|
73
|
+
<BaseProgress.Indicator
|
|
74
|
+
className={clsx(
|
|
75
|
+
'h-full rounded-full transition-[width] duration-300 ease-out',
|
|
76
|
+
variantColor[variant],
|
|
77
|
+
// Indeterminate animation when value is null
|
|
78
|
+
value === undefined &&
|
|
79
|
+
'w-1/3 animate-[progress-indeterminate_1.5s_ease-in-out_infinite]',
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
</BaseProgress.Track>
|
|
83
|
+
</BaseProgress.Root>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
Progress.displayName = 'Progress'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Radio as BaseRadio, RadioGroup as BaseRadioGroup } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
|
|
5
|
+
// ── RadioGroup ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface RadioGroupProps {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
value?: string
|
|
10
|
+
defaultValue?: string
|
|
11
|
+
onValueChange?: (value: string) => void
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
required?: boolean
|
|
14
|
+
/** Group label shown above the radios */
|
|
15
|
+
label?: string
|
|
16
|
+
/** Error message */
|
|
17
|
+
error?: string
|
|
18
|
+
/** Arrange items horizontally instead of vertically */
|
|
19
|
+
orientation?: 'horizontal' | 'vertical'
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
24
|
+
(
|
|
25
|
+
{
|
|
26
|
+
children,
|
|
27
|
+
value,
|
|
28
|
+
defaultValue,
|
|
29
|
+
onValueChange,
|
|
30
|
+
disabled,
|
|
31
|
+
required,
|
|
32
|
+
label,
|
|
33
|
+
error,
|
|
34
|
+
orientation = 'vertical',
|
|
35
|
+
className,
|
|
36
|
+
},
|
|
37
|
+
ref,
|
|
38
|
+
) => {
|
|
39
|
+
return (
|
|
40
|
+
<div className={clsx('flex flex-col gap-1.5', className)}>
|
|
41
|
+
{label && (
|
|
42
|
+
<span className="text-[14px] font-semibold text-(--text-primary)">
|
|
43
|
+
{label}
|
|
44
|
+
{required && <span className="ml-0.5 text-(--danger)">*</span>}
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
<BaseRadioGroup
|
|
49
|
+
ref={ref}
|
|
50
|
+
value={value}
|
|
51
|
+
defaultValue={defaultValue}
|
|
52
|
+
onValueChange={onValueChange}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
required={required}
|
|
55
|
+
className={clsx(
|
|
56
|
+
'flex gap-3',
|
|
57
|
+
orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</BaseRadioGroup>
|
|
62
|
+
|
|
63
|
+
{error && (
|
|
64
|
+
<span className="flex items-center gap-1 text-[12px] font-medium leading-tight text-(--danger)">
|
|
65
|
+
⚠️ {error}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
RadioGroup.displayName = 'RadioGroup'
|
|
74
|
+
|
|
75
|
+
// ── Radio ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface RadioProps {
|
|
78
|
+
value: string
|
|
79
|
+
disabled?: boolean
|
|
80
|
+
/** Label text shown next to the radio */
|
|
81
|
+
label?: string
|
|
82
|
+
/** Helper text shown below the label */
|
|
83
|
+
description?: string
|
|
84
|
+
id?: string
|
|
85
|
+
className?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const Radio = React.forwardRef<HTMLButtonElement, RadioProps>(
|
|
89
|
+
({ value, disabled, label, description, id, className }, ref) => {
|
|
90
|
+
const generatedId = React.useId()
|
|
91
|
+
const radioId = id ?? generatedId
|
|
92
|
+
|
|
93
|
+
const radioEl = (
|
|
94
|
+
<BaseRadio.Root
|
|
95
|
+
ref={ref}
|
|
96
|
+
id={radioId}
|
|
97
|
+
value={value}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
className={clsx(
|
|
100
|
+
'relative flex h-4 w-4 shrink-0 items-center justify-center rounded-full',
|
|
101
|
+
'border-2 border-(--border-gray) bg-(--bg-primary)',
|
|
102
|
+
'transition-[background-color,border-color] outline-none',
|
|
103
|
+
'focus-visible:ring-2 focus-visible:ring-(--focus-ring) focus-visible:ring-offset-1',
|
|
104
|
+
'data-[checked]:border-(--accent)',
|
|
105
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
106
|
+
className,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<BaseRadio.Indicator className="h-2 w-2 rounded-full bg-(--accent) data-[unchecked]:hidden" />
|
|
110
|
+
</BaseRadio.Root>
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if (!label) return radioEl
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="flex items-start gap-2">
|
|
117
|
+
{radioEl}
|
|
118
|
+
<div className="flex flex-col">
|
|
119
|
+
<label
|
|
120
|
+
htmlFor={radioId}
|
|
121
|
+
className={clsx(
|
|
122
|
+
'text-[14px] leading-none text-(--text-primary) select-none',
|
|
123
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{label}
|
|
127
|
+
</label>
|
|
128
|
+
{description && (
|
|
129
|
+
<span className="mt-0.5 text-[12px] text-(--text-muted)">{description}</span>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
Radio.displayName = 'Radio'
|