sid-ui 0.0.0-stage → 0.1.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/README.md +54 -2
- package/package.json +51 -4
- package/src/brand/BrandFooter.tsx +45 -0
- package/src/brand/BrandHeading.tsx +24 -0
- package/src/brand/BrandLockup.tsx +47 -0
- package/src/brand/JoinedLogo.tsx +67 -0
- package/src/brand/JoinedMark.tsx +76 -0
- package/src/brand/geometry.ts +26 -0
- package/src/brand/index.ts +11 -0
- package/src/brand/name.ts +5 -0
- package/src/brand/paint.tsx +56 -0
- package/src/components/Action.tsx +44 -0
- package/src/components/AddressSelector.tsx +99 -0
- package/src/components/BarChart.tsx +132 -0
- package/src/components/Calendar.tsx +284 -0
- package/src/components/CalendarAgenda.tsx +87 -0
- package/src/components/CalendarMonth.tsx +223 -0
- package/src/components/CalendarSchedule.tsx +210 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Charts.tsx +64 -0
- package/src/components/Chat.tsx +35 -0
- package/src/components/CitySelector.tsx +81 -0
- package/src/components/Clock.tsx +196 -0
- package/src/components/CodeInput.tsx +183 -0
- package/src/components/Container.tsx +20 -0
- package/src/components/Content.tsx +50 -0
- package/src/components/Data.tsx +43 -0
- package/src/components/DataInput.tsx +107 -0
- package/src/components/DateField.tsx +162 -0
- package/src/components/DonutChart.tsx +123 -0
- package/src/components/Drawer.tsx +110 -0
- package/src/components/Feedback.tsx +37 -0
- package/src/components/FileUploader.tsx +377 -0
- package/src/components/FunnelChart.tsx +83 -0
- package/src/components/Glyph.tsx +330 -0
- package/src/components/GoogleSignIn.tsx +67 -0
- package/src/components/GridSystem.tsx +131 -0
- package/src/components/HeatmapCalendar.tsx +167 -0
- package/src/components/KanbanBoard.tsx +421 -0
- package/src/components/Kpi.tsx +61 -0
- package/src/components/LayoutPrimitives.tsx +86 -0
- package/src/components/Lists.tsx +19 -0
- package/src/components/LocationSelector.tsx +49 -0
- package/src/components/MessageList.tsx +80 -0
- package/src/components/Nav.tsx +69 -0
- package/src/components/Navigation.tsx +52 -0
- package/src/components/Notification.tsx +115 -0
- package/src/components/NotificationTrigger.tsx +248 -0
- package/src/components/Overlay.tsx +50 -0
- package/src/components/Page.tsx +143 -0
- package/src/components/PageTabs.tsx +28 -0
- package/src/components/PickerShell.tsx +138 -0
- package/src/components/PillNav.tsx +194 -0
- package/src/components/Primitives.tsx +30 -0
- package/src/components/Rating.tsx +78 -0
- package/src/components/Responsive.tsx +128 -0
- package/src/components/ResponsiveFrame.tsx +122 -0
- package/src/components/Segment.tsx +190 -0
- package/src/components/Sparkline.tsx +52 -0
- package/src/components/StateSelector.tsx +39 -0
- package/src/components/Sticky.tsx +95 -0
- package/src/components/Table.tsx +344 -0
- package/src/components/ThemeToggle.tsx +48 -0
- package/src/components/Tile.tsx +23 -0
- package/src/components/TimeColumns.tsx +155 -0
- package/src/components/TimeDial.tsx +260 -0
- package/src/components/TimeField.tsx +196 -0
- package/src/components/TimeInput.tsx +213 -0
- package/src/components/TimeSlots.tsx +83 -0
- package/src/components/Timeline.tsx +111 -0
- package/src/components/TokenDemo.tsx +378 -0
- package/src/components/Tree.tsx +283 -0
- package/src/components/TrendChart.tsx +211 -0
- package/src/components/breakpoints.ts +42 -0
- package/src/components/calendarTypes.ts +26 -0
- package/src/components/chartMath.ts +125 -0
- package/src/components/chartParts.tsx +116 -0
- package/src/components/codeInputRules.ts +58 -0
- package/src/components/date.ts +87 -0
- package/src/components/hooks.ts +53 -0
- package/src/components/index.ts +195 -0
- package/src/components/kanban.ts +78 -0
- package/src/components/motion.ts +78 -0
- package/src/components/places-search.tsx +73 -0
- package/src/components/places.ts +209 -0
- package/src/components/size.ts +3 -0
- package/src/components/time.ts +83 -0
- package/src/components/useFileDrop.ts +104 -0
- package/src/places/geoapify.ts +129 -0
- package/src/styles/astryx.css +5 -0
- package/src/styles/components/brand.css +98 -0
- package/src/styles/components/calendar.css +680 -0
- package/src/styles/components/charts.css +659 -0
- package/src/styles/components/code-input.css +233 -0
- package/src/styles/components/google.css +11 -0
- package/src/styles/components/message-list.css +153 -0
- package/src/styles/components/notification.css +350 -0
- package/src/styles/components/picker.css +114 -0
- package/src/styles/components/pill-nav.css +179 -0
- package/src/styles/components/responsive.css +419 -0
- package/src/styles/components/sticky.css +23 -0
- package/src/styles/components/table.css +267 -0
- package/src/styles/components/time.css +526 -0
- package/src/styles/components/timeline.css +426 -0
- package/src/styles/components/tree.css +269 -0
- package/src/styles/components.css +423 -0
- package/src/styles/joined.css +4 -0
- package/src/styles/theme.css +79 -0
- package/src/styles/tokens.css +338 -0
- package/src/theme/JoinedProvider.tsx +50 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/joined.d.ts +11 -0
- package/src/theme/joined.js +569 -0
- package/src/theme/joined.theme.ts +74 -0
- package/src/theme/joined.variants.d.ts +16 -0
- package/src/theme/theme.css +786 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button, IconButton } from "./Action";
|
|
6
|
+
import { Glyph, icons } from "./Glyph";
|
|
7
|
+
import { useControllable } from "./hooks";
|
|
8
|
+
import { Icon } from "./Primitives";
|
|
9
|
+
|
|
10
|
+
export type SortDirection = "asc" | "desc";
|
|
11
|
+
|
|
12
|
+
export interface TableSort {
|
|
13
|
+
key: string;
|
|
14
|
+
direction: SortDirection;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TableColumn<T> {
|
|
18
|
+
key: keyof T | string;
|
|
19
|
+
header: string;
|
|
20
|
+
align?: "start" | "center" | "end";
|
|
21
|
+
width?: number | string;
|
|
22
|
+
sortable?: boolean;
|
|
23
|
+
/** What sorting compares when the cell renders something richer than its value. */
|
|
24
|
+
sortValue?: (row: T) => string | number;
|
|
25
|
+
render?: (row: T) => ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* card — a bordered surface with rounded corners; the default.
|
|
30
|
+
* plain — no outer chrome, for tables inside a card you already have.
|
|
31
|
+
* lined — hairlines between columns as well as rows.
|
|
32
|
+
*/
|
|
33
|
+
export type TableVariant = "card" | "plain" | "lined";
|
|
34
|
+
export type TableSelection = "none" | "single" | "multiple";
|
|
35
|
+
export type TableDensity = "compact" | "regular" | "spacious";
|
|
36
|
+
|
|
37
|
+
export interface TableProps<T> {
|
|
38
|
+
columns: TableColumn<T>[];
|
|
39
|
+
rows: T[];
|
|
40
|
+
rowKey?: (row: T) => string;
|
|
41
|
+
variant?: TableVariant;
|
|
42
|
+
density?: TableDensity;
|
|
43
|
+
striped?: boolean;
|
|
44
|
+
/** Keeps the header visible while the body scrolls inside `maxHeight`. */
|
|
45
|
+
stickyHeader?: boolean;
|
|
46
|
+
maxHeight?: number | string;
|
|
47
|
+
sort?: TableSort | null;
|
|
48
|
+
defaultSort?: TableSort | null;
|
|
49
|
+
onSortChange?: (sort: TableSort | null) => void;
|
|
50
|
+
selection?: TableSelection;
|
|
51
|
+
selectedKeys?: string[];
|
|
52
|
+
defaultSelectedKeys?: string[];
|
|
53
|
+
onSelectionChange?: (keys: string[]) => void;
|
|
54
|
+
onRowClick?: (row: T) => void;
|
|
55
|
+
/** Splits rows into pages and adds a footer to move between them. */
|
|
56
|
+
pageSize?: number;
|
|
57
|
+
loading?: boolean;
|
|
58
|
+
empty?: ReactNode;
|
|
59
|
+
/** A title and actions row inside the table surface. */
|
|
60
|
+
header?: ReactNode;
|
|
61
|
+
/** Accessible name. */
|
|
62
|
+
caption?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SKELETON_ROWS = 3;
|
|
66
|
+
|
|
67
|
+
function compare(a: string | number, b: string | number) {
|
|
68
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
69
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Check({
|
|
73
|
+
checked,
|
|
74
|
+
mixed,
|
|
75
|
+
label,
|
|
76
|
+
onChange,
|
|
77
|
+
}: {
|
|
78
|
+
checked: boolean;
|
|
79
|
+
mixed?: boolean;
|
|
80
|
+
label: string;
|
|
81
|
+
onChange: () => void;
|
|
82
|
+
}) {
|
|
83
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (ref.current) ref.current.indeterminate = Boolean(mixed);
|
|
86
|
+
}, [mixed]);
|
|
87
|
+
return (
|
|
88
|
+
<input
|
|
89
|
+
ref={ref}
|
|
90
|
+
type="checkbox"
|
|
91
|
+
className="os-table-check"
|
|
92
|
+
aria-label={label}
|
|
93
|
+
checked={checked}
|
|
94
|
+
onChange={onChange}
|
|
95
|
+
onClick={(event) => event.stopPropagation()}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rows and columns that sort, select, page, and load — one data shape for all of it. */
|
|
101
|
+
export function Table<T extends Record<string, unknown>>({
|
|
102
|
+
columns,
|
|
103
|
+
rows,
|
|
104
|
+
rowKey,
|
|
105
|
+
variant = "card",
|
|
106
|
+
density = "regular",
|
|
107
|
+
striped = false,
|
|
108
|
+
stickyHeader = false,
|
|
109
|
+
maxHeight,
|
|
110
|
+
sort: sortProp,
|
|
111
|
+
defaultSort = null,
|
|
112
|
+
onSortChange,
|
|
113
|
+
selection = "none",
|
|
114
|
+
selectedKeys: selectedProp,
|
|
115
|
+
defaultSelectedKeys = [],
|
|
116
|
+
onSelectionChange,
|
|
117
|
+
onRowClick,
|
|
118
|
+
pageSize,
|
|
119
|
+
loading = false,
|
|
120
|
+
empty = "Nothing here yet.",
|
|
121
|
+
header,
|
|
122
|
+
caption,
|
|
123
|
+
}: TableProps<T>) {
|
|
124
|
+
const [sort, setSort] = useControllable<TableSort | null>(sortProp, defaultSort, onSortChange);
|
|
125
|
+
const [selected, setSelected] = useControllable(
|
|
126
|
+
selectedProp,
|
|
127
|
+
defaultSelectedKeys,
|
|
128
|
+
onSelectionChange,
|
|
129
|
+
);
|
|
130
|
+
const [page, setPage] = useState(0);
|
|
131
|
+
const sorted = useMemo(() => {
|
|
132
|
+
const withKeys = rows.map((row, index) => ({ row, key: rowKey?.(row) ?? String(index) }));
|
|
133
|
+
if (!sort) return withKeys;
|
|
134
|
+
const column = columns.find((c) => String(c.key) === sort.key);
|
|
135
|
+
if (!column) return withKeys;
|
|
136
|
+
const read = column.sortValue ?? ((row: T) => row[column.key as keyof T] as string | number);
|
|
137
|
+
const factor = sort.direction === "asc" ? 1 : -1;
|
|
138
|
+
return [...withKeys].sort((a, b) => compare(read(a.row), read(b.row)) * factor);
|
|
139
|
+
}, [rows, rowKey, columns, sort]);
|
|
140
|
+
|
|
141
|
+
const pageCount = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
|
|
142
|
+
const current = Math.min(page, pageCount - 1);
|
|
143
|
+
const shown = pageSize ? sorted.slice(current * pageSize, current * pageSize + pageSize) : sorted;
|
|
144
|
+
const selectedSet = new Set(selected);
|
|
145
|
+
const allKeys = sorted.map((entry) => entry.key);
|
|
146
|
+
const onCount = allKeys.filter((key) => selectedSet.has(key)).length;
|
|
147
|
+
const multiple = selection === "multiple";
|
|
148
|
+
const span = columns.length + (multiple ? 1 : 0);
|
|
149
|
+
|
|
150
|
+
function cycleSort(key: string) {
|
|
151
|
+
if (sort?.key !== key) setSort({ key, direction: "asc" });
|
|
152
|
+
else if (sort.direction === "asc") setSort({ key, direction: "desc" });
|
|
153
|
+
else setSort(null);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toggleRow(key: string) {
|
|
157
|
+
if (selection === "single") setSelected(selectedSet.has(key) ? [] : [key]);
|
|
158
|
+
else setSelected(selectedSet.has(key) ? selected.filter((k) => k !== key) : [...selected, key]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toggleAll() {
|
|
162
|
+
setSelected(onCount === allKeys.length ? [] : allKeys);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const wrapStyle: CSSProperties | undefined = maxHeight != null ? { maxHeight } : undefined;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={[
|
|
170
|
+
"os-dt",
|
|
171
|
+
`os-dt-${variant}`,
|
|
172
|
+
`os-dt-${density}`,
|
|
173
|
+
striped && "os-dt-striped",
|
|
174
|
+
stickyHeader && "os-dt-sticky",
|
|
175
|
+
selection !== "none" && "os-dt-selectable",
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(" ")}
|
|
179
|
+
>
|
|
180
|
+
{header && <div className="os-dt-header">{header}</div>}
|
|
181
|
+
<div className="os-dt-scroll" style={wrapStyle}>
|
|
182
|
+
<table className="os-dt-table" aria-label={caption} aria-busy={loading || undefined}>
|
|
183
|
+
<thead>
|
|
184
|
+
<tr>
|
|
185
|
+
{multiple && (
|
|
186
|
+
<th className="os-dt-select" scope="col">
|
|
187
|
+
<Check
|
|
188
|
+
label="Select all rows"
|
|
189
|
+
checked={allKeys.length > 0 && onCount === allKeys.length}
|
|
190
|
+
mixed={onCount > 0 && onCount < allKeys.length}
|
|
191
|
+
onChange={toggleAll}
|
|
192
|
+
/>
|
|
193
|
+
</th>
|
|
194
|
+
)}
|
|
195
|
+
{columns.map((c) => {
|
|
196
|
+
const key = String(c.key);
|
|
197
|
+
const active = sort?.key === key;
|
|
198
|
+
const ariaSort = active
|
|
199
|
+
? sort.direction === "asc"
|
|
200
|
+
? "ascending"
|
|
201
|
+
: "descending"
|
|
202
|
+
: undefined;
|
|
203
|
+
return (
|
|
204
|
+
<th
|
|
205
|
+
key={key}
|
|
206
|
+
scope="col"
|
|
207
|
+
aria-sort={ariaSort}
|
|
208
|
+
className={`os-dt-align-${c.align ?? "start"}`}
|
|
209
|
+
style={c.width != null ? { width: c.width } : undefined}
|
|
210
|
+
>
|
|
211
|
+
{c.sortable ? (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className={active ? "os-dt-sort os-dt-sort-on" : "os-dt-sort"}
|
|
215
|
+
onClick={() => cycleSort(key)}
|
|
216
|
+
>
|
|
217
|
+
<span>{c.header}</span>
|
|
218
|
+
<Glyph
|
|
219
|
+
name={
|
|
220
|
+
active ? (sort.direction === "asc" ? "arrowUp" : "arrowDown") : "sort"
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
</button>
|
|
224
|
+
) : (
|
|
225
|
+
c.header
|
|
226
|
+
)}
|
|
227
|
+
</th>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
</tr>
|
|
231
|
+
</thead>
|
|
232
|
+
<tbody>
|
|
233
|
+
{loading &&
|
|
234
|
+
Array.from({ length: pageSize ?? SKELETON_ROWS }, (_, i) => (
|
|
235
|
+
<tr key={`skeleton-${i}`} className="os-dt-loading">
|
|
236
|
+
{Array.from({ length: span }, (_, j) => (
|
|
237
|
+
<td key={j}>
|
|
238
|
+
<span className="os-dt-skeleton" />
|
|
239
|
+
</td>
|
|
240
|
+
))}
|
|
241
|
+
</tr>
|
|
242
|
+
))}
|
|
243
|
+
{!loading && shown.length === 0 && (
|
|
244
|
+
<tr>
|
|
245
|
+
<td className="os-dt-empty" colSpan={span}>
|
|
246
|
+
{empty}
|
|
247
|
+
</td>
|
|
248
|
+
</tr>
|
|
249
|
+
)}
|
|
250
|
+
{!loading &&
|
|
251
|
+
shown.map(({ row, key }) => {
|
|
252
|
+
const isSelected = selectedSet.has(key);
|
|
253
|
+
const interactive = selection === "single" || Boolean(onRowClick);
|
|
254
|
+
return (
|
|
255
|
+
<tr
|
|
256
|
+
key={key}
|
|
257
|
+
aria-selected={selection !== "none" ? isSelected : undefined}
|
|
258
|
+
className={isSelected ? "os-dt-row-selected" : undefined}
|
|
259
|
+
tabIndex={interactive ? 0 : undefined}
|
|
260
|
+
onClick={
|
|
261
|
+
interactive || multiple
|
|
262
|
+
? () => {
|
|
263
|
+
if (selection !== "none") toggleRow(key);
|
|
264
|
+
onRowClick?.(row);
|
|
265
|
+
}
|
|
266
|
+
: undefined
|
|
267
|
+
}
|
|
268
|
+
onKeyDown={
|
|
269
|
+
interactive
|
|
270
|
+
? (event) => {
|
|
271
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
if (selection !== "none") toggleRow(key);
|
|
274
|
+
onRowClick?.(row);
|
|
275
|
+
}
|
|
276
|
+
: undefined
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
{multiple && (
|
|
280
|
+
<td className="os-dt-select">
|
|
281
|
+
<Check
|
|
282
|
+
label={`Select row ${key}`}
|
|
283
|
+
checked={isSelected}
|
|
284
|
+
onChange={() => toggleRow(key)}
|
|
285
|
+
/>
|
|
286
|
+
</td>
|
|
287
|
+
)}
|
|
288
|
+
{columns.map((c) => (
|
|
289
|
+
<td key={String(c.key)} className={`os-dt-align-${c.align ?? "start"}`}>
|
|
290
|
+
{c.render ? c.render(row) : String(row[c.key as keyof T] ?? "")}
|
|
291
|
+
</td>
|
|
292
|
+
))}
|
|
293
|
+
</tr>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</tbody>
|
|
297
|
+
</table>
|
|
298
|
+
</div>
|
|
299
|
+
{(pageSize || (multiple && onCount > 0)) && !loading && sorted.length > 0 && (
|
|
300
|
+
<div className="os-dt-footer">
|
|
301
|
+
<span className="os-dt-summary">
|
|
302
|
+
{multiple && onCount > 0
|
|
303
|
+
? `${onCount} of ${sorted.length} selected`
|
|
304
|
+
: pageSize
|
|
305
|
+
? `${current * pageSize + 1}–${Math.min((current + 1) * pageSize, sorted.length)} of ${sorted.length}`
|
|
306
|
+
: null}
|
|
307
|
+
</span>
|
|
308
|
+
{pageSize && pageCount > 1 && (
|
|
309
|
+
<div className="os-dt-pager">
|
|
310
|
+
<IconButton
|
|
311
|
+
label="Previous page"
|
|
312
|
+
variant="ghost"
|
|
313
|
+
size="sm"
|
|
314
|
+
icon={<Icon icon={icons.chevronLeft} />}
|
|
315
|
+
isDisabled={current === 0}
|
|
316
|
+
onClick={() => setPage(current - 1)}
|
|
317
|
+
/>
|
|
318
|
+
{Array.from({ length: pageCount }, (_, i) => (
|
|
319
|
+
<Button
|
|
320
|
+
key={i}
|
|
321
|
+
label={`Page ${i + 1}`}
|
|
322
|
+
variant={i === current ? "secondary" : "ghost"}
|
|
323
|
+
size="sm"
|
|
324
|
+
aria-current={i === current ? "page" : undefined}
|
|
325
|
+
onClick={() => setPage(i)}
|
|
326
|
+
>
|
|
327
|
+
{i + 1}
|
|
328
|
+
</Button>
|
|
329
|
+
))}
|
|
330
|
+
<IconButton
|
|
331
|
+
label="Next page"
|
|
332
|
+
variant="ghost"
|
|
333
|
+
size="sm"
|
|
334
|
+
icon={<Icon icon={icons.chevronRight} />}
|
|
335
|
+
isDisabled={current === pageCount - 1}
|
|
336
|
+
onClick={() => setPage(current + 1)}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { IconButton } from "./Action";
|
|
6
|
+
import { Glyph } from "./Glyph";
|
|
7
|
+
|
|
8
|
+
export type ThemeName = "dark" | "light";
|
|
9
|
+
|
|
10
|
+
function applyTheme(theme: ThemeName) {
|
|
11
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
12
|
+
document.querySelectorAll<HTMLElement>("[data-astryx-theme]").forEach((element) => {
|
|
13
|
+
element.setAttribute("data-theme", theme);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `onChange` lets an app that renders its theme on the server re-render after a switch. */
|
|
18
|
+
export function ThemeToggle({ onChange }: { onChange?: (theme: ThemeName) => void } = {}) {
|
|
19
|
+
const [theme, setTheme] = useState<ThemeName>("dark");
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const current = document.documentElement.getAttribute("data-theme");
|
|
23
|
+
const stored = window.localStorage.getItem("joined-theme");
|
|
24
|
+
const next = stored === "light" || stored === "dark" ? stored : current;
|
|
25
|
+
if (next === "light" || next === "dark") {
|
|
26
|
+
applyTheme(next);
|
|
27
|
+
setTheme(next);
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const switchTheme = (next: ThemeName) => {
|
|
32
|
+
applyTheme(next);
|
|
33
|
+
window.localStorage.setItem("joined-theme", next);
|
|
34
|
+
document.cookie = `joined-theme=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
|
35
|
+
setTheme(next);
|
|
36
|
+
onChange?.(next);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const next = theme === "dark" ? "light" : "dark";
|
|
40
|
+
return (
|
|
41
|
+
<IconButton
|
|
42
|
+
label={`Switch to ${next} mode`}
|
|
43
|
+
variant="ghost"
|
|
44
|
+
onClick={() => switchTheme(next)}
|
|
45
|
+
icon={<Glyph name={theme === "dark" ? "sun" : "moon"} />}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TileProps {
|
|
4
|
+
children?: ReactNode;
|
|
5
|
+
/** Quiet second line — a size, a span, a hint. */
|
|
6
|
+
meta?: ReactNode;
|
|
7
|
+
tone?: "accent" | "neutral" | "strong";
|
|
8
|
+
/** Fixed height in px, for rows and masonry demos. */
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A labeled placeholder block for sketching and documenting layouts. */
|
|
13
|
+
export function Tile({ children, meta, tone = "accent", height }: TileProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={tone === "accent" ? "os-tile" : `os-tile os-tile-${tone}`}
|
|
17
|
+
style={height ? { minHeight: height } : undefined}
|
|
18
|
+
>
|
|
19
|
+
{children}
|
|
20
|
+
{meta && <span className="os-tile-meta">{meta}</span>}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type KeyboardEvent } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
HOURS_PER_HALF,
|
|
7
|
+
MERIDIEMS,
|
|
8
|
+
MINUTES_PER_HOUR,
|
|
9
|
+
from12,
|
|
10
|
+
formatTime,
|
|
11
|
+
meridiemOf,
|
|
12
|
+
pad2,
|
|
13
|
+
parseTime,
|
|
14
|
+
range,
|
|
15
|
+
to12,
|
|
16
|
+
type HourCycle,
|
|
17
|
+
type MinuteStep,
|
|
18
|
+
} from "./time";
|
|
19
|
+
|
|
20
|
+
import type { ControlSize } from "./size";
|
|
21
|
+
|
|
22
|
+
export interface TimeColumnsProps {
|
|
23
|
+
value: string;
|
|
24
|
+
onChange?: (value: string) => void;
|
|
25
|
+
hourCycle?: HourCycle;
|
|
26
|
+
withSeconds?: boolean;
|
|
27
|
+
minuteStep?: MinuteStep;
|
|
28
|
+
size?: ControlSize;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ColumnProps<T extends string | number> {
|
|
33
|
+
label: string;
|
|
34
|
+
options: T[];
|
|
35
|
+
selected: T | null;
|
|
36
|
+
render: (option: T) => string;
|
|
37
|
+
onSelect: (option: T) => void;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Column<T extends string | number>({
|
|
42
|
+
label,
|
|
43
|
+
options,
|
|
44
|
+
selected,
|
|
45
|
+
render,
|
|
46
|
+
onSelect,
|
|
47
|
+
disabled,
|
|
48
|
+
}: ColumnProps<T>) {
|
|
49
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const list = listRef.current;
|
|
53
|
+
const on = list?.querySelector<HTMLElement>("[aria-selected='true']");
|
|
54
|
+
if (!list || !on) return;
|
|
55
|
+
list.scrollTo({
|
|
56
|
+
top: on.offsetTop - list.clientHeight / 2 + on.clientHeight / 2,
|
|
57
|
+
behavior: "smooth",
|
|
58
|
+
});
|
|
59
|
+
}, [selected]);
|
|
60
|
+
|
|
61
|
+
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
62
|
+
const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
63
|
+
if (!delta) return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
const index = selected == null ? -1 : options.indexOf(selected);
|
|
66
|
+
onSelect(options[(index + delta + options.length) % options.length]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={listRef}
|
|
72
|
+
className="os-tcol"
|
|
73
|
+
role="listbox"
|
|
74
|
+
aria-label={label}
|
|
75
|
+
tabIndex={disabled ? -1 : 0}
|
|
76
|
+
onKeyDown={disabled ? undefined : onKeyDown}
|
|
77
|
+
>
|
|
78
|
+
{options.map((option) => (
|
|
79
|
+
<button
|
|
80
|
+
key={option}
|
|
81
|
+
type="button"
|
|
82
|
+
role="option"
|
|
83
|
+
tabIndex={-1}
|
|
84
|
+
aria-selected={option === selected}
|
|
85
|
+
className="os-tcol-option"
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
onClick={() => onSelect(option)}
|
|
88
|
+
>
|
|
89
|
+
{render(option)}
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Scrolling columns, one per unit — the dropdown picker behind TimeField. */
|
|
97
|
+
export function TimeColumns({
|
|
98
|
+
value,
|
|
99
|
+
onChange,
|
|
100
|
+
hourCycle = "12h",
|
|
101
|
+
withSeconds = false,
|
|
102
|
+
minuteStep = 5,
|
|
103
|
+
size = "md",
|
|
104
|
+
disabled,
|
|
105
|
+
}: TimeColumnsProps) {
|
|
106
|
+
const parts = parseTime(value);
|
|
107
|
+
const h = parts?.h ?? null;
|
|
108
|
+
const twelve = hourCycle === "12h";
|
|
109
|
+
const base = parts ?? { h: 0, m: 0, s: 0 };
|
|
110
|
+
|
|
111
|
+
function commit(next: Partial<typeof base>) {
|
|
112
|
+
onChange?.(formatTime({ ...base, ...next }, withSeconds));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={`os-tcols os-tcols-${size}`}>
|
|
117
|
+
<Column
|
|
118
|
+
label="Hour"
|
|
119
|
+
options={twelve ? range(1, HOURS_PER_HALF) : range(0, 23)}
|
|
120
|
+
selected={h == null ? null : twelve ? to12(h) : h}
|
|
121
|
+
render={twelve ? String : pad2}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
onSelect={(hour) => commit({ h: twelve ? from12(hour, meridiemOf(base.h)) : hour })}
|
|
124
|
+
/>
|
|
125
|
+
<Column
|
|
126
|
+
label="Minute"
|
|
127
|
+
options={range(0, MINUTES_PER_HOUR - 1, minuteStep)}
|
|
128
|
+
selected={parts?.m ?? null}
|
|
129
|
+
render={pad2}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onSelect={(m) => commit({ m })}
|
|
132
|
+
/>
|
|
133
|
+
{withSeconds && (
|
|
134
|
+
<Column
|
|
135
|
+
label="Second"
|
|
136
|
+
options={range(0, MINUTES_PER_HOUR - 1)}
|
|
137
|
+
selected={parts?.s ?? null}
|
|
138
|
+
render={pad2}
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
onSelect={(s) => commit({ s })}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
{twelve && (
|
|
144
|
+
<Column
|
|
145
|
+
label="AM or PM"
|
|
146
|
+
options={[...MERIDIEMS]}
|
|
147
|
+
selected={h == null ? null : meridiemOf(h)}
|
|
148
|
+
render={String}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
onSelect={(meridiem) => commit({ h: from12(to12(base.h), meridiem) })}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|