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.
- package/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +132 -33
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/CLAUDE.md +51 -0
- package/template/README.md +43 -44
- package/template/_gitignore +6 -0
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/README.md +16 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +90 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +45 -0
- package/template/server/eslint.config.js +95 -0
- package/template/server/package.json +49 -0
- package/template/server/scripts/dev.mjs +51 -0
- package/template/server/scripts/sync-astram-guide.mjs +63 -0
- package/template/_env.example +0 -33
- package/template/package.json +0 -37
- /package/template/{src → server/src}/app.module.ts +0 -0
- /package/template/{src → server/src}/features/_gitkeep +0 -0
- /package/template/{src → server/src}/health/demo.controller.ts +0 -0
- /package/template/{src → server/src}/health/health.controller.ts +0 -0
- /package/template/{src → server/src}/main.ts +0 -0
- /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
- /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
- /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
- /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 };
|