@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.
Files changed (89) hide show
  1. package/commands/add.ts +41 -0
  2. package/commands/index.ts +7 -0
  3. package/commands/list.ts +9 -0
  4. package/dist/components.json +1 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +48 -0
  7. package/package.json +49 -0
  8. package/registry.json +1445 -0
  9. package/scripts/build.ts +44 -0
  10. package/src/components/alert-dialog.tsx +150 -0
  11. package/src/components/autocomplete.tsx +288 -0
  12. package/src/components/autosuggest.tsx +314 -0
  13. package/src/components/avatar.tsx +54 -0
  14. package/src/components/boolean-select.tsx +48 -0
  15. package/src/components/button-group.tsx +75 -0
  16. package/src/components/button-link.tsx +71 -0
  17. package/src/components/button.tsx +132 -0
  18. package/src/components/checkbox-group.tsx +172 -0
  19. package/src/components/checkbox.tsx +207 -0
  20. package/src/components/chip.tsx +158 -0
  21. package/src/components/container.tsx +82 -0
  22. package/src/components/currency-field.tsx +183 -0
  23. package/src/components/date-input.tsx +189 -0
  24. package/src/components/date-picker.tsx +211 -0
  25. package/src/components/date-range-picker.tsx +290 -0
  26. package/src/components/date-time-picker.tsx +196 -0
  27. package/src/components/dialog.tsx +97 -0
  28. package/src/components/disclosure.tsx +114 -0
  29. package/src/components/drawer.tsx +78 -0
  30. package/src/components/enum-chip.tsx +30 -0
  31. package/src/components/file-input.tsx +245 -0
  32. package/src/components/form.tsx +82 -0
  33. package/src/components/headless-file-input.tsx +362 -0
  34. package/src/components/helpers/animated-popover.tsx +24 -0
  35. package/src/components/helpers/button-context.ts +10 -0
  36. package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
  37. package/src/components/helpers/form-field.tsx +229 -0
  38. package/src/components/helpers/get-button-classes.ts +138 -0
  39. package/src/components/helpers/headless-button.tsx +36 -0
  40. package/src/components/helpers/pdf-dist.client.ts +6 -0
  41. package/src/components/icon.tsx +26 -0
  42. package/src/components/image-input.tsx +265 -0
  43. package/src/components/img.tsx +46 -0
  44. package/src/components/inline-alert.tsx +54 -0
  45. package/src/components/labeled-value.tsx +480 -0
  46. package/src/components/link-tabs.tsx +118 -0
  47. package/src/components/menu.tsx +152 -0
  48. package/src/components/month-day-input.tsx +176 -0
  49. package/src/components/multi-file-input.tsx +244 -0
  50. package/src/components/multi-image-input.tsx +389 -0
  51. package/src/components/multicomplete.tsx +322 -0
  52. package/src/components/multiselect.tsx +325 -0
  53. package/src/components/multisuggest.tsx +357 -0
  54. package/src/components/number-field.tsx +143 -0
  55. package/src/components/numeric-tag-field.tsx +271 -0
  56. package/src/components/pdf-input.tsx +249 -0
  57. package/src/components/pdf.tsx +86 -0
  58. package/src/components/percentage-field.tsx +187 -0
  59. package/src/components/phone-number-field.tsx +166 -0
  60. package/src/components/radio-group.tsx +112 -0
  61. package/src/components/radio.tsx +91 -0
  62. package/src/components/select.tsx +215 -0
  63. package/src/components/spinner.tsx +16 -0
  64. package/src/components/stepper.tsx +181 -0
  65. package/src/components/switch.tsx +186 -0
  66. package/src/components/tabs.tsx +151 -0
  67. package/src/components/tag-field.tsx +250 -0
  68. package/src/components/text-area.tsx +148 -0
  69. package/src/components/text-field.tsx +144 -0
  70. package/src/components/time-input.tsx +198 -0
  71. package/src/components/toast.tsx +176 -0
  72. package/src/components/toggle-button-group.tsx +94 -0
  73. package/src/components/year-month-input.tsx +187 -0
  74. package/src/utils/colors.ts +44 -0
  75. package/src/utils/compose-refs.ts +32 -0
  76. package/src/utils/enum-colors.ts +1 -0
  77. package/src/utils/file-input.ts +49 -0
  78. package/src/utils/icons.d.ts +20 -0
  79. package/src/utils/tw.ts +13 -0
  80. package/src/utils/use-element-size.ts +35 -0
  81. package/src/utils/use-number-input.ts +143 -0
  82. package/src/utils/use-pagination.ts +38 -0
  83. package/src/utils/use-prevent-default.ts +27 -0
  84. package/src/utils/use-render-props.ts +39 -0
  85. package/src/utils/use-spin-delay.ts +24 -0
  86. package/src/utils/use-stable-accessor.ts +11 -0
  87. package/src/utils/use-tab-indicator.ts +106 -0
  88. package/tests/commands.test.ts +81 -0
  89. package/tsconfig.json +27 -0
