create-astram-app 0.1.2 → 0.2.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 (101) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +132 -33
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/CLAUDE.md +51 -0
  7. package/template/README.md +43 -44
  8. package/template/_gitignore +6 -0
  9. package/template/frontend/FRONTEND.md +92 -0
  10. package/template/frontend/README.md +16 -0
  11. package/template/frontend/_env.example +6 -0
  12. package/template/frontend/_gitignore +9 -0
  13. package/template/frontend/eslint.config.js +71 -0
  14. package/template/frontend/index.html +13 -0
  15. package/template/frontend/package.json +52 -0
  16. package/template/frontend/public/astram-logo-full.png +0 -0
  17. package/template/frontend/public/astram-logo-white.png +0 -0
  18. package/template/frontend/public/favicon.png +0 -0
  19. package/template/frontend/src/App.jsx +167 -0
  20. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  21. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  22. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  23. package/template/frontend/src/components/EntityList.jsx +480 -0
  24. package/template/frontend/src/components/PageHeader.jsx +54 -0
  25. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  26. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  27. package/template/frontend/src/components/ui/alert.tsx +83 -0
  28. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  29. package/template/frontend/src/components/ui/badge.tsx +56 -0
  30. package/template/frontend/src/components/ui/button.tsx +67 -0
  31. package/template/frontend/src/components/ui/card.tsx +92 -0
  32. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  33. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  34. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  35. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  36. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  37. package/template/frontend/src/components/ui/input.tsx +21 -0
  38. package/template/frontend/src/components/ui/label.tsx +46 -0
  39. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  40. package/template/frontend/src/components/ui/select.tsx +199 -0
  41. package/template/frontend/src/components/ui/separator.tsx +26 -0
  42. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  43. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  44. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  45. package/template/frontend/src/components/ui/table.tsx +181 -0
  46. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  47. package/template/frontend/src/config/branding.js +80 -0
  48. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  49. package/template/frontend/src/index.css +265 -0
  50. package/template/frontend/src/lib/utils.js +7 -0
  51. package/template/frontend/src/main.jsx +27 -0
  52. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  53. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  54. package/template/frontend/src/pages/Login.jsx +393 -0
  55. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  56. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  57. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  58. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  59. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  60. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  61. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  62. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  63. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  64. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  65. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  66. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  67. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  68. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  69. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  70. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  71. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  72. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  73. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  74. package/template/frontend/src/platform/httpClient.js +125 -0
  75. package/template/frontend/src/platform/returnPathStore.js +43 -0
  76. package/template/frontend/src/platform/sessionStore.js +29 -0
  77. package/template/frontend/src/platform/shieldTransport.js +172 -0
  78. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  79. package/template/frontend/src/shell/AppShell.jsx +237 -0
  80. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  81. package/template/frontend/src/shell/nav.js +90 -0
  82. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  83. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  84. package/template/frontend/tsconfig.json +22 -0
  85. package/template/frontend/vite.config.js +30 -0
  86. package/template/server/_env.example +45 -0
  87. package/template/server/eslint.config.js +95 -0
  88. package/template/server/package.json +49 -0
  89. package/template/server/scripts/dev.mjs +51 -0
  90. package/template/server/scripts/sync-astram-guide.mjs +63 -0
  91. package/template/_env.example +0 -33
  92. package/template/package.json +0 -37
  93. /package/template/{src → server/src}/app.module.ts +0 -0
  94. /package/template/{src → server/src}/features/_gitkeep +0 -0
  95. /package/template/{src → server/src}/health/demo.controller.ts +0 -0
  96. /package/template/{src → server/src}/health/health.controller.ts +0 -0
  97. /package/template/{src → server/src}/main.ts +0 -0
  98. /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
  99. /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
  100. /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
  101. /package/template/{vitest.config.ts → server/vitest.config.ts} +0 -0
