@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,32 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
type PossibleRef<T> = React.Ref<T> | undefined
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Set a given ref to a given value
|
|
7
|
+
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
|
8
|
+
*/
|
|
9
|
+
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
|
10
|
+
if (typeof ref === 'function') {
|
|
11
|
+
ref(value)
|
|
12
|
+
} else if (ref !== null && ref !== undefined) {
|
|
13
|
+
;(ref as React.MutableRefObject<T>).current = value
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A utility to compose multiple refs together
|
|
19
|
+
* Accepts callback refs and RefObject(s)
|
|
20
|
+
*/
|
|
21
|
+
export function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
22
|
+
return (node: T) => refs.forEach((ref) => setRef(ref, node))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A custom hook that composes multiple refs
|
|
27
|
+
* Accepts callback refs and RefObject(s)
|
|
28
|
+
*/
|
|
29
|
+
export function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
return React.useCallback(composeRefs(...refs), refs)
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { COLORS, getColor, stringToNumber } from './colors'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for file-input components.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatFileSize(bytes: number) {
|
|
6
|
+
if (bytes < 1024) return `${bytes} B`
|
|
7
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
8
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function checkFileType(file: File, accept: string): string | null {
|
|
12
|
+
const acceptedTypes = accept.split(',').map((type) => type.trim())
|
|
13
|
+
const fileType = file.type
|
|
14
|
+
const fileExtension = `.${file.name.split('.').pop()?.toLowerCase() || ''}`
|
|
15
|
+
|
|
16
|
+
const isValid = acceptedTypes.some((acceptType) => {
|
|
17
|
+
if (acceptType.includes('/*')) {
|
|
18
|
+
const prefix = acceptType.split('/')[0]
|
|
19
|
+
return fileType.startsWith(`${prefix}/`)
|
|
20
|
+
}
|
|
21
|
+
if (acceptType.startsWith('.')) {
|
|
22
|
+
return acceptType.toLowerCase() === fileExtension
|
|
23
|
+
}
|
|
24
|
+
return fileType === acceptType
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return isValid ? null : `Unsupported file type. Supported types: ${acceptedTypes.join(', ')}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function checkFileMaxSize(file: File, maxSize: number): string | null {
|
|
31
|
+
if (file.size > maxSize) {
|
|
32
|
+
return `File exceeded max size of ${formatFileSize(maxSize)}`
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateFileMaxSize(value: string | File | null, maxSize?: number): string | null {
|
|
38
|
+
if (value instanceof File && maxSize) {
|
|
39
|
+
return checkFileMaxSize(value, maxSize)
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateFileType(value: string | File | null, accept?: string): string | null {
|
|
45
|
+
if (value instanceof File && accept) {
|
|
46
|
+
return checkFileType(value, accept)
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
declare module '~/icons/types' {
|
|
2
|
+
export const iconNames = [
|
|
3
|
+
'chevron-right',
|
|
4
|
+
'chevron-left',
|
|
5
|
+
'calendar',
|
|
6
|
+
'chevron-up-down',
|
|
7
|
+
'check',
|
|
8
|
+
'x-mark',
|
|
9
|
+
'cloud-arrow-up',
|
|
10
|
+
'document',
|
|
11
|
+
'photo',
|
|
12
|
+
'information-circle',
|
|
13
|
+
'chevron-down',
|
|
14
|
+
'exclamation-triangle',
|
|
15
|
+
'minus',
|
|
16
|
+
'arrow-path'
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
export type IconName = (typeof iconNames)[number]
|
|
20
|
+
}
|
package/src/utils/tw.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge'
|
|
2
|
+
import { composeRenderProps } from 'react-aria-components'
|
|
3
|
+
|
|
4
|
+
export function tw(...inputs: unknown[]) {
|
|
5
|
+
return twMerge(inputs.flat(Infinity).filter(Boolean).join(' '))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function composeTailwindRenderProps<T>(
|
|
9
|
+
className: string | ((renderProps: T) => string) | undefined,
|
|
10
|
+
tailwind: string
|
|
11
|
+
): string | ((renderProps: T) => string) {
|
|
12
|
+
return composeRenderProps(className, (prev) => tw(tailwind, prev))
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useMemo, useEffect, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
function computeSize(element: HTMLElement | null) {
|
|
4
|
+
if (element === null) return { width: 0, height: 0 }
|
|
5
|
+
const { width, height } = element.getBoundingClientRect()
|
|
6
|
+
return { width, height }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useElementSize(element: HTMLElement | null, unit?: false): { width: number; height: number }
|
|
10
|
+
export function useElementSize(element: HTMLElement | null, unit: true): { width: string; height: string }
|
|
11
|
+
export function useElementSize(element: HTMLElement | null, unit = false) {
|
|
12
|
+
const [identity, forceRerender] = useReducer(() => ({}), {})
|
|
13
|
+
|
|
14
|
+
const size = useMemo(() => computeSize(element), [element, identity])
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!element) return
|
|
18
|
+
|
|
19
|
+
const observer = new ResizeObserver(forceRerender)
|
|
20
|
+
observer.observe(element)
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
observer.disconnect()
|
|
24
|
+
}
|
|
25
|
+
}, [element])
|
|
26
|
+
|
|
27
|
+
if (unit) {
|
|
28
|
+
return {
|
|
29
|
+
width: `${size.width}px`,
|
|
30
|
+
height: `${size.height}px`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return size
|
|
35
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
3
|
+
|
|
4
|
+
export type UseNumberInputOptions = {
|
|
5
|
+
value?: number | null
|
|
6
|
+
defaultValue?: number | null
|
|
7
|
+
onChange?: ((value: number | null) => void) | null
|
|
8
|
+
minValue?: number
|
|
9
|
+
maxValue?: number
|
|
10
|
+
step?: number
|
|
11
|
+
formatOptions?: Intl.NumberFormatOptions
|
|
12
|
+
locale?: string
|
|
13
|
+
isDisabled?: boolean
|
|
14
|
+
isReadOnly?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type UseNumberInputResult = {
|
|
18
|
+
inputValue: string
|
|
19
|
+
setInputValue: (value: string) => void
|
|
20
|
+
numberValue: number | null
|
|
21
|
+
setNumberValue: (value: number | null) => void
|
|
22
|
+
minValue?: number
|
|
23
|
+
maxValue?: number
|
|
24
|
+
commit: (rawValue?: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatNumber(value: number | null | undefined, locale: string, formatOptions?: Intl.NumberFormatOptions): string {
|
|
28
|
+
if (value == null) return ''
|
|
29
|
+
try {
|
|
30
|
+
return new Intl.NumberFormat(locale, formatOptions).format(value)
|
|
31
|
+
} catch {
|
|
32
|
+
return String(value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseNumber(text: string, locale: string, formatOptions?: Intl.NumberFormatOptions): number | null {
|
|
37
|
+
if (text === '' || text == null) return null
|
|
38
|
+
|
|
39
|
+
// Get the decimal and group separators for the locale
|
|
40
|
+
const parts = new Intl.NumberFormat(locale, formatOptions).formatToParts(1234.5)
|
|
41
|
+
const groupSeparator = parts.find((p) => p.type === 'group')?.value ?? ','
|
|
42
|
+
const decimalSeparator = parts.find((p) => p.type === 'decimal')?.value ?? '.'
|
|
43
|
+
|
|
44
|
+
// Strip everything except digits, decimal separator, and minus sign
|
|
45
|
+
let cleaned = text
|
|
46
|
+
// Remove currency symbols, percent signs, and other literals
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (part.type === 'currency' || part.type === 'percentSign' || part.type === 'literal') {
|
|
49
|
+
cleaned = cleaned.replaceAll(part.value, '')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Remove group separators
|
|
53
|
+
cleaned = cleaned.replaceAll(groupSeparator, '')
|
|
54
|
+
// Normalize decimal separator to '.'
|
|
55
|
+
if (decimalSeparator !== '.') {
|
|
56
|
+
cleaned = cleaned.replaceAll(decimalSeparator, '.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cleaned = cleaned.trim()
|
|
60
|
+
if (cleaned === '' || cleaned === '-') return null
|
|
61
|
+
|
|
62
|
+
const num = Number(cleaned)
|
|
63
|
+
|
|
64
|
+
// For percent format, Intl.NumberFormat displays 0.5 as "50%",
|
|
65
|
+
// so we need to divide by 100 when parsing
|
|
66
|
+
if (formatOptions?.style === 'percent') {
|
|
67
|
+
return isNaN(num) ? null : num / 100
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return isNaN(num) ? null : num
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clamp(value: number, min?: number, max?: number): number {
|
|
74
|
+
if (min != null && value < min) return min
|
|
75
|
+
if (max != null && value > max) return max
|
|
76
|
+
return value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useNumberInput(options: UseNumberInputOptions): UseNumberInputResult {
|
|
80
|
+
const { minValue, maxValue, step, formatOptions, locale = 'en-US', isDisabled = false, isReadOnly = false } = options
|
|
81
|
+
|
|
82
|
+
const [numberValue, setNumberValue] = HeadlessForm.useControlledState<number | null>(
|
|
83
|
+
options.value !== undefined ? (options.value ?? null) : undefined!,
|
|
84
|
+
options.defaultValue !== undefined ? (options.defaultValue ?? null) : null,
|
|
85
|
+
options.onChange ?? undefined
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const [inputValue, setInputValueRaw] = React.useState(() => formatNumber(numberValue, locale, formatOptions))
|
|
89
|
+
|
|
90
|
+
// Keep inputValue in sync when numberValue changes externally (controlled mode)
|
|
91
|
+
const prevNumberValueRef = React.useRef(numberValue)
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
if (prevNumberValueRef.current !== numberValue) {
|
|
94
|
+
prevNumberValueRef.current = numberValue
|
|
95
|
+
setInputValueRaw(formatNumber(numberValue, locale, formatOptions))
|
|
96
|
+
}
|
|
97
|
+
}, [numberValue, locale, formatOptions])
|
|
98
|
+
|
|
99
|
+
const setInputValue = React.useCallback(
|
|
100
|
+
(text: string) => {
|
|
101
|
+
if (isDisabled || isReadOnly) return
|
|
102
|
+
setInputValueRaw(text)
|
|
103
|
+
},
|
|
104
|
+
[isDisabled, isReadOnly]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const commit = React.useCallback(
|
|
108
|
+
(rawValue?: string) => {
|
|
109
|
+
if (isDisabled || isReadOnly) return
|
|
110
|
+
|
|
111
|
+
const parsed = parseNumber(rawValue ?? inputValue, locale, formatOptions)
|
|
112
|
+
if (parsed == null) {
|
|
113
|
+
setNumberValue(null)
|
|
114
|
+
setInputValueRaw('')
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let clamped = clamp(parsed, minValue, maxValue)
|
|
119
|
+
|
|
120
|
+
// Snap to step if provided
|
|
121
|
+
if (step != null && minValue != null) {
|
|
122
|
+
const remainder = (clamped - minValue) % step
|
|
123
|
+
if (remainder !== 0) {
|
|
124
|
+
clamped = clamped - remainder
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setNumberValue(clamped)
|
|
129
|
+
setInputValueRaw(formatNumber(clamped, locale, formatOptions))
|
|
130
|
+
},
|
|
131
|
+
[inputValue, locale, formatOptions, minValue, maxValue, step, isDisabled, isReadOnly, setNumberValue]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
inputValue,
|
|
136
|
+
setInputValue,
|
|
137
|
+
numberValue,
|
|
138
|
+
setNumberValue,
|
|
139
|
+
minValue,
|
|
140
|
+
maxValue,
|
|
141
|
+
commit
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface PaginationPage {
|
|
4
|
+
label: string
|
|
5
|
+
value: number | null
|
|
6
|
+
readonly: boolean
|
|
7
|
+
selected: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function usePagination({ currentPage, pageCount }: { currentPage: number; pageCount: number }): PaginationPage[] {
|
|
11
|
+
return React.useMemo(() => {
|
|
12
|
+
function getPage(i: number): PaginationPage {
|
|
13
|
+
return { label: `${i + 1}`, value: i, readonly: false, selected: currentPage === i }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const first: PaginationPage = { label: '1', value: 0, readonly: false, selected: currentPage === 0 }
|
|
17
|
+
const ellipsis: PaginationPage = { label: '...', value: null, readonly: true, selected: false }
|
|
18
|
+
const last: PaginationPage = {
|
|
19
|
+
label: `${pageCount}`,
|
|
20
|
+
value: pageCount - 1,
|
|
21
|
+
readonly: false,
|
|
22
|
+
selected: currentPage === pageCount - 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pageCount < 8) {
|
|
26
|
+
return Array.from({ length: pageCount }, (_, i) => getPage(i))
|
|
27
|
+
} else if (currentPage < 5) {
|
|
28
|
+
const leading = Array.from({ length: 5 }, (_, i) => getPage(i))
|
|
29
|
+
return [...leading, ellipsis, last]
|
|
30
|
+
} else if (currentPage > pageCount - 5) {
|
|
31
|
+
const trailing = Array.from({ length: 5 }, (_, i) => getPage(i + pageCount - 4))
|
|
32
|
+
return [first, ellipsis, ...trailing.filter((p) => p.value! <= pageCount - 1)]
|
|
33
|
+
} else {
|
|
34
|
+
const siblings = Array.from({ length: 3 }, (_, i) => getPage(i + currentPage - 1))
|
|
35
|
+
return [first, ellipsis, ...siblings, ellipsis, last]
|
|
36
|
+
}
|
|
37
|
+
}, [pageCount, currentPage])
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useStableAccessor } from './use-stable-accessor'
|
|
3
|
+
|
|
4
|
+
export function usePreventDefault<E extends React.SyntheticEvent<any>>(
|
|
5
|
+
eventHandler: React.EventHandler<E> | undefined,
|
|
6
|
+
preventDefault: boolean
|
|
7
|
+
): React.EventHandler<E> {
|
|
8
|
+
const getShouldPreventDefault = useStableAccessor(preventDefault)
|
|
9
|
+
const getEventHandler = useStableAccessor(eventHandler)
|
|
10
|
+
|
|
11
|
+
const myEventHandler = React.useCallback(
|
|
12
|
+
(event: E) => {
|
|
13
|
+
if (getShouldPreventDefault()) {
|
|
14
|
+
event.preventDefault()
|
|
15
|
+
event.stopPropagation()
|
|
16
|
+
return
|
|
17
|
+
} else {
|
|
18
|
+
const eventHandler = getEventHandler()
|
|
19
|
+
if (eventHandler) {
|
|
20
|
+
return eventHandler(event)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
[getEventHandler, getShouldPreventDefault]
|
|
25
|
+
)
|
|
26
|
+
return myEventHandler
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {}
|
|
4
|
+
|
|
5
|
+
type DefaultRenderPropKeys<Props> = Extract<'children' | 'className' | 'style', keyof Props>
|
|
6
|
+
|
|
7
|
+
type MaybeRenderProp<Value, RenderProps> = Value | ((renderProps: RenderProps) => Value)
|
|
8
|
+
|
|
9
|
+
export type WithRenderProps<
|
|
10
|
+
Props extends Record<string, any>,
|
|
11
|
+
RenderProps,
|
|
12
|
+
Keys extends keyof Props = DefaultRenderPropKeys<Props>
|
|
13
|
+
> = Prettify<{
|
|
14
|
+
[K in keyof Props]: K extends Keys ? MaybeRenderProp<Props[K], RenderProps> : Props[K]
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export function useRenderProps<
|
|
18
|
+
Props extends Record<string, any>,
|
|
19
|
+
RenderProps,
|
|
20
|
+
Keys extends keyof Props = DefaultRenderPropKeys<Props>
|
|
21
|
+
>(props: Props, renderProps: RenderProps, keys: Keys[]) {
|
|
22
|
+
const withRenderProps = React.useMemo(
|
|
23
|
+
() =>
|
|
24
|
+
Object.fromEntries(
|
|
25
|
+
keys
|
|
26
|
+
.map((key) => {
|
|
27
|
+
if (key in props && typeof props[key] === 'function') {
|
|
28
|
+
return [key, props[key](renderProps)]
|
|
29
|
+
} else {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.filter((k) => k.length)
|
|
34
|
+
),
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
[...keys, renderProps, props, ...keys.map((key) => props[key])]
|
|
37
|
+
)
|
|
38
|
+
return { ...props, ...withRenderProps }
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export function useSpinDelay(loading: boolean, { delay = 500, minDuration = 200 } = {}) {
|
|
4
|
+
const [show, setShow] = React.useState(false)
|
|
5
|
+
const delayTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
6
|
+
const minTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
if (loading) {
|
|
10
|
+
delayTimer.current = setTimeout(() => setShow(true), delay)
|
|
11
|
+
} else {
|
|
12
|
+
clearTimeout(delayTimer.current)
|
|
13
|
+
if (show) {
|
|
14
|
+
minTimer.current = setTimeout(() => setShow(false), minDuration)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return () => {
|
|
18
|
+
clearTimeout(delayTimer.current)
|
|
19
|
+
clearTimeout(minTimer.current)
|
|
20
|
+
}
|
|
21
|
+
}, [loading, delay, minDuration, show])
|
|
22
|
+
|
|
23
|
+
return show
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export function useStableAccessor<T>(value: T) {
|
|
4
|
+
const initialPersisterKey = React.useRef<{ value: T }>({ value })
|
|
5
|
+
const getValue = React.useCallback(() => {
|
|
6
|
+
return initialPersisterKey.current.value
|
|
7
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
8
|
+
}, [initialPersisterKey.current])
|
|
9
|
+
initialPersisterKey.current.value = value
|
|
10
|
+
return getValue
|
|
11
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
// --- Shared Tab Styles ---
|
|
4
|
+
|
|
5
|
+
export const TAB_BASE_CLASSES = 'relative px-4 py-2 text-sm cursor-default transition-colors duration-200'
|
|
6
|
+
export const TAB_INACTIVE_CLASSES = 'text-neutral-500 font-medium'
|
|
7
|
+
export const TAB_DISABLED_CLASSES = 'opacity-50 cursor-not-allowed'
|
|
8
|
+
|
|
9
|
+
// --- Tab Variant ---
|
|
10
|
+
|
|
11
|
+
export type TabVariant = 'underline' | 'oval'
|
|
12
|
+
|
|
13
|
+
// --- Tab Color Map ---
|
|
14
|
+
|
|
15
|
+
export const tabColorMap = {
|
|
16
|
+
primary: {
|
|
17
|
+
text: 'text-primary-500',
|
|
18
|
+
bg: 'bg-primary-500',
|
|
19
|
+
outline: 'outline-primary-500'
|
|
20
|
+
},
|
|
21
|
+
neutral: {
|
|
22
|
+
text: 'text-neutral-800',
|
|
23
|
+
bg: 'bg-neutral-800',
|
|
24
|
+
outline: 'outline-neutral-800'
|
|
25
|
+
}
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export type TabColor = keyof typeof tabColorMap
|
|
29
|
+
|
|
30
|
+
// --- Variant-Specific Styles ---
|
|
31
|
+
|
|
32
|
+
export const tabListContainerClasses: Record<TabVariant, string> = {
|
|
33
|
+
underline: 'relative',
|
|
34
|
+
oval: 'relative inline-flex bg-neutral-100 rounded-full p-1'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const tabListInnerClasses: Record<TabVariant, string> = {
|
|
38
|
+
underline: 'flex space-x-1 border-b border-neutral-200',
|
|
39
|
+
oval: 'flex space-x-1'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getIndicatorClasses(variant: TabVariant, color: TabColor): string {
|
|
43
|
+
if (variant === 'underline') {
|
|
44
|
+
return `absolute bottom-0 h-[3px] rounded-full ${tabColorMap[color].bg}`
|
|
45
|
+
}
|
|
46
|
+
return `absolute inset-y-1 rounded-full ${tabColorMap[color].bg}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const TAB_OVAL_BASE_CLASSES = 'relative z-10 px-4 py-1.5 text-sm cursor-default transition-colors duration-200 rounded-full'
|
|
50
|
+
export const TAB_OVAL_SELECTED_CLASSES = 'text-white font-semibold'
|
|
51
|
+
|
|
52
|
+
// --- Animated Indicator Hook ---
|
|
53
|
+
|
|
54
|
+
export function useAnimatedIndicator(
|
|
55
|
+
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
56
|
+
activeSelector: string,
|
|
57
|
+
observedAttribute: string
|
|
58
|
+
) {
|
|
59
|
+
const indicatorRef = React.useRef<HTMLDivElement>(null)
|
|
60
|
+
const hasAnimated = React.useRef(false)
|
|
61
|
+
|
|
62
|
+
const updateIndicator = React.useCallback(() => {
|
|
63
|
+
const container = containerRef.current
|
|
64
|
+
const indicator = indicatorRef.current
|
|
65
|
+
if (!container || !indicator) return
|
|
66
|
+
|
|
67
|
+
const activeTab = container.querySelector(activeSelector) as HTMLElement | null
|
|
68
|
+
if (activeTab) {
|
|
69
|
+
const containerRect = container.getBoundingClientRect()
|
|
70
|
+
const tabRect = activeTab.getBoundingClientRect()
|
|
71
|
+
|
|
72
|
+
if (!hasAnimated.current) {
|
|
73
|
+
indicator.style.transition = 'none'
|
|
74
|
+
hasAnimated.current = true
|
|
75
|
+
} else {
|
|
76
|
+
indicator.style.transition = 'left 200ms ease, width 200ms ease'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
indicator.style.left = `${tabRect.left - containerRect.left}px`
|
|
80
|
+
indicator.style.width = `${tabRect.width}px`
|
|
81
|
+
indicator.style.opacity = '1'
|
|
82
|
+
} else {
|
|
83
|
+
indicator.style.opacity = '0'
|
|
84
|
+
}
|
|
85
|
+
}, [containerRef, activeSelector])
|
|
86
|
+
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
updateIndicator()
|
|
89
|
+
|
|
90
|
+
const container = containerRef.current
|
|
91
|
+
if (!container) return
|
|
92
|
+
|
|
93
|
+
const observer = new MutationObserver(updateIndicator)
|
|
94
|
+
observer.observe(container, { attributes: true, subtree: true, attributeFilter: [observedAttribute] })
|
|
95
|
+
|
|
96
|
+
const resizeObserver = new ResizeObserver(updateIndicator)
|
|
97
|
+
resizeObserver.observe(container)
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
observer.disconnect()
|
|
101
|
+
resizeObserver.disconnect()
|
|
102
|
+
}
|
|
103
|
+
}, [containerRef, updateIndicator, observedAttribute])
|
|
104
|
+
|
|
105
|
+
return indicatorRef
|
|
106
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test } from 'beartest-js'
|
|
2
|
+
import { expect } from 'expect'
|
|
3
|
+
import { Components } from '../dist/index.js'
|
|
4
|
+
import { readFile, rm, mkdtemp, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
|
|
8
|
+
let tempDir: string
|
|
9
|
+
|
|
10
|
+
test.beforeEach(async () => {
|
|
11
|
+
tempDir = await mkdtemp(join(tmpdir(), 'components-test-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test.afterEach(async () => {
|
|
15
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// --- list ---
|
|
19
|
+
|
|
20
|
+
test('list returns an array of component names', async () => {
|
|
21
|
+
const names = await Components.list()
|
|
22
|
+
expect(Array.isArray(names)).toBe(true)
|
|
23
|
+
expect(names.length).toBeGreaterThan(0)
|
|
24
|
+
expect(names.every((n) => typeof n === 'string')).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('list includes known components', async () => {
|
|
28
|
+
const names = await Components.list()
|
|
29
|
+
expect(names).toContain('button')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// --- add ---
|
|
33
|
+
|
|
34
|
+
test('add writes component files to the target directory', async () => {
|
|
35
|
+
const written = await Components.add({ components: ['button'], directory: tempDir })
|
|
36
|
+
expect(written.length).toBeGreaterThan(0)
|
|
37
|
+
|
|
38
|
+
for (const filePath of written) {
|
|
39
|
+
const content = await readFile(filePath, 'utf-8')
|
|
40
|
+
expect(content.length).toBeGreaterThan(0)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('add skips existing files when overwrite is false', async () => {
|
|
45
|
+
// Write all files first
|
|
46
|
+
const firstRun = await Components.add({ components: ['button'], directory: tempDir })
|
|
47
|
+
expect(firstRun.length).toBeGreaterThan(0)
|
|
48
|
+
|
|
49
|
+
// Second run should skip everything
|
|
50
|
+
const secondRun = await Components.add({ components: ['button'], directory: tempDir })
|
|
51
|
+
expect(secondRun.length).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('add overwrites existing files when overwrite is true', async () => {
|
|
55
|
+
const firstRun = await Components.add({ components: ['button'], directory: tempDir })
|
|
56
|
+
expect(firstRun.length).toBeGreaterThan(0)
|
|
57
|
+
|
|
58
|
+
// Overwrite a file with different content
|
|
59
|
+
await writeFile(firstRun[0]!, 'overwritten')
|
|
60
|
+
|
|
61
|
+
const secondRun = await Components.add({ components: ['button'], directory: tempDir, overwrite: true })
|
|
62
|
+
expect(secondRun.length).toBe(firstRun.length)
|
|
63
|
+
|
|
64
|
+
// Verify it was overwritten back to the original content
|
|
65
|
+
const content = await readFile(firstRun[0]!, 'utf-8')
|
|
66
|
+
expect(content).not.toBe('overwritten')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('add throws for unknown component', async () => {
|
|
70
|
+
await expect(Components.add({ components: ['nonexistent-component'], directory: tempDir })).rejects.toThrow(
|
|
71
|
+
'Component "nonexistent-component" not found in registry'
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('add handles multiple components', async () => {
|
|
76
|
+
const names = await Components.list()
|
|
77
|
+
if (names.length < 2) return
|
|
78
|
+
|
|
79
|
+
const written = await Components.add({ components: names.slice(0, 2), directory: tempDir })
|
|
80
|
+
expect(written.length).toBeGreaterThan(0)
|
|
81
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"allowJs": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
|
|
16
|
+
"strict": true,
|
|
17
|
+
"noImplicitAny": false,
|
|
18
|
+
"noImplicitOverride": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true,
|
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
|
21
|
+
|
|
22
|
+
"noEmit": true,
|
|
23
|
+
"verbatimModuleSyntax": true,
|
|
24
|
+
"noUncheckedIndexedAccess": true
|
|
25
|
+
},
|
|
26
|
+
"include": ["src", "scripts", "commands"]
|
|
27
|
+
}
|