@toolpath/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/index.d.ts +1174 -0
  4. package/dist/index.js +5934 -0
  5. package/package.json +68 -0
  6. package/src/attribution/action.tsx +13 -0
  7. package/src/attribution/at.tsx +28 -0
  8. package/src/attribution/attribution.tsx +16 -0
  9. package/src/attribution/by.tsx +21 -0
  10. package/src/attribution/format-time.ts +48 -0
  11. package/src/attribution/index.ts +20 -0
  12. package/src/badge/badge.tsx +38 -0
  13. package/src/badge/index.ts +1 -0
  14. package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
  15. package/src/breadcrumbs/breadcrumbs.tsx +24 -0
  16. package/src/breadcrumbs/index.ts +12 -0
  17. package/src/breadcrumbs/item.tsx +85 -0
  18. package/src/button/animated-ellipsis.tsx +44 -0
  19. package/src/button/button.tsx +168 -0
  20. package/src/button/index.ts +1 -0
  21. package/src/callout/callout.tsx +202 -0
  22. package/src/callout/index.ts +2 -0
  23. package/src/callout/use-dismissed-callouts.ts +78 -0
  24. package/src/card/card.tsx +19 -0
  25. package/src/card/index.ts +2 -0
  26. package/src/checkbox/checkbox.tsx +76 -0
  27. package/src/checkbox/index.ts +1 -0
  28. package/src/combobox/button.tsx +99 -0
  29. package/src/combobox/combobox-context.tsx +42 -0
  30. package/src/combobox/combobox.tsx +60 -0
  31. package/src/combobox/empty.tsx +21 -0
  32. package/src/combobox/group-label.tsx +20 -0
  33. package/src/combobox/group.tsx +11 -0
  34. package/src/combobox/icon.tsx +11 -0
  35. package/src/combobox/index.tsx +64 -0
  36. package/src/combobox/input.tsx +77 -0
  37. package/src/combobox/item-indicator.tsx +16 -0
  38. package/src/combobox/item.tsx +58 -0
  39. package/src/combobox/list.tsx +19 -0
  40. package/src/combobox/popover.tsx +62 -0
  41. package/src/combobox/trigger.tsx +33 -0
  42. package/src/combobox/value.tsx +12 -0
  43. package/src/common/index.ts +1 -0
  44. package/src/common/svg.tsx +17 -0
  45. package/src/dialog/actions.tsx +17 -0
  46. package/src/dialog/confirm-button.tsx +25 -0
  47. package/src/dialog/dialog-context.tsx +21 -0
  48. package/src/dialog/dialog.tsx +37 -0
  49. package/src/dialog/dismiss-button.tsx +29 -0
  50. package/src/dialog/index.ts +37 -0
  51. package/src/dialog/message.tsx +20 -0
  52. package/src/dialog/provider.tsx +102 -0
  53. package/src/dialog/title.tsx +20 -0
  54. package/src/editable-cell/editable-cell.tsx +307 -0
  55. package/src/editable-cell/index.ts +7 -0
  56. package/src/editable-cell/use-editable-grid.tsx +296 -0
  57. package/src/field/field.tsx +22 -0
  58. package/src/field/index.ts +2 -0
  59. package/src/helpers/cn.ts +6 -0
  60. package/src/helpers/index.ts +1 -0
  61. package/src/icon-button/icon-button.tsx +126 -0
  62. package/src/icon-button/index.ts +2 -0
  63. package/src/index.ts +32 -0
  64. package/src/input/index.ts +1 -0
  65. package/src/input/input.tsx +276 -0
  66. package/src/link/index.ts +4 -0
  67. package/src/link/link-context.tsx +33 -0
  68. package/src/link/link.tsx +24 -0
  69. package/src/menu/divider.tsx +17 -0
  70. package/src/menu/index.ts +40 -0
  71. package/src/menu/item.tsx +149 -0
  72. package/src/menu/menu-context.tsx +24 -0
  73. package/src/menu/menu.tsx +14 -0
  74. package/src/menu/popover.tsx +69 -0
  75. package/src/menu/submenu-trigger.tsx +48 -0
  76. package/src/menu/submenu.tsx +20 -0
  77. package/src/menu/trigger.tsx +43 -0
  78. package/src/notification/index.ts +16 -0
  79. package/src/notification/list.tsx +123 -0
  80. package/src/notification/provider.tsx +75 -0
  81. package/src/page-header/index.ts +2 -0
  82. package/src/page-header/page-header.tsx +14 -0
  83. package/src/pagination/index.ts +1 -0
  84. package/src/pagination/pagination.tsx +136 -0
  85. package/src/panels/collapsed.tsx +32 -0
  86. package/src/panels/group.tsx +27 -0
  87. package/src/panels/index.ts +57 -0
  88. package/src/panels/layout-context.ts +5 -0
  89. package/src/panels/orientation-context.tsx +5 -0
  90. package/src/panels/panel.tsx +95 -0
  91. package/src/panels/separator.tsx +44 -0
  92. package/src/pin/index.ts +1 -0
  93. package/src/pin/pin.tsx +99 -0
  94. package/src/progress-bar/index.ts +1 -0
  95. package/src/progress-bar/progress-bar.tsx +48 -0
  96. package/src/quantity-input/index.ts +1 -0
  97. package/src/quantity-input/quantity-input.tsx +158 -0
  98. package/src/scroll-area/index.ts +1 -0
  99. package/src/scroll-area/scroll-area.tsx +82 -0
  100. package/src/slider/index.ts +1 -0
  101. package/src/slider/slider.tsx +75 -0
  102. package/src/star/index.ts +1 -0
  103. package/src/star/star.tsx +99 -0
  104. package/src/switch/index.ts +1 -0
  105. package/src/switch/switch.tsx +33 -0
  106. package/src/table/body.tsx +15 -0
  107. package/src/table/cell-context.tsx +48 -0
  108. package/src/table/cell.tsx +83 -0
  109. package/src/table/group-empty-state.tsx +28 -0
  110. package/src/table/group-header.tsx +62 -0
  111. package/src/table/header-cell.tsx +158 -0
  112. package/src/table/header-row.tsx +28 -0
  113. package/src/table/index.ts +56 -0
  114. package/src/table/is-group-empty-state.ts +17 -0
  115. package/src/table/is-group-header.ts +5 -0
  116. package/src/table/row-context.tsx +61 -0
  117. package/src/table/row-disabled-context.tsx +7 -0
  118. package/src/table/row.tsx +98 -0
  119. package/src/table/table-context.tsx +127 -0
  120. package/src/table/table-empty.tsx +57 -0
  121. package/src/table/table.tsx +547 -0
  122. package/src/table/tree-icon.tsx +147 -0
  123. package/src/table/use-checkbox.ts +200 -0
  124. package/src/table/use-column-layout.ts +117 -0
  125. package/src/table/use-groups.ts +134 -0
  126. package/src/table/use-keyboard-navigation.ts +274 -0
  127. package/src/table/use-sort-functions.ts +90 -0
  128. package/src/tabs/index.ts +20 -0
  129. package/src/tabs/list.tsx +29 -0
  130. package/src/tabs/tab.tsx +141 -0
  131. package/src/tabs/tabs-context.tsx +38 -0
  132. package/src/tabs/tabs.tsx +27 -0
  133. package/src/textarea/index.ts +1 -0
  134. package/src/textarea/textarea.tsx +88 -0
  135. package/src/toast/index.ts +19 -0
  136. package/src/toast/list.tsx +133 -0
  137. package/src/toast/provider.tsx +36 -0
  138. package/src/toggle/index.ts +17 -0
  139. package/src/toggle/indicator.tsx +27 -0
  140. package/src/toggle/item.tsx +54 -0
  141. package/src/toggle/toggle-context.tsx +68 -0
  142. package/src/toggle/toggle.tsx +124 -0
  143. package/src/toggle/use-indicator.tsx +69 -0
  144. package/src/tooltip/index.ts +1 -0
  145. package/src/tooltip/tooltip.tsx +122 -0
  146. package/theme.css +70 -0
