@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,95 @@
1
+ export interface SearchableSelectRect {
2
+ readonly top: number;
3
+ readonly left: number;
4
+ readonly right: number;
5
+ readonly bottom: number;
6
+ readonly width: number;
7
+ readonly height: number;
8
+ }
9
+
10
+ interface ResolveSearchableSelectDropdownPositionArgs {
11
+ readonly triggerRect: SearchableSelectRect;
12
+ readonly boundaryRect?: SearchableSelectRect;
13
+ readonly portalRect?: SearchableSelectRect;
14
+ readonly contentWidth: number;
15
+ readonly contentHeight: number;
16
+ readonly viewportWidth: number;
17
+ readonly viewportHeight: number;
18
+ readonly offset?: number;
19
+ readonly padding?: number;
20
+ }
21
+
22
+ interface SearchableSelectDropdownPosition {
23
+ readonly top: number;
24
+ readonly left: number;
25
+ readonly width: number;
26
+ readonly maxHeight: number;
27
+ readonly direction: 'up' | 'down';
28
+ }
29
+
30
+ function clamp(value: number, min: number, max: number): number {
31
+ if (max < min) {
32
+ return min;
33
+ }
34
+
35
+ return Math.min(Math.max(value, min), max);
36
+ }
37
+
38
+ export function resolveSearchableSelectDropdownPosition({
39
+ triggerRect,
40
+ boundaryRect,
41
+ portalRect,
42
+ contentWidth,
43
+ contentHeight,
44
+ viewportWidth,
45
+ viewportHeight,
46
+ offset = 8,
47
+ padding = 8,
48
+ }: ResolveSearchableSelectDropdownPositionArgs): SearchableSelectDropdownPosition {
49
+ const boundaryLeftEdge = boundaryRect?.left ?? 0;
50
+ const boundaryRightEdge = boundaryRect?.right ?? viewportWidth;
51
+ const boundaryTopEdge = boundaryRect?.top ?? 0;
52
+ const boundaryBottomEdge = boundaryRect?.bottom ?? viewportHeight;
53
+ const boundaryLeft = Math.max(padding, boundaryLeftEdge + padding);
54
+ const boundaryRight = Math.min(
55
+ viewportWidth - padding,
56
+ boundaryRightEdge - padding,
57
+ );
58
+ const boundaryTop = Math.max(padding, boundaryTopEdge + padding);
59
+ const boundaryBottom = Math.min(
60
+ viewportHeight - padding,
61
+ boundaryBottomEdge - padding,
62
+ );
63
+ const availableWidth = Math.max(0, boundaryRight - boundaryLeft);
64
+ const width = Math.min(contentWidth, availableWidth);
65
+ const alignedRightLeft = triggerRect.right - width;
66
+ const defaultLeft = triggerRect.left;
67
+ const shouldAlignRight =
68
+ defaultLeft + width > boundaryRight && alignedRightLeft >= boundaryLeft;
69
+ const left = clamp(
70
+ shouldAlignRight ? alignedRightLeft : defaultLeft,
71
+ boundaryLeft,
72
+ boundaryRight - width,
73
+ );
74
+
75
+ const spaceAbove = triggerRect.top - boundaryTop;
76
+ const spaceBelow = boundaryBottom - triggerRect.bottom;
77
+ const shouldOpenUp = spaceBelow < contentHeight + offset && spaceAbove > spaceBelow;
78
+ const direction = shouldOpenUp ? 'up' : 'down';
79
+ const maxHeight = Math.max(
80
+ 120,
81
+ Math.floor((shouldOpenUp ? spaceAbove : spaceBelow) - offset),
82
+ );
83
+ const renderedHeight = Math.min(contentHeight, maxHeight);
84
+ const viewportTop = shouldOpenUp
85
+ ? Math.max(boundaryTop, triggerRect.top - offset - renderedHeight)
86
+ : Math.min(boundaryBottom - renderedHeight, triggerRect.bottom + offset);
87
+
88
+ return {
89
+ top: viewportTop - (portalRect?.top ?? 0),
90
+ left: left - (portalRect?.left ?? 0),
91
+ width,
92
+ maxHeight,
93
+ direction,
94
+ };
95
+ }
@@ -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,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]): string {
5
+ return twMerge(clsx(inputs))
6
+ }