@colixsystems/widget-sdk 0.234.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.
- package/README.md +5 -1
- package/dist/contract.cjs +6 -2
- package/dist/contract.js +6 -2
- package/dist/datetimepicker-calendar.js +225 -0
- package/dist/datetimepicker-cells.js +71 -0
- package/dist/datetimepicker-format.js +107 -45
- package/dist/datetimepicker-names.js +99 -0
- package/dist/datetimepicker-sheet-header.js +159 -0
- package/dist/datetimepicker-sheet-parts.js +146 -0
- package/dist/datetimepicker-sheet-state.js +117 -0
- package/dist/datetimepicker-sheet.js +113 -0
- package/dist/datetimepicker-time-columns.js +107 -0
- package/dist/datetimepicker-view.js +221 -0
- package/dist/datetimepicker.js +12 -163
- package/dist/datetimepicker.native.js +36 -124
- package/dist/hooks.js +8 -0
- package/dist/host.d.ts +4 -2
- package/package.json +1 -1
- package/src/contract.js +6 -2
package/README.md
CHANGED
|
@@ -84,7 +84,11 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.235.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
88
|
+
|
|
89
|
+
### What's new in 0.235.0 (contract 1.190.0)
|
|
90
|
+
|
|
91
|
+
**`<DateTimePicker>` is a themed field with its own calendar on the web.** The web build rendered the browser's `<input type="date">`, whose popup cannot be styled: it ignored the workspace theme and looked out of place in every app. Both hosts now render one shared field — a calendar (or clock) icon, the value in the app user's language (`1 okt. 2026`, `Oct 1, 2026, 2:30 PM`) and a clear button. On the web it opens a calendar sheet in `<Overlay>`, painted from the theme: a month grid with the chosen day in `colors.primary` and today ringed, weeks starting on the locale's first day, a month and year jump for far dates, hour and minute columns for `time` and `datetime`, and a Today / Now button. The grid is keyboard-driven (arrows, Home/End, PageUp/PageDown, Space/Enter) and `minimumDate` / `maximumDate` disable what lies outside them. Native keeps the operating system's picker behind the same field. The value contract is unchanged; new optional props: `placeholder`, `clearLabel`, `doneLabel`, `closeAccessibilityLabel` and `testID`. `formatDateTimeLabel` from `/host` takes an optional `locale` and now returns that localised label.
|
|
88
92
|
|
|
89
93
|
### What's new in 0.234.0 (contract 1.189.0)
|
|
90
94
|
|
package/dist/contract.cjs
CHANGED
|
@@ -3123,7 +3123,7 @@ const PRIMITIVES = [
|
|
|
3123
3123
|
{
|
|
3124
3124
|
name: "DateTimePicker",
|
|
3125
3125
|
description:
|
|
3126
|
-
'Cross-platform date / time / datetime
|
|
3126
|
+
'Cross-platform date / time / datetime field. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" accessibilityLabel="Due" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format): `YYYY-MM-DD` for date, `HH:mm` for time, a full UTC instant for datetime — authors never round-trip through `new Date()`. Clearing emits "". The field shows the value in the app user\'s language beside a calendar (or clock) icon, with a clear button once it holds a value. Web opens a themed calendar sheet in `<Overlay>`: a month grid (keyboard: arrows, Home/End, PageUp/PageDown), a month and year jump, hour and minute columns for time, and a Today / Now button. Native opens the operating system\'s own picker (@react-native-community/datetimepicker). `minimumDate` / `maximumDate` (ISO, `HH:mm` in time mode) bound what can be picked; `disabled` locks the field. Optional copy: `placeholder`, `clearLabel` (default "Clear"), `doneLabel` (default "Done", closes the web time sheet), `closeAccessibilityLabel`; `testID` names the field. Colours come from the workspace theme — the field from the text-field tokens, the sheet from the popover scope, the chosen day from `colors.primary`; never style it with raw CSS or `document`.',
|
|
3127
3127
|
rnComponent: "@react-native-community/datetimepicker",
|
|
3128
3128
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
3129
3129
|
},
|
|
@@ -6049,7 +6049,11 @@ const CONTRACT = deepFreeze({
|
|
|
6049
6049
|
// 1.189.0: additive (sc-11703) — `resolveFooterTokens` gains `padding`,
|
|
6050
6050
|
// the space around a bottom bar's tabs (0–12, default 4); `FOOTER_SIZES`
|
|
6051
6051
|
// gains its bound.
|
|
6052
|
-
|
|
6052
|
+
// 1.190.0: additive (sc-11777) — `<DateTimePicker>` on web opens a themed
|
|
6053
|
+
// calendar sheet instead of the browser's date popup; both hosts show
|
|
6054
|
+
// the value in the app's language with a clear button. New optional
|
|
6055
|
+
// props: placeholder, clearLabel, doneLabel, closeAccessibilityLabel, testID.
|
|
6056
|
+
version: "1.190.0",
|
|
6053
6057
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6054
6058
|
hooks: HOOKS,
|
|
6055
6059
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -3123,7 +3123,7 @@ const PRIMITIVES = [
|
|
|
3123
3123
|
{
|
|
3124
3124
|
name: "DateTimePicker",
|
|
3125
3125
|
description:
|
|
3126
|
-
'Cross-platform date / time / datetime
|
|
3126
|
+
'Cross-platform date / time / datetime field. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" accessibilityLabel="Due" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format): `YYYY-MM-DD` for date, `HH:mm` for time, a full UTC instant for datetime — authors never round-trip through `new Date()`. Clearing emits "". The field shows the value in the app user\'s language beside a calendar (or clock) icon, with a clear button once it holds a value. Web opens a themed calendar sheet in `<Overlay>`: a month grid (keyboard: arrows, Home/End, PageUp/PageDown), a month and year jump, hour and minute columns for time, and a Today / Now button. Native opens the operating system\'s own picker (@react-native-community/datetimepicker). `minimumDate` / `maximumDate` (ISO, `HH:mm` in time mode) bound what can be picked; `disabled` locks the field. Optional copy: `placeholder`, `clearLabel` (default "Clear"), `doneLabel` (default "Done", closes the web time sheet), `closeAccessibilityLabel`; `testID` names the field. Colours come from the workspace theme — the field from the text-field tokens, the sheet from the popover scope, the chosen day from `colors.primary`; never style it with raw CSS or `document`.',
|
|
3127
3127
|
rnComponent: "@react-native-community/datetimepicker",
|
|
3128
3128
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
3129
3129
|
},
|
|
@@ -6045,7 +6045,11 @@ const CONTRACT = deepFreeze({
|
|
|
6045
6045
|
// 1.189.0: additive (sc-11703) — `resolveFooterTokens` gains `padding`,
|
|
6046
6046
|
// the space around a bottom bar's tabs (0–12, default 4); `FOOTER_SIZES`
|
|
6047
6047
|
// gains its bound.
|
|
6048
|
-
|
|
6048
|
+
// 1.190.0: additive (sc-11777) — `<DateTimePicker>` on web opens a themed
|
|
6049
|
+
// calendar sheet instead of the browser's date popup; both hosts show
|
|
6050
|
+
// the value in the app's language with a clear button. New optional
|
|
6051
|
+
// props: placeholder, clearLabel, doneLabel, closeAccessibilityLabel, testID.
|
|
6052
|
+
version: "1.190.0",
|
|
6049
6053
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6050
6054
|
hooks: HOOKS,
|
|
6051
6055
|
primitives: PRIMITIVES,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — the calendar behind the `<DateTimePicker>`: which
|
|
2
|
+
// days a month shows, which may be picked, and where the keyboard moves.
|
|
3
|
+
// Pure, so it is unit-tested without a renderer. Names live in
|
|
4
|
+
// ./datetimepicker-names.js.
|
|
5
|
+
|
|
6
|
+
import { normaliseMode, valueToParts } from "./datetimepicker-format.js";
|
|
7
|
+
|
|
8
|
+
export const WEEK_LENGTH = 7;
|
|
9
|
+
// Six rows hold every month, so the sheet never changes height while paging.
|
|
10
|
+
export const GRID_ROWS = 6;
|
|
11
|
+
export const YEARS_PER_PAGE = 12;
|
|
12
|
+
// `Date` reads years 0–99 as 1900–1999, so the calendar stops before them.
|
|
13
|
+
export const MIN_YEAR = 1000;
|
|
14
|
+
|
|
15
|
+
// CLDR regions whose week starts on Sunday; Monday is the default elsewhere.
|
|
16
|
+
const SUNDAY_FIRST_REGIONS = new Set([
|
|
17
|
+
"AG", "AS", "BD", "BR", "BS", "BT", "BW", "BZ", "CA", "CO", "DM", "DO", "ET",
|
|
18
|
+
"GT", "GU", "HK", "HN", "ID", "IL", "IN", "JM", "JP", "KE", "KH", "KR", "LA",
|
|
19
|
+
"MH", "MM", "MO", "MT", "MX", "MZ", "NI", "NP", "PA", "PE", "PH", "PK", "PR",
|
|
20
|
+
"PT", "PY", "SA", "SG", "SV", "TH", "TT", "TW", "UM", "US", "VE", "VI", "WS",
|
|
21
|
+
"YE", "ZA", "ZW",
|
|
22
|
+
]);
|
|
23
|
+
const SATURDAY_FIRST_REGIONS = new Set(["AE", "AF", "BH", "DJ", "DZ", "EG", "IQ", "IR", "JO", "KW", "LY", "OM", "QA", "SD", "SY"]);
|
|
24
|
+
|
|
25
|
+
function deviceLocale() {
|
|
26
|
+
try {
|
|
27
|
+
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function localeRegion(locale) {
|
|
34
|
+
try {
|
|
35
|
+
const resolved = new Intl.Locale(locale || deviceLocale());
|
|
36
|
+
const maximised = typeof resolved.maximize === "function" ? resolved.maximize() : resolved;
|
|
37
|
+
return maximised.region || null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The weekday a week starts on in this locale: 0 Sunday … 6 Saturday. */
|
|
44
|
+
export function firstDayOfWeek(locale) {
|
|
45
|
+
try {
|
|
46
|
+
const intlLocale = new Intl.Locale(locale || deviceLocale());
|
|
47
|
+
const info =
|
|
48
|
+
typeof intlLocale.getWeekInfo === "function" ? intlLocale.getWeekInfo() : intlLocale.weekInfo;
|
|
49
|
+
// Intl counts Monday 1 … Sunday 7.
|
|
50
|
+
if (info && Number.isInteger(info.firstDay)) return info.firstDay % WEEK_LENGTH;
|
|
51
|
+
} catch {
|
|
52
|
+
// An engine without Intl.Locale falls through to the region table.
|
|
53
|
+
}
|
|
54
|
+
const region = localeRegion(locale);
|
|
55
|
+
if (region && SUNDAY_FIRST_REGIONS.has(region)) return 0;
|
|
56
|
+
if (region && SATURDAY_FIRST_REGIONS.has(region)) return 6;
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The locale the picker names dates in: the app's language, refined by the
|
|
62
|
+
* device's region when they share a language (`sv` + `sv-FI` → `sv-FI`), so
|
|
63
|
+
* a week starts where the viewer expects it to.
|
|
64
|
+
*/
|
|
65
|
+
export function resolvePickerLocale(appLocale) {
|
|
66
|
+
const device = deviceLocale();
|
|
67
|
+
if (!appLocale) return device;
|
|
68
|
+
const language = (tag) => String(tag || "").split("-")[0].toLowerCase();
|
|
69
|
+
if (!String(appLocale).includes("-") && device && language(device) === language(appLocale)) return device;
|
|
70
|
+
return appLocale;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function dayKey({ year, month, day }) {
|
|
74
|
+
return year * 10000 + (month + 1) * 100 + day;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function minuteKey(parts) {
|
|
78
|
+
return dayKey(parts) * 10000 + parts.hours * 100 + parts.minutes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function daysInMonth(year, month) {
|
|
82
|
+
return new Date(year, month + 1, 0).getDate();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The days a month's grid shows, as `GRID_ROWS` weeks of `WEEK_LENGTH` days.
|
|
87
|
+
* Days of the neighbouring months fill the edges, flagged `outside`.
|
|
88
|
+
*/
|
|
89
|
+
export function monthGrid(year, month, weekStart) {
|
|
90
|
+
const leading = (new Date(year, month, 1).getDay() - weekStart + WEEK_LENGTH) % WEEK_LENGTH;
|
|
91
|
+
const weeks = [];
|
|
92
|
+
for (let row = 0; row < GRID_ROWS; row += 1) {
|
|
93
|
+
const week = [];
|
|
94
|
+
for (let column = 0; column < WEEK_LENGTH; column += 1) {
|
|
95
|
+
const date = new Date(year, month, row * WEEK_LENGTH + column - leading + 1);
|
|
96
|
+
week.push({
|
|
97
|
+
year: date.getFullYear(),
|
|
98
|
+
month: date.getMonth(),
|
|
99
|
+
day: date.getDate(),
|
|
100
|
+
outside: date.getMonth() !== month,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
weeks.push(week);
|
|
104
|
+
}
|
|
105
|
+
return weeks;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The picker's `minimumDate` / `maximumDate` as comparable local bounds.
|
|
110
|
+
* In time mode they bound the time of day; otherwise the moment.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveBounds(minimumDate, maximumDate, mode) {
|
|
113
|
+
const effectiveMode = normaliseMode(mode);
|
|
114
|
+
const read = (value) => (value == null || value === "" ? null : valueToParts(value, effectiveMode));
|
|
115
|
+
return { mode: effectiveMode, min: read(minimumDate), max: read(maximumDate) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isYearAllowed(year, bounds) {
|
|
119
|
+
if (bounds.mode === "time") return true;
|
|
120
|
+
if (year < MIN_YEAR) return false;
|
|
121
|
+
if (bounds.min && year < bounds.min.year) return false;
|
|
122
|
+
if (bounds.max && year > bounds.max.year) return false;
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isMonthAllowed(year, month, bounds) {
|
|
127
|
+
if (!isYearAllowed(year, bounds)) return false;
|
|
128
|
+
if (bounds.mode === "time") return true;
|
|
129
|
+
const first = { year, month, day: 1 };
|
|
130
|
+
const last = { year, month, day: daysInMonth(year, month) };
|
|
131
|
+
if (bounds.min && dayKey(last) < dayKey(bounds.min)) return false;
|
|
132
|
+
if (bounds.max && dayKey(first) > dayKey(bounds.max)) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isDayAllowed(day, bounds) {
|
|
137
|
+
if (bounds.mode === "time") return true;
|
|
138
|
+
if (!isYearAllowed(day.year, bounds)) return false;
|
|
139
|
+
const key = dayKey(day);
|
|
140
|
+
if (bounds.min && key < dayKey(bounds.min)) return false;
|
|
141
|
+
if (bounds.max && key > dayKey(bounds.max)) return false;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A time of day as one comparable number; in datetime mode, on `day`.
|
|
146
|
+
function timeKey(day, hours, minutes, bounds) {
|
|
147
|
+
return bounds.mode === "time" ? hours * 100 + minutes : minuteKey({ ...day, hours, minutes });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function edgeKey(edge, bounds) {
|
|
151
|
+
if (!edge) return null;
|
|
152
|
+
return bounds.mode === "time" ? edge.hours * 100 + edge.minutes : minuteKey(edge);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Whether any moment between `from` and `to` ([hours, minutes]) is in bounds.
|
|
156
|
+
function spanAllowed(day, from, to, bounds) {
|
|
157
|
+
if (bounds.mode === "date" || (bounds.mode === "datetime" && !day)) return true;
|
|
158
|
+
const min = edgeKey(bounds.min, bounds);
|
|
159
|
+
const max = edgeKey(bounds.max, bounds);
|
|
160
|
+
if (min != null && timeKey(day, to[0], to[1], bounds) < min) return false;
|
|
161
|
+
if (max != null && timeKey(day, from[0], from[1], bounds) > max) return false;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether `hours:minutes` may be picked on `day` (ignored in time mode). */
|
|
166
|
+
export function isTimeAllowed(day, hours, minutes, bounds) {
|
|
167
|
+
return spanAllowed(day, [hours, minutes], [hours, minutes], bounds);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether any minute of `hours` may be picked, so the hour is worth offering. */
|
|
171
|
+
export function isHourAllowed(day, hours, bounds) {
|
|
172
|
+
return spanAllowed(day, [hours, 0], [hours, 59], bounds);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Pull a candidate inside the picker's bounds, so a pick never leaves them. */
|
|
176
|
+
export function clampToBounds(parts, bounds) {
|
|
177
|
+
const key = bounds.mode === "time" ? parts.hours * 100 + parts.minutes : minuteKey(parts);
|
|
178
|
+
const min = edgeKey(bounds.min, bounds);
|
|
179
|
+
const max = edgeKey(bounds.max, bounds);
|
|
180
|
+
const edge = min != null && key < min ? bounds.min : max != null && key > max ? bounds.max : null;
|
|
181
|
+
if (!edge) return parts;
|
|
182
|
+
if (bounds.mode === "time") return { ...parts, hours: edge.hours, minutes: edge.minutes };
|
|
183
|
+
return { ...edge };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The allowed day of a month nearest to `preferred`, so focus has a home. */
|
|
187
|
+
export function nearestAllowedDay({ year, month }, preferred, bounds) {
|
|
188
|
+
const last = daysInMonth(year, month);
|
|
189
|
+
const start = Math.min(preferred, last);
|
|
190
|
+
for (let offset = 0; offset < last; offset += 1) {
|
|
191
|
+
for (const day of [start - offset, start + offset]) {
|
|
192
|
+
if (day >= 1 && day <= last && isDayAllowed({ year, month, day }, bounds)) return { year, month, day };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { year, month, day: start };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function shiftMonth({ year, month }, delta) {
|
|
199
|
+
const d = new Date(year, month + delta, 1);
|
|
200
|
+
return { year: d.getFullYear(), month: d.getMonth() };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The day a calendar key moves focus to, or null for a key it ignores. */
|
|
204
|
+
export function moveDay(day, key, { shiftKey = false, weekStart = 1 } = {}) {
|
|
205
|
+
const date = new Date(day.year, day.month, day.day);
|
|
206
|
+
const weekday = (date.getDay() - weekStart + WEEK_LENGTH) % WEEK_LENGTH;
|
|
207
|
+
const byDays = {
|
|
208
|
+
ArrowLeft: -1,
|
|
209
|
+
ArrowRight: 1,
|
|
210
|
+
ArrowUp: -WEEK_LENGTH,
|
|
211
|
+
ArrowDown: WEEK_LENGTH,
|
|
212
|
+
Home: -weekday,
|
|
213
|
+
End: WEEK_LENGTH - 1 - weekday,
|
|
214
|
+
};
|
|
215
|
+
if (key in byDays) {
|
|
216
|
+
date.setDate(date.getDate() + byDays[key]);
|
|
217
|
+
} else if (key === "PageUp" || key === "PageDown") {
|
|
218
|
+
const months = (key === "PageUp" ? -1 : 1) * (shiftKey ? 12 : 1);
|
|
219
|
+
const target = shiftMonth(day, months);
|
|
220
|
+
return { ...target, day: Math.min(day.day, daysInMonth(target.year, target.month)) };
|
|
221
|
+
} else {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() };
|
|
225
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — what every pickable cell of the web
|
|
2
|
+
// `<DateTimePicker>` sheet shares: its look, and how keyboard focus reaches it.
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
|
|
6
|
+
// Seven of them fit a 360px phone inside the sheet's padding.
|
|
7
|
+
export const CELL_SIZE = 40;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The look of one pickable cell: the accent fills the chosen one, a ring
|
|
11
|
+
* marks today, and the pointer and keyboard each get a visible state.
|
|
12
|
+
*/
|
|
13
|
+
export function cellStyle(t, { selected, current, disabled, width, height, round }) {
|
|
14
|
+
return ({ pressed, hovered, focused }) => [
|
|
15
|
+
{
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
alignItems: "center",
|
|
19
|
+
justifyContent: "center",
|
|
20
|
+
borderRadius: round ? height / 2 : t.radius,
|
|
21
|
+
borderWidth: 1,
|
|
22
|
+
borderColor: current && !selected ? t.accent : "transparent",
|
|
23
|
+
backgroundColor: selected ? t.accent : hovered && !disabled ? t.background : "transparent",
|
|
24
|
+
outlineColor: "transparent",
|
|
25
|
+
},
|
|
26
|
+
focused ? { boxShadow: `0 0 0 2px ${t.focusColor}` } : null,
|
|
27
|
+
pressed && !disabled ? { opacity: 0.75 } : null,
|
|
28
|
+
disabled ? { opacity: t.disabledOpacity } : null,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cellText(t, { selected, current, muted }) {
|
|
33
|
+
return {
|
|
34
|
+
fontFamily: t.fontFamily,
|
|
35
|
+
fontSize: t.labelSize,
|
|
36
|
+
fontVariant: ["tabular-nums"],
|
|
37
|
+
fontWeight: selected || current ? "600" : "400",
|
|
38
|
+
color: selected ? t.onAccent : current ? t.accent : muted ? t.helperColor : t.popover.textColor,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keyboard focus helpers, bound to one platform. Focus is a web concern. */
|
|
43
|
+
export function makeFocus(Platform) {
|
|
44
|
+
function focusNode(node) {
|
|
45
|
+
if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Moves DOM focus whenever the sheet bumps `request`, and once on mount.
|
|
49
|
+
function useFocusOn(request, nodes, key) {
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (request > 0) focusNode(nodes.current.get(key));
|
|
52
|
+
}, [request]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One tab stop for a set of cells, following the arrow keys: `fallback`
|
|
57
|
+
* holds it until the keyboard moves it, and again once it leaves `keys`.
|
|
58
|
+
*/
|
|
59
|
+
function useRovingStop(fallback, keys) {
|
|
60
|
+
const [moved, setMoved] = React.useState(null);
|
|
61
|
+
const stop = moved != null && keys.includes(moved) ? moved : fallback;
|
|
62
|
+
return [stop, setMoved];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { focusNode, useFocusOn, useRovingStop };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A ref callback that files each mounted cell under `key`. */
|
|
69
|
+
export function register(nodes, key) {
|
|
70
|
+
return (node) => (node ? nodes.current.set(key, node) : nodes.current.delete(key));
|
|
71
|
+
}
|
|
@@ -1,28 +1,17 @@
|
|
|
1
1
|
// REQ-WSDK-PLATFORM §6 — pure, RN-free helpers for the `<DateTimePicker>`.
|
|
2
2
|
//
|
|
3
|
-
// Split out of the primitive so the
|
|
4
|
-
//
|
|
5
|
-
// (neither is installed in this package's node tree). No React, no RN here —
|
|
6
|
-
// just Date math, string shaping, and a colour-luminance test.
|
|
3
|
+
// Split out of the primitive so the ISO and label contract can be unit-tested
|
|
4
|
+
// without a renderer. No React, no RN here — just Date math and Intl.
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (value instanceof Date) return Number.isNaN(value.getTime()) ? new Date() : value;
|
|
11
|
-
if (typeof value === "string") {
|
|
12
|
-
const d = new Date(value);
|
|
13
|
-
return Number.isNaN(d.getTime()) ? new Date() : d;
|
|
14
|
-
}
|
|
15
|
-
return new Date();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
7
|
+
const TIME_ONLY = /^(\d{2}):(\d{2})/;
|
|
19
8
|
|
|
20
9
|
// A stored DATETIME instant → `<input type="datetime-local">`'s
|
|
21
10
|
// `YYYY-MM-DDTHH:mm` on the viewer's local clock. A bare calendar date is
|
|
22
11
|
// local midnight, not UTC midnight, so it never slips a day west of Greenwich.
|
|
23
12
|
export function isoToDateTimeLocalInput(value) {
|
|
24
13
|
if (value == null || value === "") return "";
|
|
25
|
-
if (typeof value === "string" &&
|
|
14
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00`;
|
|
26
15
|
const d = new Date(value);
|
|
27
16
|
if (Number.isNaN(d.getTime())) return "";
|
|
28
17
|
return `${formatToIso(d, "date")}T${formatToIso(d, "time")}`;
|
|
@@ -39,15 +28,13 @@ export function dateTimeLocalInputToIso(raw) {
|
|
|
39
28
|
export function formatToIso(date, mode) {
|
|
40
29
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
|
|
41
30
|
if (mode === "date") {
|
|
42
|
-
//
|
|
43
|
-
// "May 28" picked in Stockholm doesn't read as "May 27" in NYC.
|
|
31
|
+
// Calendar dates are timezone-free: May 28 in Stockholm stays May 28 in NYC.
|
|
44
32
|
const y = String(date.getFullYear()).padStart(4, "0");
|
|
45
33
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
46
34
|
const d = String(date.getDate()).padStart(2, "0");
|
|
47
35
|
return `${y}-${m}-${d}`;
|
|
48
36
|
}
|
|
49
37
|
if (mode === "time") {
|
|
50
|
-
// Local-time ISO (hh:mm) — time-of-day is timezone-free for the same reason.
|
|
51
38
|
const h = String(date.getHours()).padStart(2, "0");
|
|
52
39
|
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
53
40
|
return `${h}:${mm}`;
|
|
@@ -56,36 +43,111 @@ export function formatToIso(date, mode) {
|
|
|
56
43
|
return date.toISOString();
|
|
57
44
|
}
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
46
|
+
export function normaliseMode(mode) {
|
|
47
|
+
return mode === "time" || mode === "datetime" ? mode : "date";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A stored value as the local wall-clock parts the picker edits, or null when
|
|
52
|
+
* it is empty or unreadable. `month` is 0-based, as `Date` counts it.
|
|
53
|
+
*
|
|
54
|
+
* @returns {{year: number, month: number, day: number, hours: number, minutes: number} | null}
|
|
55
|
+
*/
|
|
56
|
+
export function valueToParts(value, mode) {
|
|
57
|
+
if (value == null || value === "") return null;
|
|
58
|
+
const text = String(value);
|
|
59
|
+
const effectiveMode = normaliseMode(mode);
|
|
60
|
+
if (effectiveMode === "time") {
|
|
61
|
+
const match = TIME_ONLY.exec(text);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const hours = Number(match[1]);
|
|
64
|
+
const minutes = Number(match[2]);
|
|
65
|
+
if (hours > 23 || minutes > 59) return null;
|
|
66
|
+
return { year: null, month: null, day: null, hours, minutes };
|
|
67
|
+
}
|
|
68
|
+
// A date column holds a calendar date, never an instant: read its head as is.
|
|
69
|
+
const dateOnly = DATE_ONLY.exec(text);
|
|
70
|
+
if (effectiveMode === "date" || (dateOnly && text.length === 10)) {
|
|
71
|
+
if (!dateOnly) return null;
|
|
72
|
+
return checkedParts(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]), 0, 0);
|
|
73
|
+
}
|
|
74
|
+
const d = new Date(text);
|
|
75
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
76
|
+
return {
|
|
77
|
+
year: d.getFullYear(),
|
|
78
|
+
month: d.getMonth(),
|
|
79
|
+
day: d.getDate(),
|
|
80
|
+
hours: d.getHours(),
|
|
81
|
+
minutes: d.getMinutes(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function checkedParts(year, month, day, hours, minutes) {
|
|
86
|
+
const d = new Date(year, month, day, hours, minutes);
|
|
87
|
+
if (d.getFullYear() !== year || d.getMonth() !== month || d.getDate() !== day) return null;
|
|
88
|
+
return { year, month, day, hours, minutes };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function partsToDate(parts) {
|
|
92
|
+
return new Date(parts.year, parts.month, parts.day, parts.hours || 0, parts.minutes || 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The ISO string the picker emits for local parts, in its mode's wire shape. */
|
|
96
|
+
export function partsToIso(parts, mode) {
|
|
97
|
+
const effectiveMode = normaliseMode(mode);
|
|
98
|
+
if (!parts) return "";
|
|
99
|
+
if (effectiveMode === "time") {
|
|
100
|
+
return formatToIso(new Date(2000, 0, 1, parts.hours, parts.minutes), "time");
|
|
101
|
+
}
|
|
102
|
+
return formatToIso(partsToDate(parts), effectiveMode) || "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function nowParts(now = new Date()) {
|
|
106
|
+
return {
|
|
107
|
+
year: now.getFullYear(),
|
|
108
|
+
month: now.getMonth(),
|
|
109
|
+
day: now.getDate(),
|
|
110
|
+
hours: now.getHours(),
|
|
111
|
+
minutes: now.getMinutes(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Hermes ships a partial Intl; any refusal falls back to the ISO shape.
|
|
116
|
+
function intlFormat(locale, options, date) {
|
|
117
|
+
try {
|
|
118
|
+
return new Intl.DateTimeFormat(locale || undefined, options).format(date);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const DATE_LABEL = { year: "numeric", month: "short", day: "numeric" };
|
|
125
|
+
const TIME_LABEL = { hour: "2-digit", minute: "2-digit" };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The text on the picker's field: the value in the viewer's language
|
|
129
|
+
* (`28 maj 2026`, `May 28, 2026, 2:30 PM`), or a mode-specific placeholder
|
|
130
|
+
* when empty. An unreadable value is shown as stored rather than hidden.
|
|
131
|
+
*/
|
|
132
|
+
export function formatDisplayLabel(value, mode, locale) {
|
|
133
|
+
const effectiveMode = normaliseMode(mode);
|
|
63
134
|
if (value == null || value === "") {
|
|
64
135
|
if (effectiveMode === "time") return "Select time";
|
|
65
136
|
if (effectiveMode === "datetime") return "Select date & time";
|
|
66
137
|
return "Select date";
|
|
67
138
|
}
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// surface. Anything unparseable is treated as light so a light theme is never
|
|
78
|
-
// darkened.
|
|
79
|
-
export function isDarkColor(hex) {
|
|
80
|
-
if (typeof hex !== "string") return false;
|
|
81
|
-
let h = hex.trim().replace(/^#/, "");
|
|
82
|
-
if (h.length === 3) {
|
|
83
|
-
h = h.split("").map((c) => c + c).join("");
|
|
139
|
+
const parts = valueToParts(value, effectiveMode);
|
|
140
|
+
if (!parts) return String(value);
|
|
141
|
+
if (effectiveMode === "time") {
|
|
142
|
+
const date = new Date(2000, 0, 1, parts.hours, parts.minutes);
|
|
143
|
+
return intlFormat(locale, TIME_LABEL, date) || formatToIso(date, "time");
|
|
144
|
+
}
|
|
145
|
+
const date = partsToDate(parts);
|
|
146
|
+
if (effectiveMode === "date") {
|
|
147
|
+
return intlFormat(locale, DATE_LABEL, date) || formatToIso(date, "date");
|
|
84
148
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
90
|
-
return luminance < 0.5;
|
|
149
|
+
return (
|
|
150
|
+
intlFormat(locale, { ...DATE_LABEL, ...TIME_LABEL }, date) ||
|
|
151
|
+
`${formatToIso(date, "date")} ${formatToIso(date, "time")}`
|
|
152
|
+
);
|
|
91
153
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — the names the web `<DateTimePicker>` sheet shows,
|
|
2
|
+
// in the viewer's language through Intl, with English when an engine refuses.
|
|
3
|
+
|
|
4
|
+
import { normaliseMode, partsToDate } from "./datetimepicker-format.js";
|
|
5
|
+
import { WEEK_LENGTH } from "./datetimepicker-calendar.js";
|
|
6
|
+
|
|
7
|
+
const ENGLISH_MONTHS = [
|
|
8
|
+
"January", "February", "March", "April", "May", "June",
|
|
9
|
+
"July", "August", "September", "October", "November", "December",
|
|
10
|
+
];
|
|
11
|
+
const ENGLISH_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
12
|
+
// Latin, Greek and Cyrillic: short weekdays vary in length (`Mån`, `Tors`), so
|
|
13
|
+
// they read evenly as two letters. Ranges, as Hermes lacks `\p{Script}`.
|
|
14
|
+
const ALPHABETIC = /^[A-Za-zÀ-ɏͰ-ϿЀ-ӿ]/;
|
|
15
|
+
|
|
16
|
+
function intlFormat(locale, options, date) {
|
|
17
|
+
try {
|
|
18
|
+
return new Intl.DateTimeFormat(locale || undefined, options).format(date);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capitalise(text, locale) {
|
|
25
|
+
if (!text) return text;
|
|
26
|
+
try {
|
|
27
|
+
return text.charAt(0).toLocaleUpperCase(locale || undefined) + text.slice(1);
|
|
28
|
+
} catch {
|
|
29
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `October 2026` / `Oktober 2026` — the heading over a month's days. */
|
|
34
|
+
export function monthTitle(year, month, locale) {
|
|
35
|
+
const text = intlFormat(locale, { month: "long", year: "numeric" }, new Date(year, month, 1));
|
|
36
|
+
return capitalise(text, locale) || `${ENGLISH_MONTHS[month]} ${year}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The twelve month names, short (`Jan`) or long (`January`). */
|
|
40
|
+
export function monthNames(locale, width = "short") {
|
|
41
|
+
return ENGLISH_MONTHS.map((english, month) => {
|
|
42
|
+
const text = intlFormat(locale, { month: width }, new Date(2000, month, 1));
|
|
43
|
+
if (!text) return width === "short" ? english.slice(0, 3) : english;
|
|
44
|
+
return capitalise(text.replace(/\.$/, ""), locale);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Column headings in week order, short (`Mo`) with the long name to speak. */
|
|
49
|
+
export function weekdayHeadings(locale, weekStart) {
|
|
50
|
+
const headings = [];
|
|
51
|
+
for (let index = 0; index < WEEK_LENGTH; index += 1) {
|
|
52
|
+
const weekday = (weekStart + index) % WEEK_LENGTH;
|
|
53
|
+
// 2023-01-01 was a Sunday.
|
|
54
|
+
const date = new Date(2023, 0, 1 + weekday);
|
|
55
|
+
const short = capitalise(intlFormat(locale, { weekday: "short" }, date), locale) || ENGLISH_WEEKDAYS[weekday];
|
|
56
|
+
headings.push({
|
|
57
|
+
short: ALPHABETIC.test(short) ? short.slice(0, 2) : short,
|
|
58
|
+
long: intlFormat(locale, { weekday: "long" }, date) || ENGLISH_WEEKDAYS[weekday],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return headings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A day's full spoken name: `Thursday, October 1, 2026`. */
|
|
65
|
+
export function dayLabel(day, locale) {
|
|
66
|
+
return (
|
|
67
|
+
intlFormat(
|
|
68
|
+
locale,
|
|
69
|
+
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
|
|
70
|
+
partsToDate({ ...day, hours: 0, minutes: 0 }),
|
|
71
|
+
) || `${day.year}-${twoDigits(day.month + 1)}-${twoDigits(day.day)}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `Today` / `I dag`, or `Now` / `Nu` for a time, in the viewer's language. */
|
|
76
|
+
export function relativeNowLabel(locale, mode) {
|
|
77
|
+
const unit = normaliseMode(mode) === "date" ? "day" : "second";
|
|
78
|
+
try {
|
|
79
|
+
const text = new Intl.RelativeTimeFormat(locale || undefined, { numeric: "auto" }).format(0, unit);
|
|
80
|
+
return capitalise(text, locale);
|
|
81
|
+
} catch {
|
|
82
|
+
return unit === "day" ? "Today" : "Now";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A date field's name in the viewer's language: `Hour` / `Timme`. */
|
|
87
|
+
export function fieldName(locale, field) {
|
|
88
|
+
try {
|
|
89
|
+
const name = new Intl.DisplayNames(locale || undefined, { type: "dateTimeField" }).of(field);
|
|
90
|
+
if (name) return capitalise(name, locale);
|
|
91
|
+
} catch {
|
|
92
|
+
// Older engines lack `dateTimeField`; the English name stands in.
|
|
93
|
+
}
|
|
94
|
+
return field === "hour" ? "Hour" : "Minute";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function twoDigits(value) {
|
|
98
|
+
return String(value).padStart(2, "0");
|
|
99
|
+
}
|