@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,265 @@
|
|
|
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 { formatFileSize, checkFileType, checkFileMaxSize } from '../utils/file-input'
|
|
6
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
7
|
+
import { HeadlessFileInput } from './headless-file-input'
|
|
8
|
+
import { Icon } from './icon'
|
|
9
|
+
|
|
10
|
+
export type ImageInputProps = {
|
|
11
|
+
// Form identification
|
|
12
|
+
name?: string
|
|
13
|
+
form?: string
|
|
14
|
+
|
|
15
|
+
// Display elements
|
|
16
|
+
label?: React.ReactNode
|
|
17
|
+
cornerHint?: React.ReactNode
|
|
18
|
+
helpText?: React.ReactNode
|
|
19
|
+
warningMessage?: React.ReactNode
|
|
20
|
+
emptyState?: React.ReactNode
|
|
21
|
+
renderPreview?: (props: ImageInputPreviewProps) => React.ReactNode
|
|
22
|
+
|
|
23
|
+
// Validation and state
|
|
24
|
+
isRequired?: boolean
|
|
25
|
+
isDisabled?: boolean
|
|
26
|
+
isInvalid?: boolean
|
|
27
|
+
validate?: ValidationFunction<string | File | null>
|
|
28
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
29
|
+
|
|
30
|
+
// Value management (File object or string URL, held in memory until form submit)
|
|
31
|
+
value?: string | File | null
|
|
32
|
+
defaultValue?: string | File | null
|
|
33
|
+
onChange?: (value: string | File | null) => void
|
|
34
|
+
|
|
35
|
+
// Image specific
|
|
36
|
+
accept?: string
|
|
37
|
+
maxSize?: number // in bytes
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ImageInputPreviewProps {
|
|
41
|
+
fileUrl: string
|
|
42
|
+
isDisabled: boolean
|
|
43
|
+
onChange(value: string | null | File): unknown
|
|
44
|
+
inputId: string
|
|
45
|
+
name: string | undefined
|
|
46
|
+
accept: string | undefined
|
|
47
|
+
inputRef: React.Ref<HTMLInputElement>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ImageInput(props: ImageInputProps) {
|
|
51
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
52
|
+
const helpTextId = React.useId()
|
|
53
|
+
const inputId = React.useId()
|
|
54
|
+
|
|
55
|
+
const exceededMaxSizeFn = React.useCallback(
|
|
56
|
+
(value: string | File | null) => {
|
|
57
|
+
if (value && props.maxSize && value instanceof File) {
|
|
58
|
+
return checkFileMaxSize(value, props.maxSize)
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
},
|
|
62
|
+
[props.maxSize]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const formatNotSupportedFn = React.useCallback(
|
|
66
|
+
(value: string | File | null) => {
|
|
67
|
+
if (value instanceof File) {
|
|
68
|
+
return checkFileType(value, props.accept ?? 'image/*')
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
},
|
|
72
|
+
[props.accept]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Controlled state management
|
|
76
|
+
const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, (value) => {
|
|
77
|
+
if (props.onChange) {
|
|
78
|
+
props.onChange(value)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const exceededMaxSize = React.useMemo(() => exceededMaxSizeFn(value), [value, exceededMaxSizeFn])
|
|
83
|
+
const formatNotSupported = React.useMemo(() => formatNotSupportedFn(value), [value, formatNotSupportedFn])
|
|
84
|
+
|
|
85
|
+
// Form validation
|
|
86
|
+
const {
|
|
87
|
+
validationMessage,
|
|
88
|
+
isInvalid: validationInvalid,
|
|
89
|
+
commitValidation
|
|
90
|
+
} = HeadlessForm.useValidation(
|
|
91
|
+
{
|
|
92
|
+
validate: props.validate,
|
|
93
|
+
value,
|
|
94
|
+
name: props.name,
|
|
95
|
+
isRequired: props.isRequired,
|
|
96
|
+
form: props.form,
|
|
97
|
+
focus() {
|
|
98
|
+
inputRef.current?.focus()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
inputRef
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const isInvalid = props.isInvalid || validationInvalid || Boolean(exceededMaxSize || formatNotSupported)
|
|
105
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
106
|
+
const errorMessage = exceededMaxSize || formatNotSupported || validationMessage
|
|
107
|
+
|
|
108
|
+
const ImagePreview = props.renderPreview ?? DefaultImageInputPreview
|
|
109
|
+
|
|
110
|
+
const accept = props.accept ?? 'image/*'
|
|
111
|
+
|
|
112
|
+
const emptyState = props.emptyState ?? (
|
|
113
|
+
<div className="px-6 py-12">
|
|
114
|
+
<Icon name="photo" className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
|
115
|
+
<p className="text-sm text-neutral-600 font-medium">Click to upload or drag and drop</p>
|
|
116
|
+
<p className="text-xs text-neutral-500 mt-1">{accept}</p>
|
|
117
|
+
{props.maxSize ? <p className="text-xs text-neutral-500 mt-1">Max size: {formatFileSize(props.maxSize)}</p> : null}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<HeadlessFileInput
|
|
123
|
+
value={value}
|
|
124
|
+
onChange={(v) => {
|
|
125
|
+
setValue(v)
|
|
126
|
+
commitValidation()
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<div className="relative">
|
|
130
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
131
|
+
|
|
132
|
+
<HeadlessFileInput.Preview>
|
|
133
|
+
{(files) => {
|
|
134
|
+
const hasFile = files.length > 0
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
className={tw(
|
|
138
|
+
'image-input-border relative block w-full rounded-md overflow-hidden text-center focus:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
|
|
139
|
+
'border-2 border-neutral-200 hover:border-neutral-300 min-h-56',
|
|
140
|
+
!hasFile && 'border-dashed',
|
|
141
|
+
props.isDisabled && 'border-neutral-100 cursor-not-allowed',
|
|
142
|
+
hasWarning &&
|
|
143
|
+
'border-notice-300 hover:border-notice-400 text-notice-900 placeholder-notice-300 focus-within:border-notice-500 focus-within:ring-notice-500',
|
|
144
|
+
isInvalid &&
|
|
145
|
+
'border-negative-300 hover:border-negative-400 text-negative-900 placeholder-negative-300 focus-within:border-negative-500 focus-within:ring-negative-500'
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{hasFile ? (
|
|
149
|
+
ImagePreview({
|
|
150
|
+
isDisabled: !!props.isDisabled,
|
|
151
|
+
fileUrl: files[0]!.fileUrl,
|
|
152
|
+
onChange: (v) => {
|
|
153
|
+
setValue(v)
|
|
154
|
+
commitValidation()
|
|
155
|
+
},
|
|
156
|
+
inputId,
|
|
157
|
+
inputRef,
|
|
158
|
+
name: props.name,
|
|
159
|
+
accept
|
|
160
|
+
})
|
|
161
|
+
) : (
|
|
162
|
+
<HeadlessFileInput.DropZone
|
|
163
|
+
inputRef={inputRef}
|
|
164
|
+
accept={accept}
|
|
165
|
+
name={props.name}
|
|
166
|
+
inputId={inputId}
|
|
167
|
+
disabled={props.isDisabled}
|
|
168
|
+
className="size-full"
|
|
169
|
+
>
|
|
170
|
+
{({ dragOver }) => (
|
|
171
|
+
<div className="relative pointer-events-none">
|
|
172
|
+
{emptyState}
|
|
173
|
+
{dragOver ? (
|
|
174
|
+
<div className="absolute inset-0">
|
|
175
|
+
<DragOverContent />
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</HeadlessFileInput.DropZone>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}}
|
|
185
|
+
</HeadlessFileInput.Preview>
|
|
186
|
+
|
|
187
|
+
<FormFieldComponents.FormFieldHelpText
|
|
188
|
+
id={helpTextId}
|
|
189
|
+
isInvalid={isInvalid}
|
|
190
|
+
validationMessage={errorMessage}
|
|
191
|
+
hasWarning={hasWarning}
|
|
192
|
+
warningMessage={props.warningMessage}
|
|
193
|
+
helpText={props.helpText}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</HeadlessFileInput>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function DragOverContent() {
|
|
201
|
+
return (
|
|
202
|
+
<div
|
|
203
|
+
className="absolute inset-0 p-12 rounded flex items-center justify-center"
|
|
204
|
+
style={{
|
|
205
|
+
background: `repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 20px, #d1d5db 20px, #d1d5db 50px)`
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<div>
|
|
209
|
+
<Icon name="cloud-arrow-up" className="h-12 w-12 mx-auto text-gray-400" />
|
|
210
|
+
<span className="mt-2 block text-sm font-semibold text-gray-900">Upload an Image</span>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function DefaultImageInputPreview({
|
|
217
|
+
name,
|
|
218
|
+
accept,
|
|
219
|
+
fileUrl,
|
|
220
|
+
inputRef,
|
|
221
|
+
isDisabled,
|
|
222
|
+
onChange,
|
|
223
|
+
inputId
|
|
224
|
+
}: ImageInputPreviewProps) {
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<HeadlessFileInput.DropZone
|
|
228
|
+
inputRef={inputRef}
|
|
229
|
+
accept={accept ?? 'image/*'}
|
|
230
|
+
name={name}
|
|
231
|
+
inputId={inputId}
|
|
232
|
+
disabled={isDisabled}
|
|
233
|
+
>
|
|
234
|
+
{({ dragOver }) => (
|
|
235
|
+
<div className="min-h-56 pointer-events-none">
|
|
236
|
+
{dragOver ? (
|
|
237
|
+
<div className="absolute inset-0">
|
|
238
|
+
<DragOverContent />
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
<div className="relative">
|
|
242
|
+
<div
|
|
243
|
+
aria-hidden="true"
|
|
244
|
+
className="absolute inset-x-0 top-0 rounded-t-md h-20 bg-gradient-to-b from-white opacity-75 mix-blend-lighten"
|
|
245
|
+
/>
|
|
246
|
+
<img src={fileUrl} alt="Preview" className="w-full object-cover rounded-md" />
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</HeadlessFileInput.DropZone>
|
|
251
|
+
<div className="absolute top-2 right-2">
|
|
252
|
+
{!isDisabled ? (
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
aria-label="Remove"
|
|
256
|
+
onClick={() => onChange(null)}
|
|
257
|
+
className="p-1 h-5 w-5 flex items-center justify-center bg-gray-800/60 hover:bg-gray-500/60 rounded-full transition-colors"
|
|
258
|
+
>
|
|
259
|
+
<Icon name="x-mark" className="h-4 w-4 text-gray-200" />
|
|
260
|
+
</button>
|
|
261
|
+
) : null}
|
|
262
|
+
</div>
|
|
263
|
+
</>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { tw } from '../utils/tw'
|
|
3
|
+
|
|
4
|
+
export type ImageFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
|
5
|
+
export type ImageShape = 'rounded' | 'rectangle' | 'circle'
|
|
6
|
+
|
|
7
|
+
export type ImgProps = {
|
|
8
|
+
src: string
|
|
9
|
+
alt?: string
|
|
10
|
+
className?: string
|
|
11
|
+
fit?: ImageFit
|
|
12
|
+
shape?: ImageShape
|
|
13
|
+
style?: React.CSSProperties
|
|
14
|
+
onError?: React.ReactEventHandler<HTMLImageElement>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fitClasses: Record<ImageFit, string> = {
|
|
18
|
+
cover: 'object-cover object-center',
|
|
19
|
+
contain: 'object-contain',
|
|
20
|
+
fill: 'object-fill',
|
|
21
|
+
none: 'object-none',
|
|
22
|
+
'scale-down': 'object-scale-down'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const shapeClasses: Record<ImageShape, string> = {
|
|
26
|
+
rounded: 'rounded',
|
|
27
|
+
rectangle: 'rounded-none',
|
|
28
|
+
circle: 'rounded-full'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Img = React.forwardRef<HTMLImageElement, ImgProps>(
|
|
32
|
+
({ src, alt, className, fit = 'cover', shape = 'rounded', style, onError }, ref) => {
|
|
33
|
+
return (
|
|
34
|
+
<img
|
|
35
|
+
ref={ref}
|
|
36
|
+
src={src}
|
|
37
|
+
alt={alt}
|
|
38
|
+
className={tw('w-full h-full', fitClasses[fit], shapeClasses[shape], className)}
|
|
39
|
+
style={style}
|
|
40
|
+
onError={onError}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Img.displayName = 'Img'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { tw } from '../utils/tw'
|
|
3
|
+
import { Icon } from './icon'
|
|
4
|
+
|
|
5
|
+
export type InlineAlertProps = {
|
|
6
|
+
children?: React.ReactNode
|
|
7
|
+
variant: 'info' | 'positive' | 'notice' | 'negative' | 'neutral'
|
|
8
|
+
icon?: React.ReactNode
|
|
9
|
+
className?: string
|
|
10
|
+
title?: React.ReactNode
|
|
11
|
+
description?: React.ReactNode
|
|
12
|
+
actions?: React.ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const variantClasses = {
|
|
16
|
+
info: 'bg-info-500/10 text-info-500 border-info-500/20',
|
|
17
|
+
positive: 'bg-positive-50 text-positive-800 border-positive-200',
|
|
18
|
+
notice: 'bg-notice-50 text-notice-800 border-notice-200',
|
|
19
|
+
negative: 'bg-negative-50 text-negative-800 border-negative-200',
|
|
20
|
+
neutral: 'bg-neutral-100 text-neutral-700 border-neutral-200'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultIconNames = {
|
|
24
|
+
info: 'information-circle',
|
|
25
|
+
positive: 'check',
|
|
26
|
+
notice: 'exclamation-triangle',
|
|
27
|
+
negative: 'x-mark',
|
|
28
|
+
neutral: 'information-circle'
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
export function InlineAlert({ children, actions, variant, icon, className, title, description }: InlineAlertProps) {
|
|
32
|
+
const displayIcon = icon !== undefined ? icon : <Icon name={defaultIconNames[variant]} className="h-5 w-5" />
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={tw(
|
|
37
|
+
'rounded-md border px-4 py-3 flex gap-3 items-start justify-between text-left',
|
|
38
|
+
variantClasses[variant],
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
role="alert"
|
|
42
|
+
>
|
|
43
|
+
<div className="flex gap-3 items-start">
|
|
44
|
+
{displayIcon && <div className="flex-shrink-0">{displayIcon}</div>}
|
|
45
|
+
<div className="flex-1 gap-1">
|
|
46
|
+
{title && <div className={tw('font-semibold text-sm')}>{title}</div>}
|
|
47
|
+
{description && <div className="text-sm">{description}</div>}
|
|
48
|
+
{children && <div className="text-sm">{children}</div>}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
{actions}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|