@xyhp915/slack-base-ui 0.0.1 → 0.0.2
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/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 +129 -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 +200 -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 +165 -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 +16 -0
- package/src/pages/ComponentShowcase.tsx +1618 -0
- package/src/pages/Dashboard.tsx +87 -0
- package/src/pages/QuickStartDemo.tsx +262 -0
|
@@ -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'
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Select as BaseSelect } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
export interface SelectOption {
|
|
7
|
+
value: string
|
|
8
|
+
label: string
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SelectGroup {
|
|
13
|
+
label: string
|
|
14
|
+
options: SelectOption[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectProps {
|
|
18
|
+
/** Option items (flat list or grouped) */
|
|
19
|
+
options?: SelectOption[]
|
|
20
|
+
groups?: SelectGroup[]
|
|
21
|
+
value?: string
|
|
22
|
+
defaultValue?: string
|
|
23
|
+
onValueChange?: (value: string | null) => void
|
|
24
|
+
placeholder?: string
|
|
25
|
+
disabled?: boolean
|
|
26
|
+
required?: boolean
|
|
27
|
+
/** Label shown above the trigger */
|
|
28
|
+
label?: string
|
|
29
|
+
/** Error message shown below the trigger */
|
|
30
|
+
error?: string
|
|
31
|
+
/** Full width */
|
|
32
|
+
fullWidth?: boolean
|
|
33
|
+
className?: string
|
|
34
|
+
id?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const itemClass = clsx(
|
|
38
|
+
'relative flex cursor-default select-none items-center rounded px-3 py-1.5 text-[14px] text-(--text-primary)',
|
|
39
|
+
'data-[highlighted]:bg-(--bg-hover) data-[highlighted]:outline-none',
|
|
40
|
+
'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
|
44
|
+
(
|
|
45
|
+
{
|
|
46
|
+
options,
|
|
47
|
+
groups,
|
|
48
|
+
value,
|
|
49
|
+
defaultValue,
|
|
50
|
+
onValueChange,
|
|
51
|
+
placeholder = 'Select an option',
|
|
52
|
+
disabled,
|
|
53
|
+
required,
|
|
54
|
+
label,
|
|
55
|
+
error,
|
|
56
|
+
fullWidth,
|
|
57
|
+
className,
|
|
58
|
+
id,
|
|
59
|
+
},
|
|
60
|
+
ref,
|
|
61
|
+
) => {
|
|
62
|
+
const generatedId = React.useId()
|
|
63
|
+
const triggerId = id ?? generatedId
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
|
|
67
|
+
{label && (
|
|
68
|
+
<label
|
|
69
|
+
htmlFor={triggerId}
|
|
70
|
+
className="text-[14px] font-semibold text-(--text-primary)"
|
|
71
|
+
>
|
|
72
|
+
{label}
|
|
73
|
+
{required && <span className="ml-0.5 text-(--danger)">*</span>}
|
|
74
|
+
</label>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<BaseSelect.Root
|
|
78
|
+
value={value}
|
|
79
|
+
defaultValue={defaultValue}
|
|
80
|
+
onValueChange={onValueChange}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
required={required}
|
|
83
|
+
>
|
|
84
|
+
<BaseSelect.Trigger
|
|
85
|
+
id={triggerId}
|
|
86
|
+
ref={ref}
|
|
87
|
+
className={clsx(
|
|
88
|
+
'inline-flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 text-[14px]',
|
|
89
|
+
'bg-(--bg-primary) text-(--text-primary)',
|
|
90
|
+
'transition-[border-color,box-shadow] outline-none',
|
|
91
|
+
'focus:border-(--accent)/70',
|
|
92
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
93
|
+
error
|
|
94
|
+
? 'border-(--danger) focus:shadow-[0_0_0_2px_var(--danger)]'
|
|
95
|
+
: 'border-(--border-light) hover:border-(--text-primary)/30',
|
|
96
|
+
className,
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<BaseSelect.Value
|
|
100
|
+
className="flex-1 text-left data-[placeholder]:text-(--text-muted)"
|
|
101
|
+
placeholder={placeholder}
|
|
102
|
+
/>
|
|
103
|
+
<BaseSelect.Icon className="shrink-0 text-(--text-muted)">
|
|
104
|
+
<ChevronDown size={14} />
|
|
105
|
+
</BaseSelect.Icon>
|
|
106
|
+
</BaseSelect.Trigger>
|
|
107
|
+
|
|
108
|
+
<BaseSelect.Portal>
|
|
109
|
+
<BaseSelect.Positioner sideOffset={4} className="z-50">
|
|
110
|
+
<BaseSelect.ScrollUpArrow className="flex h-5 w-full cursor-default items-center justify-center rounded-t-md bg-(--bg-primary) text-(--text-muted)">
|
|
111
|
+
<ChevronUp size={12} />
|
|
112
|
+
</BaseSelect.ScrollUpArrow>
|
|
113
|
+
|
|
114
|
+
<BaseSelect.Popup
|
|
115
|
+
className={clsx(
|
|
116
|
+
'min-w-[var(--anchor-width)] overflow-auto rounded-lg border border-(--border-light) bg-(--bg-primary)',
|
|
117
|
+
'py-1 shadow-lg',
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{groups
|
|
121
|
+
? groups.map((group) => (
|
|
122
|
+
<BaseSelect.Group key={group.label}>
|
|
123
|
+
<BaseSelect.GroupLabel className="px-3 py-1 text-[12px] font-semibold text-(--text-muted) uppercase tracking-wider">
|
|
124
|
+
{group.label}
|
|
125
|
+
</BaseSelect.GroupLabel>
|
|
126
|
+
{group.options.map((opt) => (
|
|
127
|
+
<SelectItem key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
128
|
+
{opt.label}
|
|
129
|
+
</SelectItem>
|
|
130
|
+
))}
|
|
131
|
+
</BaseSelect.Group>
|
|
132
|
+
))
|
|
133
|
+
: options?.map((opt) => (
|
|
134
|
+
<SelectItem key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
135
|
+
{opt.label}
|
|
136
|
+
</SelectItem>
|
|
137
|
+
))}
|
|
138
|
+
</BaseSelect.Popup>
|
|
139
|
+
|
|
140
|
+
<BaseSelect.ScrollDownArrow className="flex h-5 w-full cursor-default items-center justify-center rounded-b-md bg-(--bg-primary) text-(--text-muted)">
|
|
141
|
+
<ChevronDown size={12} />
|
|
142
|
+
</BaseSelect.ScrollDownArrow>
|
|
143
|
+
</BaseSelect.Positioner>
|
|
144
|
+
</BaseSelect.Portal>
|
|
145
|
+
</BaseSelect.Root>
|
|
146
|
+
|
|
147
|
+
{error && (
|
|
148
|
+
<span className="flex items-center gap-1 text-[12px] font-medium leading-tight text-(--danger)">
|
|
149
|
+
⚠️ {error}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
Select.displayName = 'Select'
|
|
158
|
+
|
|
159
|
+
// Internal SelectItem helper
|
|
160
|
+
function SelectItem({
|
|
161
|
+
value,
|
|
162
|
+
disabled,
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
value: string
|
|
166
|
+
disabled?: boolean
|
|
167
|
+
children: React.ReactNode
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<BaseSelect.Item value={value} disabled={disabled} className={itemClass}>
|
|
171
|
+
<BaseSelect.ItemIndicator className="absolute right-3 flex items-center text-(--accent)">
|
|
172
|
+
<Check size={14} />
|
|
173
|
+
</BaseSelect.ItemIndicator>
|
|
174
|
+
<BaseSelect.ItemText>{children}</BaseSelect.ItemText>
|
|
175
|
+
</BaseSelect.Item>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Switch as BaseSwitch } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
|
|
5
|
+
export type SwitchSize = 'sm' | 'md' | 'lg'
|
|
6
|
+
|
|
7
|
+
export interface SwitchProps {
|
|
8
|
+
checked?: boolean
|
|
9
|
+
defaultChecked?: boolean
|
|
10
|
+
onCheckedChange?: (checked: boolean) => void
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
required?: boolean
|
|
13
|
+
size?: SwitchSize
|
|
14
|
+
/** Label text shown next to the switch */
|
|
15
|
+
label?: string
|
|
16
|
+
/** Helper text shown below the label */
|
|
17
|
+
description?: string
|
|
18
|
+
name?: string
|
|
19
|
+
value?: string
|
|
20
|
+
id?: string
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rootSizes: Record<SwitchSize, string> = {
|
|
25
|
+
sm: 'h-4 w-7',
|
|
26
|
+
md: 'h-5 w-9',
|
|
27
|
+
lg: 'h-6 w-11',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const thumbSizes: Record<SwitchSize, string> = {
|
|
31
|
+
sm: 'h-3 w-3 data-[checked]:translate-x-3',
|
|
32
|
+
md: 'h-3.5 w-3.5 data-[checked]:translate-x-4',
|
|
33
|
+
lg: 'h-4.5 w-4.5 data-[checked]:translate-x-5',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
|
37
|
+
(
|
|
38
|
+
{
|
|
39
|
+
checked,
|
|
40
|
+
defaultChecked,
|
|
41
|
+
onCheckedChange,
|
|
42
|
+
disabled,
|
|
43
|
+
required,
|
|
44
|
+
size = 'md',
|
|
45
|
+
label,
|
|
46
|
+
description,
|
|
47
|
+
name,
|
|
48
|
+
value,
|
|
49
|
+
id,
|
|
50
|
+
className,
|
|
51
|
+
},
|
|
52
|
+
ref,
|
|
53
|
+
) => {
|
|
54
|
+
const generatedId = React.useId()
|
|
55
|
+
const switchId = id ?? generatedId
|
|
56
|
+
|
|
57
|
+
const switchEl = (
|
|
58
|
+
<BaseSwitch.Root
|
|
59
|
+
ref={ref}
|
|
60
|
+
id={switchId}
|
|
61
|
+
checked={checked}
|
|
62
|
+
defaultChecked={defaultChecked}
|
|
63
|
+
onCheckedChange={onCheckedChange}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
required={required}
|
|
66
|
+
name={name}
|
|
67
|
+
value={value}
|
|
68
|
+
className={clsx(
|
|
69
|
+
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5',
|
|
70
|
+
'border-2 border-transparent outline-none',
|
|
71
|
+
'transition-colors duration-200',
|
|
72
|
+
'bg-(--border-gray)',
|
|
73
|
+
'data-[checked]:bg-(--accent)',
|
|
74
|
+
'focus-visible:ring-2 focus-visible:ring-(--focus-ring) focus-visible:ring-offset-1',
|
|
75
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
76
|
+
rootSizes[size],
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<BaseSwitch.Thumb
|
|
81
|
+
className={clsx(
|
|
82
|
+
'block rounded-full bg-white shadow-sm',
|
|
83
|
+
'transition-transform duration-200',
|
|
84
|
+
'translate-x-0',
|
|
85
|
+
thumbSizes[size],
|
|
86
|
+
)}
|
|
87
|
+
/>
|
|
88
|
+
</BaseSwitch.Root>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (!label) return switchEl
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-start gap-3">
|
|
95
|
+
{switchEl}
|
|
96
|
+
<div className="flex flex-col">
|
|
97
|
+
<label
|
|
98
|
+
htmlFor={switchId}
|
|
99
|
+
className={clsx(
|
|
100
|
+
'text-[14px] leading-none text-(--text-primary) select-none',
|
|
101
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
{required && <span className="ml-0.5 text-(--danger)">*</span>}
|
|
106
|
+
</label>
|
|
107
|
+
{description && (
|
|
108
|
+
<span className="mt-0.5 text-[12px] text-(--text-muted)">{description}</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
Switch.displayName = 'Switch'
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Tabs as BaseTabs } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
|
|
5
|
+
// ── Tabs Root ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface TabsProps {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
value?: string | number
|
|
10
|
+
defaultValue?: string | number
|
|
11
|
+
onValueChange?: (value: string | number) => void
|
|
12
|
+
orientation?: 'horizontal' | 'vertical'
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
|
|
17
|
+
({ children, value, defaultValue, onValueChange, orientation = 'horizontal', className }, ref) => {
|
|
18
|
+
return (
|
|
19
|
+
<BaseTabs.Root
|
|
20
|
+
ref={ref}
|
|
21
|
+
value={value}
|
|
22
|
+
defaultValue={defaultValue}
|
|
23
|
+
onValueChange={onValueChange}
|
|
24
|
+
orientation={orientation}
|
|
25
|
+
className={clsx(
|
|
26
|
+
'flex',
|
|
27
|
+
orientation === 'horizontal' ? 'flex-col' : 'flex-row gap-4',
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</BaseTabs.Root>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Tabs.displayName = 'Tabs'
|
|
38
|
+
|
|
39
|
+
// ── TabList ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface TabListProps {
|
|
42
|
+
children: React.ReactNode
|
|
43
|
+
className?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const TabList = React.forwardRef<HTMLDivElement, TabListProps>(
|
|
47
|
+
({ children, className }, ref) => {
|
|
48
|
+
return (
|
|
49
|
+
<BaseTabs.List
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={clsx(
|
|
52
|
+
'relative flex border-b border-(--border-light)',
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
<BaseTabs.Indicator
|
|
58
|
+
className={clsx(
|
|
59
|
+
'absolute bottom-0 left-0 h-0.5 bg-(--accent)',
|
|
60
|
+
'transition-[left,width] duration-200 ease-out',
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
</BaseTabs.List>
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
TabList.displayName = 'TabList'
|
|
69
|
+
|
|
70
|
+
// ── Tab ───────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface TabProps {
|
|
73
|
+
children: React.ReactNode
|
|
74
|
+
value: string | number
|
|
75
|
+
disabled?: boolean
|
|
76
|
+
className?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Tab = React.forwardRef<HTMLButtonElement, TabProps>(
|
|
80
|
+
({ children, value, disabled, className }, ref) => {
|
|
81
|
+
return (
|
|
82
|
+
<BaseTabs.Tab
|
|
83
|
+
ref={ref}
|
|
84
|
+
value={value}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
className={clsx(
|
|
87
|
+
'relative px-4 py-2.5 text-[14px] font-medium outline-none',
|
|
88
|
+
'text-(--text-secondary) transition-colors',
|
|
89
|
+
'hover:text-(--text-primary)',
|
|
90
|
+
'data-[selected]:text-(--accent)',
|
|
91
|
+
'focus-visible:ring-2 focus-visible:ring-(--focus-ring) focus-visible:ring-inset',
|
|
92
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
93
|
+
className,
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</BaseTabs.Tab>
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
Tab.displayName = 'Tab'
|
|
103
|
+
|
|
104
|
+
// ── TabPanel ──────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export interface TabPanelProps {
|
|
107
|
+
children: React.ReactNode
|
|
108
|
+
value: string | number
|
|
109
|
+
keepMounted?: boolean
|
|
110
|
+
className?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const TabPanel = React.forwardRef<HTMLDivElement, TabPanelProps>(
|
|
114
|
+
({ children, value, keepMounted = false, className }, ref) => {
|
|
115
|
+
return (
|
|
116
|
+
<BaseTabs.Panel
|
|
117
|
+
ref={ref}
|
|
118
|
+
value={value}
|
|
119
|
+
keepMounted={keepMounted}
|
|
120
|
+
className={clsx('pt-4 outline-none', className)}
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</BaseTabs.Panel>
|
|
124
|
+
)
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
TabPanel.displayName = 'TabPanel'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Toast as BaseToast } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { X, Info, CheckCircle, AlertTriangle, XCircle } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type ToastType = 'default' | 'info' | 'success' | 'warning' | 'error'
|
|
9
|
+
|
|
10
|
+
// ── ToastProvider ─────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface ToastProviderProps {
|
|
13
|
+
children: React.ReactNode
|
|
14
|
+
timeout?: number
|
|
15
|
+
limit?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wrap your app (or part of it) with `ToastProvider` to enable toasts.
|
|
20
|
+
* Then use the `useToast()` hook inside to add toasts.
|
|
21
|
+
*/
|
|
22
|
+
export const ToastProvider: React.FC<ToastProviderProps> = ({
|
|
23
|
+
children,
|
|
24
|
+
timeout = 5000,
|
|
25
|
+
limit = 5,
|
|
26
|
+
}) => {
|
|
27
|
+
return (
|
|
28
|
+
<BaseToast.Provider timeout={timeout} limit={limit}>
|
|
29
|
+
{children}
|
|
30
|
+
<ToastViewport />
|
|
31
|
+
</BaseToast.Provider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── useToast hook ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface ToastOptions {
|
|
38
|
+
title?: React.ReactNode
|
|
39
|
+
description?: React.ReactNode
|
|
40
|
+
type?: ToastType
|
|
41
|
+
timeout?: number
|
|
42
|
+
/** Action button rendered inside the toast */
|
|
43
|
+
action?: {
|
|
44
|
+
label: string
|
|
45
|
+
onClick: () => void
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook to imperatively add, close and update toasts.
|
|
51
|
+
*
|
|
52
|
+
* Must be used inside `<ToastProvider>`.
|
|
53
|
+
*/
|
|
54
|
+
export function useToast() {
|
|
55
|
+
const manager = BaseToast.useToastManager()
|
|
56
|
+
return {
|
|
57
|
+
toast: (options: ToastOptions) =>
|
|
58
|
+
manager.add({
|
|
59
|
+
title: options.title,
|
|
60
|
+
description: options.description,
|
|
61
|
+
type: options.type ?? 'default',
|
|
62
|
+
timeout: options.timeout,
|
|
63
|
+
actionProps: options.action
|
|
64
|
+
? { children: options.action.label, onClick: options.action.onClick }
|
|
65
|
+
: undefined,
|
|
66
|
+
}),
|
|
67
|
+
dismiss: manager.close,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── ToastViewport (internal) ──────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function ToastViewport() {
|
|
74
|
+
const { toasts } = BaseToast.useToastManager()
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<BaseToast.Viewport className="fixed bottom-4 right-4 z-[100] flex w-80 flex-col-reverse gap-2 outline-none">
|
|
78
|
+
{toasts.map((toast) => (
|
|
79
|
+
<ToastItem key={toast.id} toast={toast} />
|
|
80
|
+
))}
|
|
81
|
+
</BaseToast.Viewport>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── ToastItem (internal) ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const typeStyles: Record<ToastType, { icon: React.ReactNode; color: string }> = {
|
|
88
|
+
default: { icon: null, color: 'border-(--border-light)' },
|
|
89
|
+
info: {
|
|
90
|
+
icon: <Info size={16} className="shrink-0 text-blue-500" />,
|
|
91
|
+
color: 'border-blue-200 dark:border-blue-900',
|
|
92
|
+
},
|
|
93
|
+
success: {
|
|
94
|
+
icon: <CheckCircle size={16} className="shrink-0 text-(--slack-green)" />,
|
|
95
|
+
color: 'border-green-200 dark:border-green-900',
|
|
96
|
+
},
|
|
97
|
+
warning: {
|
|
98
|
+
icon: <AlertTriangle size={16} className="shrink-0 text-amber-500" />,
|
|
99
|
+
color: 'border-amber-200 dark:border-amber-900',
|
|
100
|
+
},
|
|
101
|
+
error: {
|
|
102
|
+
icon: <XCircle size={16} className="shrink-0 text-(--danger)" />,
|
|
103
|
+
color: 'border-red-200 dark:border-red-900',
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ToastItem({ toast }: { toast: BaseToast.Root.ToastObject }) {
|
|
108
|
+
const type = (toast.type ?? 'default') as ToastType
|
|
109
|
+
const style = typeStyles[type] ?? typeStyles.default
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<BaseToast.Root
|
|
113
|
+
toast={toast}
|
|
114
|
+
className={clsx(
|
|
115
|
+
'flex w-full items-start gap-3 rounded-lg border bg-(--bg-primary) px-4 py-3 shadow-lg',
|
|
116
|
+
style.color,
|
|
117
|
+
'data-[ending]:animate-[fade-out_150ms_ease-in] data-[starting]:animate-[zoom-in_150ms_ease-out]',
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{style.icon && <div className="pt-0.5">{style.icon}</div>}
|
|
121
|
+
|
|
122
|
+
<div className="flex flex-1 flex-col gap-0.5 min-w-0">
|
|
123
|
+
{toast.title && (
|
|
124
|
+
<BaseToast.Title className="text-[14px] font-semibold text-(--text-primary) leading-snug">
|
|
125
|
+
{toast.title}
|
|
126
|
+
</BaseToast.Title>
|
|
127
|
+
)}
|
|
128
|
+
{toast.description && (
|
|
129
|
+
<BaseToast.Description className="text-[13px] text-(--text-secondary) leading-snug">
|
|
130
|
+
{toast.description}
|
|
131
|
+
</BaseToast.Description>
|
|
132
|
+
)}
|
|
133
|
+
{toast.actionProps && (
|
|
134
|
+
<button
|
|
135
|
+
{...toast.actionProps}
|
|
136
|
+
className={clsx(
|
|
137
|
+
'mt-1.5 self-start text-[13px] font-semibold text-(--accent) hover:underline outline-none',
|
|
138
|
+
'focus-visible:ring-1 focus-visible:ring-(--focus-ring) rounded',
|
|
139
|
+
)}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<BaseToast.Close className="shrink-0 rounded p-0.5 text-(--text-muted) hover:text-(--text-primary) hover:bg-(--bg-hover) outline-none focus-visible:ring-1 focus-visible:ring-(--focus-ring) transition-colors">
|
|
145
|
+
<X size={14} />
|
|
146
|
+
</BaseToast.Close>
|
|
147
|
+
</BaseToast.Root>
|
|
148
|
+
)
|
|
149
|
+
}
|