@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.
@@ -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
+ }