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.
Files changed (116) hide show
  1. package/README.md +54 -2
  2. package/package.json +51 -4
  3. package/src/brand/BrandFooter.tsx +45 -0
  4. package/src/brand/BrandHeading.tsx +24 -0
  5. package/src/brand/BrandLockup.tsx +47 -0
  6. package/src/brand/JoinedLogo.tsx +67 -0
  7. package/src/brand/JoinedMark.tsx +76 -0
  8. package/src/brand/geometry.ts +26 -0
  9. package/src/brand/index.ts +11 -0
  10. package/src/brand/name.ts +5 -0
  11. package/src/brand/paint.tsx +56 -0
  12. package/src/components/Action.tsx +44 -0
  13. package/src/components/AddressSelector.tsx +99 -0
  14. package/src/components/BarChart.tsx +132 -0
  15. package/src/components/Calendar.tsx +284 -0
  16. package/src/components/CalendarAgenda.tsx +87 -0
  17. package/src/components/CalendarMonth.tsx +223 -0
  18. package/src/components/CalendarSchedule.tsx +210 -0
  19. package/src/components/Card.tsx +80 -0
  20. package/src/components/Charts.tsx +64 -0
  21. package/src/components/Chat.tsx +35 -0
  22. package/src/components/CitySelector.tsx +81 -0
  23. package/src/components/Clock.tsx +196 -0
  24. package/src/components/CodeInput.tsx +183 -0
  25. package/src/components/Container.tsx +20 -0
  26. package/src/components/Content.tsx +50 -0
  27. package/src/components/Data.tsx +43 -0
  28. package/src/components/DataInput.tsx +107 -0
  29. package/src/components/DateField.tsx +162 -0
  30. package/src/components/DonutChart.tsx +123 -0
  31. package/src/components/Drawer.tsx +110 -0
  32. package/src/components/Feedback.tsx +37 -0
  33. package/src/components/FileUploader.tsx +377 -0
  34. package/src/components/FunnelChart.tsx +83 -0
  35. package/src/components/Glyph.tsx +330 -0
  36. package/src/components/GoogleSignIn.tsx +67 -0
  37. package/src/components/GridSystem.tsx +131 -0
  38. package/src/components/HeatmapCalendar.tsx +167 -0
  39. package/src/components/KanbanBoard.tsx +421 -0
  40. package/src/components/Kpi.tsx +61 -0
  41. package/src/components/LayoutPrimitives.tsx +86 -0
  42. package/src/components/Lists.tsx +19 -0
  43. package/src/components/LocationSelector.tsx +49 -0
  44. package/src/components/MessageList.tsx +80 -0
  45. package/src/components/Nav.tsx +69 -0
  46. package/src/components/Navigation.tsx +52 -0
  47. package/src/components/Notification.tsx +115 -0
  48. package/src/components/NotificationTrigger.tsx +248 -0
  49. package/src/components/Overlay.tsx +50 -0
  50. package/src/components/Page.tsx +143 -0
  51. package/src/components/PageTabs.tsx +28 -0
  52. package/src/components/PickerShell.tsx +138 -0
  53. package/src/components/PillNav.tsx +194 -0
  54. package/src/components/Primitives.tsx +30 -0
  55. package/src/components/Rating.tsx +78 -0
  56. package/src/components/Responsive.tsx +128 -0
  57. package/src/components/ResponsiveFrame.tsx +122 -0
  58. package/src/components/Segment.tsx +190 -0
  59. package/src/components/Sparkline.tsx +52 -0
  60. package/src/components/StateSelector.tsx +39 -0
  61. package/src/components/Sticky.tsx +95 -0
  62. package/src/components/Table.tsx +344 -0
  63. package/src/components/ThemeToggle.tsx +48 -0
  64. package/src/components/Tile.tsx +23 -0
  65. package/src/components/TimeColumns.tsx +155 -0
  66. package/src/components/TimeDial.tsx +260 -0
  67. package/src/components/TimeField.tsx +196 -0
  68. package/src/components/TimeInput.tsx +213 -0
  69. package/src/components/TimeSlots.tsx +83 -0
  70. package/src/components/Timeline.tsx +111 -0
  71. package/src/components/TokenDemo.tsx +378 -0
  72. package/src/components/Tree.tsx +283 -0
  73. package/src/components/TrendChart.tsx +211 -0
  74. package/src/components/breakpoints.ts +42 -0
  75. package/src/components/calendarTypes.ts +26 -0
  76. package/src/components/chartMath.ts +125 -0
  77. package/src/components/chartParts.tsx +116 -0
  78. package/src/components/codeInputRules.ts +58 -0
  79. package/src/components/date.ts +87 -0
  80. package/src/components/hooks.ts +53 -0
  81. package/src/components/index.ts +195 -0
  82. package/src/components/kanban.ts +78 -0
  83. package/src/components/motion.ts +78 -0
  84. package/src/components/places-search.tsx +73 -0
  85. package/src/components/places.ts +209 -0
  86. package/src/components/size.ts +3 -0
  87. package/src/components/time.ts +83 -0
  88. package/src/components/useFileDrop.ts +104 -0
  89. package/src/places/geoapify.ts +129 -0
  90. package/src/styles/astryx.css +5 -0
  91. package/src/styles/components/brand.css +98 -0
  92. package/src/styles/components/calendar.css +680 -0
  93. package/src/styles/components/charts.css +659 -0
  94. package/src/styles/components/code-input.css +233 -0
  95. package/src/styles/components/google.css +11 -0
  96. package/src/styles/components/message-list.css +153 -0
  97. package/src/styles/components/notification.css +350 -0
  98. package/src/styles/components/picker.css +114 -0
  99. package/src/styles/components/pill-nav.css +179 -0
  100. package/src/styles/components/responsive.css +419 -0
  101. package/src/styles/components/sticky.css +23 -0
  102. package/src/styles/components/table.css +267 -0
  103. package/src/styles/components/time.css +526 -0
  104. package/src/styles/components/timeline.css +426 -0
  105. package/src/styles/components/tree.css +269 -0
  106. package/src/styles/components.css +423 -0
  107. package/src/styles/joined.css +4 -0
  108. package/src/styles/theme.css +79 -0
  109. package/src/styles/tokens.css +338 -0
  110. package/src/theme/JoinedProvider.tsx +50 -0
  111. package/src/theme/index.ts +3 -0
  112. package/src/theme/joined.d.ts +11 -0
  113. package/src/theme/joined.js +569 -0
  114. package/src/theme/joined.theme.ts +74 -0
  115. package/src/theme/joined.variants.d.ts +16 -0
  116. 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
+ }