@carefully-built/cli 0.1.0 → 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 (212) hide show
  1. package/README.md +148 -7
  2. package/dist/index.mjs +71 -11
  3. package/dist/index.mjs.map +1 -1
  4. package/package.json +4 -3
  5. package/registry/ui/avatar/manifest.json +33 -0
  6. package/registry/ui/avatar/primitives/avatar.tsx +64 -0
  7. package/registry/ui/avatar/utils/cn.ts +6 -0
  8. package/registry/ui/button/manifest.json +24 -5
  9. package/registry/ui/button/utils/cn.ts +6 -0
  10. package/registry/ui/calendar/manifest.json +35 -0
  11. package/registry/ui/calendar/primitives/button.tsx +89 -0
  12. package/registry/ui/calendar/primitives/calendar.tsx +68 -0
  13. package/registry/ui/calendar/utils/cn.ts +6 -0
  14. package/registry/ui/card/manifest.json +36 -0
  15. package/registry/ui/card/primitives/card.tsx +80 -0
  16. package/registry/ui/card/utils/cn.ts +6 -0
  17. package/registry/ui/chip/manifest.json +36 -0
  18. package/registry/ui/chip/primitives/chip-utils.ts +10 -0
  19. package/registry/ui/chip/primitives/chip.tsx +74 -0
  20. package/registry/ui/chip/utils/cn.ts +6 -0
  21. package/registry/ui/chip-utils/manifest.json +33 -0
  22. package/registry/ui/chip-utils/primitives/chip-utils.ts +10 -0
  23. package/registry/ui/chip-utils/utils/cn.ts +6 -0
  24. package/registry/ui/date-display/manifest.json +33 -0
  25. package/registry/ui/date-display/utils/cn.ts +6 -0
  26. package/registry/ui/date-display/utils/date-display.ts +61 -0
  27. package/registry/ui/dialog/manifest.json +43 -0
  28. package/registry/ui/dialog/primitives/button.tsx +89 -0
  29. package/registry/ui/dialog/primitives/dialog.tsx +147 -0
  30. package/registry/ui/dialog/utils/cn.ts +6 -0
  31. package/registry/ui/display-date/manifest.json +36 -0
  32. package/registry/ui/display-date/primitives/display-date.tsx +20 -0
  33. package/registry/ui/display-date/utils/cn.ts +6 -0
  34. package/registry/ui/display-date/utils/date-display.ts +61 -0
  35. package/registry/ui/drawer/manifest.json +37 -0
  36. package/registry/ui/drawer/primitives/drawer.tsx +99 -0
  37. package/registry/ui/drawer/utils/cn.ts +6 -0
  38. package/registry/ui/dropdown-menu/manifest.json +37 -0
  39. package/registry/ui/dropdown-menu/primitives/dropdown-menu.tsx +140 -0
  40. package/registry/ui/dropdown-menu/utils/cn.ts +6 -0
  41. package/registry/ui/empty-state/empty-state/collection-empty-state.ts +29 -0
  42. package/registry/ui/empty-state/empty-state/empty-state-card.tsx +72 -0
  43. package/registry/ui/empty-state/empty-state/index.ts +8 -0
  44. package/registry/ui/empty-state/empty-state/initial-empty-state.tsx +36 -0
  45. package/registry/ui/empty-state/empty-state/no-results-state.tsx +20 -0
  46. package/registry/ui/empty-state/manifest.json +63 -0
  47. package/registry/ui/empty-state/primitives/button.tsx +89 -0
  48. package/registry/ui/empty-state/primitives/card.tsx +80 -0
  49. package/registry/ui/empty-state/utils/cn.ts +6 -0
  50. package/registry/ui/error-page/error-page/error-code.tsx +16 -0
  51. package/registry/ui/error-page/error-page/error-page-content.ts +75 -0
  52. package/registry/ui/error-page/error-page/index.ts +19 -0
  53. package/registry/ui/error-page/error-page/posthog-error-capture.ts +83 -0
  54. package/registry/ui/error-page/error-page/saas-error-page.tsx +146 -0
  55. package/registry/ui/error-page/manifest.json +64 -0
  56. package/registry/ui/error-page/primitives/button.tsx +89 -0
  57. package/registry/ui/error-page/utils/cn.ts +6 -0
  58. package/registry/ui/field-detail-row/manifest.json +32 -0
  59. package/registry/ui/field-detail-row/primitives/field-detail-row.tsx +28 -0
  60. package/registry/ui/field-detail-row/utils/cn.ts +6 -0
  61. package/registry/ui/file-dropzone/manifest.json +35 -0
  62. package/registry/ui/file-dropzone/primitives/button.tsx +89 -0
  63. package/registry/ui/file-dropzone/primitives/file-dropzone.tsx +236 -0
  64. package/registry/ui/file-dropzone/utils/cn.ts +6 -0
  65. package/registry/ui/help-info-button/manifest.json +72 -0
  66. package/registry/ui/help-info-button/overlays/responsive-sheet.footer.tsx +88 -0
  67. package/registry/ui/help-info-button/overlays/responsive-sheet.layouts.tsx +207 -0
  68. package/registry/ui/help-info-button/overlays/responsive-sheet.shortcuts.ts +103 -0
  69. package/registry/ui/help-info-button/overlays/responsive-sheet.tsx +132 -0
  70. package/registry/ui/help-info-button/primitives/button.tsx +89 -0
  71. package/registry/ui/help-info-button/primitives/drawer.tsx +99 -0
  72. package/registry/ui/help-info-button/primitives/help-info-button.tsx +63 -0
  73. package/registry/ui/help-info-button/primitives/keyboard-shortcut-hint.tsx +40 -0
  74. package/registry/ui/help-info-button/primitives/sheet.tsx +103 -0
  75. package/registry/ui/help-info-button/primitives/tooltip.tsx +57 -0
  76. package/registry/ui/help-info-button/utils/cn.ts +6 -0
  77. package/registry/ui/help-info-button/utils/use-media-query.ts +28 -0
  78. package/registry/ui/input/manifest.json +31 -0
  79. package/registry/ui/input/primitives/input.tsx +19 -0
  80. package/registry/ui/input/utils/cn.ts +6 -0
  81. package/registry/ui/keyboard-shortcut-hint/manifest.json +32 -0
  82. package/registry/ui/keyboard-shortcut-hint/primitives/keyboard-shortcut-hint.tsx +40 -0
  83. package/registry/ui/keyboard-shortcut-hint/utils/cn.ts +6 -0
  84. package/registry/ui/label/manifest.json +31 -0
  85. package/registry/ui/label/primitives/label.tsx +21 -0
  86. package/registry/ui/label/utils/cn.ts +6 -0
  87. package/registry/ui/pagination/manifest.json +36 -0
  88. package/registry/ui/pagination/primitives/button.tsx +89 -0
  89. package/registry/ui/pagination/primitives/pagination.tsx +143 -0
  90. package/registry/ui/pagination/utils/cn.ts +6 -0
  91. package/registry/ui/popover/manifest.json +33 -0
  92. package/registry/ui/popover/primitives/popover.tsx +46 -0
  93. package/registry/ui/popover/utils/cn.ts +6 -0
  94. package/registry/ui/responsive-sheet/manifest.json +66 -0
  95. package/registry/ui/responsive-sheet/overlays/responsive-sheet.footer.tsx +88 -0
  96. package/registry/ui/responsive-sheet/overlays/responsive-sheet.layouts.tsx +207 -0
  97. package/registry/ui/responsive-sheet/overlays/responsive-sheet.shortcuts.ts +103 -0
  98. package/registry/ui/responsive-sheet/overlays/responsive-sheet.tsx +132 -0
  99. package/registry/ui/responsive-sheet/primitives/button.tsx +89 -0
  100. package/registry/ui/responsive-sheet/primitives/drawer.tsx +99 -0
  101. package/registry/ui/responsive-sheet/primitives/keyboard-shortcut-hint.tsx +40 -0
  102. package/registry/ui/responsive-sheet/primitives/sheet.tsx +103 -0
  103. package/registry/ui/responsive-sheet/utils/cn.ts +6 -0
  104. package/registry/ui/responsive-sheet/utils/use-media-query.ts +28 -0
  105. package/registry/ui/responsive-sheet.footer/manifest.json +40 -0
  106. package/registry/ui/responsive-sheet.footer/overlays/responsive-sheet.footer.tsx +88 -0
  107. package/registry/ui/responsive-sheet.footer/primitives/button.tsx +89 -0
  108. package/registry/ui/responsive-sheet.footer/primitives/keyboard-shortcut-hint.tsx +40 -0
  109. package/registry/ui/responsive-sheet.footer/utils/cn.ts +6 -0
  110. package/registry/ui/responsive-sheet.shortcuts/manifest.json +34 -0
  111. package/registry/ui/responsive-sheet.shortcuts/overlays/responsive-sheet.shortcuts.ts +103 -0
  112. package/registry/ui/responsive-sheet.shortcuts/utils/cn.ts +6 -0
  113. package/registry/ui/scroll-fade-area/manifest.json +31 -0
  114. package/registry/ui/scroll-fade-area/primitives/scroll-fade-area.tsx +295 -0
  115. package/registry/ui/scroll-fade-area/utils/cn.ts +6 -0
  116. package/registry/ui/search/manifest.json +35 -0
  117. package/registry/ui/search/utils/cn.ts +6 -0
  118. package/registry/ui/search/utils/search.ts +227 -0
  119. package/registry/ui/searchable-select/manifest.json +48 -0
  120. package/registry/ui/searchable-select/primitives/input.tsx +19 -0
  121. package/registry/ui/searchable-select/search/searchable-select-position.ts +95 -0
  122. package/registry/ui/searchable-select/search/searchable-select.tsx +431 -0
  123. package/registry/ui/searchable-select/utils/cn.ts +6 -0
  124. package/registry/ui/searchable-select/utils/search.ts +227 -0
  125. package/registry/ui/searchable-select-position/manifest.json +32 -0
  126. package/registry/ui/searchable-select-position/search/searchable-select-position.ts +95 -0
  127. package/registry/ui/searchable-select-position/utils/cn.ts +6 -0
  128. package/registry/ui/segmented-toggle/manifest.json +41 -0
  129. package/registry/ui/segmented-toggle/primitives/scroll-fade-area.tsx +295 -0
  130. package/registry/ui/segmented-toggle/primitives/segmented-toggle.tsx +106 -0
  131. package/registry/ui/segmented-toggle/primitives/tabs.tsx +97 -0
  132. package/registry/ui/segmented-toggle/utils/cn.ts +6 -0
  133. package/registry/ui/select/manifest.json +37 -0
  134. package/registry/ui/select/primitives/select.tsx +142 -0
  135. package/registry/ui/select/utils/cn.ts +6 -0
  136. package/registry/ui/sheet/manifest.json +39 -0
  137. package/registry/ui/sheet/primitives/button.tsx +89 -0
  138. package/registry/ui/sheet/primitives/sheet.tsx +103 -0
  139. package/registry/ui/sheet/utils/cn.ts +6 -0
  140. package/registry/ui/skeleton/manifest.json +31 -0
  141. package/registry/ui/skeleton/primitives/skeleton.tsx +13 -0
  142. package/registry/ui/skeleton/utils/cn.ts +6 -0
  143. package/registry/ui/smart-table/manifest.json +115 -0
  144. package/registry/ui/smart-table/primitives/button.tsx +89 -0
  145. package/registry/ui/smart-table/primitives/card.tsx +80 -0
  146. package/registry/ui/smart-table/primitives/display-date.tsx +20 -0
  147. package/registry/ui/smart-table/primitives/pagination.tsx +143 -0
  148. package/registry/ui/smart-table/primitives/skeleton.tsx +13 -0
  149. package/registry/ui/smart-table/primitives/table.tsx +92 -0
  150. package/registry/ui/smart-table/primitives/tooltip.tsx +57 -0
  151. package/registry/ui/smart-table/smart-table/DesktopView.tsx +343 -0
  152. package/registry/ui/smart-table/smart-table/MobileView.tsx +170 -0
  153. package/registry/ui/smart-table/smart-table/SmartTable.tsx +85 -0
  154. package/registry/ui/smart-table/smart-table/SmartTableActions.tsx +71 -0
  155. package/registry/ui/smart-table/smart-table/TruncatedContent.tsx +147 -0
  156. package/registry/ui/smart-table/smart-table/index.ts +15 -0
  157. package/registry/ui/smart-table/smart-table/sorting.ts +148 -0
  158. package/registry/ui/smart-table/smart-table/truncated-content.utils.ts +22 -0
  159. package/registry/ui/smart-table/smart-table/types.ts +95 -0
  160. package/registry/ui/smart-table/smart-table/utils.ts +150 -0
  161. package/registry/ui/smart-table/utils/cn.ts +6 -0
  162. package/registry/ui/smart-table/utils/date-display.ts +61 -0
  163. package/registry/ui/smart-table/utils/use-media-query.ts +28 -0
  164. package/registry/ui/switch/manifest.json +31 -0
  165. package/registry/ui/switch/primitives/switch.tsx +31 -0
  166. package/registry/ui/switch/utils/cn.ts +6 -0
  167. package/registry/ui/table/manifest.json +38 -0
  168. package/registry/ui/table/primitives/table.tsx +92 -0
  169. package/registry/ui/table/utils/cn.ts +6 -0
  170. package/registry/ui/table-toolbar/manifest.json +93 -0
  171. package/registry/ui/table-toolbar/overlays/responsive-sheet.footer.tsx +88 -0
  172. package/registry/ui/table-toolbar/overlays/responsive-sheet.layouts.tsx +207 -0
  173. package/registry/ui/table-toolbar/overlays/responsive-sheet.shortcuts.ts +103 -0
  174. package/registry/ui/table-toolbar/overlays/responsive-sheet.tsx +132 -0
  175. package/registry/ui/table-toolbar/primitives/button.tsx +89 -0
  176. package/registry/ui/table-toolbar/primitives/drawer.tsx +99 -0
  177. package/registry/ui/table-toolbar/primitives/input.tsx +19 -0
  178. package/registry/ui/table-toolbar/primitives/keyboard-shortcut-hint.tsx +40 -0
  179. package/registry/ui/table-toolbar/primitives/sheet.tsx +103 -0
  180. package/registry/ui/table-toolbar/search/searchable-select-position.ts +95 -0
  181. package/registry/ui/table-toolbar/search/searchable-select.tsx +431 -0
  182. package/registry/ui/table-toolbar/table-toolbar/index.ts +9 -0
  183. package/registry/ui/table-toolbar/table-toolbar/table-toolbar.tsx +552 -0
  184. package/registry/ui/table-toolbar/utils/cn.ts +6 -0
  185. package/registry/ui/table-toolbar/utils/search.ts +227 -0
  186. package/registry/ui/table-toolbar/utils/use-media-query.ts +28 -0
  187. package/registry/ui/tabs/manifest.json +40 -0
  188. package/registry/ui/tabs/primitives/scroll-fade-area.tsx +295 -0
  189. package/registry/ui/tabs/primitives/tabs.tsx +97 -0
  190. package/registry/ui/tabs/utils/cn.ts +6 -0
  191. package/registry/ui/textarea/manifest.json +31 -0
  192. package/registry/ui/textarea/primitives/textarea.tsx +18 -0
  193. package/registry/ui/textarea/utils/cn.ts +6 -0
  194. package/registry/ui/tooltip/manifest.json +34 -0
  195. package/registry/ui/tooltip/primitives/tooltip.tsx +57 -0
  196. package/registry/ui/tooltip/utils/cn.ts +6 -0
  197. package/registry/ui/use-media-query/manifest.json +32 -0
  198. package/registry/ui/use-media-query/utils/cn.ts +6 -0
  199. package/registry/ui/use-media-query/utils/use-media-query.ts +28 -0
  200. package/registry/ui/user-picker/manifest.json +52 -0
  201. package/registry/ui/user-picker/primitives/avatar.tsx +64 -0
  202. package/registry/ui/user-picker/primitives/button.tsx +89 -0
  203. package/registry/ui/user-picker/primitives/input.tsx +19 -0
  204. package/registry/ui/user-picker/primitives/popover.tsx +46 -0
  205. package/registry/ui/user-picker/primitives/user-picker-utils.ts +113 -0
  206. package/registry/ui/user-picker/primitives/user-picker.tsx +226 -0
  207. package/registry/ui/user-picker/utils/cn.ts +6 -0
  208. package/registry/ui/user-picker-utils/manifest.json +38 -0
  209. package/registry/ui/user-picker-utils/primitives/user-picker-utils.ts +113 -0
  210. package/registry/ui/user-picker-utils/utils/cn.ts +6 -0
  211. package/registry/ui/button/cn.ts +0 -6
  212. /package/registry/ui/button/{button.tsx → primitives/button.tsx} +0 -0
