@mshuja288/aksuicore 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/CHANGELOG.md +46 -0
- package/PLAN.md +139 -0
- package/README.md +192 -0
- package/VERSIONING.md +33 -0
- package/dist/aksui.css +1633 -0
- package/dist/index.cjs +1145 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +182 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.js +1127 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/index.ts +5 -0
- package/src/patterns/AppShell.tsx +69 -0
- package/src/patterns/RecordWorkspace.tsx +567 -0
- package/src/patterns/index.ts +2 -0
- package/src/primitives/Button.tsx +29 -0
- package/src/primitives/DateField.tsx +255 -0
- package/src/primitives/Dialog.tsx +45 -0
- package/src/primitives/FormActions.tsx +36 -0
- package/src/primitives/Pagination.tsx +75 -0
- package/src/primitives/Select.tsx +195 -0
- package/src/primitives/SideNav.tsx +35 -0
- package/src/primitives/Snackbar.tsx +87 -0
- package/src/primitives/Topbar.tsx +89 -0
- package/src/primitives/index.ts +9 -0
- package/src/theme/aksui.css +70 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/patterns.css +733 -0
- package/src/theme/primitives.css +732 -0
- package/src/theme/shell.css +92 -0
- package/src/theme/styles.css +4 -0
- package/src/theme/tokens.ts +22 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
7
|
+
|
|
8
|
+
function pad(value: number) {
|
|
9
|
+
return String(value).padStart(2, "0");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toIso(date: Date) {
|
|
13
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseIso(value: string) {
|
|
17
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
21
|
+
const date = new Date(year, month - 1, day);
|
|
22
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDisplay(value: string) {
|
|
29
|
+
const date = parseIso(value);
|
|
30
|
+
if (!date) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function startOfMonth(date: Date) {
|
|
37
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildCells(month: Date) {
|
|
41
|
+
const first = startOfMonth(month);
|
|
42
|
+
const mondayBased = (first.getDay() + 6) % 7;
|
|
43
|
+
const start = new Date(first);
|
|
44
|
+
start.setDate(first.getDate() - mondayBased);
|
|
45
|
+
return Array.from({ length: 42 }, (_, index) => {
|
|
46
|
+
const day = new Date(start);
|
|
47
|
+
day.setDate(start.getDate() + index);
|
|
48
|
+
return day;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type MenuPosition = {
|
|
53
|
+
top: number;
|
|
54
|
+
left: number;
|
|
55
|
+
width: number;
|
|
56
|
+
openUp: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function DateField({
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
placeholder = "dd/mm/yyyy",
|
|
63
|
+
disabled = false,
|
|
64
|
+
readOnly = false,
|
|
65
|
+
}: {
|
|
66
|
+
value: string;
|
|
67
|
+
onChange?: (value: string) => void;
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
disabled?: boolean;
|
|
70
|
+
readOnly?: boolean;
|
|
71
|
+
}) {
|
|
72
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const selected = parseIso(value);
|
|
75
|
+
const [open, setOpen] = useState(false);
|
|
76
|
+
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null);
|
|
77
|
+
const [viewMonth, setViewMonth] = useState(() => startOfMonth(selected ?? new Date()));
|
|
78
|
+
const cells = useMemo(() => buildCells(viewMonth), [viewMonth]);
|
|
79
|
+
const today = toIso(new Date());
|
|
80
|
+
const locked = disabled || readOnly;
|
|
81
|
+
|
|
82
|
+
function updatePosition() {
|
|
83
|
+
const root = rootRef.current;
|
|
84
|
+
if (!root) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const rect = root.getBoundingClientRect();
|
|
88
|
+
const openUp = window.innerHeight - rect.bottom < 340 && rect.top > 340;
|
|
89
|
+
setMenuPos({
|
|
90
|
+
top: openUp ? rect.top - 8 : rect.bottom + 6,
|
|
91
|
+
left: rect.left,
|
|
92
|
+
width: Math.max(rect.width, 280),
|
|
93
|
+
openUp,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
if (!open) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
updatePosition();
|
|
102
|
+
function onReposition() {
|
|
103
|
+
updatePosition();
|
|
104
|
+
}
|
|
105
|
+
window.addEventListener("resize", onReposition);
|
|
106
|
+
window.addEventListener("scroll", onReposition, true);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener("resize", onReposition);
|
|
109
|
+
window.removeEventListener("scroll", onReposition, true);
|
|
110
|
+
};
|
|
111
|
+
}, [open]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!open) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
function onPointerDown(event: MouseEvent) {
|
|
118
|
+
const target = event.target as Node;
|
|
119
|
+
if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setOpen(false);
|
|
123
|
+
}
|
|
124
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
125
|
+
if (event.key === "Escape") {
|
|
126
|
+
setOpen(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
130
|
+
document.addEventListener("keydown", onKeyDown);
|
|
131
|
+
return () => {
|
|
132
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
133
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
134
|
+
};
|
|
135
|
+
}, [open]);
|
|
136
|
+
|
|
137
|
+
function openPicker() {
|
|
138
|
+
if (locked) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setViewMonth(startOfMonth(selected ?? new Date()));
|
|
142
|
+
setOpen(true);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function choose(date: Date) {
|
|
146
|
+
onChange?.(toIso(date));
|
|
147
|
+
setOpen(false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const monthLabel = viewMonth.toLocaleString(undefined, { month: "long", year: "numeric" });
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={readOnly ? "date-field read-only" : "date-field"} ref={rootRef}>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className={open ? "date-field-trigger open" : "date-field-trigger"}
|
|
157
|
+
disabled={disabled}
|
|
158
|
+
onClick={() => {
|
|
159
|
+
if (locked) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (open) {
|
|
163
|
+
setOpen(false);
|
|
164
|
+
} else {
|
|
165
|
+
openPicker();
|
|
166
|
+
}
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<span className={value ? "date-field-value" : "date-field-placeholder"}>
|
|
170
|
+
{value ? formatDisplay(value) : placeholder}
|
|
171
|
+
</span>
|
|
172
|
+
<svg className="date-field-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
173
|
+
<rect x="3.5" y="5" width="17" height="15.5" rx="2" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
|
174
|
+
<path d="M3.5 9.5h17M8 3.5v3M16 3.5v3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
|
175
|
+
</svg>
|
|
176
|
+
</button>
|
|
177
|
+
{open && menuPos
|
|
178
|
+
? createPortal(
|
|
179
|
+
<div
|
|
180
|
+
ref={menuRef}
|
|
181
|
+
className="date-picker"
|
|
182
|
+
style={{
|
|
183
|
+
top: menuPos.openUp ? undefined : menuPos.top,
|
|
184
|
+
bottom: menuPos.openUp ? window.innerHeight - menuPos.top : undefined,
|
|
185
|
+
left: menuPos.left,
|
|
186
|
+
width: menuPos.width,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<div className="date-picker-head">
|
|
190
|
+
<strong>{monthLabel}</strong>
|
|
191
|
+
<div className="date-picker-nav">
|
|
192
|
+
<button type="button" className="ghost" aria-label="Previous month" onClick={() => setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1))}>
|
|
193
|
+
‹
|
|
194
|
+
</button>
|
|
195
|
+
<button type="button" className="ghost" aria-label="Next month" onClick={() => setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1))}>
|
|
196
|
+
›
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
<div className="date-picker-weekdays">
|
|
201
|
+
{weekdayLabels.map((label) => (
|
|
202
|
+
<span key={label}>{label}</span>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
<div className="date-picker-grid">
|
|
206
|
+
{cells.map((day) => {
|
|
207
|
+
const iso = toIso(day);
|
|
208
|
+
const outside = day.getMonth() !== viewMonth.getMonth();
|
|
209
|
+
const isSelected = value === iso;
|
|
210
|
+
const isToday = today === iso;
|
|
211
|
+
const className = [
|
|
212
|
+
"date-picker-day",
|
|
213
|
+
outside ? "outside" : "",
|
|
214
|
+
isSelected ? "selected" : "",
|
|
215
|
+
isToday ? "today" : "",
|
|
216
|
+
]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join(" ");
|
|
219
|
+
return (
|
|
220
|
+
<button key={iso + String(outside)} type="button" className={className} onClick={() => choose(day)}>
|
|
221
|
+
{day.getDate()}
|
|
222
|
+
</button>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</div>
|
|
226
|
+
<div className="date-picker-footer">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="ghost"
|
|
230
|
+
onClick={() => {
|
|
231
|
+
onChange?.("");
|
|
232
|
+
setOpen(false);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
Clear
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
className="ghost"
|
|
240
|
+
onClick={() => {
|
|
241
|
+
const now = new Date();
|
|
242
|
+
setViewMonth(startOfMonth(now));
|
|
243
|
+
choose(now);
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
Today
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>,
|
|
250
|
+
document.body,
|
|
251
|
+
)
|
|
252
|
+
: null}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
|
|
6
|
+
export function Dialog({
|
|
7
|
+
title,
|
|
8
|
+
open,
|
|
9
|
+
onClose,
|
|
10
|
+
children,
|
|
11
|
+
wide = true,
|
|
12
|
+
}: {
|
|
13
|
+
title: string;
|
|
14
|
+
open: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
wide?: boolean;
|
|
18
|
+
}) {
|
|
19
|
+
if (!open) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="dialog-backdrop" onClick={onClose}>
|
|
25
|
+
<div
|
|
26
|
+
className={wide ? "dialog form-dialog" : "dialog"}
|
|
27
|
+
role="dialog"
|
|
28
|
+
aria-modal="true"
|
|
29
|
+
aria-label={title}
|
|
30
|
+
onClick={(event) => event.stopPropagation()}
|
|
31
|
+
>
|
|
32
|
+
<div className="dialog-head">
|
|
33
|
+
<h2>{title}</h2>
|
|
34
|
+
<Button variant="ghost" onClick={onClose}>
|
|
35
|
+
Close
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @deprecated Prefer Dialog — alias kept for configurator migration */
|
|
45
|
+
export const FormDialog = Dialog;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export function FormActions({
|
|
4
|
+
pending,
|
|
5
|
+
editing,
|
|
6
|
+
saveLabel,
|
|
7
|
+
updateLabel,
|
|
8
|
+
newLabel,
|
|
9
|
+
onNew,
|
|
10
|
+
saveDisabled = false,
|
|
11
|
+
replaceSaveWhenEditing = false,
|
|
12
|
+
}: {
|
|
13
|
+
pending: boolean;
|
|
14
|
+
editing: boolean;
|
|
15
|
+
saveLabel: string;
|
|
16
|
+
updateLabel?: string;
|
|
17
|
+
newLabel: string;
|
|
18
|
+
onNew: () => void;
|
|
19
|
+
saveDisabled?: boolean;
|
|
20
|
+
replaceSaveWhenEditing?: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
if (editing && replaceSaveWhenEditing) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="actions">
|
|
28
|
+
<button type="submit" disabled={pending || saveDisabled}>
|
|
29
|
+
{editing && updateLabel ? updateLabel : saveLabel}
|
|
30
|
+
</button>
|
|
31
|
+
<button type="button" className="ghost" onClick={onNew}>
|
|
32
|
+
{newLabel}
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Select } from "./Select";
|
|
4
|
+
|
|
5
|
+
export type PageSizeOption = {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const defaultPageSizes: PageSizeOption[] = [
|
|
11
|
+
{ value: "15", label: "15" },
|
|
12
|
+
{ value: "20", label: "20" },
|
|
13
|
+
{ value: "50", label: "50" },
|
|
14
|
+
{ value: "100", label: "100" },
|
|
15
|
+
{ value: "all", label: "All" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function Pagination({
|
|
19
|
+
page,
|
|
20
|
+
pageCount,
|
|
21
|
+
pageSize,
|
|
22
|
+
onPageChange,
|
|
23
|
+
onPageSizeChange,
|
|
24
|
+
summary,
|
|
25
|
+
pageSizeOptions = defaultPageSizes,
|
|
26
|
+
disabled = false,
|
|
27
|
+
}: {
|
|
28
|
+
page: number;
|
|
29
|
+
pageCount: number;
|
|
30
|
+
pageSize: string;
|
|
31
|
+
onPageChange: (page: number) => void;
|
|
32
|
+
onPageSizeChange: (pageSize: string) => void;
|
|
33
|
+
summary: string;
|
|
34
|
+
pageSizeOptions?: PageSizeOption[];
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const showAll = pageSize === "all";
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="table-pagination">
|
|
41
|
+
<label className="page-size">
|
|
42
|
+
Rows
|
|
43
|
+
<Select
|
|
44
|
+
className="page-size-select"
|
|
45
|
+
value={pageSize}
|
|
46
|
+
options={pageSizeOptions}
|
|
47
|
+
onChange={onPageSizeChange}
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
/>
|
|
50
|
+
</label>
|
|
51
|
+
<p className="page-summary">{summary}</p>
|
|
52
|
+
<div className="page-buttons">
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="ghost"
|
|
56
|
+
disabled={disabled || showAll || page <= 1}
|
|
57
|
+
onClick={() => onPageChange(page - 1)}
|
|
58
|
+
>
|
|
59
|
+
Previous
|
|
60
|
+
</button>
|
|
61
|
+
<span className="page-indicator">
|
|
62
|
+
Page {showAll ? 1 : page} of {pageCount}
|
|
63
|
+
</span>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="ghost"
|
|
67
|
+
disabled={disabled || showAll || page >= pageCount}
|
|
68
|
+
onClick={() => onPageChange(page + 1)}
|
|
69
|
+
>
|
|
70
|
+
Next
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
export type SelectOption = {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
tone?: "neutral" | "ok" | "warn" | "danger" | "accent";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type MenuPosition = {
|
|
13
|
+
top: number;
|
|
14
|
+
left: number;
|
|
15
|
+
width: number;
|
|
16
|
+
maxHeight: number;
|
|
17
|
+
openUp: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Select({
|
|
21
|
+
value,
|
|
22
|
+
options,
|
|
23
|
+
onChange,
|
|
24
|
+
placeholder = "Select",
|
|
25
|
+
disabled = false,
|
|
26
|
+
required = false,
|
|
27
|
+
className = "",
|
|
28
|
+
}: {
|
|
29
|
+
value: string;
|
|
30
|
+
options: SelectOption[];
|
|
31
|
+
onChange: (value: string) => void;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
required?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const listId = useId();
|
|
38
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null);
|
|
42
|
+
const selected = options.find((option) => option.value === value) ?? null;
|
|
43
|
+
|
|
44
|
+
function updatePosition() {
|
|
45
|
+
const root = rootRef.current;
|
|
46
|
+
if (!root) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const rect = root.getBoundingClientRect();
|
|
50
|
+
const spaceBelow = window.innerHeight - rect.bottom - 12;
|
|
51
|
+
const spaceAbove = rect.top - 12;
|
|
52
|
+
const openUp = spaceBelow < 220 && spaceAbove > spaceBelow;
|
|
53
|
+
const maxHeight = Math.max(160, Math.min(280, openUp ? spaceAbove : spaceBelow));
|
|
54
|
+
setMenuPos({
|
|
55
|
+
top: openUp ? rect.top - 8 : rect.bottom + 6,
|
|
56
|
+
left: rect.left,
|
|
57
|
+
width: rect.width,
|
|
58
|
+
maxHeight,
|
|
59
|
+
openUp,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
if (!open) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
updatePosition();
|
|
68
|
+
function onReposition() {
|
|
69
|
+
updatePosition();
|
|
70
|
+
}
|
|
71
|
+
window.addEventListener("resize", onReposition);
|
|
72
|
+
window.addEventListener("scroll", onReposition, true);
|
|
73
|
+
return () => {
|
|
74
|
+
window.removeEventListener("resize", onReposition);
|
|
75
|
+
window.removeEventListener("scroll", onReposition, true);
|
|
76
|
+
};
|
|
77
|
+
}, [open]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
function onPointerDown(event: MouseEvent) {
|
|
84
|
+
const target = event.target as Node;
|
|
85
|
+
if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setOpen(false);
|
|
89
|
+
}
|
|
90
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
91
|
+
if (event.key === "Escape") {
|
|
92
|
+
setOpen(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
96
|
+
document.addEventListener("keydown", onKeyDown);
|
|
97
|
+
return () => {
|
|
98
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
99
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
100
|
+
};
|
|
101
|
+
}, [open]);
|
|
102
|
+
|
|
103
|
+
function choose(next: string) {
|
|
104
|
+
onChange(next);
|
|
105
|
+
setOpen(false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={`app-select ${className}`.trim()} ref={rootRef}>
|
|
110
|
+
<input
|
|
111
|
+
className="app-select-mirror"
|
|
112
|
+
tabIndex={-1}
|
|
113
|
+
value={value}
|
|
114
|
+
required={required}
|
|
115
|
+
onChange={() => undefined}
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
/>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className={open ? "app-select-trigger open" : "app-select-trigger"}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
aria-haspopup="listbox"
|
|
123
|
+
aria-expanded={open}
|
|
124
|
+
aria-controls={listId}
|
|
125
|
+
onClick={() => {
|
|
126
|
+
if (!disabled) {
|
|
127
|
+
setOpen((current) => !current);
|
|
128
|
+
}
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<span className={selected ? "app-select-value" : "app-select-placeholder"}>
|
|
132
|
+
{selected ? (
|
|
133
|
+
<>
|
|
134
|
+
{selected.tone ? <span className={`app-select-dot ${selected.tone}`} aria-hidden="true" /> : null}
|
|
135
|
+
{selected.label}
|
|
136
|
+
</>
|
|
137
|
+
) : (
|
|
138
|
+
placeholder
|
|
139
|
+
)}
|
|
140
|
+
</span>
|
|
141
|
+
<svg className="app-select-chevron" viewBox="0 0 20 20" aria-hidden="true">
|
|
142
|
+
<path d="M5 7.5 10 12.5 15 7.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
|
143
|
+
</svg>
|
|
144
|
+
</button>
|
|
145
|
+
{open && menuPos
|
|
146
|
+
? createPortal(
|
|
147
|
+
<div
|
|
148
|
+
ref={menuRef}
|
|
149
|
+
id={listId}
|
|
150
|
+
className={menuPos.openUp ? "app-select-menu open-up" : "app-select-menu"}
|
|
151
|
+
role="listbox"
|
|
152
|
+
style={{
|
|
153
|
+
top: menuPos.openUp ? undefined : menuPos.top,
|
|
154
|
+
bottom: menuPos.openUp ? window.innerHeight - menuPos.top : undefined,
|
|
155
|
+
left: menuPos.left,
|
|
156
|
+
width: menuPos.width,
|
|
157
|
+
maxHeight: menuPos.maxHeight,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{options.length === 0 ? (
|
|
161
|
+
<div className="app-select-empty">No options</div>
|
|
162
|
+
) : (
|
|
163
|
+
options.map((option) => {
|
|
164
|
+
const active = option.value === value;
|
|
165
|
+
return (
|
|
166
|
+
<button
|
|
167
|
+
key={option.value || "__empty"}
|
|
168
|
+
type="button"
|
|
169
|
+
role="option"
|
|
170
|
+
aria-selected={active}
|
|
171
|
+
className={active ? "app-select-option active" : "app-select-option"}
|
|
172
|
+
onClick={() => choose(option.value)}
|
|
173
|
+
>
|
|
174
|
+
{option.tone ? <span className={`app-select-dot ${option.tone}`} aria-hidden="true" /> : null}
|
|
175
|
+
<span>{option.label}</span>
|
|
176
|
+
{active ? (
|
|
177
|
+
<svg className="app-select-check" viewBox="0 0 20 20" aria-hidden="true">
|
|
178
|
+
<path d="M4.5 10.5 8.2 14.2 15.5 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
|
179
|
+
</svg>
|
|
180
|
+
) : null}
|
|
181
|
+
</button>
|
|
182
|
+
);
|
|
183
|
+
})
|
|
184
|
+
)}
|
|
185
|
+
</div>,
|
|
186
|
+
document.body,
|
|
187
|
+
)
|
|
188
|
+
: null}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @deprecated Prefer Select — alias kept for configurator migration */
|
|
194
|
+
export const AppSelect = Select;
|
|
195
|
+
export type AppSelectOption = SelectOption;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export type SideNavItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Compact label for the mobile bottom nav (falls back to `label`). */
|
|
7
|
+
short?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function SideNav({
|
|
11
|
+
items,
|
|
12
|
+
activeId,
|
|
13
|
+
onSelect,
|
|
14
|
+
label = "Sections",
|
|
15
|
+
}: {
|
|
16
|
+
items: SideNavItem[];
|
|
17
|
+
activeId: string;
|
|
18
|
+
onSelect: (id: string) => void;
|
|
19
|
+
label?: string;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<nav className="side-nav" aria-label={label}>
|
|
23
|
+
{items.map((item) => (
|
|
24
|
+
<button
|
|
25
|
+
key={item.id}
|
|
26
|
+
type="button"
|
|
27
|
+
className={activeId === item.id ? "active" : ""}
|
|
28
|
+
onClick={() => onSelect(item.id)}
|
|
29
|
+
>
|
|
30
|
+
{item.label}
|
|
31
|
+
</button>
|
|
32
|
+
))}
|
|
33
|
+
</nav>
|
|
34
|
+
);
|
|
35
|
+
}
|