@@ -0,0 +1,480 @@
1
+ import * as React from 'react'
2
+ import { tw } from '../utils/tw'
3
+ import {
4
+ type Iso,
5
+ dateFns,
6
+ timeFns,
7
+ dateTimeFns,
8
+ instantFns,
9
+ zonedDateTimeFns,
10
+ yearMonthFns,
11
+ monthDayFns,
12
+ durationFns
13
+ } from 'iso-fns'
14
+
15
+ type LabeledValueBaseProps = {
16
+ label?: React.ReactNode
17
+ align?: 'start' | 'end'
18
+ labelAlign?: 'start' | 'end'
19
+ orientation?: 'horizontal' | 'vertical'
20
+ emptyText?: React.ReactNode
21
+ className?: string
22
+ labelClassName?: string
23
+ valueClassName?: string
24
+ id?: string
25
+ 'aria-labelledby'?: string
26
+ 'aria-describedby'?: string
27
+ }
28
+
29
+ type RangeValue<T> = { start: T; end: T }
30
+
31
+ type StringProps = {
32
+ value: string | null | undefined
33
+ formatOptions?: never
34
+ }
35
+
36
+ type NumberProps = {
37
+ value: number | RangeValue<number> | null | undefined
38
+ formatOptions?: Intl.NumberFormatOptions
39
+ }
40
+
41
+ type BooleanProps = {
42
+ value: boolean | null | undefined
43
+ formatOptions?: {
44
+ trueLabel?: string
45
+ falseLabel?: string
46
+ }
47
+ }
48
+
49
+ type ReactNodeProps = {
50
+ value: React.ReactNode
51
+ formatOptions?: never
52
+ }
53
+
54
+ type StringListProps = {
55
+ value: string[] | null | undefined
56
+ formatOptions?: Intl.ListFormatOptions
57
+ }
58
+
59
+ type DateProps = {
60
+ value: Iso.Date | RangeValue<Iso.Date> | null | undefined
61
+ formatOptions?: {
62
+ dateFormat?: string
63
+ }
64
+ }
65
+
66
+ type TimeProps = {
67
+ value: Iso.Time | RangeValue<Iso.Time> | null | undefined
68
+ formatOptions?: {
69
+ timeFormat?: string
70
+ }
71
+ }
72
+
73
+ type DateTimeProps = {
74
+ value: Iso.DateTime | RangeValue<Iso.DateTime> | null | undefined
75
+ formatOptions?: {
76
+ dateTimeFormat?: string
77
+ }
78
+ }
79
+
80
+ type InstantProps = {
81
+ value: Iso.Instant | RangeValue<Iso.Instant> | null | undefined
82
+ formatOptions?: {
83
+ dateTimeFormat?: string
84
+ timeZone?: string
85
+ }
86
+ }
87
+
88
+ type ZonedDateTimeProps = {
89
+ value: Iso.ZonedDateTime | RangeValue<Iso.ZonedDateTime> | null | undefined
90
+ formatOptions?: {
91
+ dateTimeFormat?: string
92
+ }
93
+ }
94
+
95
+ type YearMonthProps = {
96
+ value: Iso.YearMonth | RangeValue<Iso.YearMonth> | null | undefined
97
+ formatOptions?: {
98
+ dateFormat?: string
99
+ }
100
+ }
101
+
102
+ type MonthDayProps = {
103
+ value: Iso.MonthDay | RangeValue<Iso.MonthDay> | null | undefined
104
+ formatOptions?: {
105
+ dateFormat?: string
106
+ }
107
+ }
108
+
109
+ type DurationProps = {
110
+ value: Iso.Duration | RangeValue<Iso.Duration> | null | undefined
111
+ formatOptions?: {
112
+ style?: 'short' | 'long'
113
+ }
114
+ }
115
+
116
+ type LabeledValueFormatProps =
117
+ | StringProps
118
+ | NumberProps
119
+ | BooleanProps
120
+ | ReactNodeProps
121
+ | StringListProps
122
+ | DateProps
123
+ | TimeProps
124
+ | DateTimeProps
125
+ | InstantProps
126
+ | ZonedDateTimeProps
127
+ | YearMonthProps
128
+ | MonthDayProps
129
+ | DurationProps
130
+
131
+ export type LabeledValueProps = LabeledValueBaseProps & LabeledValueFormatProps
132
+
133
+ export const LabeledValue = React.forwardRef<HTMLDivElement, LabeledValueProps>(function LabeledValue(props, ref) {
134
+ const {
135
+ label,
136
+ align = 'start',
137
+ labelAlign = 'start',
138
+ orientation = 'vertical',
139
+ className,
140
+ labelClassName,
141
+ valueClassName,
142
+ id,
143
+ 'aria-labelledby': ariaLabelledBy,
144
+ 'aria-describedby': ariaDescribedBy
145
+ } = props
146
+
147
+ const labelId = React.useId()
148
+ const actualLabelId = id ?? labelId
149
+
150
+ const formattedValue = FormattedValue(props)
151
+
152
+ return (
153
+ <div
154
+ ref={ref}
155
+ className={tw('relative', orientation === 'horizontal' ? 'flex items-baseline gap-4' : 'block', className)}
156
+ >
157
+ {label && (
158
+ <LabeledValueLabel
159
+ id={actualLabelId}
160
+ className={tw(
161
+ orientation === 'horizontal' ? 'flex-shrink-0' : 'mb-1',
162
+ labelAlign === 'end' && 'text-right',
163
+ labelClassName
164
+ )}
165
+ >
166
+ {label}
167
+ </LabeledValueLabel>
168
+ )}
169
+
170
+ <div
171
+ className={tw(
172
+ 'text-sm text-neutral-900 min-h-4 max-w-full break-words overflow-wrap-anywhere',
173
+ align === 'end' && 'text-right',
174
+ !formattedValue && 'text-neutral-400',
175
+ valueClassName
176
+ )}
177
+ aria-labelledby={ariaLabelledBy ?? (label ? actualLabelId : undefined)}
178
+ aria-describedby={ariaDescribedBy}
179
+ >
180
+ {formattedValue}
181
+ </div>
182
+ </div>
183
+ )
184
+ })
185
+
186
+ LabeledValue.displayName = 'LabeledValue'
187
+
188
+ function FormattedValue(props: LabeledValueFormatProps & { emptyText?: React.ReactNode }) {
189
+ if (props.value == null || props.value === '') {
190
+ return <>{props.emptyText}</>
191
+ }
192
+
193
+ if (React.isValidElement(props.value)) {
194
+ return <>{props.value}</>
195
+ }
196
+
197
+ if (typeof props.value === 'boolean') {
198
+ const boolProps = props as BooleanProps
199
+ return <>{props.value ? (boolProps.formatOptions?.trueLabel ?? 'Yes') : (boolProps.formatOptions?.falseLabel ?? 'No')}</>
200
+ }
201
+
202
+ if (Array.isArray(props.value)) {
203
+ return <ListValue {...(props as StringListProps)} />
204
+ }
205
+
206
+ if (typeof props.value === 'object' && props.value && 'start' in props.value && 'end' in props.value) {
207
+ if (typeof props.value.start === 'number') {
208
+ return <NumberValue {...(props as NumberProps)} />
209
+ }
210
+ if (typeof props.value.start === 'string' && dateFns.isValid(props.value.start)) {
211
+ return <DateValue {...(props as DateProps)} />
212
+ }
213
+ if (typeof props.value.start === 'string' && timeFns.isValid(props.value.start)) {
214
+ return <TimeValue {...(props as TimeProps)} />
215
+ }
216
+ if (typeof props.value.start === 'string' && dateTimeFns.isValid(props.value.start)) {
217
+ return <DateTimeValue {...(props as DateTimeProps)} />
218
+ }
219
+ if (typeof props.value.start === 'string' && instantFns.isValid(props.value.start)) {
220
+ return <InstantValue {...(props as InstantProps)} />
221
+ }
222
+ if (typeof props.value.start === 'string' && zonedDateTimeFns.isValid(props.value.start)) {
223
+ return <ZonedDateTimeValue {...(props as ZonedDateTimeProps)} />
224
+ }
225
+ if (typeof props.value.start === 'string' && yearMonthFns.isValid(props.value.start)) {
226
+ return <YearMonthValue {...(props as YearMonthProps)} />
227
+ }
228
+ if (typeof props.value.start === 'string' && monthDayFns.isValid(props.value.start)) {
229
+ return <MonthDayValue {...(props as MonthDayProps)} />
230
+ }
231
+ if (typeof props.value.start === 'string' && durationFns.isValid(props.value.start)) {
232
+ return <DurationValue {...(props as DurationProps)} />
233
+ }
234
+ }
235
+
236
+ if (typeof props.value === 'string') {
237
+ if (dateFns.isValid(props.value)) {
238
+ return <DateValue {...(props as DateProps)} />
239
+ }
240
+ if (timeFns.isValid(props.value)) {
241
+ return <TimeValue {...(props as TimeProps)} />
242
+ }
243
+ if (dateTimeFns.isValid(props.value)) {
244
+ return <DateTimeValue {...(props as DateTimeProps)} />
245
+ }
246
+ if (instantFns.isValid(props.value)) {
247
+ return <InstantValue {...(props as InstantProps)} />
248
+ }
249
+ if (zonedDateTimeFns.isValid(props.value)) {
250
+ return <ZonedDateTimeValue {...(props as ZonedDateTimeProps)} />
251
+ }
252
+ if (yearMonthFns.isValid(props.value)) {
253
+ return <YearMonthValue {...(props as YearMonthProps)} />
254
+ }
255
+ if (monthDayFns.isValid(props.value)) {
256
+ return <MonthDayValue {...(props as MonthDayProps)} />
257
+ }
258
+ if (durationFns.isValid(props.value)) {
259
+ return <DurationValue {...(props as DurationProps)} />
260
+ }
261
+
262
+ return <>{props.value}</>
263
+ }
264
+
265
+ if (typeof props.value === 'number') {
266
+ return <NumberValue {...(props as NumberProps)} />
267
+ }
268
+
269
+ return <>{String(props.value)}</>
270
+ }
271
+
272
+ function NumberValue(props: NumberProps) {
273
+ const formatter = React.useMemo(() => new Intl.NumberFormat('en-us', props.formatOptions ?? {}), [props.formatOptions])
274
+ const formatted = React.useMemo(() => {
275
+ if (props.value && typeof props.value === 'object') {
276
+ return `${formatter.format(props.value.start)} – ${formatter.format(props.value.end)}`
277
+ } else if (typeof props.value === 'number') {
278
+ return formatter.format(props.value)
279
+ } else {
280
+ return props.value ?? null
281
+ }
282
+ }, [props.value, formatter])
283
+ return <>{formatted}</>
284
+ }
285
+
286
+ function ListValue(props: StringListProps) {
287
+ const formatter = React.useMemo(
288
+ () => new Intl.ListFormat('en-us', props.formatOptions ?? { style: 'short' }),
289
+ [props.formatOptions]
290
+ )
291
+ const formatted = React.useMemo(() => {
292
+ if (props.value && props.value.length) {
293
+ return formatter.format(props.value)
294
+ } else {
295
+ return null
296
+ }
297
+ }, [props.value, formatter])
298
+ return <>{formatted}</>
299
+ }
300
+
301
+ function DateValue(props: DateProps) {
302
+ const format = props.formatOptions?.dateFormat ?? 'M/d/yyyy'
303
+ const formatted = React.useMemo(() => {
304
+ if (props.value && typeof props.value === 'object') {
305
+ return `${dateFns.format(props.value.start, format)} – ${dateFns.format(props.value.end, format)}`
306
+ } else if (typeof props.value === 'string') {
307
+ return dateFns.format(props.value, format)
308
+ } else {
309
+ return null
310
+ }
311
+ }, [props.value, format])
312
+ return <>{formatted}</>
313
+ }
314
+
315
+ function TimeValue(props: TimeProps) {
316
+ const format = props.formatOptions?.timeFormat ?? 'h:mm a'
317
+ const formatted = React.useMemo(() => {
318
+ if (props.value && typeof props.value === 'object') {
319
+ return `${timeFns.format(props.value.start, format)} – ${timeFns.format(props.value.end, format)}`
320
+ } else if (typeof props.value === 'string') {
321
+ return timeFns.format(props.value, format)
322
+ } else {
323
+ return null
324
+ }
325
+ }, [props.value, format])
326
+ return <>{formatted}</>
327
+ }
328
+
329
+ function DateTimeValue(props: DateTimeProps) {
330
+ const format = props.formatOptions?.dateTimeFormat ?? 'M/d/yyyy h:mm a'
331
+ const formatted = React.useMemo(() => {
332
+ if (props.value && typeof props.value === 'object') {
333
+ return `${dateTimeFns.format(props.value.start, format)} – ${dateTimeFns.format(props.value.end, format)}`
334
+ } else if (typeof props.value === 'string') {
335
+ return dateTimeFns.format(props.value, format)
336
+ } else {
337
+ return null
338
+ }
339
+ }, [props.value, format])
340
+ return <>{formatted}</>
341
+ }
342
+
343
+ function InstantValue(props: InstantProps) {
344
+ const timeZone = props.formatOptions?.timeZone
345
+ const format = props.formatOptions?.dateTimeFormat ?? (timeZone ? 'M/d/yyyy h:mm a z' : 'M/d/yyyy h:mm a')
346
+
347
+ const formatted = React.useMemo(() => {
348
+ const formatInstant = (instant: Iso.Instant) => {
349
+ if (timeZone) {
350
+ const zonedDateTime = instantFns.toZonedDateTime(instant, timeZone)
351
+ return zonedDateTimeFns.format(zonedDateTime, format)
352
+ }
353
+ return instantFns.chain(instant).toZonedDateTime('UTC').format(format).value()
354
+ }
355
+
356
+ if (props.value && typeof props.value === 'object') {
357
+ return `${formatInstant(props.value.start)} – ${formatInstant(props.value.end)}`
358
+ } else if (typeof props.value === 'string') {
359
+ return formatInstant(props.value)
360
+ } else {
361
+ return null
362
+ }
363
+ }, [props.value, format, timeZone])
364
+ return <>{formatted}</>
365
+ }
366
+
367
+ function ZonedDateTimeValue(props: ZonedDateTimeProps) {
368
+ const format = props.formatOptions?.dateTimeFormat ?? 'M/d/yyyy h:mm a z'
369
+ const formatted = React.useMemo(() => {
370
+ if (props.value && typeof props.value === 'object') {
371
+ return `${zonedDateTimeFns.format(props.value.start, format)} – ${zonedDateTimeFns.format(props.value.end, format)}`
372
+ } else if (typeof props.value === 'string') {
373
+ return zonedDateTimeFns.format(props.value, format)
374
+ } else {
375
+ return null
376
+ }
377
+ }, [props.value, format])
378
+ return <>{formatted}</>
379
+ }
380
+
381
+ function YearMonthValue(props: YearMonthProps) {
382
+ const format = props.formatOptions?.dateFormat ?? 'MMMM yyyy'
383
+ const formatted = React.useMemo(() => {
384
+ if (props.value && typeof props.value === 'object') {
385
+ return `${yearMonthFns.format(props.value.start, format)} – ${yearMonthFns.format(props.value.end, format)}`
386
+ } else if (typeof props.value === 'string') {
387
+ return yearMonthFns.format(props.value, format)
388
+ } else {
389
+ return null
390
+ }
391
+ }, [props.value, format])
392
+ return <>{formatted}</>
393
+ }
394
+
395
+ function MonthDayValue(props: MonthDayProps) {
396
+ const format = props.formatOptions?.dateFormat ?? 'MMMM d'
397
+ const formatted = React.useMemo(() => {
398
+ if (props.value && typeof props.value === 'object') {
399
+ return `${monthDayFns.format(props.value.start, format)} – ${monthDayFns.format(props.value.end, format)}`
400
+ } else if (typeof props.value === 'string') {
401
+ return monthDayFns.format(props.value, format)
402
+ } else {
403
+ return null
404
+ }
405
+ }, [props.value, format])
406
+ return <>{formatted}</>
407
+ }
408
+
409
+ function DurationValue(props: DurationProps) {
410
+ const durationFormat = props.formatOptions?.style ?? 'short'
411
+
412
+ const formatted = React.useMemo(() => {
413
+ const formatDuration = (duration: Iso.Duration) => {
414
+ const fields = durationFns.getFields(duration)
415
+
416
+ const partFormatters: Record<string, Record<string, string>> = {
417
+ years: {
418
+ long: Math.abs(fields.years) > 1 ? `${fields.years} years` : `${fields.years} year`,
419
+ short: `${fields.years}y`
420
+ },
421
+ months: {
422
+ long: Math.abs(fields.months) > 1 ? `${fields.months} months` : `${fields.months} month`,
423
+ short: `${fields.months}M`
424
+ },
425
+ weeks: {
426
+ long: Math.abs(fields.weeks) > 1 ? `${fields.weeks} weeks` : `${fields.weeks} week`,
427
+ short: `${fields.weeks}w`
428
+ },
429
+ days: {
430
+ long: Math.abs(fields.days) > 1 ? `${fields.days} days` : `${fields.days} day`,
431
+ short: `${fields.days}d`
432
+ },
433
+ hours: {
434
+ long: Math.abs(fields.hours) > 1 ? `${fields.hours} hours` : `${fields.hours} hour`,
435
+ short: `${fields.hours}h`
436
+ },
437
+ minutes: {
438
+ long: Math.abs(fields.minutes) > 1 ? `${fields.minutes} minutes` : `${fields.minutes} minute`,
439
+ short: `${fields.minutes}m`
440
+ },
441
+ seconds: {
442
+ long: Math.abs(fields.seconds) > 1 ? `${fields.seconds} seconds` : `${fields.seconds} second`,
443
+ short: `${fields.seconds}s`
444
+ },
445
+ milliseconds: {
446
+ long:
447
+ Math.abs(fields.milliseconds) > 1 ? `${fields.milliseconds} milliseconds` : `${fields.milliseconds} millisecond`,
448
+ short: `${fields.milliseconds}ms`
449
+ }
450
+ }
451
+
452
+ const parts = Object.entries(partFormatters)
453
+ .map(([key, value]) => (fields[key as keyof typeof fields] !== 0 ? value[durationFormat] : null))
454
+ .filter(Boolean) as string[]
455
+ if (!parts.length) {
456
+ return durationFormat === 'long' ? '0 seconds' : '0s'
457
+ } else {
458
+ return parts.join(' ')
459
+ }
460
+ }
461
+
462
+ if (props.value && typeof props.value === 'object') {
463
+ return `${formatDuration(props.value.start)} – ${formatDuration(props.value.end)}`
464
+ } else if (typeof props.value === 'string') {
465
+ return formatDuration(props.value)
466
+ } else {
467
+ return null
468
+ }
469
+ }, [props.value, durationFormat])
470
+ return <>{formatted}</>
471
+ }
472
+
473
+ export function LabeledValueLabel(props: React.ComponentProps<'label'>) {
474
+ return (
475
+ <label
476
+ {...props}
477
+ className={tw('block uppercase text-xs tracking-widest font-normal text-neutral-400', props.className)}
478
+ />
479
+ )
480
+ }
@@ -0,0 +1,118 @@
1
+ import * as React from 'react'
2
+ import { NavLink } from 'react-router'
3
+ import type { To } from 'react-router'
4
+ import { tw } from '../utils/tw'
5
+ import {
6
+ TAB_BASE_CLASSES,
7
+ TAB_DISABLED_CLASSES,
8
+ TAB_INACTIVE_CLASSES,
9
+ TAB_OVAL_BASE_CLASSES,
10
+ TAB_OVAL_SELECTED_CLASSES,
11
+ tabColorMap,
12
+ tabListContainerClasses,
13
+ tabListInnerClasses,
14
+ getIndicatorClasses,
15
+ useAnimatedIndicator
16
+ } from '../utils/use-tab-indicator'
17
+ import type { TabColor, TabVariant } from '../utils/use-tab-indicator'
18
+
19
+ // --- Types ---
20
+
21
+ export type LinkTabListProps = {
22
+ children: React.ReactNode
23
+ className?: string
24
+ color?: TabColor
25
+ variant?: TabVariant
26
+ }
27
+
28
+ export type LinkTabProps = {
29
+ to: To
30
+ children: React.ReactNode
31
+ className?: string
32
+ disabled?: boolean
33
+ replace?: boolean
34
+ end?: boolean
35
+ }
36
+
37
+ // --- LinkTabList ---
38
+
39
+ const LinkTabListContext = React.createContext<{ color: TabColor; variant: TabVariant }>({ color: 'primary', variant: 'underline' })
40
+
41
+ function LinkTabList({ children, className, color = 'primary', variant = 'underline' }: LinkTabListProps) {
42
+ const containerRef = React.useRef<HTMLDivElement>(null)
43
+ const indicatorRef = useAnimatedIndicator(containerRef, '[aria-current="page"]', 'aria-current')
44
+
45
+ return (
46
+ <LinkTabListContext.Provider value={{ color, variant }}>
47
+ <div ref={containerRef} className={tabListContainerClasses[variant]}>
48
+ <div role="tablist" className={tw(tabListInnerClasses[variant], className)}>
49
+ {children}
50
+ </div>
51
+ <div
52
+ ref={indicatorRef}
53
+ className={getIndicatorClasses(variant, color)}
54
+ style={{ opacity: 0 }}
55
+ />
56
+ </div>
57
+ </LinkTabListContext.Provider>
58
+ )
59
+ }
60
+
61
+ LinkTabList.displayName = 'LinkTabList'
62
+
63
+ // --- LinkTab ---
64
+
65
+ function LinkTab({ to, children, className, disabled, replace, end }: LinkTabProps) {
66
+ const { color, variant } = React.useContext(LinkTabListContext)
67
+ const colors = tabColorMap[color]
68
+ const baseClasses = variant === 'oval' ? TAB_OVAL_BASE_CLASSES : TAB_BASE_CLASSES
69
+ const focusClasses = `outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:${colors.outline}`
70
+
71
+ if (disabled) {
72
+ return (
73
+ <span
74
+ role="tab"
75
+ aria-disabled="true"
76
+ className={tw(
77
+ baseClasses,
78
+ 'outline-none',
79
+ TAB_INACTIVE_CLASSES,
80
+ TAB_DISABLED_CLASSES,
81
+ className
82
+ )}
83
+ >
84
+ {children}
85
+ </span>
86
+ )
87
+ }
88
+
89
+ return (
90
+ <NavLink
91
+ to={to}
92
+ replace={replace}
93
+ end={end}
94
+ role="tab"
95
+ className={({ isActive }) =>
96
+ tw(
97
+ baseClasses,
98
+ focusClasses,
99
+ isActive
100
+ ? (variant === 'oval' ? TAB_OVAL_SELECTED_CLASSES : `${colors.text} font-bold`)
101
+ : `${TAB_INACTIVE_CLASSES} hover:text-neutral-700`,
102
+ className
103
+ )
104
+ }
105
+ >
106
+ {children}
107
+ </NavLink>
108
+ )
109
+ }
110
+
111
+ LinkTab.displayName = 'LinkTab'
112
+
113
+ // --- Export ---
114
+
115
+ export const LinkTabs = {
116
+ List: LinkTabList,
117
+ Tab: LinkTab
118
+ }