@@ -0,0 +1,202 @@
1
+ import React, {
2
+ ReactNode,
3
+ SyntheticEvent,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useState,
8
+ } from 'react'
9
+ import { Popover, type PopoverRootChangeEventDetails } from '@base-ui/react/popover'
10
+ import { cn } from '../helpers'
11
+ import { XIcon } from '@phosphor-icons/react'
12
+ import { useDismissedCallouts } from './use-dismissed-callouts'
13
+
14
+ type CalloutContextValue = {
15
+ open: boolean
16
+ show: boolean
17
+ dismissed: boolean
18
+ side: 'top' | 'bottom' | 'left' | 'right'
19
+ sideOffset: number
20
+ align: 'start' | 'center' | 'end'
21
+ highlight: boolean
22
+ }
23
+
24
+ const CalloutContext = createContext<CalloutContextValue | null>(null)
25
+
26
+ const useCalloutContext = () => {
27
+ const ctx = useContext(CalloutContext)
28
+ if (!ctx) {
29
+ throw new Error('Callout sub-components must be used inside <Callout>')
30
+ }
31
+ return ctx
32
+ }
33
+
34
+ export type CalloutProps = {
35
+ id: string
36
+ children: ReactNode
37
+ show?: boolean
38
+ side?: 'top' | 'bottom' | 'left' | 'right'
39
+ sideOffset?: number
40
+ align?: 'start' | 'center' | 'end'
41
+ highlight?: boolean
42
+ /** When false, dismiss state is local only and not persisted to localStorage. Defaults to true. */
43
+ rememberDismissed?: boolean
44
+ }
45
+
46
+ export const Callout = ({
47
+ id,
48
+ children,
49
+ show = true,
50
+ highlight = false,
51
+ side = 'bottom',
52
+ sideOffset = 8,
53
+ align = 'center',
54
+ rememberDismissed = true,
55
+ }: CalloutProps) => {
56
+ const { isDismissed, dismiss } = useDismissedCallouts()
57
+ const persistedDismissed = rememberDismissed && isDismissed(id)
58
+ const [open, setOpen] = useState(!persistedDismissed)
59
+
60
+ const dismissed = rememberDismissed ? persistedDismissed : !open
61
+
62
+ const handleOpenChange = useCallback(
63
+ (nextOpen: boolean, event: PopoverRootChangeEventDetails) => {
64
+ if (nextOpen) {
65
+ setOpen(true)
66
+ return
67
+ }
68
+ const intentional =
69
+ event.reason === 'close-press' ||
70
+ event.reason === 'escape-key' ||
71
+ event.reason === 'trigger-press'
72
+ if (!intentional) {
73
+ return
74
+ }
75
+ if (rememberDismissed) {
76
+ dismiss(id)
77
+ }
78
+ setOpen(false)
79
+ },
80
+ [dismiss, id, rememberDismissed],
81
+ )
82
+
83
+ return (
84
+ <CalloutContext.Provider value={{ open, show, dismissed, side, sideOffset, align, highlight }}>
85
+ <Popover.Root open={show && open && !dismissed} onOpenChange={handleOpenChange}>
86
+ {children}
87
+ </Popover.Root>
88
+ </CalloutContext.Provider>
89
+ )
90
+ }
91
+
92
+ type CalloutTriggerProps = {
93
+ children: ReactNode
94
+ className?: string
95
+ /** Set to -1 to remove the trigger from the tab order and suppress focus styling. */
96
+ tabIndex?: number
97
+ }
98
+
99
+ const CalloutTrigger = ({ children, className, tabIndex }: CalloutTriggerProps) => {
100
+ const { highlight, show, open, dismissed } = useCalloutContext()
101
+
102
+ return (
103
+ <Popover.Trigger
104
+ render={<span />}
105
+ nativeButton={false}
106
+ tabIndex={tabIndex}
107
+ className={cn(
108
+ 'rounded outline-none focus-visible:ring-2 focus-visible:ring-info/75',
109
+ highlight &&
110
+ show &&
111
+ open &&
112
+ !dismissed &&
113
+ 'bg-info-lighten/50 dark:bg-info/5 rounded-sm shadow-[0_0_2px_2px_theme(colors.info/0.3)] dark: ring-1 dark:ring-info/40',
114
+ className,
115
+ )}
116
+ >
117
+ {children}
118
+ </Popover.Trigger>
119
+ )
120
+ }
121
+
122
+ type CalloutContentProps = {
123
+ children: ReactNode
124
+ className?: string
125
+ }
126
+
127
+ const stopCalloutPointerBubbling = (e: SyntheticEvent) => {
128
+ e.stopPropagation()
129
+ }
130
+
131
+ const CalloutContent = ({ children, className }: CalloutContentProps) => {
132
+ const { side, sideOffset, align } = useCalloutContext()
133
+
134
+ return (
135
+ <Popover.Portal>
136
+ <Popover.Positioner
137
+ className={cn(
138
+ 'z-50 outline-none',
139
+ // Base UI + Floating UI `hide` middleware sets `data-anchor-hidden` when the
140
+ // trigger is clipped or scrolled out of view; hide the portaled popup to match.
141
+ 'data-[anchor-hidden]:invisible data-[anchor-hidden]:pointer-events-none',
142
+ )}
143
+ side={side}
144
+ sideOffset={sideOffset}
145
+ align={align}
146
+ >
147
+ <Popover.Popup
148
+ initialFocus={false}
149
+ onClick={stopCalloutPointerBubbling}
150
+ onPointerDown={stopCalloutPointerBubbling}
151
+ className={cn(
152
+ 'bg-white dark:bg-zinc-900 ring-1 ring-gray-200 dark:ring-zinc-800 rounded-lg shadow-lg',
153
+ 'font-normal font-body text-xs tracking-normal text-gray-700 dark:text-zinc-100',
154
+ 'px-3 py-2.5 pr-8 max-w-72',
155
+ 'transition duration-200 ease-in-out',
156
+ 'data-[side=top]:origin-bottom',
157
+ 'data-[side=bottom]:origin-top',
158
+ 'data-[side=left]:origin-right',
159
+ 'data-[side=right]:origin-left',
160
+ className,
161
+ )}
162
+ >
163
+ <Popover.Arrow className="flex data-[side=top]:bottom-[-8px] data-[side=top]:rotate-180 data-[side=bottom]:top-[-8px] data-[side=left]:right-[-13px] data-[side=left]:rotate-90 data-[side=right]:left-[-13px] data-[side=right]:-rotate-90">
164
+ <ArrowSvg />
165
+ </Popover.Arrow>
166
+ <Popover.Close
167
+ className={cn(
168
+ 'absolute top-1.5 right-1.5 p-0.5 rounded',
169
+ 'text-gray-400 dark:text-zinc-400',
170
+ 'hover:text-gray-600 dark:hover:text-zinc-100',
171
+ 'hover:bg-gray-100 dark:hover:bg-zinc-800',
172
+ 'transition-colors cursor-pointer',
173
+ )}
174
+ >
175
+ <XIcon className="size-3.5" />
176
+ </Popover.Close>
177
+ {children}
178
+ </Popover.Popup>
179
+ </Popover.Positioner>
180
+ </Popover.Portal>
181
+ )
182
+ }
183
+
184
+ Callout.Trigger = CalloutTrigger
185
+ Callout.Content = CalloutContent
186
+
187
+ const ArrowSvg = (props: React.ComponentProps<'svg'>) => (
188
+ <svg width="20" height="10" viewBox="0 0 20 10" fill="none" {...props}>
189
+ <path
190
+ d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
191
+ className="fill-white dark:fill-zinc-900"
192
+ />
193
+ <path
194
+ d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
195
+ className="fill-gray-200 dark:fill-zinc-800"
196
+ />
197
+ <path
198
+ d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
199
+ className="fill-none"
200
+ />
201
+ </svg>
202
+ )
@@ -0,0 +1,2 @@
1
+ export * from './callout'
2
+ export * from './use-dismissed-callouts'
@@ -0,0 +1,78 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+
3
+ const STORAGE_KEY = 'dismissed-callouts'
4
+
5
+ const getSnapshot = (): Array<string> => {
6
+ if (typeof window === 'undefined') {
7
+ return []
8
+ }
9
+ try {
10
+ const raw = localStorage.getItem(STORAGE_KEY)
11
+ return raw ? JSON.parse(raw) : []
12
+ } catch {
13
+ return []
14
+ }
15
+ }
16
+
17
+ const getServerSnapshot = (): Array<string> => []
18
+
19
+ const listeners = new Set<() => void>()
20
+
21
+ const subscribe = (callback: () => void) => {
22
+ listeners.add(callback)
23
+ return () => {
24
+ listeners.delete(callback)
25
+ }
26
+ }
27
+
28
+ const notifyListeners = () => {
29
+ for (const listener of listeners) {
30
+ listener()
31
+ }
32
+ }
33
+
34
+ let cachedSnapshot: Array<string> | null = null
35
+
36
+ const getStableSnapshot = (): Array<string> => {
37
+ const next = getSnapshot()
38
+ if (
39
+ cachedSnapshot !== null &&
40
+ cachedSnapshot.length === next.length &&
41
+ cachedSnapshot.every((id, i) => id === next[i])
42
+ ) {
43
+ return cachedSnapshot
44
+ }
45
+ cachedSnapshot = next
46
+ return next
47
+ }
48
+
49
+ export const useDismissedCallouts = () => {
50
+ const dismissed = useSyncExternalStore(subscribe, getStableSnapshot, getServerSnapshot)
51
+
52
+ const isDismissed = useCallback((id: string) => dismissed.includes(id), [dismissed])
53
+
54
+ const dismiss = useCallback((id: string) => {
55
+ const current = getSnapshot()
56
+ if (current.includes(id)) {
57
+ return
58
+ }
59
+ const next = [...current, id]
60
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
61
+ cachedSnapshot = null
62
+ notifyListeners()
63
+ }, [])
64
+
65
+ const reset = useCallback((id?: string) => {
66
+ if (id) {
67
+ const current = getSnapshot()
68
+ const next = current.filter((d) => d !== id)
69
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
70
+ } else {
71
+ localStorage.removeItem(STORAGE_KEY)
72
+ }
73
+ cachedSnapshot = null
74
+ notifyListeners()
75
+ }, [])
76
+
77
+ return { isDismissed, dismiss, reset }
78
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '../helpers'
3
+
4
+ export interface CardProps {
5
+ children: ReactNode
6
+ className?: string
7
+ }
8
+
9
+ /** A standard Toolpath application surface. */
10
+ export const Card = ({ children, className }: CardProps) => (
11
+ <div
12
+ className={cn(
13
+ 'rounded-xl border border-gray-100 bg-white dark:border-zinc-800 dark:bg-zinc-900',
14
+ className,
15
+ )}
16
+ >
17
+ {children}
18
+ </div>
19
+ )
@@ -0,0 +1,2 @@
1
+ export { Card } from './card'
2
+ export type { CardProps } from './card'
@@ -0,0 +1,76 @@
1
+ import React, { useRef } from 'react'
2
+ import { Checkbox as BaseCheckbox, CheckboxRootProps } from '@base-ui/react'
3
+ import { cn } from '../helpers'
4
+ import { CheckIcon, MinusIcon } from '@phosphor-icons/react'
5
+
6
+ export interface CheckboxChangeEvent {
7
+ shiftKey: boolean
8
+ }
9
+
10
+ export type CheckboxProps = CheckboxRootProps & {
11
+ checked: boolean
12
+ onChange: (checked: boolean, event: CheckboxChangeEvent) => void
13
+ name: string
14
+ className?: string
15
+ disabled?: boolean
16
+ indeterminate?: boolean
17
+ tabIndex?: number
18
+ variant?: 'default' | 'muted'
19
+ size?: 'md' | 'sm'
20
+ }
21
+
22
+ export const Checkbox = ({
23
+ checked,
24
+ onChange,
25
+ name,
26
+ className,
27
+ disabled = false,
28
+ indeterminate = false,
29
+ variant = 'default',
30
+ size = 'md',
31
+ ...props
32
+ }: CheckboxProps) => {
33
+ if (checked && indeterminate) {
34
+ throw new Error('Checkbox cannot be both checked & indeterminate.')
35
+ }
36
+
37
+ const isActive = checked || indeterminate
38
+ const shiftKeyRef = useRef(false)
39
+
40
+ return (
41
+ <BaseCheckbox.Root
42
+ name={name}
43
+ checked={checked}
44
+ indeterminate={indeterminate}
45
+ onClick={(e) => {
46
+ shiftKeyRef.current = e.shiftKey
47
+ }}
48
+ onCheckedChange={(newChecked) => {
49
+ onChange(newChecked, { shiftKey: shiftKeyRef.current })
50
+ }}
51
+ disabled={disabled}
52
+ className={cn(
53
+ 'flex cursor-pointer items-center justify-center bg-white outline-none focus-visible:ring-2 ring-info/75 ring-offset-1 dark:bg-zinc-900 dark:ring-offset-zinc-950',
54
+ 'border dark:border-zinc-500',
55
+ {
56
+ 'size-4 rounded': size === 'md',
57
+ 'size-3 rounded-sm': size === 'sm',
58
+ 'bg-gray-300 dark:bg-zinc-800': variant === 'muted' && isActive,
59
+ 'bg-info': variant === 'default' && isActive,
60
+ 'bg-transparent': !isActive,
61
+ 'border-none text-white': isActive,
62
+ 'cursor-not-allowed saturate-50': disabled,
63
+ 'bg-gray-50 dark:bg-zinc-950': !isActive && disabled,
64
+ 'text-white/65': isActive && disabled,
65
+ },
66
+ className,
67
+ )}
68
+ {...props}
69
+ >
70
+ <BaseCheckbox.Indicator className="flex items-center justify-center text-white" keepMounted>
71
+ {checked && <CheckIcon className="w-full h-full" />}
72
+ {indeterminate && <MinusIcon className="w-full h-full" />}
73
+ </BaseCheckbox.Indicator>
74
+ </BaseCheckbox.Root>
75
+ )
76
+ }
@@ -0,0 +1 @@
1
+ export * from './checkbox'
@@ -0,0 +1,99 @@
1
+ import React, { FC, ReactNode, SVGProps } from 'react'
2
+ import { cn } from '../helpers'
3
+ import { ArrowsOutLineVerticalIcon } from '@phosphor-icons/react'
4
+ import { useCombobox } from './combobox-context'
5
+ import { Icon } from './icon'
6
+ import { Trigger } from './trigger'
7
+ import { Value } from './value'
8
+
9
+ export type ComboboxButtonProps = {
10
+ placeholder?: string
11
+ icon?: FC<SVGProps<SVGSVGElement>>
12
+ children?: ReactNode
13
+ }
14
+
15
+ export const Button = ({
16
+ placeholder = 'Select Option',
17
+ icon: IconComponent,
18
+ children,
19
+ }: ComboboxButtonProps) => {
20
+ const { size, variant, disabled, invalid } = useCombobox()
21
+
22
+ return (
23
+ <Trigger
24
+ nativeButton={true}
25
+ render={undefined}
26
+ tabIndex={0}
27
+ className={cn(
28
+ 'group [&:has(~[data-input])]:data-[popup-open]:hidden',
29
+ 'w-full rounded flex gap-1 items-center text-left',
30
+ 'outline-none font-medium',
31
+ 'data-[popup-open]:data-[popup-side="top"]:rounded-t-none',
32
+ 'data-[popup-open]:data-[popup-side="bottom"]:rounded-b-none',
33
+ {
34
+ 'px-1': variant !== 'ghost',
35
+ 'focus-visible:ring-2 ring-info/75': !invalid,
36
+ 'border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 focus-visible:ring-1 focus-visible:border-info':
37
+ variant === 'secondary' && !invalid,
38
+ 'bg-gray-50 dark:bg-zinc-800':
39
+ (variant === 'default' || (disabled && variant === 'secondary')) && !invalid,
40
+ 'bg-gray-100 dark:bg-zinc-800': variant === 'muted' && !invalid,
41
+ 'border border-[#FFD4E0] bg-[#FFD4E0]/25 focus-visible:border-[#FFD4E0] focus-visible:ring-2 ring-[#FFD4E0] dark:border-danger/50 dark:bg-danger/25 dark:ring-danger/50 dark:focus-visible:border-danger/50':
42
+ invalid && !disabled,
43
+ 'h-9': size === 'xl' && variant !== 'ghost',
44
+ 'h-7': size === 'lg' && variant !== 'ghost',
45
+ 'h-6': size === 'md' && variant !== 'ghost',
46
+ 'h-5': size === 'sm' && variant !== 'ghost',
47
+ 'text-lg': size === 'xl',
48
+ 'text-sm': size === 'lg',
49
+ 'text-xs': size === 'md',
50
+ 'text-2xs': size === 'sm',
51
+ 'cursor-not-allowed': disabled,
52
+ },
53
+ )}
54
+ >
55
+ {IconComponent && (
56
+ <IconComponent
57
+ data-icon
58
+ className={cn('text-gray-300 dark:text-zinc-400 shrink-0', {
59
+ 'group-hover:text-gray-400 dark:group-hover:text-zinc-100': !disabled,
60
+ 'text-gray-200 dark:text-zinc-500': disabled,
61
+ 'size-2.5': size === 'sm',
62
+ 'size-3.5': size === 'md' || size === 'lg',
63
+ 'size-4': size === 'xl',
64
+ })}
65
+ />
66
+ )}
67
+ <div
68
+ className={cn('truncate dark:text-zinc-100', {
69
+ 'text-gray-400': variant === 'secondary',
70
+ 'text-gray-500': variant !== 'secondary' && variant !== 'ghost',
71
+ 'text-inherit': variant === 'ghost',
72
+ 'group-hover:text-gray-500 dark:group-hover:text-zinc-50': !disabled,
73
+ 'text-gray-200 dark:text-zinc-400': disabled,
74
+ 'pl-1': !IconComponent && variant !== 'ghost',
75
+ })}
76
+ >
77
+ <Value
78
+ placeholder={placeholder}
79
+ className={cn('truncate dark:text-zinc-100', {
80
+ 'text-gray-400': variant === 'secondary',
81
+ 'text-gray-500': variant !== 'secondary' && variant !== 'ghost',
82
+ 'text-inherit': variant === 'ghost',
83
+ 'group-hover:text-gray-500 dark:group-hover:text-zinc-50': !disabled,
84
+ 'text-gray-200 dark:text-zinc-400': disabled,
85
+ })}
86
+ />
87
+ </div>
88
+ {Boolean(children) && children}
89
+ <Icon
90
+ className={cn('ml-auto', {
91
+ 'opacity-0 group-hover:opacity-100 group-data-[popup-open]:opacity-100':
92
+ variant === 'ghost',
93
+ })}
94
+ >
95
+ <ArrowsOutLineVerticalIcon className="size-3.5 text-gray-200 dark:text-zinc-400" />
96
+ </Icon>
97
+ </Trigger>
98
+ )
99
+ }
@@ -0,0 +1,42 @@
1
+ import React, { FC, ReactNode, createContext, useContext, useMemo } from 'react'
2
+
3
+ export interface ComboboxContextInterface {
4
+ size: 'xl' | 'lg' | 'md' | 'sm'
5
+ variant: 'default' | 'secondary' | 'muted' | 'ghost'
6
+ disabled: boolean
7
+ invalid: boolean
8
+ itemToStringLabel?: (item: any) => string
9
+ }
10
+
11
+ const ComboboxContext = createContext<Partial<ComboboxContextInterface>>(
12
+ {} as ComboboxContextInterface,
13
+ )
14
+
15
+ interface ProviderProps {
16
+ size: 'xl' | 'lg' | 'md' | 'sm'
17
+ variant: 'default' | 'secondary' | 'muted' | 'ghost'
18
+ disabled: boolean
19
+ invalid: boolean
20
+ itemToStringLabel?: (item: any) => string
21
+ children: ReactNode
22
+ }
23
+
24
+ const ComboboxProvider: FC<ProviderProps> = ({
25
+ size = 'md',
26
+ variant = 'default',
27
+ disabled = false,
28
+ invalid = false,
29
+ itemToStringLabel,
30
+ children,
31
+ }) => {
32
+ const value = useMemo(
33
+ () => ({ size, variant, disabled, invalid, itemToStringLabel }),
34
+ [size, variant, disabled, invalid, itemToStringLabel],
35
+ )
36
+
37
+ return <ComboboxContext.Provider value={value}>{children}</ComboboxContext.Provider>
38
+ }
39
+
40
+ const useCombobox = () => useContext(ComboboxContext)
41
+ const ComboboxConsumer = ComboboxContext.Consumer
42
+ export { useCombobox, ComboboxProvider, ComboboxContext, ComboboxConsumer }
@@ -0,0 +1,60 @@
1
+ import React, { memo, useCallback, useState } from 'react'
2
+ import { Combobox as BaseCombobox, ComboboxRootProps } from '@base-ui/react'
3
+ import { ComboboxProvider } from './combobox-context'
4
+
5
+ export type ComboboxProps<T> = ComboboxRootProps<T> & {
6
+ size?: 'xl' | 'lg' | 'md' | 'sm'
7
+ variant?: 'default' | 'secondary' | 'muted' | 'ghost'
8
+ invalid?: boolean
9
+ }
10
+
11
+ // Use a type assertion for the generic memo
12
+ export const Combobox = memo(
13
+ <T,>({
14
+ children,
15
+ size = 'md',
16
+ variant = 'default',
17
+ invalid = false,
18
+ onOpenChange,
19
+ ...props
20
+ }: ComboboxProps<T>) => {
21
+ if (!props.items) {
22
+ throw new Error('Combobox requires an `items` prop.')
23
+ }
24
+
25
+ const [inputValue, setInputValue] = useState('')
26
+
27
+ const handleOpenChange = useCallback<NonNullable<typeof onOpenChange>>(
28
+ (open, eventDetails) => {
29
+ if (open) {
30
+ // Clear input value when combobox opens
31
+ setInputValue('')
32
+ }
33
+ onOpenChange?.(open, eventDetails)
34
+ },
35
+ [onOpenChange],
36
+ )
37
+
38
+ return (
39
+ <ComboboxProvider
40
+ disabled={Boolean(props.disabled)}
41
+ size={size}
42
+ variant={variant}
43
+ invalid={invalid}
44
+ itemToStringLabel={props.itemToStringLabel}
45
+ >
46
+ <BaseCombobox.Root
47
+ data-variant={variant}
48
+ data-size={size}
49
+ data-invalid={invalid || undefined}
50
+ inputValue={inputValue}
51
+ onInputValueChange={setInputValue}
52
+ onOpenChange={handleOpenChange}
53
+ {...props}
54
+ >
55
+ {children}
56
+ </BaseCombobox.Root>
57
+ </ComboboxProvider>
58
+ )
59
+ },
60
+ ) as <T>(props: ComboboxProps<T>) => React.ReactElement
@@ -0,0 +1,21 @@
1
+ import React, { HTMLAttributes, ReactNode } from 'react'
2
+ import { Combobox as BaseCombobox } from '@base-ui/react'
3
+ import { cn } from '../helpers'
4
+
5
+ export type ComboboxEmptyProps = HTMLAttributes<HTMLDivElement> & {
6
+ children?: ReactNode
7
+ }
8
+
9
+ export const Empty = ({ className, children = 'No matches', ...props }: ComboboxEmptyProps) => {
10
+ return (
11
+ <BaseCombobox.Empty
12
+ className={cn(
13
+ 'w-full [[data-empty]_&]:py-4 text-center text-gray-300 text-xs dark:text-zinc-400',
14
+ className,
15
+ )}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </BaseCombobox.Empty>
20
+ )
21
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react'
2
+ import {
3
+ Combobox as BaseCombobox,
4
+ ComboboxGroupLabelProps as BaseComboboxGroupLabelProps,
5
+ } from '@base-ui/react'
6
+ import { cn } from '../helpers'
7
+
8
+ export type ComboboxGroupLabelProps = BaseComboboxGroupLabelProps
9
+
10
+ export const GroupLabel = ({ className, children, ...props }: ComboboxGroupLabelProps) => (
11
+ <BaseCombobox.GroupLabel
12
+ className={cn(
13
+ 'px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-300 font-body dark:text-zinc-500',
14
+ className,
15
+ )}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </BaseCombobox.GroupLabel>
20
+ )
@@ -0,0 +1,11 @@
1
+ import React from 'react'
2
+ import {
3
+ Combobox as BaseCombobox,
4
+ ComboboxGroupProps as BaseComboboxGroupProps,
5
+ } from '@base-ui/react'
6
+
7
+ export type ComboboxGroupProps = BaseComboboxGroupProps
8
+
9
+ export const Group = ({ children, ...props }: ComboboxGroupProps) => (
10
+ <BaseCombobox.Group {...props}>{children}</BaseCombobox.Group>
11
+ )
@@ -0,0 +1,11 @@
1
+ import React, { RefAttributes } from 'react'
2
+ import {
3
+ Combobox as BaseCombobox,
4
+ ComboboxIconProps as BaseComboboxIconProps,
5
+ } from '@base-ui/react'
6
+
7
+ export type ComboboxIconProps = BaseComboboxIconProps & RefAttributes<HTMLDivElement>
8
+
9
+ export const Icon = ({ children, ...props }: ComboboxIconProps) => {
10
+ return <BaseCombobox.Icon {...props}>{children}</BaseCombobox.Icon>
11
+ }