@otfdashkit/ui 0.1.1 → 0.1.3

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 (120) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +28 -41
  3. package/dist/index.js +972 -655
  4. package/dist/index.mjs +826 -525
  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 +11 -3
  111. package/src/primitives/Button/Button.tsx +18 -0
  112. package/src/primitives/cart-line-item.tsx +81 -0
  113. package/src/primitives/code-block.tsx +1 -1
  114. package/src/primitives/comparison.tsx +1 -1
  115. package/src/primitives/empty-cart.tsx +52 -0
  116. package/src/primitives/price-tag.tsx +62 -0
  117. package/src/primitives/qr-code.tsx +5 -1
  118. package/src/primitives/quantity-stepper.tsx +92 -0
  119. package/src/primitives/variant-picker.tsx +74 -0
  120. package/src/themes/base.css +18 -0
@@ -1,69 +1 @@
1
- import React from 'react'
2
- import { cn } from '../utils/cn'
3
-
4
- type TimelineVariant = 'default' | 'success' | 'error' | 'warning'
5
-
6
- const dotColors: Record<TimelineVariant, string> = {
7
- default: 'bg-[hsl(var(--primary))]',
8
- success: 'bg-green-500',
9
- error: 'bg-red-500',
10
- warning: 'bg-yellow-500',
11
- }
12
-
13
- interface TimelineContentProps extends React.HTMLAttributes<HTMLDivElement> {}
14
- export const TimelineContent = React.forwardRef<HTMLDivElement, TimelineContentProps>(
15
- ({ className, ...props }, ref) => <div ref={ref} className={cn('pb-1', className)} {...props} />
16
- )
17
- TimelineContent.displayName = 'TimelineContent'
18
-
19
- export function TimelineConnector({ className }: { className?: string }) {
20
- return <div className={cn('absolute left-[7px] top-5 bottom-0 w-0.5 bg-[hsl(var(--border))]', className)} />
21
- }
22
-
23
- interface TimelineItemProps {
24
- icon?: React.ReactNode
25
- title: string
26
- description?: string
27
- timestamp?: string
28
- variant?: TimelineVariant
29
- isLast?: boolean
30
- className?: string
31
- }
32
-
33
- export function TimelineItem({ icon, title, description, timestamp, variant = 'default', isLast, className }: TimelineItemProps) {
34
- return (
35
- <div className={cn('relative flex gap-4', !isLast && 'pb-6', className)}>
36
- <div className="relative flex-none">
37
- <div className={cn('h-4 w-4 rounded-full border-2 border-[hsl(var(--background))] flex items-center justify-center mt-0.5', dotColors[variant])}>
38
- {icon && <span className="text-white text-[8px]">{icon}</span>}
39
- </div>
40
- {!isLast && <TimelineConnector />}
41
- </div>
42
- <TimelineContent>
43
- <div className="flex items-baseline justify-between gap-2">
44
- <p className="text-sm font-medium text-[hsl(var(--foreground))]">{title}</p>
45
- {timestamp && <span className="text-xs text-[hsl(var(--muted-foreground))] shrink-0">{timestamp}</span>}
46
- </div>
47
- {description && <p className="text-sm text-[hsl(var(--muted-foreground))] mt-0.5">{description}</p>}
48
- </TimelineContent>
49
- </div>
50
- )
51
- }
52
-
53
- interface TimelineProps {
54
- children: React.ReactNode
55
- className?: string
56
- }
57
-
58
- export function Timeline({ children, className }: TimelineProps) {
59
- const items = React.Children.toArray(children)
60
- return (
61
- <div className={cn('', className)}>
62
- {items.map((child, i) =>
63
- React.isValidElement(child)
64
- ? React.cloneElement(child as React.ReactElement<TimelineItemProps>, { isLast: i === items.length - 1 })
65
- : child
66
- )}
67
- </div>
68
- )
69
- }
1
+ export * from './Timeline/index'
@@ -1,42 +1 @@
1
- 'use client'
2
-
3
- import React from 'react'
4
- import { Toaster as SonnerToaster, toast } from 'sonner'
5
- import { useOtfUI } from '../provider/otf-ui-provider'
6
-
7
- export interface ToasterProps extends React.ComponentProps<typeof SonnerToaster> {}
8
-
9
- export function Toaster(props: ToasterProps) {
10
- const ctx = useOtfUI?.()
11
- const dark = ctx?.darkMode === 'dark'
12
-
13
- return (
14
- <SonnerToaster
15
- theme={dark ? 'dark' : 'light'}
16
- position="bottom-right"
17
- toastOptions={{
18
- classNames: {
19
- toast: [
20
- 'group flex items-start gap-3 rounded-lg border px-4 py-3.5 text-sm shadow-md',
21
- 'border-[hsl(var(--border))] bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))]',
22
- ].join(' '),
23
- title: 'font-semibold text-sm leading-snug',
24
- description: 'text-xs text-[hsl(var(--muted-foreground))] mt-0.5 leading-snug',
25
- actionButton: [
26
- 'mt-2 inline-flex items-center gap-1 text-xs font-medium',
27
- 'text-[hsl(var(--primary))] underline-offset-2 hover:underline',
28
- ].join(' '),
29
- cancelButton: 'mt-2 text-xs text-[hsl(var(--muted-foreground))] hover:opacity-80',
30
- closeButton: 'text-[hsl(var(--muted-foreground))] opacity-60 hover:opacity-100 transition-opacity',
31
- error: 'border-red-200/80 bg-red-50/60 dark:border-red-800/60 dark:bg-red-950/30',
32
- success: 'border-green-200/80 bg-green-50/60 dark:border-green-800/60 dark:bg-green-950/30',
33
- warning: 'border-yellow-200/80 bg-yellow-50/60 dark:border-yellow-800/60 dark:bg-yellow-950/30',
34
- info: 'border-blue-200/80 bg-blue-50/60 dark:border-blue-800/60 dark:bg-blue-950/30',
35
- },
36
- }}
37
- {...props}
38
- />
39
- )
40
- }
41
-
42
- export { toast }
1
+ export * from './Toaster'
@@ -31,7 +31,7 @@ export const DataList = React.forwardRef<HTMLUListElement, DataListProps>(
31
31
  )
32
32
  DataList.displayName = 'DataList'
33
33
 
34
- export interface DataListItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
34
+ export interface DataListItemProps extends Omit<React.LiHTMLAttributes<HTMLLIElement>, 'title'> {
35
35
  left?: React.ReactNode
36
36
  title?: React.ReactNode
37
37
  description?: React.ReactNode
@@ -1,6 +1,6 @@
1
1
  import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
2
2
  import { cn } from '../utils/cn'
3
- import { Button } from '../primitives/button'
3
+ import { Button } from '../../primitives/button'
4
4
 
5
5
  interface TourContextValue {
6
6
  currentStep: number
@@ -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'