@@ -0,0 +1,459 @@
1
+ import * as React from 'react';
2
+ import * as PopperPrimitive from '@radix-ui/react-popper';
3
+ import { Portal } from '@radix-ui/react-portal';
4
+ import { DismissableLayer } from '@radix-ui/react-dismissable-layer';
5
+ import { Check, ChevronDown, ChevronLeft, ChevronRight, Loader2, Search, X } from 'lucide-react';
6
+
7
+ import { cn } from '@/lib/utils';
8
+
9
+ /**
10
+ * SearchableSelect — a searchable + server-paginated dynamic dropdown, the astram3
11
+ * translation of OCA's SearchableSelect (`oca/src/components/dynamic-form/SearchableSelect.jsx`).
12
+ * It is the default control for GROWABLE lists (companies / roles / users) whose row count
13
+ * outgrows a plain <select>: the caller supplies HOW to fetch a page, the component owns the
14
+ * search box, a discrete PREV/NEXT pager, portal positioning, keyboard nav, and the
15
+ * loading / empty states.
16
+ *
17
+ * Pagination model — PAGE-AT-A-TIME (matches OCA, NOT infinite scroll): a discrete
18
+ * `◀ Page X of N ▶` pager sits UNDER the option list. Clicking Prev/Next fetches that page
19
+ * and REPLACES the visible options (never appends). Prev is disabled on page 1; Next is
20
+ * disabled on the last page. The option LIST is its OWN scroll region (`overflow-y-auto` +
21
+ * a capped max-height), so the mouse wheel scrolls the CURRENT page's options — the pager is
22
+ * separate. Search resets to page 1.
23
+ *
24
+ * Composition (no new dependency): built from the SAME Radix primitives that
25
+ * `@radix-ui/react-popover` is itself assembled from — `react-popper`
26
+ * (anchor + flipping/collision-aware Content), `react-portal` (render to `document.body`
27
+ * so the panel is NEVER clipped by a modal's `overflow`), and `react-dismissable-layer`
28
+ * (outside-click + Esc to close). Tokens only → the trigger mirrors `Input`, the panel
29
+ * mirrors `SelectContent` (light + dark both correct).
30
+ *
31
+ * ⛔ No raw UUIDs: the VALUE is a uuid, the LABEL is a name. The trigger shows the
32
+ * selected option's LABEL — the caller passes `selectedLabel` (the row already knows it),
33
+ * so the trigger renders a name even before any page is fetched. An option's `value` uuid
34
+ * is never rendered as visible text.
35
+ *
36
+ * `fetchPage` contract — the single seam that makes one component serve every entity:
37
+ * fetchPage({ search, page, limit }) =>
38
+ * Promise<{ options: Array<{ value: string, label: string }>, total: number, page: number }>
39
+ * `total` + `limit` give "Page X of N". The component debounces search (~300ms) and always
40
+ * requests page 1 on open / on a new term.
41
+ *
42
+ * For SMALL, non-paginated catalogs (department / branch), pass a `fetchPage` that returns
43
+ * the full list once (total = length) with `clientFilter`; the panel then filters client-side
44
+ * by the typed term (single page → the pager stays hidden).
45
+ */
46
+
47
+ export interface SearchableOption {
48
+ value: string;
49
+ label: string;
50
+ }
51
+
52
+ export interface FetchPageArgs {
53
+ search: string;
54
+ page: number;
55
+ limit: number;
56
+ }
57
+
58
+ export interface FetchPageResult {
59
+ options: SearchableOption[];
60
+ total: number;
61
+ page: number;
62
+ }
63
+
64
+ export interface SearchableSelectProps {
65
+ /** The selected value (a uuid) or null when nothing is chosen. */
66
+ value: string | null;
67
+ /** Called with the newly-picked uuid, or null when cleared. */
68
+ onChange: (value: string | null) => void;
69
+ /**
70
+ * Optional: called with the picked option's LABEL (or null on clear) whenever the
71
+ * selection changes. Lets a caller persist the display name for its own trigger/state
72
+ * without re-deriving it — the value is the uuid, this is the name.
73
+ */
74
+ onLabelChange?: (label: string | null) => void;
75
+ /**
76
+ * The display LABEL for the current `value` (a name — the caller already knows it, e.g.
77
+ * from the resolved row). Shown on the trigger so no uuid ever surfaces. Optional: if a
78
+ * matching option is loaded in the panel, its label is used as a fallback.
79
+ */
80
+ selectedLabel?: string | null;
81
+ /** How to fetch a page of options (see the contract above). */
82
+ fetchPage: (args: FetchPageArgs) => Promise<FetchPageResult>;
83
+ placeholder?: string;
84
+ /** Placeholder for the in-panel search box. */
85
+ searchPlaceholder?: string;
86
+ disabled?: boolean;
87
+ /** Mark the trigger invalid (red ring), like an aria-invalid Input. */
88
+ invalid?: boolean;
89
+ /** Show an inline "clear" (×) affordance when a value is set. */
90
+ allowClear?: boolean;
91
+ /**
92
+ * Client-filter mode: the panel filters the fetched options locally by the typed term
93
+ * instead of re-calling `fetchPage`. Use for small, non-paginated catalogs where
94
+ * `fetchPage` returns the whole list in one page (the pager stays hidden).
95
+ */
96
+ clientFilter?: boolean;
97
+ /** Page size for server pagination (ignored in `clientFilter` mode). Default 20. */
98
+ pageSize?: number;
99
+ className?: string;
100
+ 'aria-label'?: string;
101
+ id?: string;
102
+ }
103
+
104
+ const DEBOUNCE_MS = 300;
105
+ const DEFAULT_PAGE_SIZE = 20;
106
+
107
+ export function SearchableSelect({
108
+ value,
109
+ onChange,
110
+ onLabelChange,
111
+ selectedLabel,
112
+ fetchPage,
113
+ placeholder = 'Select…',
114
+ searchPlaceholder = 'Search…',
115
+ disabled = false,
116
+ invalid = false,
117
+ allowClear = false,
118
+ clientFilter = false,
119
+ pageSize = DEFAULT_PAGE_SIZE,
120
+ className,
121
+ 'aria-label': ariaLabel,
122
+ id,
123
+ }: SearchableSelectProps) {
124
+ const [open, setOpen] = React.useState(false);
125
+ const [search, setSearch] = React.useState('');
126
+ const [debounced, setDebounced] = React.useState('');
127
+ const [options, setOptions] = React.useState<SearchableOption[]>([]);
128
+ const [total, setTotal] = React.useState(0);
129
+ const [page, setPage] = React.useState(1);
130
+ const [loading, setLoading] = React.useState(false);
131
+ const [active, setActive] = React.useState(0); // keyboard-highlighted index
132
+
133
+ const listRef = React.useRef<HTMLDivElement>(null);
134
+ const searchRef = React.useRef<HTMLInputElement>(null);
135
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
136
+ const reqId = React.useRef(0); // guards against out-of-order fetch resolutions
137
+
138
+ // Where to portal the panel. When this select lives INSIDE a Radix Dialog (the routed
139
+ // Add/Edit modal), portalling to <body> puts the panel OUTSIDE the Dialog's focus scope
140
+ // (its FocusScope yanks focus back inside → the in-panel search can't hold focus) AND
141
+ // outside its pointer-events shard (RemoveScroll marks the rest of the body inert). Both
142
+ // break the panel. So we portal into the Dialog's own content element when there is one —
143
+ // the panel is `position: fixed`, so the Dialog's `overflow: hidden` does NOT clip it
144
+ // (fixed descendants escape overflow clipping). Falls back to <body> outside any modal.
145
+ const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
146
+
147
+ // Remember the label of the last-fetched option matching `value`, so the trigger keeps a
148
+ // name even if the caller didn't pass `selectedLabel` (fallback only — caller wins).
149
+ const [resolvedLabel, setResolvedLabel] = React.useState<string | null>(null);
150
+
151
+ // The label shown on the trigger: caller-supplied wins, else a matched loaded option,
152
+ // else the remembered resolved label. Never the uuid.
153
+ const displayLabel =
154
+ (selectedLabel && selectedLabel.trim()) ||
155
+ options.find((o) => o.value === value)?.label ||
156
+ resolvedLabel ||
157
+ '';
158
+
159
+ // Debounce the search box → `debounced`.
160
+ React.useEffect(() => {
161
+ const t = setTimeout(() => setDebounced(search.trim()), DEBOUNCE_MS);
162
+ return () => clearTimeout(t);
163
+ }, [search]);
164
+
165
+ // Client-filtered view (only used in clientFilter mode): filter the loaded set locally.
166
+ const visibleOptions = React.useMemo(() => {
167
+ if (!clientFilter) return options;
168
+ const q = debounced.toLowerCase();
169
+ if (!q) return options;
170
+ return options.filter((o) => o.label.toLowerCase().includes(q));
171
+ }, [clientFilter, options, debounced]);
172
+
173
+ // Load a specific page — always REPLACES the visible options (page-at-a-time; no append).
174
+ const loadPage = React.useCallback(
175
+ async (targetPage: number, term: string) => {
176
+ const myReq = ++reqId.current;
177
+ setLoading(true);
178
+ try {
179
+ const res = await fetchPage({ search: term, page: targetPage, limit: pageSize });
180
+ if (myReq !== reqId.current) return; // a newer request superseded this one
181
+ const incoming = res.options || [];
182
+ setTotal(res.total ?? incoming.length);
183
+ setPage(res.page ?? targetPage);
184
+ setOptions(incoming);
185
+ setActive(0);
186
+ // Cache the label for the current value if it appears in this page.
187
+ const match = incoming.find((o) => o.value === value);
188
+ if (match) setResolvedLabel(match.label);
189
+ // A fresh page: scroll the list region back to the top.
190
+ if (listRef.current) listRef.current.scrollTop = 0;
191
+ } catch {
192
+ if (myReq === reqId.current) {
193
+ setOptions([]);
194
+ setTotal(0);
195
+ }
196
+ } finally {
197
+ if (myReq === reqId.current) setLoading(false);
198
+ }
199
+ },
200
+ [fetchPage, pageSize, value],
201
+ );
202
+
203
+ // On open, and whenever the (debounced) search term changes while open, (re)load page 1.
204
+ // Server mode refetches for the term; clientFilter mode loads the full list once on open
205
+ // (empty term) and filters the term locally, so it does NOT refetch per keystroke.
206
+ React.useEffect(() => {
207
+ if (!open) return;
208
+ loadPage(1, clientFilter ? '' : debounced);
209
+ // eslint-disable-next-line react-hooks/exhaustive-deps
210
+ }, [open, clientFilter ? open : debounced, clientFilter]);
211
+
212
+ // Focus the search box when the panel opens; reset the search on close.
213
+ React.useEffect(() => {
214
+ if (open) {
215
+ const t = setTimeout(() => searchRef.current?.focus(), 0);
216
+ return () => clearTimeout(t);
217
+ }
218
+ setSearch('');
219
+ setDebounced('');
220
+ return undefined;
221
+ }, [open]);
222
+
223
+ // Pagination math. In clientFilter mode there is a single (local) page → no pager.
224
+ const totalPages = clientFilter ? 1 : Math.max(1, Math.ceil((total || 0) / pageSize));
225
+ const showPager = !clientFilter && totalPages > 1;
226
+
227
+ const goPrev = () => {
228
+ if (loading || page <= 1) return;
229
+ loadPage(page - 1, debounced);
230
+ };
231
+ const goNext = () => {
232
+ if (loading || page >= totalPages) return;
233
+ loadPage(page + 1, debounced);
234
+ };
235
+
236
+ // Open the panel, resolving the portal target from the trigger's DOM position first:
237
+ // the nearest enclosing Dialog content (so the panel lives inside the modal's focus
238
+ // scope + pointer-events shard), else <body> outside any modal (see portalContainer).
239
+ const openPanel = () => {
240
+ setPortalContainer(
241
+ triggerRef.current?.closest<HTMLElement>('[data-slot="dialog-content"]') ?? null,
242
+ );
243
+ setOpen(true);
244
+ };
245
+
246
+ const pick = (opt: SearchableOption) => {
247
+ onChange(opt.value);
248
+ onLabelChange?.(opt.label);
249
+ setResolvedLabel(opt.label);
250
+ setOpen(false);
251
+ triggerRef.current?.focus();
252
+ };
253
+
254
+ const clear = (e: React.MouseEvent) => {
255
+ e.stopPropagation();
256
+ onChange(null);
257
+ onLabelChange?.(null);
258
+ setResolvedLabel(null);
259
+ };
260
+
261
+ // Keyboard navigation on the search box: ↑/↓ move the active item, Enter selects it,
262
+ // Esc closes. Type-to-search is the input itself.
263
+ const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
264
+ const list = visibleOptions;
265
+ if (e.key === 'ArrowDown') {
266
+ e.preventDefault();
267
+ setActive((i) => Math.min(i + 1, Math.max(list.length - 1, 0)));
268
+ } else if (e.key === 'ArrowUp') {
269
+ e.preventDefault();
270
+ setActive((i) => Math.max(i - 1, 0));
271
+ } else if (e.key === 'Enter') {
272
+ e.preventDefault();
273
+ const opt = list[active];
274
+ if (opt) pick(opt);
275
+ } else if (e.key === 'Escape') {
276
+ e.preventDefault();
277
+ setOpen(false);
278
+ triggerRef.current?.focus();
279
+ }
280
+ };
281
+
282
+ // Keep the active item scrolled into view.
283
+ React.useEffect(() => {
284
+ if (!open) return;
285
+ const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
286
+ el?.scrollIntoView({ block: 'nearest' });
287
+ }, [active, open]);
288
+
289
+ const showEmpty = !loading && visibleOptions.length === 0;
290
+
291
+ return (
292
+ <PopperPrimitive.Root>
293
+ <PopperPrimitive.Anchor asChild>
294
+ <button
295
+ ref={triggerRef}
296
+ id={id}
297
+ type="button"
298
+ role="combobox"
299
+ aria-expanded={open}
300
+ aria-haspopup="listbox"
301
+ aria-label={ariaLabel}
302
+ aria-invalid={invalid || undefined}
303
+ disabled={disabled}
304
+ onClick={() => (open ? setOpen(false) : openPanel())}
305
+ onKeyDown={(e) => {
306
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
307
+ e.preventDefault();
308
+ openPanel();
309
+ }
310
+ }}
311
+ className={cn(
312
+ 'border-input flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
313
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
314
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
315
+ 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
316
+ className,
317
+ )}
318
+ >
319
+ <span
320
+ className={cn(
321
+ 'line-clamp-1 text-left',
322
+ displayLabel ? 'text-foreground' : 'text-muted-foreground',
323
+ )}
324
+ >
325
+ {displayLabel || placeholder}
326
+ </span>
327
+ <span className="flex shrink-0 items-center gap-1">
328
+ {allowClear && value && !disabled && (
329
+ <X
330
+ role="button"
331
+ aria-label="Clear selection"
332
+ tabIndex={-1}
333
+ onClick={clear}
334
+ className="size-4 opacity-50 hover:opacity-100"
335
+ />
336
+ )}
337
+ <ChevronDown className="size-4 opacity-50" />
338
+ </span>
339
+ </button>
340
+ </PopperPrimitive.Anchor>
341
+
342
+ {open && (
343
+ <Portal container={portalContainer ?? undefined}>
344
+ <DismissableLayer
345
+ onEscapeKeyDown={() => setOpen(false)}
346
+ onPointerDownOutside={() => setOpen(false)}
347
+ onFocusOutside={(e) => e.preventDefault()}
348
+ asChild
349
+ >
350
+ <PopperPrimitive.Content
351
+ side="bottom"
352
+ align="start"
353
+ sideOffset={4}
354
+ collisionPadding={8}
355
+ // `data-searchable-select` is the HOOK for the load-bearing pointer-events
356
+ // fix (see index.css). This panel is portalled to document.body, so when the
357
+ // dropdown opens INSIDE a Radix Dialog (the routed Add/Edit modal), react-popper's
358
+ // OUTER positioning wrapper (`[data-radix-popper-content-wrapper]`, which we can't
359
+ // className) is a body sibling of the Dialog content → Radix Dialog's RemoveScroll
360
+ // sets `pointer-events: none` on it, which blocks hit-testing to the search box,
361
+ // the option rows, AND the wheel-scroll over the list. A `pointer-events-auto`
362
+ // on THIS element alone is not enough — the un-stylable wrapper above it still
363
+ // swallows the events. index.css re-enables that wrapper by matching
364
+ // `[data-radix-popper-content-wrapper]:has(> [data-searchable-select])`.
365
+ data-searchable-select=""
366
+ className={cn(
367
+ 'pointer-events-auto',
368
+ 'bg-popover text-popover-foreground z-50 flex max-h-[min(22rem,var(--radix-popper-available-height))] w-[var(--radix-popper-anchor-width)] min-w-[10rem] flex-col overflow-hidden rounded-md border shadow-md',
369
+ )}
370
+ >
371
+ {/* Search box */}
372
+ <div className="flex items-center gap-2 border-b px-2.5">
373
+ <Search className="size-4 shrink-0 opacity-50" />
374
+ <input
375
+ ref={searchRef}
376
+ value={search}
377
+ onChange={(e) => setSearch(e.target.value)}
378
+ onKeyDown={onSearchKeyDown}
379
+ placeholder={searchPlaceholder}
380
+ className="h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
381
+ aria-label={searchPlaceholder}
382
+ />
383
+ {loading && <Loader2 className="size-4 shrink-0 animate-spin opacity-60" />}
384
+ </div>
385
+
386
+ {/* Options list — its OWN scroll region (overflow-y-auto + a capped
387
+ max-height) so the mouse wheel scrolls the CURRENT page's options.
388
+ `overscroll-contain` keeps the wheel from chaining to the page. Paging
389
+ REPLACES this list (page-at-a-time) — it never appends. */}
390
+ <div
391
+ ref={listRef}
392
+ role="listbox"
393
+ className="max-h-64 min-h-0 flex-1 overflow-y-auto overscroll-contain p-1"
394
+ >
395
+ {showEmpty ? (
396
+ <div className="px-2 py-6 text-center text-sm text-muted-foreground">
397
+ No results
398
+ </div>
399
+ ) : (
400
+ visibleOptions.map((opt, i) => {
401
+ const selected = opt.value === value;
402
+ return (
403
+ <div
404
+ key={opt.value}
405
+ data-index={i}
406
+ role="option"
407
+ aria-selected={selected}
408
+ onClick={() => pick(opt)}
409
+ onMouseEnter={() => setActive(i)}
410
+ className={cn(
411
+ 'relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm select-none',
412
+ i === active && 'bg-accent text-accent-foreground',
413
+ )}
414
+ >
415
+ <span className="line-clamp-1">{opt.label}</span>
416
+ {selected && (
417
+ <Check className="absolute right-2 size-4 shrink-0" />
418
+ )}
419
+ </div>
420
+ );
421
+ })
422
+ )}
423
+ </div>
424
+
425
+ {/* Discrete Prev/Next pager (server pagination only, >1 page). Page-at-a-time:
426
+ each click REPLACES the list with that page. Prev disabled on page 1, Next
427
+ on the last page. Subtle, mono, token-styled — the OCA `.oca-select-pager`. */}
428
+ {showPager && (
429
+ <div className="flex items-center justify-between gap-2 border-t px-2 py-1.5">
430
+ <button
431
+ type="button"
432
+ aria-label="Previous page"
433
+ disabled={loading || page <= 1}
434
+ onClick={goPrev}
435
+ className="inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40"
436
+ >
437
+ <ChevronLeft className="size-4" />
438
+ </button>
439
+ <span className="font-mono text-[0.68rem] tracking-[0.04em] text-muted-foreground tabular-nums">
440
+ Page {page} of {totalPages}
441
+ </span>
442
+ <button
443
+ type="button"
444
+ aria-label="Next page"
445
+ disabled={loading || page >= totalPages}
446
+ onClick={goNext}
447
+ className="inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40"
448
+ >
449
+ <ChevronRight className="size-4" />
450
+ </button>
451
+ </div>
452
+ )}
453
+ </PopperPrimitive.Content>
454
+ </DismissableLayer>
455
+ </Portal>
456
+ )}
457
+ </PopperPrimitive.Root>
458
+ );
459
+ }
@@ -0,0 +1,199 @@
1
+ import * as React from "react";
2
+ import * as SelectPrimitive from "@radix-ui/react-select";
3
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ /**
8
+ * Radix Select — the shadcn/new-york Select set, styled to match this repo's
9
+ * `Input` and `dialog` primitives. Replaces the native `<select>`s in Phase B.
10
+ *
11
+ * The `SelectTrigger` mirrors the `Input` field look (`h-9`, `border-input`,
12
+ * the same 3px focus ring), and `SelectContent` matches the popover surface
13
+ * used by `dropdown-menu` / `dialog` (portal-rendered so it's never clipped by
14
+ * a modal — the OCA SearchableSelect portal behavior). Tokens only → light +
15
+ * dark (next-themes `.dark`) both correct.
16
+ *
17
+ * React 18.3: every Radix-primitive-backed sub-component uses `React.forwardRef`
18
+ * (mirroring `dialog.tsx`) so Radix's ref-forwarding (Trigger/Content/Item/…)
19
+ * lands on the underlying primitive and does NOT log "Function components
20
+ * cannot be given refs".
21
+ *
22
+ * NOTE: requires `@radix-ui/react-select` to be installed.
23
+ */
24
+
25
+ function Select({
26
+ ...props
27
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
28
+ return <SelectPrimitive.Root data-slot="select" {...props} />;
29
+ }
30
+
31
+ function SelectGroup({
32
+ ...props
33
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
34
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />;
35
+ }
36
+
37
+ function SelectValue({
38
+ ...props
39
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
40
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />;
41
+ }
42
+
43
+ const SelectTrigger = React.forwardRef<
44
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
45
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
46
+ size?: "sm" | "default";
47
+ }
48
+ >(({ className, size = "default", children, ...props }, ref) => (
49
+ <SelectPrimitive.Trigger
50
+ ref={ref}
51
+ data-slot="select-trigger"
52
+ data-size={size}
53
+ className={cn(
54
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
55
+ className
56
+ )}
57
+ {...props}
58
+ >
59
+ {children}
60
+ <SelectPrimitive.Icon asChild>
61
+ <ChevronDownIcon className="size-4 opacity-50" />
62
+ </SelectPrimitive.Icon>
63
+ </SelectPrimitive.Trigger>
64
+ ));
65
+ SelectTrigger.displayName = "SelectTrigger";
66
+
67
+ const SelectScrollUpButton = React.forwardRef<
68
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
69
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
70
+ >(({ className, ...props }, ref) => (
71
+ <SelectPrimitive.ScrollUpButton
72
+ ref={ref}
73
+ data-slot="select-scroll-up-button"
74
+ className={cn(
75
+ "flex cursor-default items-center justify-center py-1",
76
+ className
77
+ )}
78
+ {...props}
79
+ >
80
+ <ChevronUpIcon className="size-4" />
81
+ </SelectPrimitive.ScrollUpButton>
82
+ ));
83
+ SelectScrollUpButton.displayName = "SelectScrollUpButton";
84
+
85
+ const SelectScrollDownButton = React.forwardRef<
86
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
87
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
88
+ >(({ className, ...props }, ref) => (
89
+ <SelectPrimitive.ScrollDownButton
90
+ ref={ref}
91
+ data-slot="select-scroll-down-button"
92
+ className={cn(
93
+ "flex cursor-default items-center justify-center py-1",
94
+ className
95
+ )}
96
+ {...props}
97
+ >
98
+ <ChevronDownIcon className="size-4" />
99
+ </SelectPrimitive.ScrollDownButton>
100
+ ));
101
+ SelectScrollDownButton.displayName = "SelectScrollDownButton";
102
+
103
+ const SelectContent = React.forwardRef<
104
+ React.ElementRef<typeof SelectPrimitive.Content>,
105
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
106
+ >(({ className, children, position = "popper", ...props }, ref) => (
107
+ <SelectPrimitive.Portal>
108
+ <SelectPrimitive.Content
109
+ ref={ref}
110
+ data-slot="select-content"
111
+ className={cn(
112
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
113
+ position === "popper" &&
114
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
115
+ className
116
+ )}
117
+ position={position}
118
+ {...props}
119
+ >
120
+ <SelectScrollUpButton />
121
+ <SelectPrimitive.Viewport
122
+ className={cn(
123
+ "p-1",
124
+ position === "popper" &&
125
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
126
+ )}
127
+ >
128
+ {children}
129
+ </SelectPrimitive.Viewport>
130
+ <SelectScrollDownButton />
131
+ </SelectPrimitive.Content>
132
+ </SelectPrimitive.Portal>
133
+ ));
134
+ SelectContent.displayName = "SelectContent";
135
+
136
+ const SelectLabel = React.forwardRef<
137
+ React.ElementRef<typeof SelectPrimitive.Label>,
138
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
139
+ >(({ className, ...props }, ref) => (
140
+ <SelectPrimitive.Label
141
+ ref={ref}
142
+ data-slot="select-label"
143
+ className={cn(
144
+ "text-muted-foreground px-2 py-1.5 font-mono text-[0.68rem] font-semibold tracking-[0.08em] uppercase",
145
+ className
146
+ )}
147
+ {...props}
148
+ />
149
+ ));
150
+ SelectLabel.displayName = "SelectLabel";
151
+
152
+ const SelectItem = React.forwardRef<
153
+ React.ElementRef<typeof SelectPrimitive.Item>,
154
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
155
+ >(({ className, children, ...props }, ref) => (
156
+ <SelectPrimitive.Item
157
+ ref={ref}
158
+ data-slot="select-item"
159
+ className={cn(
160
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
161
+ className
162
+ )}
163
+ {...props}
164
+ >
165
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
166
+ <SelectPrimitive.ItemIndicator>
167
+ <CheckIcon className="size-4" />
168
+ </SelectPrimitive.ItemIndicator>
169
+ </span>
170
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
171
+ </SelectPrimitive.Item>
172
+ ));
173
+ SelectItem.displayName = "SelectItem";
174
+
175
+ const SelectSeparator = React.forwardRef<
176
+ React.ElementRef<typeof SelectPrimitive.Separator>,
177
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
178
+ >(({ className, ...props }, ref) => (
179
+ <SelectPrimitive.Separator
180
+ ref={ref}
181
+ data-slot="select-separator"
182
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
183
+ {...props}
184
+ />
185
+ ));
186
+ SelectSeparator.displayName = "SelectSeparator";
187
+
188
+ export {
189
+ Select,
190
+ SelectContent,
191
+ SelectGroup,
192
+ SelectItem,
193
+ SelectLabel,
194
+ SelectScrollDownButton,
195
+ SelectScrollUpButton,
196
+ SelectSeparator,
197
+ SelectTrigger,
198
+ SelectValue,
199
+ };
@@ -0,0 +1,26 @@
1
+ import * as React from "react";
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ function Separator({
7
+ className,
8
+ orientation = "horizontal",
9
+ decorative = true,
10
+ ...props
11
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
12
+ return (
13
+ <SeparatorPrimitive.Root
14
+ data-slot="separator"
15
+ decorative={decorative}
16
+ orientation={orientation}
17
+ className={cn(
18
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ );
24
+ }
25
+
26
+ export { Separator };