@@ -0,0 +1,431 @@
1
+ 'use client';
2
+
3
+ import { Check, ChevronDown, Search } from 'lucide-react';
4
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+
7
+ import { buildSearchText, rankBySearch } from '@/components/ui/search';
8
+
9
+ import type { LucideIcon } from 'lucide-react';
10
+
11
+ import { Input } from '@/components/ui/input';
12
+ import { cn } from '@/lib/utils';
13
+ import { resolveSearchableSelectDropdownPosition } from '@/components/ui/search/searchable-select-position';
14
+
15
+ export const AUTO_SEARCHABLE_SELECT_THRESHOLD = 5;
16
+
17
+ export interface SearchableSelectOption {
18
+ readonly value: string;
19
+ readonly label: string;
20
+ readonly searchText?: string;
21
+ }
22
+
23
+ export interface SearchableSelectProps<TOption extends SearchableSelectOption> {
24
+ readonly value: string;
25
+ readonly onValueChange: (value: string) => void;
26
+ readonly options: readonly TOption[];
27
+ readonly placeholder?: string;
28
+ readonly disabled?: boolean;
29
+ readonly className?: string;
30
+ readonly triggerClassName?: string;
31
+ readonly contentClassName?: string;
32
+ readonly ariaLabel?: string;
33
+ readonly searchPlaceholder?: string;
34
+ readonly emptyMessage?: string;
35
+ readonly leadingIcon?: LucideIcon;
36
+ readonly searchThreshold?: number;
37
+ readonly size?: 'sm' | 'default';
38
+ readonly renderOption?: (option: TOption) => React.ReactNode;
39
+ readonly renderValue?: (option: TOption) => React.ReactNode;
40
+ }
41
+
42
+ interface SearchableSelectBoundaryElement {
43
+ contains(target: EventTarget | null): boolean;
44
+ }
45
+
46
+ interface SearchableSelectPortalAnchor {
47
+ closest(selectors: string): Element | null;
48
+ }
49
+
50
+ const SEARCHABLE_SELECT_MODAL_CONTENT_SELECTOR =
51
+ '[data-slot="sheet-content"], [data-slot="drawer-content"]';
52
+
53
+ function getSearchableSelectBoundaryRect(element: HTMLElement): DOMRect | undefined {
54
+ return element.closest(SEARCHABLE_SELECT_MODAL_CONTENT_SELECTOR)?.getBoundingClientRect();
55
+ }
56
+
57
+ export function isSearchableSelectPointerInside(
58
+ target: EventTarget | null,
59
+ triggerContainer: SearchableSelectBoundaryElement | null,
60
+ dropdownContent: SearchableSelectBoundaryElement | null,
61
+ ): boolean {
62
+ return Boolean(triggerContainer?.contains(target) || dropdownContent?.contains(target));
63
+ }
64
+
65
+ export function getSearchableSelectPortalContainer(
66
+ trigger: SearchableSelectPortalAnchor | null,
67
+ ): Element | null {
68
+ return trigger?.closest(SEARCHABLE_SELECT_MODAL_CONTENT_SELECTOR) ?? null;
69
+ }
70
+
71
+ export function SearchableSelect<TOption extends SearchableSelectOption>({
72
+ value,
73
+ onValueChange,
74
+ options,
75
+ placeholder = 'Select...',
76
+ disabled = false,
77
+ className,
78
+ triggerClassName,
79
+ contentClassName,
80
+ ariaLabel,
81
+ searchPlaceholder = 'Search...',
82
+ emptyMessage = 'No results found',
83
+ leadingIcon: LeadingIcon,
84
+ searchThreshold = AUTO_SEARCHABLE_SELECT_THRESHOLD,
85
+ size = 'default',
86
+ renderOption,
87
+ renderValue,
88
+ }: SearchableSelectProps<TOption>): React.ReactElement {
89
+ const selectId = useId();
90
+ const [isOpen, setIsOpen] = useState(false);
91
+ const [isMounted, setIsMounted] = useState(false);
92
+ const [search, setSearch] = useState('');
93
+ const [highlightedOptionIndex, setHighlightedOptionIndex] = useState(0);
94
+ const containerRef = useRef<HTMLDivElement | null>(null);
95
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
96
+ const contentRef = useRef<HTMLDivElement | null>(null);
97
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
98
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
99
+ const [portalContainer, setPortalContainer] = useState<Element | null>(null);
100
+ const [dropdownPosition, setDropdownPosition] = useState<{
101
+ top: number;
102
+ left: number;
103
+ width: number;
104
+ maxHeight: number;
105
+ } | null>(null);
106
+ const selectedOption = options.find((option) => option.value === value);
107
+ const showSearchInput = options.length > searchThreshold;
108
+
109
+ useEffect(() => {
110
+ setIsMounted(true);
111
+ }, []);
112
+
113
+ useEffect(() => {
114
+ function closeOtherSearchableSelects(event: Event): void {
115
+ if (event instanceof CustomEvent && event.detail !== selectId) {
116
+ setIsOpen(false);
117
+ }
118
+ }
119
+
120
+ window.addEventListener('searchable-select:open', closeOtherSearchableSelects);
121
+
122
+ return () => {
123
+ window.removeEventListener('searchable-select:open', closeOtherSearchableSelects);
124
+ };
125
+ }, [selectId]);
126
+
127
+ useEffect(() => {
128
+ if (!isOpen) {
129
+ return;
130
+ }
131
+
132
+ window.dispatchEvent(new CustomEvent('searchable-select:open', { detail: selectId }));
133
+ }, [isOpen, selectId]);
134
+
135
+ useEffect(() => {
136
+ if (!showSearchInput || !isOpen) {
137
+ setSearch('');
138
+ return;
139
+ }
140
+
141
+ requestAnimationFrame(() => {
142
+ searchInputRef.current?.focus();
143
+ });
144
+ }, [isOpen, showSearchInput]);
145
+
146
+ useEffect(() => {
147
+ function handlePointerDown(event: PointerEvent): void {
148
+ if (isSearchableSelectPointerInside(event.target, containerRef.current, contentRef.current)) {
149
+ return;
150
+ }
151
+
152
+ setIsOpen(false);
153
+ }
154
+
155
+ if (!isOpen) {
156
+ return undefined;
157
+ }
158
+
159
+ document.addEventListener('pointerdown', handlePointerDown);
160
+ return () => {
161
+ document.removeEventListener('pointerdown', handlePointerDown);
162
+ };
163
+ }, [isOpen]);
164
+
165
+ const filteredOptions = useMemo(
166
+ () =>
167
+ rankBySearch(options, search, (option) => buildSearchText(option.label, option.searchText)),
168
+ [options, search],
169
+ );
170
+
171
+ useEffect(() => {
172
+ if (!isOpen) {
173
+ return;
174
+ }
175
+
176
+ const selectedIndex = filteredOptions.findIndex((option) => option.value === value);
177
+ if (selectedIndex >= 0) {
178
+ setHighlightedOptionIndex(selectedIndex);
179
+ return;
180
+ }
181
+
182
+ setHighlightedOptionIndex(0);
183
+ }, [filteredOptions, isOpen, value]);
184
+
185
+ useEffect(() => {
186
+ optionRefs.current = optionRefs.current.slice(0, filteredOptions.length);
187
+
188
+ if (filteredOptions.length === 0) {
189
+ setHighlightedOptionIndex(0);
190
+ return;
191
+ }
192
+
193
+ setHighlightedOptionIndex((currentValue) => Math.min(currentValue, filteredOptions.length - 1));
194
+ }, [filteredOptions]);
195
+
196
+ useEffect(() => {
197
+ if (!isOpen || filteredOptions.length === 0) {
198
+ return;
199
+ }
200
+
201
+ optionRefs.current[highlightedOptionIndex]?.scrollIntoView({ block: 'nearest' });
202
+ }, [filteredOptions.length, highlightedOptionIndex, isOpen]);
203
+
204
+ useLayoutEffect(() => {
205
+ if (!isOpen) {
206
+ setDropdownPosition(null);
207
+ setPortalContainer(null);
208
+ return;
209
+ }
210
+
211
+ function updateDropdownPosition(): void {
212
+ const trigger = triggerRef.current;
213
+ const content = contentRef.current;
214
+
215
+ if (!trigger || !content) {
216
+ return;
217
+ }
218
+
219
+ const nextPortalContainer = getSearchableSelectPortalContainer(trigger);
220
+ setPortalContainer(nextPortalContainer);
221
+
222
+ const triggerRect = trigger.getBoundingClientRect();
223
+ const contentRect = content.getBoundingClientRect();
224
+ const nextPosition = resolveSearchableSelectDropdownPosition({
225
+ triggerRect,
226
+ boundaryRect: getSearchableSelectBoundaryRect(trigger),
227
+ portalRect: nextPortalContainer?.getBoundingClientRect(),
228
+ contentWidth: Math.max(triggerRect.width, contentRect.width, 250),
229
+ contentHeight: contentRect.height,
230
+ viewportWidth: window.innerWidth,
231
+ viewportHeight: window.innerHeight,
232
+ });
233
+
234
+ setDropdownPosition((currentValue) => {
235
+ if (
236
+ currentValue?.top === nextPosition.top &&
237
+ currentValue?.left === nextPosition.left &&
238
+ currentValue?.width === nextPosition.width &&
239
+ currentValue?.maxHeight === nextPosition.maxHeight
240
+ ) {
241
+ return currentValue;
242
+ }
243
+
244
+ return nextPosition;
245
+ });
246
+ }
247
+
248
+ updateDropdownPosition();
249
+ const animationFrameId = window.requestAnimationFrame(updateDropdownPosition);
250
+
251
+ window.addEventListener('resize', updateDropdownPosition);
252
+ window.addEventListener('scroll', updateDropdownPosition, true);
253
+
254
+ return () => {
255
+ window.cancelAnimationFrame(animationFrameId);
256
+ window.removeEventListener('resize', updateDropdownPosition);
257
+ window.removeEventListener('scroll', updateDropdownPosition, true);
258
+ };
259
+ }, [filteredOptions.length, isOpen, search, showSearchInput]);
260
+
261
+ function selectHighlightedOption(): void {
262
+ const highlightedOption = filteredOptions[highlightedOptionIndex];
263
+
264
+ if (!highlightedOption) {
265
+ return;
266
+ }
267
+
268
+ onValueChange(highlightedOption.value);
269
+ setIsOpen(false);
270
+ }
271
+
272
+ function selectOption(value: string): void {
273
+ onValueChange(value);
274
+ setIsOpen(false);
275
+ }
276
+
277
+ function moveHighlightedOption(direction: 'up' | 'down'): void {
278
+ if (filteredOptions.length === 0) {
279
+ return;
280
+ }
281
+
282
+ setHighlightedOptionIndex((currentValue) => {
283
+ if (direction === 'down') {
284
+ return (currentValue + 1) % filteredOptions.length;
285
+ }
286
+
287
+ return (currentValue - 1 + filteredOptions.length) % filteredOptions.length;
288
+ });
289
+ }
290
+
291
+ return (
292
+ <div ref={containerRef} className={cn('relative', className)}>
293
+ <button
294
+ ref={triggerRef}
295
+ type="button"
296
+ aria-label={ariaLabel}
297
+ aria-expanded={isOpen}
298
+ disabled={disabled}
299
+ onClick={() => setIsOpen((currentValue) => !currentValue)}
300
+ onKeyDown={(event) => {
301
+ if (event.key === 'Escape') {
302
+ setIsOpen(false);
303
+ }
304
+ }}
305
+ className={cn(
306
+ 'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 flex w-full items-center justify-between gap-1.5 rounded-lg border bg-transparent py-1 pr-2 pl-2 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
307
+ size === 'sm' ? 'h-7 rounded-[min(var(--radius-md),10px)]' : 'h-8',
308
+ triggerClassName,
309
+ )}
310
+ >
311
+ {LeadingIcon ? (
312
+ <LeadingIcon className="text-muted-foreground size-4 shrink-0" strokeWidth={1.8} />
313
+ ) : null}
314
+ <span className="min-w-0 flex-1 truncate text-left">
315
+ {selectedOption ? (
316
+ (renderValue?.(selectedOption) ?? selectedOption.label)
317
+ ) : (
318
+ <span className="text-muted-foreground">{placeholder}</span>
319
+ )}
320
+ </span>
321
+ <ChevronDown
322
+ className={cn(
323
+ 'text-muted-foreground size-4 shrink-0 transition-transform',
324
+ isOpen && 'rotate-180',
325
+ )}
326
+ />
327
+ </button>
328
+
329
+ {isOpen && isMounted
330
+ ? createPortal(
331
+ <div
332
+ ref={contentRef}
333
+ data-searchable-select-content=""
334
+ className={cn(
335
+ 'bg-popover text-popover-foreground pointer-events-auto z-[60] min-w-0 rounded-xl border p-2 shadow-lg',
336
+ portalContainer ? 'absolute' : 'fixed',
337
+ contentClassName,
338
+ )}
339
+ style={{
340
+ top: dropdownPosition?.top ?? 0,
341
+ left: dropdownPosition?.left ?? 0,
342
+ width: dropdownPosition?.width ?? triggerRef.current?.getBoundingClientRect().width,
343
+ visibility: dropdownPosition ? 'visible' : 'hidden',
344
+ }}
345
+ >
346
+ {showSearchInput ? (
347
+ <div className="relative mb-2">
348
+ <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
349
+ <Input
350
+ ref={searchInputRef}
351
+ value={search}
352
+ onChange={(event) => setSearch(event.target.value)}
353
+ onKeyDown={(event) => {
354
+ if (event.key === 'ArrowDown') {
355
+ event.preventDefault();
356
+ moveHighlightedOption('down');
357
+ }
358
+
359
+ if (event.key === 'ArrowUp') {
360
+ event.preventDefault();
361
+ moveHighlightedOption('up');
362
+ }
363
+
364
+ if (event.key === 'Enter') {
365
+ event.preventDefault();
366
+ selectHighlightedOption();
367
+ }
368
+
369
+ if (event.key === 'Escape') {
370
+ event.preventDefault();
371
+ setIsOpen(false);
372
+ }
373
+ }}
374
+ placeholder={searchPlaceholder}
375
+ className="pl-9"
376
+ />
377
+ </div>
378
+ ) : null}
379
+
380
+ <div
381
+ className="space-y-1 overflow-y-auto"
382
+ style={{
383
+ maxHeight: Math.min(256, dropdownPosition?.maxHeight ?? 256),
384
+ }}
385
+ >
386
+ {filteredOptions.map((option, index) => {
387
+ const isSelected = option.value === value;
388
+
389
+ return (
390
+ <button
391
+ key={option.value}
392
+ ref={(element) => {
393
+ optionRefs.current[index] = element;
394
+ }}
395
+ type="button"
396
+ className={cn(
397
+ 'hover:bg-accent hover:text-accent-foreground flex h-auto w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
398
+ isSelected && 'bg-muted/60',
399
+ highlightedOptionIndex === index && 'bg-accent text-accent-foreground',
400
+ )}
401
+ onMouseEnter={() => setHighlightedOptionIndex(index)}
402
+ onPointerDown={(event) => {
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+ selectOption(option.value);
406
+ }}
407
+ onClick={(event) => {
408
+ if (event.detail === 0) {
409
+ selectOption(option.value);
410
+ }
411
+ }}
412
+ >
413
+ <span className="min-w-0 flex-1 truncate">
414
+ {renderOption?.(option) ?? option.label}
415
+ </span>
416
+ {isSelected ? <Check className="size-4 shrink-0" /> : null}
417
+ </button>
418
+ );
419
+ })}
420
+
421
+ {filteredOptions.length === 0 ? (
422
+ <p className="text-muted-foreground px-2 py-4 text-sm">{emptyMessage}</p>
423
+ ) : null}
424
+ </div>
425
+ </div>,
426
+ portalContainer ?? document.body,
427
+ )
428
+ : null}
429
+ </div>
430
+ );
431
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ FilterDropdown,
3
+ SearchInput,
4
+ TableToolbar,
5
+ type CustomTableToolbarFilter,
6
+ type FilterConfig,
7
+ type FilterOption,
8
+ type TableToolbarProps,
9
+ } from '@/components/ui/table-toolbar/table-toolbar';