@otfdashkit/ui 0.1.1 → 0.1.2

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 (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +28 -41
  3. package/dist/index.js +206 -125
  4. package/dist/index.mjs +194 -124
  5. package/package.json +5 -4
  6. package/src/advanced/BulkActions/BulkActions.tsx +2 -2
  7. package/src/advanced/CommandBar/CommandBar.tsx +2 -2
  8. package/src/advanced/Filters/Filters.tsx +6 -6
  9. package/src/advanced/Kanban/Kanban.tsx +1 -1
  10. package/src/advanced/ToggleButton/ToggleButton.tsx +1 -1
  11. package/src/advanced/bulk-actions.tsx +1 -46
  12. package/src/advanced/command-bar.tsx +1 -115
  13. package/src/advanced/filters.tsx +1 -103
  14. package/src/advanced/kanban.tsx +1 -95
  15. package/src/advanced/toggle-button.tsx +1 -78
  16. package/src/blocks/ChatDetail/ChatDetail.tsx +4 -4
  17. package/src/blocks/FeedbackModal/FeedbackModal.tsx +3 -3
  18. package/src/blocks/FileCards/FileCards.tsx +2 -2
  19. package/src/blocks/FilesList/FilesList.tsx +3 -3
  20. package/src/blocks/IntegrationCard/IntegrationCard.tsx +3 -3
  21. package/src/blocks/InviteModal/InviteModal.tsx +7 -7
  22. package/src/blocks/ManageTagsModal/ManageTagsModal.tsx +4 -4
  23. package/src/blocks/MessagesCard/MessagesCard.tsx +3 -3
  24. package/src/blocks/MetricCard/MetricCard.tsx +3 -3
  25. package/src/blocks/NotificationSettings/NotificationSettings.tsx +4 -4
  26. package/src/blocks/OrganizationMenu/OrganizationMenu.tsx +30 -11
  27. package/src/blocks/RolesMenu/RolesMenu.tsx +4 -4
  28. package/src/blocks/SelectUsersModal/SelectUsersModal.tsx +5 -5
  29. package/src/blocks/SidebarLayoutDashboard/SidebarLayoutDashboard.tsx +5 -5
  30. package/src/blocks/SidebarLayoutGroups/SidebarLayoutGroups.tsx +2 -2
  31. package/src/blocks/SidebarLayoutMinimal/SidebarLayoutMinimal.tsx +3 -3
  32. package/src/blocks/SidebarLayoutSearch/SidebarLayoutSearch.tsx +3 -3
  33. package/src/blocks/SidebarLayoutUser/SidebarLayoutUser.tsx +4 -4
  34. package/src/blocks/SortableTaskList/SortableTaskList.tsx +3 -3
  35. package/src/blocks/StackedLayoutBranded/StackedLayoutBranded.tsx +4 -4
  36. package/src/blocks/StackedLayoutTabs/StackedLayoutTabs.tsx +4 -4
  37. package/src/blocks/TaskCard/TaskCard.tsx +4 -4
  38. package/src/blocks/UserMenu/UserMenu.tsx +3 -3
  39. package/src/blocks/WorkspaceMembers/WorkspaceMembers.tsx +5 -5
  40. package/src/blocks/chat-detail.tsx +1 -73
  41. package/src/blocks/feedback-modal.tsx +1 -68
  42. package/src/blocks/file-cards.tsx +1 -56
  43. package/src/blocks/files-list.tsx +1 -72
  44. package/src/blocks/integration-card.tsx +1 -50
  45. package/src/blocks/invite-modal.tsx +1 -69
  46. package/src/blocks/manage-tags-modal.tsx +1 -63
  47. package/src/blocks/messages-card.tsx +1 -59
  48. package/src/blocks/metric-card.tsx +1 -79
  49. package/src/blocks/notification-settings.tsx +1 -61
  50. package/src/blocks/organization-menu.tsx +1 -85
  51. package/src/blocks/roles-menu.tsx +1 -61
  52. package/src/blocks/select-users-modal.tsx +1 -64
  53. package/src/blocks/sidebar-layout-dashboard.tsx +1 -97
  54. package/src/blocks/sidebar-layout-groups.tsx +1 -79
  55. package/src/blocks/sidebar-layout-minimal.tsx +1 -73
  56. package/src/blocks/sidebar-layout-search.tsx +1 -79
  57. package/src/blocks/sidebar-layout-user.tsx +1 -82
  58. package/src/blocks/sortable-task-list.tsx +1 -70
  59. package/src/blocks/stacked-layout-branded.tsx +1 -64
  60. package/src/blocks/stacked-layout-tabs.tsx +1 -80
  61. package/src/blocks/task-card.tsx +1 -88
  62. package/src/blocks/user-menu.tsx +1 -62
  63. package/src/blocks/workspace-members.tsx +1 -61
  64. package/src/charts/sparkline.tsx +1 -1
  65. package/src/components/DataGrid/DataGrid.tsx +1 -1
  66. package/src/components/DataTable/DataTable.tsx +4 -4
  67. package/src/components/EmptyState/EmptyState.tsx +6 -22
  68. package/src/components/Persona/Persona.tsx +1 -1
  69. package/src/components/Stat/Stat.tsx +1 -1
  70. package/src/components/Stepper/Stepper.tsx +13 -2
  71. package/src/components/banner.tsx +1 -203
  72. package/src/components/breadcrumb.tsx +1 -58
  73. package/src/components/command-item.tsx +1 -36
  74. package/src/components/data-grid.tsx +1 -125
  75. package/src/components/data-table.tsx +1 -139
  76. package/src/components/empty-state.tsx +1 -31
  77. package/src/components/hotkeys.tsx +1 -31
  78. package/src/components/icon-badge.tsx +1 -35
  79. package/src/components/loading-overlay.tsx +1 -38
  80. package/src/components/persona.tsx +1 -38
  81. package/src/components/property-list.tsx +1 -29
  82. package/src/components/stat.tsx +1 -51
  83. package/src/components/stepper.tsx +1 -69
  84. package/src/components/structured-list.tsx +1 -51
  85. package/src/components/timeline.tsx +1 -69
  86. package/src/components/toaster.tsx +1 -42
  87. package/src/data/DataList.tsx +1 -1
  88. package/src/engagement/Tour/Tour.tsx +1 -1
  89. package/src/engagement/beacon.tsx +1 -50
  90. package/src/engagement/tour.tsx +1 -106
  91. package/src/forms/ArrayField/ArrayField.tsx +2 -2
  92. package/src/forms/AutoForm/AutoForm.tsx +9 -8
  93. package/src/forms/DatePicker/DatePicker.tsx +3 -3
  94. package/src/forms/FloatingLabelInput/FloatingLabelInput.tsx +104 -0
  95. package/src/forms/FloatingLabelInput/index.ts +1 -0
  96. package/src/forms/PasswordInput/PasswordInput.tsx +2 -2
  97. package/src/forms/SearchInput/SearchInput.tsx +2 -2
  98. package/src/forms/StepForm/StepForm.tsx +2 -2
  99. package/src/forms/array-field.tsx +1 -61
  100. package/src/forms/auto-form.tsx +1 -133
  101. package/src/forms/date-picker.tsx +1 -109
  102. package/src/forms/field.tsx +1 -62
  103. package/src/forms/file-upload.tsx +1 -101
  104. package/src/forms/floating-label-input.tsx +1 -0
  105. package/src/forms/form.tsx +1 -57
  106. package/src/forms/object-field.tsx +1 -53
  107. package/src/forms/password-input.tsx +1 -54
  108. package/src/forms/search-input.tsx +1 -64
  109. package/src/forms/step-form.tsx +1 -113
  110. package/src/index.ts +6 -3
  111. package/src/primitives/Button/Button.tsx +18 -0
  112. package/src/primitives/code-block.tsx +1 -1
  113. package/src/primitives/comparison.tsx +1 -1
  114. package/src/primitives/qr-code.tsx +5 -1
  115. package/src/themes/base.css +18 -0
@@ -1,50 +1 @@
1
- import React from 'react'
2
- import { cn } from '../utils/cn'
3
-
4
- const colorMap = {
5
- primary: 'bg-[hsl(var(--primary))]',
6
- success: 'bg-green-500',
7
- warning: 'bg-yellow-500',
8
- error: 'bg-red-500',
9
- }
10
-
11
- const sizeMap = {
12
- sm: 'h-1.5 w-1.5',
13
- md: 'h-2.5 w-2.5',
14
- lg: 'h-3.5 w-3.5',
15
- }
16
-
17
- export interface BeaconProps {
18
- color?: keyof typeof colorMap
19
- size?: keyof typeof sizeMap
20
- children?: React.ReactNode
21
- className?: string
22
- }
23
-
24
- function BeaconDot({ color = 'primary', size = 'md' }: Pick<BeaconProps, 'color' | 'size'>) {
25
- return (
26
- <span className="relative flex">
27
- <span className={cn('animate-ping absolute inline-flex h-full w-full rounded-full opacity-75', colorMap[color])} />
28
- <span className={cn('relative inline-flex rounded-full', sizeMap[size], colorMap[color])} />
29
- </span>
30
- )
31
- }
32
-
33
- export function Beacon({ color = 'primary', size = 'md', children, className }: BeaconProps) {
34
- if (!children) {
35
- return (
36
- <span className={cn('inline-flex', className)}>
37
- <BeaconDot color={color} size={size} />
38
- </span>
39
- )
40
- }
41
-
42
- return (
43
- <span className={cn('relative inline-flex', className)}>
44
- {children}
45
- <span className="absolute -top-1 -right-1 flex">
46
- <BeaconDot color={color} size={size} />
47
- </span>
48
- </span>
49
- )
50
- }
1
+ export * from './Beacon/index'
@@ -1,106 +1 @@
1
- import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
2
- import { cn } from '../utils/cn'
3
- import { Button } from '../primitives/button'
4
-
5
- interface TourContextValue {
6
- currentStep: number
7
- totalSteps: number
8
- isActive: boolean
9
- start: () => void
10
- stop: () => void
11
- next: () => void
12
- prev: () => void
13
- goTo: (step: number) => void
14
- }
15
-
16
- const TourContext = createContext<TourContextValue | null>(null)
17
-
18
- export function useTour(): TourContextValue {
19
- const ctx = useContext(TourContext)
20
- if (!ctx) throw new Error('useTour must be used within <Tour>')
21
- return ctx
22
- }
23
-
24
- export interface TourProps {
25
- totalSteps: number
26
- children: React.ReactNode
27
- }
28
-
29
- export function Tour({ totalSteps, children }: TourProps) {
30
- const [currentStep, setCurrentStep] = useState(0)
31
- const [isActive, setIsActive] = useState(false)
32
-
33
- const stop = useCallback(() => setIsActive(false), [])
34
- const start = useCallback(() => { setCurrentStep(0); setIsActive(true) }, [])
35
- const next = useCallback(() => setCurrentStep(s => Math.min(s + 1, totalSteps - 1)), [totalSteps])
36
- const prev = useCallback(() => setCurrentStep(s => Math.max(s - 1, 0)), [])
37
- const goTo = useCallback((step: number) => setCurrentStep(Math.max(0, Math.min(step, totalSteps - 1))), [totalSteps])
38
-
39
- useEffect(() => {
40
- if (!isActive) return
41
- const onKey = (e: KeyboardEvent) => {
42
- if (e.key === 'Escape') stop()
43
- if (e.key === 'ArrowRight') next()
44
- if (e.key === 'ArrowLeft') prev()
45
- }
46
- window.addEventListener('keydown', onKey)
47
- return () => window.removeEventListener('keydown', onKey)
48
- }, [isActive, stop, next, prev])
49
-
50
- return (
51
- <TourContext.Provider value={{ currentStep, totalSteps, isActive, start, stop, next, prev, goTo }}>
52
- {children}
53
- </TourContext.Provider>
54
- )
55
- }
56
-
57
- const placementClasses: Record<string, string> = {
58
- top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
59
- bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
60
- left: 'right-full top-1/2 -translate-y-1/2 mr-2',
61
- right: 'left-full top-1/2 -translate-y-1/2 ml-2',
62
- }
63
-
64
- export interface TourStepProps {
65
- step: number
66
- title: string
67
- description: string
68
- placement?: 'top' | 'bottom' | 'left' | 'right'
69
- children: React.ReactNode
70
- }
71
-
72
- export function TourStep({ step, title, description, placement = 'bottom', children }: TourStepProps) {
73
- const { currentStep, isActive, stop, next, prev, totalSteps } = useTour()
74
- const active = isActive && currentStep === step
75
- const isLast = step === totalSteps - 1
76
-
77
- return (
78
- <span className="relative inline-block">
79
- {active && (
80
- <span
81
- className="fixed inset-0 z-40 pointer-events-none"
82
- style={{ boxShadow: '0 0 0 9999px rgba(0,0,0,0.5)' }}
83
- />
84
- )}
85
- <span className={cn('relative', active && 'z-50 ring-2 ring-[hsl(var(--primary))] ring-offset-2 rounded-md')}>
86
- {children}
87
- </span>
88
- {active && (
89
- <div className={cn('absolute z-50 w-64 rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 shadow-lg', placementClasses[placement])}>
90
- <p className="font-semibold text-sm mb-1">{title}</p>
91
- <p className="text-xs text-[hsl(var(--muted-foreground))] mb-3">{description}</p>
92
- <div className="flex items-center justify-between gap-2">
93
- <span className="text-xs text-[hsl(var(--muted-foreground))]">{step + 1} / {totalSteps}</span>
94
- <div className="flex gap-1.5">
95
- {step > 0 && <Button size="sm" variant="outline" onClick={prev}>Back</Button>}
96
- {isLast
97
- ? <Button size="sm" onClick={stop}>Done</Button>
98
- : <Button size="sm" onClick={next}>Next</Button>
99
- }
100
- </div>
101
- </div>
102
- </div>
103
- )}
104
- </span>
105
- )
106
- }
1
+ export * from './Tour/index'
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { Plus, X } from 'lucide-react'
3
- import { Button } from '../primitives/button'
4
- import { cn } from '../utils/cn'
3
+ import { Button } from '../../primitives/button'
4
+ import { cn } from '../../utils/cn'
5
5
 
6
6
  export interface ArrayFieldProps<T> {
7
7
  value: T[]
@@ -2,12 +2,12 @@ import React from 'react'
2
2
  import { useForm } from 'react-hook-form'
3
3
  import { zodResolver } from '@hookform/resolvers/zod'
4
4
  import * as z from 'zod'
5
- import { Input } from '../primitives/input'
6
- import { Checkbox } from '../primitives/checkbox'
7
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'
8
- import { Button } from '../primitives/button'
9
- import { DatePicker } from './date-picker'
10
- import { Field } from './field'
5
+ import { Input } from '../../primitives/input'
6
+ import { Checkbox } from '../../primitives/checkbox'
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../primitives/select'
8
+ import { Button } from '../../primitives/button'
9
+ import { DatePicker } from '../date-picker'
10
+ import { Field } from '../field'
11
11
 
12
12
  export interface AutoFormProps {
13
13
  schema: z.ZodObject<z.ZodRawShape>
@@ -108,14 +108,15 @@ function FieldRenderer({ name, field, register, setValue, watch, error }: FieldR
108
108
 
109
109
  export function AutoForm({ schema, onSubmit, defaultValues, submitLabel = 'Submit' }: AutoFormProps) {
110
110
  const { register, handleSubmit, setValue, watch, formState } = useForm({
111
- resolver: zodResolver(schema),
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ resolver: zodResolver(schema as any),
112
113
  defaultValues: defaultValues as Record<string, unknown>,
113
114
  })
114
115
 
115
116
  const shape = schema.shape
116
117
 
117
118
  return (
118
- <form onSubmit={handleSubmit(d => onSubmit(d as Record<string, unknown>))} className="flex flex-col gap-4">
119
+ <form onSubmit={handleSubmit((d) => onSubmit(d as unknown as Record<string, unknown>))} className="flex flex-col gap-4">
119
120
  {Object.entries(shape).map(([name, field]) => (
120
121
  <FieldRenderer
121
122
  key={name}
@@ -1,8 +1,8 @@
1
1
  import React, { useState } from 'react'
2
2
  import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
3
- import { Button } from '../primitives/button'
4
- import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
5
- import { cn } from '../utils/cn'
3
+ import { Button } from '../../primitives/button'
4
+ import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
5
+ import { cn } from '../../utils/cn'
6
6
 
7
7
  const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
8
8
  const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useId } from 'react'
4
+ import { cn } from '../../utils/cn'
5
+
6
+ /**
7
+ * FloatingLabelInput — input where the label sits inside the field at rest
8
+ * and floats up + shrinks when the field is focused OR contains text. Cross-
9
+ * platform parity with @otfdashkit/ui-native's FloatingLabelInput.
10
+ *
11
+ * Reference (visual spec): https://www.grepped.ai/animations/components-ui/floating-label
12
+ *
13
+ * Forms pattern, not a primitive — pairs with Form, Field, AutoForm helpers.
14
+ */
15
+
16
+ export interface FloatingLabelInputProps
17
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'placeholder'> {
18
+ /** Label that floats up when focused or filled. */
19
+ label: string
20
+ /** Inline error text shown below the input. */
21
+ error?: string
22
+ /** Helper text shown below the input when there's no error. */
23
+ hint?: string
24
+ /** Wrap the field + label + helper text in this className for layout. */
25
+ wrapperClassName?: string
26
+ }
27
+
28
+ export const FloatingLabelInput = React.forwardRef<HTMLInputElement, FloatingLabelInputProps>(
29
+ ({ label, error, hint, wrapperClassName, className, id, value, defaultValue, onFocus, onBlur, onChange, ...props }, ref) => {
30
+ const autoId = useId()
31
+ const inputId = id ?? autoId
32
+ const errorId = error ? `${inputId}-error` : undefined
33
+ const hintId = hint ? `${inputId}-hint` : undefined
34
+ const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined
35
+
36
+ // "Filled" tracks whether the field has any user-visible content. We
37
+ // compute it from the initial defaultValue / value, then update on every
38
+ // change so the label floats correctly even before focus.
39
+ const [focused, setFocused] = useState(false)
40
+ const [filled, setFilled] = useState(() => {
41
+ if (value !== undefined && value !== null && value !== '') return true
42
+ if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') return true
43
+ return false
44
+ })
45
+
46
+ const isFloated = focused || filled
47
+
48
+ return (
49
+ <div data-slot="floating-label-input" className={cn('flex flex-col gap-1.5', wrapperClassName)}>
50
+ <div className="relative">
51
+ <input
52
+ ref={ref}
53
+ id={inputId}
54
+ value={value}
55
+ defaultValue={defaultValue}
56
+ data-slot="input"
57
+ data-state={isFloated ? 'floated' : 'rest'}
58
+ aria-invalid={Boolean(error) || undefined}
59
+ aria-describedby={describedBy}
60
+ onFocus={(e) => { setFocused(true); onFocus?.(e) }}
61
+ onBlur ={(e) => { setFocused(false); onBlur?.(e) }}
62
+ onChange={(e) => { setFilled(e.target.value.length > 0); onChange?.(e) }}
63
+ className={cn(
64
+ // Reset + structure
65
+ 'peer w-full appearance-none bg-transparent',
66
+ 'h-14 rounded-md border px-3 pt-5 pb-1.5',
67
+ 'text-base text-foreground leading-tight',
68
+ // Border / focus ring colors
69
+ 'border-input focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30',
70
+ error && 'border-destructive focus:border-destructive focus:ring-destructive/30',
71
+ 'disabled:cursor-not-allowed disabled:opacity-50',
72
+ 'placeholder:text-transparent', // hide native placeholder; label takes its job
73
+ className,
74
+ )}
75
+ {...props}
76
+ />
77
+
78
+ <label
79
+ htmlFor={inputId}
80
+ className={cn(
81
+ 'pointer-events-none absolute left-3',
82
+ 'origin-[0_50%] transition-all duration-150 ease-out',
83
+ 'text-muted-foreground',
84
+ // Rest: centered vertically inside the field, full size
85
+ !isFloated && 'top-1/2 -translate-y-1/2 text-base',
86
+ // Floated: tucked into the top with a small subtle background
87
+ isFloated && 'top-1.5 text-[11px] uppercase tracking-wider text-foreground/85',
88
+ error && 'text-destructive',
89
+ )}
90
+ >
91
+ {label}
92
+ </label>
93
+ </div>
94
+
95
+ {error ? (
96
+ <p id={errorId} className="text-xs text-destructive">{error}</p>
97
+ ) : hint ? (
98
+ <p id={hintId} className="text-xs text-muted-foreground">{hint}</p>
99
+ ) : null}
100
+ </div>
101
+ )
102
+ },
103
+ )
104
+ FloatingLabelInput.displayName = 'FloatingLabelInput'
@@ -0,0 +1 @@
1
+ export * from './FloatingLabelInput'
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react'
2
2
  import { Eye, EyeOff } from 'lucide-react'
3
- import { Input, type InputProps } from '../primitives/input'
4
- import { cn } from '../utils/cn'
3
+ import { Input, type InputProps } from '../../primitives/input'
4
+ import { cn } from '../../utils/cn'
5
5
 
6
6
  export interface PasswordInputProps extends InputProps {
7
7
  strength?: boolean
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react'
2
2
  import { Search, X } from 'lucide-react'
3
- import { cn } from '../utils/cn'
4
- import { type InputProps } from '../primitives/input'
3
+ import { cn } from '../../utils/cn'
4
+ import { type InputProps } from '../../primitives/input'
5
5
 
6
6
  export interface SearchInputProps extends Omit<InputProps, 'onChange'> {
7
7
  shortcut?: string
@@ -1,6 +1,6 @@
1
1
  import React, { createContext, useContext, useState } from 'react'
2
- import { Button } from '../primitives/button'
3
- import { cn } from '../utils/cn'
2
+ import { Button } from '../../primitives/button'
3
+ import { cn } from '../../utils/cn'
4
4
 
5
5
  interface StepFormContextValue {
6
6
  currentStep: number
@@ -1,61 +1 @@
1
- import React from 'react'
2
- import { Plus, X } from 'lucide-react'
3
- import { Button } from '../primitives/button'
4
- import { cn } from '../utils/cn'
5
-
6
- export interface ArrayFieldProps<T> {
7
- value: T[]
8
- onChange: (v: T[]) => void
9
- renderItem: (item: T, index: number, remove: () => void) => React.ReactNode
10
- addLabel?: string
11
- maxItems?: number
12
- defaultItem: T
13
- className?: string
14
- }
15
-
16
- export function ArrayField<T>({
17
- value,
18
- onChange,
19
- renderItem,
20
- addLabel = 'Add item',
21
- maxItems,
22
- defaultItem,
23
- className,
24
- }: ArrayFieldProps<T>) {
25
- function add() {
26
- if (maxItems && value.length >= maxItems) return
27
- onChange([...value, defaultItem])
28
- }
29
-
30
- function remove(index: number) {
31
- onChange(value.filter((_, i) => i !== index))
32
- }
33
-
34
- const atMax = maxItems !== undefined && value.length >= maxItems
35
-
36
- return (
37
- <div className={cn('flex flex-col gap-2', className)}>
38
- {value.map((item, i) => (
39
- <div key={i} className="flex items-start gap-2">
40
- <div className="flex-1 min-w-0">{renderItem(item, i, () => remove(i))}</div>
41
- <button
42
- type="button"
43
- onClick={() => remove(i)}
44
- className="mt-2 shrink-0 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--destructive))] transition-colors"
45
- >
46
- <X className="h-4 w-4" />
47
- </button>
48
- </div>
49
- ))}
50
- <div className="flex items-center justify-between">
51
- <Button type="button" variant="outline" size="sm" onClick={add} disabled={atMax}>
52
- <Plus className="h-4 w-4" />
53
- {addLabel}
54
- </Button>
55
- {maxItems && (
56
- <span className="text-xs text-[hsl(var(--muted-foreground))]">{value.length}/{maxItems}</span>
57
- )}
58
- </div>
59
- </div>
60
- )
61
- }
1
+ export * from './ArrayField'
@@ -1,133 +1 @@
1
- import React from 'react'
2
- import { useForm } from 'react-hook-form'
3
- import { zodResolver } from '@hookform/resolvers/zod'
4
- import * as z from 'zod'
5
- import { Input } from '../primitives/input'
6
- import { Checkbox } from '../primitives/checkbox'
7
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'
8
- import { Button } from '../primitives/button'
9
- import { DatePicker } from './date-picker'
10
- import { Field } from './field'
11
-
12
- export interface AutoFormProps {
13
- schema: z.ZodObject<z.ZodRawShape>
14
- onSubmit: (data: Record<string, unknown>) => void
15
- defaultValues?: Record<string, unknown>
16
- submitLabel?: string
17
- }
18
-
19
- function toLabel(key: string) {
20
- return key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase())
21
- }
22
-
23
- function getInputType(field: z.ZodTypeAny): string {
24
- if (field instanceof z.ZodEmail) return 'email'
25
- if (field instanceof z.ZodURL) return 'url'
26
- return 'text'
27
- }
28
-
29
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
- function unwrapOptional(field: z.ZodTypeAny): any {
31
- if (field instanceof z.ZodOptional || field instanceof z.ZodNullable) {
32
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
- return unwrapOptional((field as any).unwrap())
34
- }
35
- return field
36
- }
37
-
38
- interface FieldRendererProps {
39
- name: string
40
- field: z.ZodTypeAny
41
- register: ReturnType<typeof useForm>['register']
42
- setValue: ReturnType<typeof useForm>['setValue']
43
- watch: ReturnType<typeof useForm>['watch']
44
- error?: string
45
- }
46
-
47
- function FieldRenderer({ name, field, register, setValue, watch, error }: FieldRendererProps) {
48
- const inner = unwrapOptional(field)
49
- const label = toLabel(name)
50
- const isOptional = field instanceof z.ZodOptional
51
-
52
- if (inner instanceof z.ZodBoolean) {
53
- const checked = Boolean(watch(name))
54
- return (
55
- <Field label={label} error={error}>
56
- <div className="flex items-center gap-2">
57
- <Checkbox
58
- id={name}
59
- checked={checked}
60
- onCheckedChange={v => setValue(name, v)}
61
- />
62
- <label htmlFor={name} className="text-sm text-[hsl(var(--foreground))]">{label}</label>
63
- </div>
64
- </Field>
65
- )
66
- }
67
-
68
- if (inner instanceof z.ZodEnum) {
69
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- const options = ((inner as any).options ?? []) as string[]
71
- const value = watch(name) as string | undefined
72
- return (
73
- <Field label={label} required={!isOptional} error={error}>
74
- <Select value={value} onValueChange={v => setValue(name, v)}>
75
- <SelectTrigger><SelectValue placeholder={`Select ${label.toLowerCase()}`} /></SelectTrigger>
76
- <SelectContent>
77
- {options.map(opt => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
78
- </SelectContent>
79
- </Select>
80
- </Field>
81
- )
82
- }
83
-
84
- if (inner instanceof z.ZodDate) {
85
- const value = watch(name) as Date | undefined
86
- return (
87
- <Field label={label} required={!isOptional} error={error}>
88
- <DatePicker value={value} onChange={d => setValue(name, d)} placeholder={`Select ${label.toLowerCase()}`} />
89
- </Field>
90
- )
91
- }
92
-
93
- if (inner instanceof z.ZodNumber) {
94
- return (
95
- <Field label={label} required={!isOptional} error={error}>
96
- <Input type="number" {...register(name, { valueAsNumber: true })} />
97
- </Field>
98
- )
99
- }
100
-
101
- const inputType = getInputType(inner)
102
- return (
103
- <Field label={label} required={!isOptional} error={error}>
104
- <Input type={inputType} {...register(name)} />
105
- </Field>
106
- )
107
- }
108
-
109
- export function AutoForm({ schema, onSubmit, defaultValues, submitLabel = 'Submit' }: AutoFormProps) {
110
- const { register, handleSubmit, setValue, watch, formState } = useForm({
111
- resolver: zodResolver(schema),
112
- defaultValues: defaultValues as Record<string, unknown>,
113
- })
114
-
115
- const shape = schema.shape
116
-
117
- return (
118
- <form onSubmit={handleSubmit(d => onSubmit(d as Record<string, unknown>))} className="flex flex-col gap-4">
119
- {Object.entries(shape).map(([name, field]) => (
120
- <FieldRenderer
121
- key={name}
122
- name={name}
123
- field={field as z.ZodTypeAny}
124
- register={register}
125
- setValue={setValue}
126
- watch={watch}
127
- error={formState.errors[name]?.message as string | undefined}
128
- />
129
- ))}
130
- <Button type="submit">{submitLabel}</Button>
131
- </form>
132
- )
133
- }
1
+ export * from './AutoForm'
@@ -1,109 +1 @@
1
- import React, { useState } from 'react'
2
- import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
3
- import { Button } from '../primitives/button'
4
- import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
5
- import { cn } from '../utils/cn'
6
-
7
- const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
8
- const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
9
-
10
- export interface DatePickerProps {
11
- value?: Date
12
- onChange?: (date: Date) => void
13
- placeholder?: string
14
- disabled?: boolean
15
- className?: string
16
- }
17
-
18
- function isSameDay(a: Date, b: Date) {
19
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
20
- }
21
-
22
- function getDaysInMonth(year: number, month: number) {
23
- return new Date(year, month + 1, 0).getDate()
24
- }
25
-
26
- function getFirstDayOfWeek(year: number, month: number) {
27
- return new Date(year, month, 1).getDay()
28
- }
29
-
30
- function CalendarGrid({ year, month, selected, onSelect }: {
31
- year: number; month: number; selected?: Date; onSelect: (d: Date) => void
32
- }) {
33
- const today = new Date()
34
- const daysInMonth = getDaysInMonth(year, month)
35
- const firstDay = getFirstDayOfWeek(year, month)
36
- const cells = Array.from({ length: firstDay + daysInMonth }, (_, i) => i < firstDay ? null : i - firstDay + 1)
37
-
38
- return (
39
- <div className="grid grid-cols-7 gap-0.5">
40
- {DAYS.map(d => <div key={d} className="py-1 text-center text-xs font-medium text-[hsl(var(--muted-foreground))]">{d}</div>)}
41
- {cells.map((day, i) => {
42
- if (!day) return <div key={i} />
43
- const date = new Date(year, month, day)
44
- const isToday = isSameDay(date, today)
45
- const isSelected = selected && isSameDay(date, selected)
46
- return (
47
- <button
48
- key={i}
49
- type="button"
50
- onClick={() => onSelect(date)}
51
- className={cn(
52
- 'flex h-8 w-8 items-center justify-center rounded-md text-sm transition-colors',
53
- isSelected && 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]',
54
- !isSelected && isToday && 'border border-[hsl(var(--primary))] text-[hsl(var(--primary))]',
55
- !isSelected && !isToday && 'hover:bg-[hsl(var(--accent))]'
56
- )}
57
- >
58
- {day}
59
- </button>
60
- )
61
- })}
62
- </div>
63
- )
64
- }
65
-
66
- export function DatePicker({ value, onChange, placeholder = 'Pick a date', disabled, className }: DatePickerProps) {
67
- const [open, setOpen] = useState(false)
68
- const now = value ?? new Date()
69
- const [view, setView] = useState({ year: now.getFullYear(), month: now.getMonth() })
70
-
71
- function navigate(delta: number) {
72
- setView(v => {
73
- const date = new Date(v.year, v.month + delta)
74
- return { year: date.getFullYear(), month: date.getMonth() }
75
- })
76
- }
77
-
78
- function handleSelect(date: Date) {
79
- onChange?.(date)
80
- setOpen(false)
81
- }
82
-
83
- return (
84
- <Popover open={open} onOpenChange={setOpen}>
85
- <PopoverTrigger asChild>
86
- <Button
87
- variant="outline"
88
- disabled={disabled}
89
- className={cn('w-full justify-start font-normal', !value && 'text-[hsl(var(--muted-foreground))]', className)}
90
- >
91
- <Calendar className="mr-2 h-4 w-4 shrink-0" />
92
- {value ? value.toLocaleDateString() : placeholder}
93
- </Button>
94
- </PopoverTrigger>
95
- <PopoverContent className="w-auto p-3" align="start">
96
- <div className="flex items-center justify-between mb-2">
97
- <button type="button" onClick={() => navigate(-1)} className="rounded p-1 hover:bg-[hsl(var(--accent))]">
98
- <ChevronLeft className="h-4 w-4" />
99
- </button>
100
- <span className="text-sm font-medium">{MONTHS[view.month]} {view.year}</span>
101
- <button type="button" onClick={() => navigate(1)} className="rounded p-1 hover:bg-[hsl(var(--accent))]">
102
- <ChevronRight className="h-4 w-4" />
103
- </button>
104
- </div>
105
- <CalendarGrid year={view.year} month={view.month} selected={value} onSelect={handleSelect} />
106
- </PopoverContent>
107
- </Popover>
108
- )
109
- }
1
+ export * from './DatePicker'