@maestro-js/components 1.0.0-alpha.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.
- package/commands/add.ts +41 -0
- package/commands/index.ts +7 -0
- package/commands/list.ts +9 -0
- package/dist/components.json +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +48 -0
- package/package.json +49 -0
- package/registry.json +1445 -0
- package/scripts/build.ts +44 -0
- package/src/components/alert-dialog.tsx +150 -0
- package/src/components/autocomplete.tsx +288 -0
- package/src/components/autosuggest.tsx +314 -0
- package/src/components/avatar.tsx +54 -0
- package/src/components/boolean-select.tsx +48 -0
- package/src/components/button-group.tsx +75 -0
- package/src/components/button-link.tsx +71 -0
- package/src/components/button.tsx +132 -0
- package/src/components/checkbox-group.tsx +172 -0
- package/src/components/checkbox.tsx +207 -0
- package/src/components/chip.tsx +158 -0
- package/src/components/container.tsx +82 -0
- package/src/components/currency-field.tsx +183 -0
- package/src/components/date-input.tsx +189 -0
- package/src/components/date-picker.tsx +211 -0
- package/src/components/date-range-picker.tsx +290 -0
- package/src/components/date-time-picker.tsx +196 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/disclosure.tsx +114 -0
- package/src/components/drawer.tsx +78 -0
- package/src/components/enum-chip.tsx +30 -0
- package/src/components/file-input.tsx +245 -0
- package/src/components/form.tsx +82 -0
- package/src/components/headless-file-input.tsx +362 -0
- package/src/components/helpers/animated-popover.tsx +24 -0
- package/src/components/helpers/button-context.ts +10 -0
- package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
- package/src/components/helpers/form-field.tsx +229 -0
- package/src/components/helpers/get-button-classes.ts +138 -0
- package/src/components/helpers/headless-button.tsx +36 -0
- package/src/components/helpers/pdf-dist.client.ts +6 -0
- package/src/components/icon.tsx +26 -0
- package/src/components/image-input.tsx +265 -0
- package/src/components/img.tsx +46 -0
- package/src/components/inline-alert.tsx +54 -0
- package/src/components/labeled-value.tsx +480 -0
- package/src/components/link-tabs.tsx +118 -0
- package/src/components/menu.tsx +152 -0
- package/src/components/month-day-input.tsx +176 -0
- package/src/components/multi-file-input.tsx +244 -0
- package/src/components/multi-image-input.tsx +389 -0
- package/src/components/multicomplete.tsx +322 -0
- package/src/components/multiselect.tsx +325 -0
- package/src/components/multisuggest.tsx +357 -0
- package/src/components/number-field.tsx +143 -0
- package/src/components/numeric-tag-field.tsx +271 -0
- package/src/components/pdf-input.tsx +249 -0
- package/src/components/pdf.tsx +86 -0
- package/src/components/percentage-field.tsx +187 -0
- package/src/components/phone-number-field.tsx +166 -0
- package/src/components/radio-group.tsx +112 -0
- package/src/components/radio.tsx +91 -0
- package/src/components/select.tsx +215 -0
- package/src/components/spinner.tsx +16 -0
- package/src/components/stepper.tsx +181 -0
- package/src/components/switch.tsx +186 -0
- package/src/components/tabs.tsx +151 -0
- package/src/components/tag-field.tsx +250 -0
- package/src/components/text-area.tsx +148 -0
- package/src/components/text-field.tsx +144 -0
- package/src/components/time-input.tsx +198 -0
- package/src/components/toast.tsx +176 -0
- package/src/components/toggle-button-group.tsx +94 -0
- package/src/components/year-month-input.tsx +187 -0
- package/src/utils/colors.ts +44 -0
- package/src/utils/compose-refs.ts +32 -0
- package/src/utils/enum-colors.ts +1 -0
- package/src/utils/file-input.ts +49 -0
- package/src/utils/icons.d.ts +20 -0
- package/src/utils/tw.ts +13 -0
- package/src/utils/use-element-size.ts +35 -0
- package/src/utils/use-number-input.ts +143 -0
- package/src/utils/use-pagination.ts +38 -0
- package/src/utils/use-prevent-default.ts +27 -0
- package/src/utils/use-render-props.ts +39 -0
- package/src/utils/use-spin-delay.ts +24 -0
- package/src/utils/use-stable-accessor.ts +11 -0
- package/src/utils/use-tab-indicator.ts +106 -0
- package/tests/commands.test.ts +81 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
3
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
4
|
+
import { tw } from '../utils/tw'
|
|
5
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
6
|
+
import { LabeledCheckbox } from './checkbox'
|
|
7
|
+
|
|
8
|
+
export type CheckboxGroupOption = {
|
|
9
|
+
value: string
|
|
10
|
+
label: React.ReactNode
|
|
11
|
+
description?: React.ReactNode
|
|
12
|
+
isDisabled?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CheckboxGroupProps = {
|
|
16
|
+
name?: string
|
|
17
|
+
form?: string
|
|
18
|
+
|
|
19
|
+
label?: React.ReactNode
|
|
20
|
+
cornerHint?: React.ReactNode
|
|
21
|
+
helpText?: React.ReactNode
|
|
22
|
+
warningMessage?: React.ReactNode
|
|
23
|
+
|
|
24
|
+
isRequired?: boolean
|
|
25
|
+
isDisabled?: boolean
|
|
26
|
+
isInvalid?: boolean
|
|
27
|
+
validate?: ValidationFunction<string[]>
|
|
28
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
29
|
+
|
|
30
|
+
value?: string[]
|
|
31
|
+
defaultValue?: string[]
|
|
32
|
+
onChange?: (value: string[]) => void
|
|
33
|
+
|
|
34
|
+
options: CheckboxGroupOption[]
|
|
35
|
+
orientation?: 'horizontal' | 'vertical'
|
|
36
|
+
showSelectAll?: boolean
|
|
37
|
+
minSelection?: number
|
|
38
|
+
maxSelection?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CheckboxGroup(props: CheckboxGroupProps) {
|
|
42
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
43
|
+
const helpTextId = React.useId()
|
|
44
|
+
|
|
45
|
+
const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? [], props.onChange)
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
validationMessage,
|
|
49
|
+
isInvalid: validationInvalid,
|
|
50
|
+
commitValidation
|
|
51
|
+
} = HeadlessForm.useValidation(
|
|
52
|
+
{
|
|
53
|
+
validate: props.validate,
|
|
54
|
+
value,
|
|
55
|
+
name: props.name,
|
|
56
|
+
isRequired: props.isRequired,
|
|
57
|
+
form: props.form,
|
|
58
|
+
focus() {
|
|
59
|
+
// Focus is handled by individual checkboxes
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
hiddenInputRef
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
66
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
67
|
+
const orientation = props.orientation ?? 'vertical'
|
|
68
|
+
|
|
69
|
+
const enabledOptions = props.options.filter((o) => !o.isDisabled)
|
|
70
|
+
const allSelected = value.length === enabledOptions.length
|
|
71
|
+
const someSelected = value.length > 0 && !allSelected
|
|
72
|
+
|
|
73
|
+
const handleOptionToggle = (optionValue: string) => {
|
|
74
|
+
const newValue = value.includes(optionValue) ? value.filter((v) => v !== optionValue) : [...value, optionValue]
|
|
75
|
+
|
|
76
|
+
if (props.minSelection && newValue.length < props.minSelection) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (props.maxSelection && newValue.length > props.maxSelection) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setValue(newValue)
|
|
84
|
+
commitValidation()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const handleSelectAll = () => {
|
|
88
|
+
if (allSelected) {
|
|
89
|
+
setValue([])
|
|
90
|
+
} else {
|
|
91
|
+
setValue(enabledOptions.map((o) => o.value))
|
|
92
|
+
}
|
|
93
|
+
commitValidation()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className="relative"
|
|
99
|
+
role="group"
|
|
100
|
+
aria-labelledby={props.label ? `${helpTextId}-label` : undefined}
|
|
101
|
+
aria-describedby={helpTextId}
|
|
102
|
+
>
|
|
103
|
+
{value.map((v, i) => (
|
|
104
|
+
<HeadlessForm.HiddenInput
|
|
105
|
+
key={i}
|
|
106
|
+
ref={i === 0 ? hiddenInputRef : undefined}
|
|
107
|
+
name={props.name}
|
|
108
|
+
value={v}
|
|
109
|
+
form={props.form}
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
{value.length === 0 && <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value="" form={props.form} />}
|
|
113
|
+
|
|
114
|
+
{props.label && (
|
|
115
|
+
<div className="flex justify-between items-center mb-2">
|
|
116
|
+
<FormFieldComponents.Label as="span" id={`${helpTextId}-label`}>
|
|
117
|
+
{props.label}
|
|
118
|
+
</FormFieldComponents.Label>
|
|
119
|
+
{props.cornerHint && <div className="text-sm text-neutral-500">{props.cornerHint}</div>}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{props.showSelectAll && (
|
|
124
|
+
<div className="mb-2 pb-2 border-b border-neutral-200">
|
|
125
|
+
<LabeledCheckbox
|
|
126
|
+
checked={allSelected}
|
|
127
|
+
isIndeterminate={someSelected}
|
|
128
|
+
onChange={handleSelectAll}
|
|
129
|
+
isDisabled={props.isDisabled}
|
|
130
|
+
label={<span className="text-sm font-medium">Select All</span>}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<div className={tw('flex', orientation === 'vertical' ? 'flex-col space-y-1.5' : 'flex-row flex-wrap gap-4')}>
|
|
136
|
+
{props.options.map((option) => (
|
|
137
|
+
<LabeledCheckbox
|
|
138
|
+
key={option.value}
|
|
139
|
+
label={option.label}
|
|
140
|
+
helpText={option.description ?? null}
|
|
141
|
+
checked={value.includes(option.value)}
|
|
142
|
+
onChange={() => handleOptionToggle(option.value)}
|
|
143
|
+
isDisabled={option.isDisabled || props.isDisabled}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{(props.minSelection || props.maxSelection) && (
|
|
149
|
+
<div className="mt-2 text-xs text-neutral-500">
|
|
150
|
+
{props.minSelection && props.maxSelection
|
|
151
|
+
? `Select between ${props.minSelection} and ${props.maxSelection} options`
|
|
152
|
+
: props.minSelection
|
|
153
|
+
? `Select at least ${props.minSelection} option${props.minSelection > 1 ? 's' : ''}`
|
|
154
|
+
: `Select up to ${props.maxSelection} option${props.maxSelection! > 1 ? 's' : ''}`}
|
|
155
|
+
{' · '}
|
|
156
|
+
{value.length} selected
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
<FormFieldComponents.FormFieldHelpText
|
|
161
|
+
id={helpTextId}
|
|
162
|
+
isInvalid={isInvalid}
|
|
163
|
+
validationMessage={validationMessage}
|
|
164
|
+
hasWarning={hasWarning}
|
|
165
|
+
warningMessage={props.warningMessage}
|
|
166
|
+
helpText={props.helpText}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
CheckboxGroup.displayName = 'CheckboxGroup'
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Checkbox as AriaCheckbox } from 'react-aria-components'
|
|
3
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
4
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
5
|
+
import { tw } from '../utils/tw'
|
|
6
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
7
|
+
import { Icon } from './icon'
|
|
8
|
+
|
|
9
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type PlainCheckboxProps = {
|
|
12
|
+
name?: string
|
|
13
|
+
form?: string
|
|
14
|
+
|
|
15
|
+
isDisabled?: boolean
|
|
16
|
+
isRequired?: boolean
|
|
17
|
+
isInvalid?: boolean
|
|
18
|
+
validate?: ValidationFunction<boolean>
|
|
19
|
+
|
|
20
|
+
checked?: boolean
|
|
21
|
+
defaultChecked?: boolean
|
|
22
|
+
onChange?: (checked: boolean) => void
|
|
23
|
+
|
|
24
|
+
'aria-label'?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LabeledCheckboxProps = {
|
|
28
|
+
name?: string
|
|
29
|
+
form?: string
|
|
30
|
+
|
|
31
|
+
label?: React.ReactNode
|
|
32
|
+
cornerHint?: React.ReactNode
|
|
33
|
+
helpText?: React.ReactNode
|
|
34
|
+
warningMessage?: React.ReactNode
|
|
35
|
+
|
|
36
|
+
isRequired?: boolean
|
|
37
|
+
isDisabled?: boolean
|
|
38
|
+
isIndeterminate?: boolean
|
|
39
|
+
isInvalid?: boolean
|
|
40
|
+
validate?: ValidationFunction<boolean>
|
|
41
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
42
|
+
|
|
43
|
+
checked?: boolean
|
|
44
|
+
defaultChecked?: boolean
|
|
45
|
+
onChange?: (checked: boolean) => void
|
|
46
|
+
|
|
47
|
+
value?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Plain Checkbox ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function PlainCheckbox(props: PlainCheckboxProps) {
|
|
53
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
54
|
+
|
|
55
|
+
const [checked, setChecked] = HeadlessForm.useControlledState(props.checked, props.defaultChecked ?? false, props.onChange)
|
|
56
|
+
|
|
57
|
+
const { isInvalid: validationInvalid, commitValidation } = HeadlessForm.useValidation(
|
|
58
|
+
{
|
|
59
|
+
validate: props.validate,
|
|
60
|
+
value: checked,
|
|
61
|
+
name: props.name,
|
|
62
|
+
isRequired: false,
|
|
63
|
+
form: props.form,
|
|
64
|
+
focus() {
|
|
65
|
+
hiddenInputRef.current?.closest('label')?.focus()
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
hiddenInputRef
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<AriaCheckbox
|
|
73
|
+
isSelected={checked}
|
|
74
|
+
onChange={(v) => {
|
|
75
|
+
setChecked(v)
|
|
76
|
+
commitValidation()
|
|
77
|
+
}}
|
|
78
|
+
isDisabled={props.isDisabled}
|
|
79
|
+
isInvalid={props.isInvalid || validationInvalid}
|
|
80
|
+
aria-label={props['aria-label']}
|
|
81
|
+
className="group inline-flex items-center focus:outline-none [&>div]:focus-visible:ring-2 [&>div]:focus-visible:ring-primary-500/20 [&>div]:focus-visible:ring-offset-2"
|
|
82
|
+
>
|
|
83
|
+
{({ isSelected, isIndeterminate, isDisabled }) => (
|
|
84
|
+
<>
|
|
85
|
+
{props.isDisabled || !props.name ? null : (
|
|
86
|
+
<HeadlessForm.HiddenInput
|
|
87
|
+
ref={hiddenInputRef}
|
|
88
|
+
name={props.name}
|
|
89
|
+
form={props.form}
|
|
90
|
+
value={checked ? 'true' : 'false'}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
<CheckboxBox isSelected={isSelected} isIndeterminate={isIndeterminate} isDisabled={isDisabled} />
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</AriaCheckbox>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
PlainCheckbox.displayName = 'PlainCheckbox'
|
|
101
|
+
|
|
102
|
+
// ── Labeled Checkbox ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function LabeledCheckbox(props: LabeledCheckboxProps) {
|
|
105
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
106
|
+
const helpTextId = React.useId()
|
|
107
|
+
|
|
108
|
+
const [checked, setChecked] = HeadlessForm.useControlledState(props.checked, props.defaultChecked ?? false, props.onChange)
|
|
109
|
+
|
|
110
|
+
const {
|
|
111
|
+
validationMessage,
|
|
112
|
+
isInvalid: validationInvalid,
|
|
113
|
+
commitValidation
|
|
114
|
+
} = HeadlessForm.useValidation(
|
|
115
|
+
{
|
|
116
|
+
validate: props.validate,
|
|
117
|
+
value: checked,
|
|
118
|
+
name: props.name,
|
|
119
|
+
isRequired: props.isRequired,
|
|
120
|
+
form: props.form,
|
|
121
|
+
focus() {
|
|
122
|
+
hiddenInputRef.current?.closest('label')?.focus()
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
hiddenInputRef
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
129
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<AriaCheckbox
|
|
133
|
+
isSelected={checked}
|
|
134
|
+
isIndeterminate={props.isIndeterminate}
|
|
135
|
+
onChange={(v) => {
|
|
136
|
+
setChecked(v)
|
|
137
|
+
commitValidation()
|
|
138
|
+
}}
|
|
139
|
+
isDisabled={props.isDisabled}
|
|
140
|
+
isInvalid={isInvalid}
|
|
141
|
+
aria-describedby={helpTextId}
|
|
142
|
+
className="group relative flex items-start gap-3 select-none focus:outline-none [&>div:first-child>div]:focus-visible:ring-2 [&>div:first-child>div]:focus-visible:ring-primary-500/20 [&>div:first-child>div]:focus-visible:ring-offset-2"
|
|
143
|
+
>
|
|
144
|
+
{({ isSelected, isIndeterminate, isDisabled }) => (
|
|
145
|
+
<>
|
|
146
|
+
{props.isDisabled || !props.name ? null : (
|
|
147
|
+
<HeadlessForm.HiddenInput
|
|
148
|
+
ref={hiddenInputRef}
|
|
149
|
+
name={props.name}
|
|
150
|
+
form={props.form}
|
|
151
|
+
value={checked ? 'true' : 'false'}
|
|
152
|
+
/>
|
|
153
|
+
)}
|
|
154
|
+
<div className="shrink-0">
|
|
155
|
+
<CheckboxBox isSelected={isSelected} isIndeterminate={isIndeterminate} isDisabled={isDisabled} />
|
|
156
|
+
</div>
|
|
157
|
+
<div className="flex flex-col justify-center">
|
|
158
|
+
<div className="flex items-center gap-2 justify-between">
|
|
159
|
+
<FormFieldComponents.Label as="span">{props.label}</FormFieldComponents.Label>
|
|
160
|
+
{props.cornerHint && <div className="text-sm text-neutral-500">{props.cornerHint}</div>}
|
|
161
|
+
</div>
|
|
162
|
+
<FormFieldComponents.FormFieldHelpText
|
|
163
|
+
id={helpTextId}
|
|
164
|
+
isInvalid={isInvalid}
|
|
165
|
+
validationMessage={validationMessage}
|
|
166
|
+
hasWarning={hasWarning}
|
|
167
|
+
warningMessage={props.warningMessage}
|
|
168
|
+
helpText={props.helpText}
|
|
169
|
+
UNSAFE_className="mt-0.5"
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</AriaCheckbox>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
LabeledCheckbox.displayName = 'LabeledCheckbox'
|
|
179
|
+
|
|
180
|
+
// ── Helper components ──────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function CheckboxBox({
|
|
183
|
+
isSelected,
|
|
184
|
+
isIndeterminate,
|
|
185
|
+
isDisabled
|
|
186
|
+
}: {
|
|
187
|
+
isSelected: boolean
|
|
188
|
+
isIndeterminate: boolean
|
|
189
|
+
isDisabled: boolean
|
|
190
|
+
}) {
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
className={tw(
|
|
194
|
+
'relative h-5 w-5 rounded border transition-all duration-50',
|
|
195
|
+
isSelected || isIndeterminate ? 'bg-primary-500 border-primary-500' : 'bg-white border-neutral-300',
|
|
196
|
+
!isDisabled && !(isSelected || isIndeterminate) && 'group-hovered:border-neutral-400',
|
|
197
|
+
isDisabled && 'opacity-50 cursor-not-allowed'
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
{isIndeterminate ? (
|
|
201
|
+
<Icon name="minus" className="h-5/6 absolute inset-0 m-auto aspect-square text-white" />
|
|
202
|
+
) : (
|
|
203
|
+
<Icon name="check" className="h-5/6 absolute inset-0 m-auto aspect-square text-white" />
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { tw } from '../utils/tw'
|
|
3
|
+
import { Icon } from './icon'
|
|
4
|
+
|
|
5
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type ChipProps = {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
onRemove?: () => void
|
|
10
|
+
isDisabled?: boolean
|
|
11
|
+
size?: 'sm' | 'md' | 'lg'
|
|
12
|
+
color?: 'stone' | 'info' | 'green' | 'yellow' | 'orange' | 'red'
|
|
13
|
+
colorMode?: 'light' | 'dark'
|
|
14
|
+
className?: string
|
|
15
|
+
style?: React.CSSProperties
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ChipButtonProps = {
|
|
19
|
+
children: React.ReactNode
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
size?: 'sm' | 'md' | 'lg'
|
|
22
|
+
color?: 'stone' | 'info' | 'green' | 'yellow' | 'orange' | 'red'
|
|
23
|
+
colorMode?: 'light' | 'dark'
|
|
24
|
+
className?: string
|
|
25
|
+
} & Omit<React.ComponentProps<'button'>, 'disabled' | 'size' | 'color'>
|
|
26
|
+
|
|
27
|
+
// ── Chip ──────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function Chip(props: ChipProps) {
|
|
30
|
+
const {
|
|
31
|
+
children,
|
|
32
|
+
onRemove,
|
|
33
|
+
isDisabled = false,
|
|
34
|
+
size = 'md',
|
|
35
|
+
color = 'stone',
|
|
36
|
+
colorMode = 'light',
|
|
37
|
+
className,
|
|
38
|
+
style
|
|
39
|
+
} = props
|
|
40
|
+
|
|
41
|
+
const colorClasses = colorMode === 'dark' ? darkColorClasses : lightColorClasses
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={tw(
|
|
46
|
+
'inline-flex items-center rounded-full font-normal transition-colors max-w-full',
|
|
47
|
+
sizeClasses[size],
|
|
48
|
+
!isDisabled && !style && colorClasses[color],
|
|
49
|
+
isDisabled && 'opacity-50 cursor-not-allowed',
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
style={style}
|
|
53
|
+
>
|
|
54
|
+
<span className="truncate">{children}</span>
|
|
55
|
+
{onRemove && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={onRemove}
|
|
59
|
+
disabled={isDisabled}
|
|
60
|
+
className={tw(
|
|
61
|
+
'inline-flex items-center justify-center rounded-full transition-colors translate-x-1',
|
|
62
|
+
removeBtnSizeClasses[size],
|
|
63
|
+
!isDisabled && 'hover:bg-black/10',
|
|
64
|
+
isDisabled && 'cursor-not-allowed'
|
|
65
|
+
)}
|
|
66
|
+
aria-label="Remove"
|
|
67
|
+
tabIndex={-1}
|
|
68
|
+
>
|
|
69
|
+
<Icon name="x-mark" className={removeIconSizeClasses[size]} aria-hidden="true" />
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── ChipButton ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export const ChipButton = React.forwardRef<HTMLButtonElement, ChipButtonProps>((props, ref) => {
|
|
79
|
+
const { children, size = 'md', color = 'stone', colorMode = 'light', className, disabled, ...rest } = props
|
|
80
|
+
|
|
81
|
+
const colorClasses = colorMode === 'dark' ? darkHoverColorClasses : lightHoverColorClasses
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
ref={ref}
|
|
86
|
+
type="button"
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
className={tw(
|
|
89
|
+
'inline-flex items-center rounded-full font-normal transition-colors',
|
|
90
|
+
sizeClasses[size],
|
|
91
|
+
!disabled && colorClasses[color],
|
|
92
|
+
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
93
|
+
className
|
|
94
|
+
)}
|
|
95
|
+
{...rest}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</button>
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
ChipButton.displayName = 'ChipButton'
|
|
103
|
+
|
|
104
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const sizeClasses = {
|
|
107
|
+
sm: 'px-2 py-0.5 text-xs',
|
|
108
|
+
md: 'px-2.5 py-1 text-sm',
|
|
109
|
+
lg: 'px-3 py-1.5 text-base'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lightColorClasses = {
|
|
113
|
+
stone: 'bg-stone-100 text-stone-700',
|
|
114
|
+
info: 'bg-info-100 text-info-700',
|
|
115
|
+
green: 'bg-green-100 text-green-700',
|
|
116
|
+
yellow: 'bg-yellow-100 text-yellow-700',
|
|
117
|
+
orange: 'bg-orange-100 text-orange-700',
|
|
118
|
+
red: 'bg-red-100 text-red-700'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const darkColorClasses = {
|
|
122
|
+
stone: 'bg-stone-600 text-white',
|
|
123
|
+
info: 'bg-info-600 text-white',
|
|
124
|
+
green: 'bg-green-600 text-white',
|
|
125
|
+
yellow: 'bg-yellow-600 text-white',
|
|
126
|
+
orange: 'bg-orange-600 text-white',
|
|
127
|
+
red: 'bg-red-600 text-white'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lightHoverColorClasses = {
|
|
131
|
+
stone: 'bg-stone-100 text-stone-700 hover:bg-stone-200',
|
|
132
|
+
info: 'bg-info-100 text-info-700 hover:bg-info-200',
|
|
133
|
+
green: 'bg-green-100 text-green-700 hover:bg-green-200',
|
|
134
|
+
yellow: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200',
|
|
135
|
+
orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200',
|
|
136
|
+
red: 'bg-red-100 text-red-700 hover:bg-red-200'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const darkHoverColorClasses = {
|
|
140
|
+
stone: 'bg-stone-600 text-white hover:bg-stone-700',
|
|
141
|
+
info: 'bg-info-600 text-white hover:bg-info-700',
|
|
142
|
+
green: 'bg-green-600 text-white hover:bg-green-700',
|
|
143
|
+
yellow: 'bg-yellow-600 text-white hover:bg-yellow-700',
|
|
144
|
+
orange: 'bg-orange-600 text-white hover:bg-orange-700',
|
|
145
|
+
red: 'bg-red-600 text-white hover:bg-red-700'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const removeBtnSizeClasses = {
|
|
149
|
+
sm: 'h-4 w-4 -m-0.5 p-0.5',
|
|
150
|
+
md: 'h-5 w-5 -m-1 p-1',
|
|
151
|
+
lg: 'h-6 w-6 -m-1.5 p-1.5'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const removeIconSizeClasses = {
|
|
155
|
+
sm: 'h-2 w-2',
|
|
156
|
+
md: 'h-3 w-3',
|
|
157
|
+
lg: 'h-4 w-4'
|
|
158
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { tw } from '../utils/tw'
|
|
3
|
+
|
|
4
|
+
// ── Props ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export type ContainerSize = 'lg' | 'md' | 'sm'
|
|
7
|
+
export type ContainerElevation = 0 | -1 | -2 | 'inherit'
|
|
8
|
+
|
|
9
|
+
export type ContainerProps = React.ComponentProps<'div'> & {
|
|
10
|
+
size?: ContainerSize
|
|
11
|
+
elevation?: ContainerElevation
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ContainerSectionProps = ContainerProps & {
|
|
15
|
+
label?: React.ReactNode
|
|
16
|
+
actions?: React.ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SectionProps = {
|
|
20
|
+
label?: React.ReactNode
|
|
21
|
+
actions?: React.ReactNode
|
|
22
|
+
children?: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Container ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function Container({ size = 'lg', elevation = 0, className, ...props }: ContainerProps) {
|
|
28
|
+
return <div {...props} className={tw(elevationClasses[String(elevation)], sizeClasses[size], className)} />
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Container.displayName = 'Container'
|
|
32
|
+
|
|
33
|
+
// ── ContainerSection ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function ContainerSection({ label, actions, ...props }: ContainerSectionProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col gap-1">
|
|
38
|
+
<div className="flex items-start justify-between">
|
|
39
|
+
<h3 className="text-lg font-semibold">{label}</h3>
|
|
40
|
+
{actions}
|
|
41
|
+
</div>
|
|
42
|
+
<Container {...props} />
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ContainerSection.displayName = 'ContainerSection'
|
|
48
|
+
|
|
49
|
+
// ── Section ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function Section({ label, actions, children }: SectionProps) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex flex-col gap-1">
|
|
54
|
+
<div className="flex items-start justify-between">
|
|
55
|
+
<h3 className="text-lg font-semibold">{label}</h3>
|
|
56
|
+
{actions}
|
|
57
|
+
</div>
|
|
58
|
+
<div>{children}</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Section.displayName = 'Section'
|
|
64
|
+
|
|
65
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const sizeClasses: Record<ContainerSize, string> = {
|
|
68
|
+
lg: 'p-6 rounded-lg',
|
|
69
|
+
md: 'p-3 rounded',
|
|
70
|
+
sm: 'p-2 rounded-sm'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const elevationClasses: Record<string, string> = {
|
|
74
|
+
'0': 'bg-white border border-neutral-200',
|
|
75
|
+
'-1': 'bg-neutral-50 border border-neutral-200',
|
|
76
|
+
'-2': 'bg-neutral-100 border border-neutral-300',
|
|
77
|
+
inherit: ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getSizeClassesForContainer(size: ContainerSize) {
|
|
81
|
+
return sizeClasses[size]
|
|
82
|
+
}
|