@colixsystems/widget-sdk 0.233.0 → 0.235.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,159 @@
1
+ // REQ-WSDK-PLATFORM §6 — the controls of the web `<DateTimePicker>` sheet:
2
+ // the header that pages and zooms the calendar, its buttons, and the items of
3
+ // the month and year grids.
4
+
5
+ import React from "react";
6
+ import { Icon } from "./icon.js";
7
+ import { FIELD_OUTLINE_OFF } from "./form-control-tokens.js";
8
+ import { YEARS_PER_PAGE, isMonthAllowed, isYearAllowed, shiftMonth } from "./datetimepicker-calendar.js";
9
+ import { monthNames, monthTitle } from "./datetimepicker-names.js";
10
+ import { CELL_SIZE } from "./datetimepicker-cells.js";
11
+
12
+ const h = React.createElement;
13
+ export const CALENDAR_WIDTH = CELL_SIZE * 7;
14
+ const NAV_SIZE = 36;
15
+
16
+ function yearPageStart(year) {
17
+ return Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
18
+ }
19
+
20
+ function yearRange(start) {
21
+ return `${start} – ${start + YEARS_PER_PAGE - 1}`;
22
+ }
23
+
24
+ /** What the header shows and does at the sheet's current level. */
25
+ function headerModel(state, bounds, locale) {
26
+ const { view, level } = state;
27
+ if (level === "days") {
28
+ const prev = shiftMonth(view, -1);
29
+ const next = shiftMonth(view, 1);
30
+ return {
31
+ title: monthTitle(view.year, view.month, locale),
32
+ zoom: () => state.showLevel("months"),
33
+ prev: { label: monthTitle(prev.year, prev.month, locale), disabled: !isMonthAllowed(prev.year, prev.month, bounds), go: () => state.pageMonths(-1) },
34
+ next: { label: monthTitle(next.year, next.month, locale), disabled: !isMonthAllowed(next.year, next.month, bounds), go: () => state.pageMonths(1) },
35
+ };
36
+ }
37
+ if (level === "months") {
38
+ const year = (delta) => ({ label: String(view.year + delta), disabled: !isYearAllowed(view.year + delta, bounds), go: () => state.showPeriod({ ...view, year: view.year + delta }) });
39
+ return { title: String(view.year), zoom: () => state.showLevel("years"), prev: year(-1), next: year(1) };
40
+ }
41
+ const start = yearPageStart(view.year);
42
+ const page = (delta) => ({
43
+ label: yearRange(start + delta),
44
+ disabled: !isYearAllowed(delta < 0 ? start - 1 : start + YEARS_PER_PAGE, bounds),
45
+ go: () => state.showPeriod({ ...view, year: view.year + delta }),
46
+ });
47
+ return { title: yearRange(start), zoom: null, prev: page(-YEARS_PER_PAGE), next: page(YEARS_PER_PAGE) };
48
+ }
49
+
50
+ /** The months of the shown year, or the years of the shown page. */
51
+ export function periodItems(state, bounds, locale) {
52
+ const { view, draft, today, level } = state;
53
+ if (level === "months") {
54
+ return monthNames(locale, "short").map((label, month) => ({
55
+ key: month,
56
+ testKey: `month-${month + 1}`,
57
+ label,
58
+ spoken: monthTitle(view.year, month, locale),
59
+ selected: !!draft && draft.year === view.year && draft.month === month,
60
+ current: today.year === view.year && today.month === month,
61
+ disabled: !isMonthAllowed(view.year, month, bounds),
62
+ period: { year: view.year, month },
63
+ }));
64
+ }
65
+ const start = yearPageStart(view.year);
66
+ return Array.from({ length: YEARS_PER_PAGE }, (_, i) => start + i).map((year) => ({
67
+ key: year,
68
+ testKey: `year-${year}`,
69
+ label: String(year),
70
+ spoken: String(year),
71
+ selected: !!draft && draft.year === year,
72
+ current: today.year === year,
73
+ disabled: !isYearAllowed(year, bounds),
74
+ period: { year, month: view.month },
75
+ }));
76
+ }
77
+
78
+ /** @param {{View: any, Text: any, Pressable: any}} rn */
79
+ export function makeSheetControls(rn) {
80
+ const { View, Text, Pressable } = rn;
81
+
82
+ const ringed = (t, extra) => ({ hovered, focused, pressed }) => [
83
+ FIELD_OUTLINE_OFF,
84
+ ...extra({ hovered, pressed }),
85
+ focused ? { boxShadow: `0 0 0 2px ${t.focusColor}` } : null,
86
+ ];
87
+
88
+ function NavButton({ t, icon, step, testID }) {
89
+ return h(
90
+ Pressable,
91
+ {
92
+ onPress: () => {
93
+ if (!step.disabled) step.go();
94
+ },
95
+ accessibilityRole: "button",
96
+ accessibilityLabel: step.label,
97
+ disabled: step.disabled,
98
+ testID,
99
+ style: ringed(t, ({ hovered }) => [
100
+ { width: NAV_SIZE, height: NAV_SIZE, borderRadius: NAV_SIZE / 2, alignItems: "center", justifyContent: "center" },
101
+ hovered && !step.disabled ? { backgroundColor: t.background } : null,
102
+ step.disabled ? { opacity: t.disabledOpacity } : null,
103
+ ]),
104
+ },
105
+ h(Icon, { name: icon, size: 18, color: t.popover.textColor }),
106
+ );
107
+ }
108
+
109
+ function TextButton({ t, label, filled, disabled = false, onPress, testID }) {
110
+ return h(
111
+ Pressable,
112
+ {
113
+ onPress: () => {
114
+ if (!disabled) onPress();
115
+ },
116
+ accessibilityRole: "button",
117
+ disabled,
118
+ testID,
119
+ style: ringed(t, ({ hovered, pressed }) => [
120
+ { minHeight: 36, paddingHorizontal: 14, borderRadius: t.radius, alignItems: "center", justifyContent: "center" },
121
+ filled ? { backgroundColor: t.accent } : hovered && !disabled ? { backgroundColor: t.background } : null,
122
+ pressed ? { opacity: 0.8 } : null,
123
+ disabled ? { opacity: t.disabledOpacity } : null,
124
+ ]),
125
+ },
126
+ h(Text, { style: { fontFamily: t.fontFamily, fontSize: t.labelSize, fontWeight: "600", color: filled ? t.onAccent : t.accent } }, label),
127
+ );
128
+ }
129
+
130
+ function Header({ t, state, bounds, locale, titleId, testID }) {
131
+ const model = headerModel(state, bounds, locale);
132
+ const title = h(
133
+ Pressable,
134
+ {
135
+ nativeID: titleId,
136
+ onPress: model.zoom || undefined,
137
+ disabled: !model.zoom,
138
+ accessibilityRole: model.zoom ? "button" : "text",
139
+ accessibilityLabel: model.title,
140
+ testID: testID ? `${testID}-title` : undefined,
141
+ style: ringed(t, ({ hovered }) => [
142
+ { flexDirection: "row", alignItems: "center", gap: 4, minHeight: NAV_SIZE, paddingHorizontal: 10, borderRadius: t.radius },
143
+ hovered && model.zoom ? { backgroundColor: t.background } : null,
144
+ ]),
145
+ },
146
+ h(Text, { style: { fontFamily: t.fontFamily, fontSize: t.fontSize, fontWeight: "600", color: t.popover.textColor } }, model.title),
147
+ model.zoom ? h(Icon, { name: "chevron-down", size: 16, color: t.helperColor }) : null,
148
+ );
149
+ return h(
150
+ View,
151
+ { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", width: CALENDAR_WIDTH } },
152
+ h(NavButton, { t, icon: "chevron-left", step: model.prev, testID: testID ? `${testID}-previous` : undefined }),
153
+ title,
154
+ h(NavButton, { t, icon: "chevron-right", step: model.next, testID: testID ? `${testID}-next` : undefined }),
155
+ );
156
+ }
157
+
158
+ return { Header, TextButton };
159
+ }
@@ -0,0 +1,146 @@
1
+ // REQ-WSDK-PLATFORM §6 — the grids of the web `<DateTimePicker>` sheet: the
2
+ // month's days, and the months or years of the jump. Bound to react-native-web
3
+ // by ./datetimepicker-sheet.js.
4
+
5
+ import React from "react";
6
+ import { spaceActivates } from "./form-control-logic.js";
7
+ import { dayKey, isDayAllowed, monthGrid, moveDay } from "./datetimepicker-calendar.js";
8
+ import { dayLabel } from "./datetimepicker-names.js";
9
+ import { CELL_SIZE, cellStyle, cellText, makeFocus, register } from "./datetimepicker-cells.js";
10
+
11
+ const h = React.createElement;
12
+ const PERIOD_COLUMNS = 3;
13
+ const PERIOD_STEPS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -PERIOD_COLUMNS, ArrowDown: PERIOD_COLUMNS };
14
+
15
+ /** @param {{View: any, Text: any, Pressable: any, Platform: any}} rn */
16
+ export function makeSheetParts(rn) {
17
+ const { View, Text, Pressable, Platform } = rn;
18
+ const { focusNode, useFocusOn, useRovingStop } = makeFocus(Platform);
19
+
20
+ function WeekdayRow({ t, headings }) {
21
+ return h(
22
+ View,
23
+ { role: "row", style: { flexDirection: "row" } },
24
+ ...headings.map((heading) =>
25
+ h(
26
+ View,
27
+ { key: heading.long, role: "columnheader", "aria-label": heading.long, style: { width: CELL_SIZE, alignItems: "center", paddingVertical: 4 } },
28
+ h(Text, { "aria-hidden": true, style: { fontFamily: t.fontFamily, fontSize: t.helperSize, fontWeight: "500", color: t.helperColor } }, heading.short),
29
+ ),
30
+ ),
31
+ );
32
+ }
33
+
34
+ function DayCell({ t, cell, state, bounds, locale, weekStart, nodes, onPick, onMove, testID }) {
35
+ const { outside, ...day } = cell;
36
+ const key = dayKey(day);
37
+ const disabled = !isDayAllowed(day, bounds);
38
+ const selected = key === state.selectedKey;
39
+ const isToday = key === state.todayKey;
40
+ const pick = () => {
41
+ if (!disabled) onPick(day);
42
+ };
43
+ return h(
44
+ Pressable,
45
+ {
46
+ ref: register(nodes, key),
47
+ onPress: pick,
48
+ onKeyDown: (event) => {
49
+ const next = moveDay(day, event.key, { shiftKey: event.shiftKey, weekStart });
50
+ if (!next) {
51
+ spaceActivates(pick)(event);
52
+ return;
53
+ }
54
+ event.preventDefault();
55
+ // A bound is a wall: focus never lands on a day it refuses.
56
+ if (isDayAllowed(next, bounds)) onMove(next);
57
+ },
58
+ focusable: key === state.focusKey,
59
+ role: "gridcell",
60
+ "aria-selected": selected,
61
+ "aria-current": isToday ? "date" : undefined,
62
+ disabled,
63
+ accessibilityLabel: dayLabel(day, locale),
64
+ testID: testID ? `${testID}-day-${key}` : undefined,
65
+ style: cellStyle(t, { selected, current: isToday, disabled, width: CELL_SIZE, height: CELL_SIZE, round: true }),
66
+ },
67
+ h(Text, { style: cellText(t, { selected, current: isToday, muted: outside }) }, String(day.day)),
68
+ );
69
+ }
70
+
71
+ function DayGrid({ t, view, weekStart, headings, selected, today, focusDay, focusRequest, bounds, locale, labelledBy, onPick, onMove, testID }) {
72
+ const nodes = React.useRef(new Map());
73
+ useFocusOn(focusRequest, nodes, dayKey(focusDay));
74
+ const state = {
75
+ selectedKey: selected && selected.year != null ? dayKey(selected) : null,
76
+ todayKey: dayKey(today),
77
+ focusKey: dayKey(focusDay),
78
+ };
79
+ const cellProps = { t, state, bounds, locale, weekStart, nodes, onPick, onMove, testID };
80
+ return h(
81
+ View,
82
+ { role: "grid", "aria-labelledby": labelledBy },
83
+ h(WeekdayRow, { t, headings }),
84
+ ...monthGrid(view.year, view.month, weekStart).map((week, row) =>
85
+ h(
86
+ View,
87
+ { key: row, role: "row", style: { flexDirection: "row" } },
88
+ ...week.map((cell) => h(DayCell, { key: dayKey(cell), cell, ...cellProps })),
89
+ ),
90
+ ),
91
+ );
92
+ }
93
+
94
+ // The next pickable item `step` away, or `from` when there is none.
95
+ function nextPeriod(items, from, step) {
96
+ for (let i = from + step; i >= 0 && i < items.length; i += step) {
97
+ if (!items[i].disabled) return i;
98
+ }
99
+ return from;
100
+ }
101
+
102
+ /** A 3-column grid of months or years; `items` carry their own state. */
103
+ function PeriodGrid({ t, items, focusRequest, onPick, testID }) {
104
+ const nodes = React.useRef(new Map());
105
+ const pickable = items.filter((item) => !item.disabled);
106
+ const fallback = (pickable.find((item) => item.selected) || pickable[0] || {}).key;
107
+ const [stop, setStop] = useRovingStop(fallback, items.map((item) => item.key));
108
+ useFocusOn(focusRequest, nodes, fallback);
109
+ const width = (CELL_SIZE * 7) / PERIOD_COLUMNS;
110
+ const moveFrom = (index, event) => {
111
+ const step = PERIOD_STEPS[event.key];
112
+ if (!step) return;
113
+ event.preventDefault();
114
+ const target = items[nextPeriod(items, index, step)];
115
+ setStop(target.key);
116
+ focusNode(nodes.current.get(target.key));
117
+ };
118
+ return h(
119
+ View,
120
+ { style: { flexDirection: "row", flexWrap: "wrap", width: CELL_SIZE * 7, rowGap: 8 } },
121
+ ...items.map((item, index) =>
122
+ h(
123
+ Pressable,
124
+ {
125
+ key: item.key,
126
+ ref: register(nodes, item.key),
127
+ onPress: () => {
128
+ if (!item.disabled) onPick(item);
129
+ },
130
+ onKeyDown: (event) => moveFrom(index, event),
131
+ focusable: item.key === stop,
132
+ accessibilityRole: "button",
133
+ "aria-pressed": item.selected,
134
+ disabled: item.disabled,
135
+ accessibilityLabel: item.spoken,
136
+ testID: testID ? `${testID}-${item.testKey}` : undefined,
137
+ style: cellStyle(t, { selected: item.selected, current: item.current, disabled: item.disabled, width, height: CELL_SIZE, round: false }),
138
+ },
139
+ h(Text, { style: cellText(t, { selected: item.selected, current: item.current }) }, item.label),
140
+ ),
141
+ ),
142
+ );
143
+ }
144
+
145
+ return { DayGrid, PeriodGrid };
146
+ }
@@ -0,0 +1,117 @@
1
+ // REQ-WSDK-PLATFORM §6 — the state of an open web `<DateTimePicker>` sheet:
2
+ // the draft value, the month or period on show, the day holding keyboard
3
+ // focus, and every way the viewer changes them.
4
+
5
+ import React from "react";
6
+ import { nowParts } from "./datetimepicker-format.js";
7
+ import {
8
+ clampToBounds,
9
+ isDayAllowed,
10
+ isTimeAllowed,
11
+ nearestAllowedDay,
12
+ shiftMonth,
13
+ } from "./datetimepicker-calendar.js";
14
+
15
+ function dayOf(parts) {
16
+ return { year: parts.year, month: parts.month, day: parts.day };
17
+ }
18
+
19
+ function canPickNow(now, mode, bounds) {
20
+ if (mode === "time") return isTimeAllowed(null, now.hours, now.minutes, bounds);
21
+ if (!isDayAllowed(now, bounds)) return false;
22
+ return mode === "date" || isTimeAllowed(now, now.hours, now.minutes, bounds);
23
+ }
24
+
25
+ // Where the calendar opens: the value, else today pulled inside the bounds.
26
+ function useOpening(parts, bounds) {
27
+ return React.useMemo(() => {
28
+ const today = nowParts();
29
+ const anchor = parts && parts.year != null ? parts : clampToBounds(today, bounds);
30
+ return { today, anchor, focus: nearestAllowedDay(anchor, anchor.day, bounds) };
31
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
32
+ }
33
+
34
+ function useTimePicks({ mode, draft, anchor, bounds, commit }) {
35
+ const timeDay = mode === "datetime" ? dayOf(draft && draft.year != null ? draft : anchor) : null;
36
+ const base = draft || { ...(timeDay || {}), hours: 0, minutes: 0 };
37
+ const pickTime = (hours, minutes) =>
38
+ commit(clampToBounds(mode === "time" ? { ...base, hours, minutes } : { ...timeDay, hours, minutes }, bounds));
39
+ return {
40
+ timeDay,
41
+ pickHour: (hours) => pickTime(hours, base.minutes),
42
+ pickMinute: (minutes) => pickTime(base.hours, minutes),
43
+ };
44
+ }
45
+
46
+ function usePaging({ bounds, setView, focusDay, setFocusDay, setLevel, refocus }) {
47
+ return {
48
+ pageMonths(view, delta) {
49
+ const next = shiftMonth(view, delta);
50
+ setView(next);
51
+ setFocusDay(nearestAllowedDay(next, focusDay.day, bounds));
52
+ },
53
+ // Paging months or years keeps focus on the button that paged.
54
+ showPeriod(period) {
55
+ setView(period);
56
+ },
57
+ showLevel(next, period) {
58
+ if (period) setView(period);
59
+ if (period && next === "days") setFocusDay(nearestAllowedDay(period, focusDay.day, bounds));
60
+ setLevel(next);
61
+ refocus();
62
+ },
63
+ };
64
+ }
65
+
66
+ export function useSheetState({ mode, parts, bounds, onPick, onClose }) {
67
+ const opening = useOpening(parts, bounds);
68
+ const [draft, setDraft] = React.useState(parts);
69
+ const [view, setView] = React.useState({ year: opening.anchor.year, month: opening.anchor.month });
70
+ const [focusDay, setFocusDay] = React.useState(opening.focus);
71
+ const [level, setLevel] = React.useState("days");
72
+ const [focusRequest, setFocusRequest] = React.useState(1);
73
+ const refocus = () => setFocusRequest((n) => n + 1);
74
+ const commit = (next) => {
75
+ setDraft(next);
76
+ onPick(next);
77
+ };
78
+ const show = (day) => {
79
+ setView({ year: day.year, month: day.month });
80
+ setFocusDay(dayOf(day));
81
+ };
82
+ const time = useTimePicks({ mode, draft, anchor: opening.anchor, bounds, commit });
83
+ const paging = usePaging({ bounds, setView, focusDay, setFocusDay, setLevel, refocus });
84
+ const now = nowParts();
85
+
86
+ return {
87
+ draft, view, focusDay, level, focusRequest, ...time,
88
+ today: opening.today,
89
+ nowAllowed: canPickNow(now, mode, bounds),
90
+ pickDay(day) {
91
+ if (mode === "date") {
92
+ commit({ ...day, hours: 0, minutes: 0 });
93
+ onClose();
94
+ return;
95
+ }
96
+ const clock = draft || now;
97
+ commit(clampToBounds({ ...day, hours: clock.hours, minutes: clock.minutes }, bounds));
98
+ show(day);
99
+ },
100
+ moveFocus(day) {
101
+ show(day);
102
+ refocus();
103
+ },
104
+ pickNow() {
105
+ if (mode === "date") {
106
+ commit({ ...dayOf(now), hours: 0, minutes: 0 });
107
+ onClose();
108
+ return;
109
+ }
110
+ commit(now);
111
+ if (mode === "datetime") show(now);
112
+ },
113
+ pageMonths: (delta) => paging.pageMonths(view, delta),
114
+ showPeriod: paging.showPeriod,
115
+ showLevel: paging.showLevel,
116
+ };
117
+ }
@@ -0,0 +1,113 @@
1
+ // REQ-WSDK-PLATFORM §6 — the web `<DateTimePicker>`'s picking surface: a
2
+ // themed calendar and time sheet in `<Overlay>`. The browser's own date popup
3
+ // cannot be themed or styled, so the web draws its own; a device keeps the
4
+ // operating system's picker (./datetimepicker.native.js).
5
+
6
+ import React from "react";
7
+ import { GRID_ROWS, firstDayOfWeek, isHourAllowed, isTimeAllowed } from "./datetimepicker-calendar.js";
8
+ import { relativeNowLabel, weekdayHeadings } from "./datetimepicker-names.js";
9
+ import { CELL_SIZE } from "./datetimepicker-cells.js";
10
+ import { CALENDAR_WIDTH, makeSheetControls, periodItems } from "./datetimepicker-sheet-header.js";
11
+ import { makeSheetParts } from "./datetimepicker-sheet-parts.js";
12
+ import { makeTimeColumns } from "./datetimepicker-time-columns.js";
13
+ import { useSheetState } from "./datetimepicker-sheet-state.js";
14
+
15
+ const h = React.createElement;
16
+ // The weekday row plus six weeks, so switching to months or years keeps the size.
17
+ const CALENDAR_BODY_HEIGHT = CELL_SIZE * GRID_ROWS + 26;
18
+ const TIME_ONLY_HEIGHT = 220;
19
+ const SHEET_PADDING = 16;
20
+
21
+ /** @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any, Overlay: any}} rn */
22
+ export function makeDateTimeSheet(rn) {
23
+ const { View, Text, Overlay } = rn;
24
+ const { DayGrid, PeriodGrid } = makeSheetParts(rn);
25
+ const TimeColumns = makeTimeColumns(rn);
26
+ const { Header, TextButton } = makeSheetControls(rn);
27
+
28
+ function Calendar({ t, state, bounds, locale, testID }) {
29
+ const titleId = React.useId();
30
+ const weekStart = React.useMemo(() => firstDayOfWeek(locale), [locale]);
31
+ const headings = React.useMemo(() => weekdayHeadings(locale, weekStart), [locale, weekStart]);
32
+ const body =
33
+ state.level === "days"
34
+ ? h(DayGrid, {
35
+ t, view: state.view, weekStart, headings, bounds, locale, testID,
36
+ selected: state.draft, today: state.today, focusDay: state.focusDay,
37
+ focusRequest: state.focusRequest, labelledBy: titleId,
38
+ onPick: state.pickDay, onMove: state.moveFocus,
39
+ })
40
+ : h(PeriodGrid, {
41
+ t, testID, focusRequest: state.focusRequest,
42
+ items: periodItems(state, bounds, locale),
43
+ onPick: (item) => state.showLevel(state.level === "months" ? "days" : "months", item.period),
44
+ });
45
+ return h(
46
+ View,
47
+ { style: { width: CALENDAR_WIDTH, gap: 8 } },
48
+ h(Header, { t, state, bounds, locale, titleId, testID }),
49
+ h(View, { style: { minHeight: CALENDAR_BODY_HEIGHT } }, body),
50
+ );
51
+ }
52
+
53
+ function Time({ t, mode, state, bounds, locale, testID }) {
54
+ return h(TimeColumns, {
55
+ t, locale, testID,
56
+ parts: state.draft,
57
+ height: mode === "time" ? TIME_ONLY_HEIGHT : CALENDAR_BODY_HEIGHT,
58
+ focusRequest: mode === "time" ? 1 : 0,
59
+ isHourDisabled: (hours) => !isHourAllowed(state.timeDay, hours, bounds),
60
+ isMinuteDisabled: (minutes) => !!state.draft && !isTimeAllowed(state.timeDay, state.draft.hours, minutes, bounds),
61
+ onHour: state.pickHour,
62
+ onMinute: state.pickMinute,
63
+ });
64
+ }
65
+
66
+ function SheetBody({ t, mode, parts, bounds, locale, heading, onPick, onClose, doneLabel, testID }) {
67
+ const state = useSheetState({ mode, parts, bounds, onPick, onClose });
68
+ const shared = { t, state, bounds, locale, testID };
69
+ return h(
70
+ View,
71
+ { style: { gap: 16, alignItems: "center" } },
72
+ // The sheet covers its field, so it names the field it edits.
73
+ heading
74
+ ? h(Text, { style: { alignSelf: "stretch", fontFamily: t.fontFamily, fontSize: t.labelSize, fontWeight: "500", color: t.helperColor } }, heading)
75
+ : null,
76
+ h(
77
+ View,
78
+ { style: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", alignItems: "flex-end", gap: 16 } },
79
+ mode === "time" ? null : h(Calendar, shared),
80
+ mode === "date" ? null : h(Time, { ...shared, mode }),
81
+ ),
82
+ h(
83
+ View,
84
+ { style: { flexDirection: "row", alignSelf: "stretch", justifyContent: "space-between", alignItems: "center" } },
85
+ h(TextButton, { t, label: relativeNowLabel(locale, mode), disabled: !state.nowAllowed, onPress: state.pickNow, testID: testID ? `${testID}-now` : undefined }),
86
+ mode === "date" ? null : h(TextButton, { t, label: doneLabel, filled: true, onPress: onClose, testID: testID ? `${testID}-done` : undefined }),
87
+ ),
88
+ );
89
+ }
90
+
91
+ function DateTimeSheet({ open, onClose, title, closeAccessibilityLabel, testID, t, ...rest }) {
92
+ return h(
93
+ Overlay,
94
+ {
95
+ visible: open,
96
+ onRequestClose: onClose,
97
+ size: rest.mode === "datetime" ? "md" : "sm",
98
+ accessibilityLabel: title,
99
+ ...(closeAccessibilityLabel ? { closeAccessibilityLabel } : null),
100
+ testID: testID ? `${testID}-sheet` : undefined,
101
+ style: {
102
+ backgroundColor: t.popover.background,
103
+ borderWidth: 1,
104
+ borderColor: t.popover.borderColor,
105
+ padding: SHEET_PADDING,
106
+ },
107
+ },
108
+ open ? h(SheetBody, { ...rest, t, onClose, testID }) : null,
109
+ );
110
+ }
111
+
112
+ return DateTimeSheet;
113
+ }
@@ -0,0 +1,107 @@
1
+ // REQ-WSDK-PLATFORM §6 — the hour and minute columns of the web
2
+ // `<DateTimePicker>` sheet. Bound to react-native-web by ./datetimepicker-sheet.js.
3
+
4
+ import React from "react";
5
+ import { nextOptionIndex } from "./form-control-tokens.js";
6
+ import { spaceActivates } from "./form-control-logic.js";
7
+ import { fieldName, twoDigits } from "./datetimepicker-names.js";
8
+ import { cellStyle, cellText, makeFocus, register } from "./datetimepicker-cells.js";
9
+
10
+ const h = React.createElement;
11
+ const ITEM_HEIGHT = 40;
12
+ const ITEM_GAP = 2;
13
+ const COLUMN_WIDTH = 64;
14
+ const HOURS = Array.from({ length: 24 }, (_, i) => i);
15
+ const MINUTES = Array.from({ length: 60 }, (_, i) => i);
16
+ const LIST_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
17
+
18
+ /** @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any}} rn */
19
+ export function makeTimeColumns(rn) {
20
+ const { View, Text, Pressable, ScrollView, Platform } = rn;
21
+ const { focusNode, useFocusOn, useRovingStop } = makeFocus(Platform);
22
+
23
+ // Centre the choice on open, so both columns hold it level; afterwards
24
+ // (Now, a pick) only bring it into view when it has scrolled out.
25
+ function useKeepInView(scroller, nodes, chosen, values, height) {
26
+ const mounted = React.useRef(false);
27
+ React.useEffect(() => {
28
+ if (!mounted.current) {
29
+ mounted.current = true;
30
+ const top = values.indexOf(chosen) * (ITEM_HEIGHT + ITEM_GAP) - (height - ITEM_HEIGHT) / 2;
31
+ if (scroller.current && typeof scroller.current.scrollTo === "function") {
32
+ scroller.current.scrollTo({ y: Math.max(0, top), animated: false });
33
+ }
34
+ return;
35
+ }
36
+ const node = nodes.current.get(chosen);
37
+ if (Platform.OS === "web" && node && typeof node.scrollIntoView === "function") {
38
+ node.scrollIntoView({ block: "nearest" });
39
+ }
40
+ }, [chosen]); // eslint-disable-line react-hooks/exhaustive-deps
41
+ }
42
+
43
+ function TimeColumn({ t, field, values, chosen, isDisabled, onPick, height, locale, focusRequest, testID }) {
44
+ const nodes = React.useRef(new Map());
45
+ const scroller = React.useRef(null);
46
+ const options = values.map((value) => ({ value, disabled: isDisabled(value) }));
47
+ const firstEnabled = options.find((option) => !option.disabled);
48
+ const fallback = chosen != null && !isDisabled(chosen) ? chosen : (firstEnabled || options[0]).value;
49
+ const [stop, setStop] = useRovingStop(fallback, values);
50
+ useFocusOn(focusRequest, nodes, fallback);
51
+ useKeepInView(scroller, nodes, chosen ?? fallback, values, height);
52
+ const name = fieldName(locale, field);
53
+ const item = (option, index) => {
54
+ const pick = () => {
55
+ if (!option.disabled) onPick(option.value);
56
+ };
57
+ const selected = option.value === chosen;
58
+ return h(
59
+ Pressable,
60
+ {
61
+ key: option.value,
62
+ ref: register(nodes, option.value),
63
+ onPress: pick,
64
+ onKeyDown: (event) => {
65
+ if (!LIST_KEYS.includes(event.key)) {
66
+ spaceActivates(pick)(event);
67
+ return;
68
+ }
69
+ event.preventDefault();
70
+ const target = values[nextOptionIndex(options, index, event.key)];
71
+ setStop(target);
72
+ focusNode(nodes.current.get(target));
73
+ },
74
+ focusable: option.value === stop,
75
+ role: "option",
76
+ "aria-selected": selected,
77
+ disabled: option.disabled,
78
+ testID: testID ? `${testID}-${field}-${twoDigits(option.value)}` : undefined,
79
+ style: cellStyle(t, { selected, current: false, disabled: option.disabled, width: COLUMN_WIDTH, height: ITEM_HEIGHT, round: true }),
80
+ },
81
+ h(Text, { style: cellText(t, { selected }) }, twoDigits(option.value)),
82
+ );
83
+ };
84
+ return h(
85
+ View,
86
+ { style: { width: COLUMN_WIDTH, gap: 8 } },
87
+ h(Text, { "aria-hidden": true, style: { textAlign: "center", fontFamily: t.fontFamily, fontSize: t.helperSize, fontWeight: "500", color: t.helperColor } }, name),
88
+ h(
89
+ ScrollView,
90
+ { ref: scroller, role: "listbox", accessibilityLabel: name, style: { height }, contentContainerStyle: { gap: ITEM_GAP, paddingVertical: ITEM_GAP } },
91
+ ...options.map(item),
92
+ ),
93
+ );
94
+ }
95
+
96
+ function TimeColumns({ t, parts, isHourDisabled, isMinuteDisabled, onHour, onMinute, height, locale, focusRequest, testID }) {
97
+ const shared = { t, height, locale, testID };
98
+ return h(
99
+ View,
100
+ { style: { flexDirection: "row", gap: 8, justifyContent: "center" } },
101
+ h(TimeColumn, { ...shared, field: "hour", values: HOURS, chosen: parts ? parts.hours : null, isDisabled: isHourDisabled, onPick: onHour, focusRequest }),
102
+ h(TimeColumn, { ...shared, field: "minute", values: MINUTES, chosen: parts ? parts.minutes : null, isDisabled: isMinuteDisabled, onPick: onMinute, focusRequest: 0 }),
103
+ );
104
+ }
105
+
106
+ return TimeColumns;
107
+ }