@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.
- package/LICENSE +1 -1
- package/README.md +28 -41
- package/dist/index.js +206 -125
- package/dist/index.mjs +194 -124
- package/package.json +5 -4
- package/src/advanced/BulkActions/BulkActions.tsx +2 -2
- package/src/advanced/CommandBar/CommandBar.tsx +2 -2
- package/src/advanced/Filters/Filters.tsx +6 -6
- package/src/advanced/Kanban/Kanban.tsx +1 -1
- package/src/advanced/ToggleButton/ToggleButton.tsx +1 -1
- package/src/advanced/bulk-actions.tsx +1 -46
- package/src/advanced/command-bar.tsx +1 -115
- package/src/advanced/filters.tsx +1 -103
- package/src/advanced/kanban.tsx +1 -95
- package/src/advanced/toggle-button.tsx +1 -78
- package/src/blocks/ChatDetail/ChatDetail.tsx +4 -4
- package/src/blocks/FeedbackModal/FeedbackModal.tsx +3 -3
- package/src/blocks/FileCards/FileCards.tsx +2 -2
- package/src/blocks/FilesList/FilesList.tsx +3 -3
- package/src/blocks/IntegrationCard/IntegrationCard.tsx +3 -3
- package/src/blocks/InviteModal/InviteModal.tsx +7 -7
- package/src/blocks/ManageTagsModal/ManageTagsModal.tsx +4 -4
- package/src/blocks/MessagesCard/MessagesCard.tsx +3 -3
- package/src/blocks/MetricCard/MetricCard.tsx +3 -3
- package/src/blocks/NotificationSettings/NotificationSettings.tsx +4 -4
- package/src/blocks/OrganizationMenu/OrganizationMenu.tsx +30 -11
- package/src/blocks/RolesMenu/RolesMenu.tsx +4 -4
- package/src/blocks/SelectUsersModal/SelectUsersModal.tsx +5 -5
- package/src/blocks/SidebarLayoutDashboard/SidebarLayoutDashboard.tsx +5 -5
- package/src/blocks/SidebarLayoutGroups/SidebarLayoutGroups.tsx +2 -2
- package/src/blocks/SidebarLayoutMinimal/SidebarLayoutMinimal.tsx +3 -3
- package/src/blocks/SidebarLayoutSearch/SidebarLayoutSearch.tsx +3 -3
- package/src/blocks/SidebarLayoutUser/SidebarLayoutUser.tsx +4 -4
- package/src/blocks/SortableTaskList/SortableTaskList.tsx +3 -3
- package/src/blocks/StackedLayoutBranded/StackedLayoutBranded.tsx +4 -4
- package/src/blocks/StackedLayoutTabs/StackedLayoutTabs.tsx +4 -4
- package/src/blocks/TaskCard/TaskCard.tsx +4 -4
- package/src/blocks/UserMenu/UserMenu.tsx +3 -3
- package/src/blocks/WorkspaceMembers/WorkspaceMembers.tsx +5 -5
- package/src/blocks/chat-detail.tsx +1 -73
- package/src/blocks/feedback-modal.tsx +1 -68
- package/src/blocks/file-cards.tsx +1 -56
- package/src/blocks/files-list.tsx +1 -72
- package/src/blocks/integration-card.tsx +1 -50
- package/src/blocks/invite-modal.tsx +1 -69
- package/src/blocks/manage-tags-modal.tsx +1 -63
- package/src/blocks/messages-card.tsx +1 -59
- package/src/blocks/metric-card.tsx +1 -79
- package/src/blocks/notification-settings.tsx +1 -61
- package/src/blocks/organization-menu.tsx +1 -85
- package/src/blocks/roles-menu.tsx +1 -61
- package/src/blocks/select-users-modal.tsx +1 -64
- package/src/blocks/sidebar-layout-dashboard.tsx +1 -97
- package/src/blocks/sidebar-layout-groups.tsx +1 -79
- package/src/blocks/sidebar-layout-minimal.tsx +1 -73
- package/src/blocks/sidebar-layout-search.tsx +1 -79
- package/src/blocks/sidebar-layout-user.tsx +1 -82
- package/src/blocks/sortable-task-list.tsx +1 -70
- package/src/blocks/stacked-layout-branded.tsx +1 -64
- package/src/blocks/stacked-layout-tabs.tsx +1 -80
- package/src/blocks/task-card.tsx +1 -88
- package/src/blocks/user-menu.tsx +1 -62
- package/src/blocks/workspace-members.tsx +1 -61
- package/src/charts/sparkline.tsx +1 -1
- package/src/components/DataGrid/DataGrid.tsx +1 -1
- package/src/components/DataTable/DataTable.tsx +4 -4
- package/src/components/EmptyState/EmptyState.tsx +6 -22
- package/src/components/Persona/Persona.tsx +1 -1
- package/src/components/Stat/Stat.tsx +1 -1
- package/src/components/Stepper/Stepper.tsx +13 -2
- package/src/components/banner.tsx +1 -203
- package/src/components/breadcrumb.tsx +1 -58
- package/src/components/command-item.tsx +1 -36
- package/src/components/data-grid.tsx +1 -125
- package/src/components/data-table.tsx +1 -139
- package/src/components/empty-state.tsx +1 -31
- package/src/components/hotkeys.tsx +1 -31
- package/src/components/icon-badge.tsx +1 -35
- package/src/components/loading-overlay.tsx +1 -38
- package/src/components/persona.tsx +1 -38
- package/src/components/property-list.tsx +1 -29
- package/src/components/stat.tsx +1 -51
- package/src/components/stepper.tsx +1 -69
- package/src/components/structured-list.tsx +1 -51
- package/src/components/timeline.tsx +1 -69
- package/src/components/toaster.tsx +1 -42
- package/src/data/DataList.tsx +1 -1
- package/src/engagement/Tour/Tour.tsx +1 -1
- package/src/engagement/beacon.tsx +1 -50
- package/src/engagement/tour.tsx +1 -106
- package/src/forms/ArrayField/ArrayField.tsx +2 -2
- package/src/forms/AutoForm/AutoForm.tsx +9 -8
- package/src/forms/DatePicker/DatePicker.tsx +3 -3
- package/src/forms/FloatingLabelInput/FloatingLabelInput.tsx +104 -0
- package/src/forms/FloatingLabelInput/index.ts +1 -0
- package/src/forms/PasswordInput/PasswordInput.tsx +2 -2
- package/src/forms/SearchInput/SearchInput.tsx +2 -2
- package/src/forms/StepForm/StepForm.tsx +2 -2
- package/src/forms/array-field.tsx +1 -61
- package/src/forms/auto-form.tsx +1 -133
- package/src/forms/date-picker.tsx +1 -109
- package/src/forms/field.tsx +1 -62
- package/src/forms/file-upload.tsx +1 -101
- package/src/forms/floating-label-input.tsx +1 -0
- package/src/forms/form.tsx +1 -57
- package/src/forms/object-field.tsx +1 -53
- package/src/forms/password-input.tsx +1 -54
- package/src/forms/search-input.tsx +1 -64
- package/src/forms/step-form.tsx +1 -113
- package/src/index.ts +6 -3
- package/src/primitives/Button/Button.tsx +18 -0
- package/src/primitives/code-block.tsx +1 -1
- package/src/primitives/comparison.tsx +1 -1
- package/src/primitives/qr-code.tsx +5 -1
- package/src/themes/base.css +18 -0
|
@@ -1,50 +1 @@
|
|
|
1
|
-
|
|
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'
|
package/src/engagement/tour.tsx
CHANGED
|
@@ -1,106 +1 @@
|
|
|
1
|
-
|
|
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 '
|
|
4
|
-
import { cn } from '
|
|
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 '
|
|
6
|
-
import { Checkbox } from '
|
|
7
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '
|
|
8
|
-
import { Button } from '
|
|
9
|
-
import { DatePicker } from '
|
|
10
|
-
import { Field } from '
|
|
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
|
-
|
|
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 '
|
|
4
|
-
import { Popover, PopoverContent, PopoverTrigger } from '
|
|
5
|
-
import { cn } from '
|
|
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 '
|
|
4
|
-
import { cn } from '
|
|
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 '
|
|
4
|
-
import { type InputProps } from '
|
|
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 '
|
|
3
|
-
import { cn } from '
|
|
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
|
-
|
|
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'
|
package/src/forms/auto-form.tsx
CHANGED
|
@@ -1,133 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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'
|