@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,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
+ }
@@ -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
+ }