@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,221 @@
1
+ // REQ-WSDK-PLATFORM §6 — the ONE `<DateTimePicker>` field both hosts render.
2
+ // The field (icon, value in the viewer's language, clear button) is shared;
3
+ // what opens when it is pressed is the host's `Surface`: a themed calendar
4
+ // sheet on the web, the operating system's own picker on a device.
5
+
6
+ import React from "react";
7
+ import { useHostLocale, useHostTheme } from "./hooks.js";
8
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
9
+ import { Icon } from "./icon.js";
10
+ import { FIELD_OUTLINE_OFF, fieldStateStyle, resolveFormControlTokens } from "./form-control-tokens.js";
11
+ import {
12
+ formatDisplayLabel,
13
+ normaliseMode,
14
+ partsToIso,
15
+ valueToParts,
16
+ } from "./datetimepicker-format.js";
17
+ import { resolveBounds, resolvePickerLocale } from "./datetimepicker-calendar.js";
18
+
19
+ const h = React.createElement;
20
+ const CLEAR_ICON_SIZE = 18;
21
+
22
+ function isSet(value) {
23
+ return value != null && value !== "";
24
+ }
25
+
26
+ /**
27
+ * Bind the shared field to one platform's primitives and picking surface.
28
+ *
29
+ * @param {{View: any, Text: any, Pressable: any, Platform: any, Surface: any}} rn
30
+ */
31
+ export function makeDateTimePicker(rn) {
32
+ const { View, Text, Pressable, Platform, Surface } = rn;
33
+
34
+ function focusNode(node) {
35
+ if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
36
+ }
37
+
38
+ function ClearButton({ t, label, onPress, testID }) {
39
+ return h(
40
+ Pressable,
41
+ {
42
+ onPress,
43
+ accessibilityRole: "button",
44
+ accessibilityLabel: label,
45
+ hitSlop: 8,
46
+ testID: testID ? `${testID}-clear` : undefined,
47
+ style: ({ pressed }) => [
48
+ {
49
+ alignItems: "center",
50
+ justifyContent: "center",
51
+ alignSelf: "stretch",
52
+ paddingHorizontal: t.paddingHorizontal,
53
+ },
54
+ FIELD_OUTLINE_OFF,
55
+ pressed ? { opacity: 0.6 } : null,
56
+ ],
57
+ },
58
+ h(Icon, { name: "x", size: CLEAR_ICON_SIZE, color: t.placeholderColor }),
59
+ );
60
+ }
61
+
62
+ function Trigger({ t, triggerRef, open, canEdit, hasValue, shown, mode, spoken, onOpen, onFocusChange, testID }) {
63
+ const ink = hasValue ? t.textColor : t.placeholderColor;
64
+ return h(
65
+ Pressable,
66
+ {
67
+ ref: triggerRef,
68
+ onPress: () => {
69
+ if (canEdit) onOpen();
70
+ },
71
+ onKeyDown: (event) => {
72
+ if (canEdit && event.key === "ArrowDown") {
73
+ event.preventDefault();
74
+ onOpen();
75
+ }
76
+ },
77
+ onFocus: () => onFocusChange(true),
78
+ onBlur: () => onFocusChange(false),
79
+ disabled: !canEdit,
80
+ accessibilityRole: "button",
81
+ "aria-haspopup": "dialog",
82
+ "aria-expanded": open,
83
+ "aria-disabled": !canEdit,
84
+ accessibilityLabel: spoken,
85
+ testID,
86
+ style: [
87
+ {
88
+ flex: 1,
89
+ flexDirection: "row",
90
+ alignItems: "center",
91
+ gap: t.rowGap,
92
+ minHeight: t.height,
93
+ paddingLeft: t.paddingHorizontal,
94
+ paddingRight: hasValue && canEdit ? 0 : t.paddingHorizontal,
95
+ paddingVertical: t.paddingVertical,
96
+ },
97
+ FIELD_OUTLINE_OFF,
98
+ ],
99
+ },
100
+ h(Icon, { name: mode === "time" ? "clock" : "calendar", size: CLEAR_ICON_SIZE, color: ink }),
101
+ h(
102
+ Text,
103
+ { numberOfLines: 1, style: { flexShrink: 1, color: ink, fontFamily: t.fontFamily, fontSize: t.fontSize } },
104
+ shown,
105
+ ),
106
+ );
107
+ }
108
+
109
+ function DateTimePicker({
110
+ value,
111
+ onChange,
112
+ mode,
113
+ minimumDate,
114
+ maximumDate,
115
+ disabled = false,
116
+ accessibilityLabel,
117
+ placeholder,
118
+ clearLabel = "Clear",
119
+ doneLabel = "Done",
120
+ closeAccessibilityLabel,
121
+ testID,
122
+ }) {
123
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
124
+ const t = resolveFormControlTokens(theme);
125
+ const appLocale = useHostLocale();
126
+ const locale = React.useMemo(() => resolvePickerLocale(appLocale), [appLocale]);
127
+ const effectiveMode = normaliseMode(mode);
128
+ const [open, setOpen] = React.useState(false);
129
+ const [focused, setFocused] = React.useState(false);
130
+ const triggerRef = React.useRef(null);
131
+ const parts = React.useMemo(() => valueToParts(value, effectiveMode), [value, effectiveMode]);
132
+ const bounds = React.useMemo(
133
+ () => resolveBounds(minimumDate, maximumDate, effectiveMode),
134
+ [minimumDate, maximumDate, effectiveMode],
135
+ );
136
+ const hasValue = isSet(value);
137
+ const shown =
138
+ !hasValue && isSet(placeholder) ? placeholder : formatDisplayLabel(value, effectiveMode, locale);
139
+ const canEdit = !disabled && typeof onChange === "function";
140
+
141
+ const emit = (next) => {
142
+ if (typeof onChange === "function") onChange(partsToIso(next, effectiveMode));
143
+ };
144
+ // Back to the field once the sheet is gone, or its focus trap pulls it in.
145
+ const wasOpen = React.useRef(false);
146
+ React.useEffect(() => {
147
+ if (wasOpen.current && !open) focusNode(triggerRef.current);
148
+ wasOpen.current = open;
149
+ }, [open]);
150
+
151
+ const trigger = h(Trigger, {
152
+ t,
153
+ triggerRef,
154
+ open,
155
+ canEdit,
156
+ hasValue,
157
+ shown,
158
+ mode: effectiveMode,
159
+ spoken: [accessibilityLabel, shown].filter(Boolean).join(", "),
160
+ onOpen: () => setOpen(true),
161
+ onFocusChange: setFocused,
162
+ testID,
163
+ });
164
+
165
+ const field = h(
166
+ View,
167
+ {
168
+ style: [
169
+ {
170
+ flexDirection: "row",
171
+ alignItems: "center",
172
+ borderWidth: 1,
173
+ borderColor: t.borderColor,
174
+ borderRadius: t.radius,
175
+ backgroundColor: t.background,
176
+ },
177
+ fieldStateStyle(t, { focused, invalid: false }),
178
+ disabled ? { opacity: t.disabledOpacity } : null,
179
+ ],
180
+ },
181
+ trigger,
182
+ hasValue && canEdit
183
+ ? h(ClearButton, {
184
+ t,
185
+ label: clearLabel,
186
+ testID,
187
+ onPress: () => {
188
+ emit(null);
189
+ // The button unmounts once cleared; focus stays on the field.
190
+ focusNode(triggerRef.current);
191
+ },
192
+ })
193
+ : null,
194
+ );
195
+
196
+ // A sibling of the field: iOS draws its picker inline, below it.
197
+ return h(
198
+ React.Fragment,
199
+ null,
200
+ field,
201
+ h(Surface, {
202
+ open,
203
+ onClose: () => setOpen(false),
204
+ mode: effectiveMode,
205
+ parts,
206
+ bounds,
207
+ locale,
208
+ onPick: emit,
209
+ title: accessibilityLabel || shown,
210
+ heading: accessibilityLabel,
211
+ doneLabel,
212
+ closeAccessibilityLabel,
213
+ testID,
214
+ t,
215
+ }),
216
+ );
217
+ }
218
+
219
+ DateTimePicker.displayName = "DateTimePicker";
220
+ return DateTimePicker;
221
+ }
@@ -1,166 +1,15 @@
1
- // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` SDK primitive (web implementation).
2
- //
3
- // `@react-native-community/datetimepicker` ships iOS / Android only — it has
4
- // no react-native-web mapping, so importing it in the browser yields no
5
- // rendered control. The web build therefore uses the browser's native
6
- // `<input type="date" | "time" | "datetime-local">` directly and keeps the
7
- // public contract identical to the native build: the value prop and the
8
- // onChange callback both speak ISO 8601 strings (the datastore wire format),
9
- // so a widget's JSX is byte-for-byte the same on both platforms.
10
- //
11
- // Props (mirrors datetimepicker.native.js):
12
- // value: string | null — ISO 8601 (`2026-05-28` for date mode,
13
- // `14:30` for time mode,
14
- // `2026-05-28T14:30:00.000Z` for datetime mode).
15
- // `null` / "" leaves the input blank.
16
- // onChange: (iso: string) => void
17
- // mode: "date" | "time" | "datetime" — default "date"
18
- // minimumDate / maximumDate: string | null — ISO bounds
19
- // disabled: boolean
20
- // accessibilityLabel: string | undefined — written to the DOM input as
21
- // `aria-label`. The shared form-field renderer
22
- // in `frontend/src/components/widgets/_shared/
23
- // formFields.jsx` passes the column's label
24
- // through this prop so Playwright / screen
25
- // readers can locate the input by its label.
1
+ // `<DateTimePicker>` (web). Binds ./datetimepicker-view.js to react-native-web
2
+ // and the themed calendar sheet; see ./primitives.js for why not the bare
3
+ // specifier.
26
4
 
27
- import React from "react";
28
- import { useHostTheme } from "./hooks.js";
29
- import {
30
- isDarkColor,
31
- isoToDateTimeLocalInput,
32
- dateTimeLocalInputToIso,
33
- } from "./datetimepicker-format.js";
34
- import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
5
+ import { View, Text, Pressable, ScrollView, Platform } from "react-native-web";
6
+ import { makeDateTimePicker } from "./datetimepicker-view.js";
7
+ import { makeDateTimeSheet } from "./datetimepicker-sheet.js";
8
+ import { Overlay } from "./overlay.js";
35
9
 
36
- const MODE_TO_INPUT_TYPE = {
37
- date: "date",
38
- time: "time",
39
- datetime: "datetime-local",
40
- };
10
+ const primitives = { View, Text, Pressable, ScrollView, Platform };
41
11
 
42
- // Render the saved ISO value in the format each `<input>` accepts.
43
- //
44
- // `date` and `time` are already local / timezone-free in the SDK contract,
45
- // so they pass through verbatim. `datetime-local` needs `YYYY-MM-DDTHH:mm`
46
- // in LOCAL time — we round-trip through Date to convert a stored UTC ISO
47
- // (e.g. `2026-05-28T13:30:00.000Z`) to the user's local clock.
48
- function _isoToInputValue(value, mode) {
49
- if (value == null || value === "") return "";
50
- if (mode === "date") {
51
- // Strip any time portion if a datetime ISO accidentally lands in a
52
- // date-mode picker — keep the YYYY-MM-DD head.
53
- return String(value).slice(0, 10);
54
- }
55
- if (mode === "time") {
56
- return String(value).slice(0, 5);
57
- }
58
- return isoToDateTimeLocalInput(value);
59
- }
60
-
61
- // Translate the input's typed string back to the ISO format the wire wants.
62
- // `date` and `time` are already in the right shape; `datetime-local` is
63
- // parsed as local time and serialized as the full UTC ISO so the value
64
- // round-trips into a DATE column unchanged.
65
- function _inputValueToIso(raw, mode) {
66
- if (raw == null || raw === "") return "";
67
- if (mode === "date" || mode === "time") return raw;
68
- return dateTimeLocalInputToIso(raw);
69
- }
70
-
71
- export function DateTimePicker({
72
- value,
73
- onChange,
74
- mode,
75
- minimumDate,
76
- maximumDate,
77
- disabled,
78
- accessibilityLabel,
79
- }) {
80
- const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
81
- const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
82
- const inputType = MODE_TO_INPUT_TYPE[effectiveMode];
83
- const inputValue = _isoToInputValue(value, effectiveMode);
84
-
85
- const handleChange = (event) => {
86
- if (typeof onChange !== "function") return;
87
- const iso = _inputValueToIso(event.target.value, effectiveMode);
88
- onChange(iso);
89
- };
90
-
91
- // The min/max props accept ISO strings too — pass them through in the
92
- // input-friendly representation. Undefined when not provided so the
93
- // browser doesn't show an empty constraint chip.
94
- const min =
95
- minimumDate != null && minimumDate !== ""
96
- ? _isoToInputValue(minimumDate, effectiveMode)
97
- : undefined;
98
- const max =
99
- maximumDate != null && maximumDate !== ""
100
- ? _isoToInputValue(maximumDate, effectiveMode)
101
- : undefined;
102
-
103
- // sc-1878 — clicking the formatted date text (or focusing it and pressing
104
- // Enter / Space) opens the native calendar, not just the small built-in
105
- // calendar icon. `showPicker()` requires a user gesture; click/keydown both
106
- // qualify. Guarded: not every browser/build exposes it, and calling it while
107
- // the picker is already open throws — neither should break the field.
108
- const openPicker = (target) => {
109
- if (disabled || !target || typeof target.showPicker !== "function") return;
110
- try {
111
- target.showPicker();
112
- } catch {
113
- // Already open, or blocked outside a user gesture — leave the input as-is.
114
- }
115
- };
116
-
117
- const handleClick = (event) => openPicker(event.currentTarget);
118
-
119
- const handleKeyDown = (event) => {
120
- if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
121
- event.preventDefault();
122
- openPicker(event.currentTarget);
123
- }
124
- };
125
-
126
- // Inline styles match the SDK's other web-only primitives — the host's
127
- // form widgets wrap this in their own labelled field, so the input just
128
- // needs to look like a normal text input. `cursor: pointer` signals the
129
- // whole field is the click affordance (sc-1878). Colours come from the
130
- // theme so the field is legible on any surface (sc-3370); `colorScheme`
131
- // steers the browser's built-in date UI (edit segments + calendar icon).
132
- // The scheme keys off the theme's TEXT colour (onSurface, which the host
133
- // already picked to contrast the surface) rather than re-deriving dark-vs-
134
- // light from the surface with a second luminance formula that could disagree
135
- // with the host: light text ⇒ dark surface ⇒ dark scheme.
136
- const style = {
137
- boxSizing: "border-box",
138
- width: "100%",
139
- minHeight: 44,
140
- padding: "8px 12px",
141
- fontSize: 16,
142
- fontFamily: "inherit",
143
- color: theme.colors.onSurface,
144
- backgroundColor: "transparent",
145
- colorScheme: isDarkColor(theme.colors.onSurface) ? "light" : "dark",
146
- borderWidth: 1,
147
- borderStyle: "solid",
148
- borderColor: theme.colors.border,
149
- borderRadius: 6,
150
- outline: "none",
151
- cursor: disabled ? "default" : "pointer",
152
- };
153
-
154
- return React.createElement("input", {
155
- type: inputType,
156
- value: inputValue,
157
- onChange: handleChange,
158
- onClick: handleClick,
159
- onKeyDown: handleKeyDown,
160
- min,
161
- max,
162
- disabled: !!disabled,
163
- style,
164
- "aria-label": accessibilityLabel || undefined,
165
- });
166
- }
12
+ export const DateTimePicker = makeDateTimePicker({
13
+ ...primitives,
14
+ Surface: makeDateTimeSheet({ ...primitives, Overlay }),
15
+ });
@@ -1,131 +1,43 @@
1
- // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` SDK primitive (native).
2
- //
3
- // Wraps `@react-native-community/datetimepicker`. That library is imperative
4
- // on Android — rendering it shows the dialog immediately and re-mounting is
5
- // the only way to reopen it — so the primitive owns a small open/closed state
6
- // and renders a tappable, formatted-date trigger (sc-1878). Tapping the
7
- // formatted date (the trigger is an accessible button, reachable by keyboard /
8
- // screen-reader) opens the picker; choosing a value closes it and emits the
9
- // ISO string. This mirrors the web build, where clicking the `<input>` text
10
- // calls `showPicker()` — both hosts: tap the date, the picker opens.
11
- //
12
- // Props (mirrors datetimepicker.js):
13
- // value: string | null — ISO 8601 (`2026-05-28` for date mode,
14
- // `14:30` for time mode,
15
- // `2026-05-28T14:30:00.000Z` for datetime mode).
16
- // `null` / "" shows the placeholder, opens at "now".
17
- // onChange: (iso: string) => void
18
- // mode: "date" | "time" | "datetime" — default "date"
19
- // minimumDate / maximumDate: string | null — ISO bounds
20
- // disabled: boolean
21
- // accessibilityLabel: string | undefined — names the trigger button so the
22
- // shared form-field renderer's column label reaches
23
- // screen readers / test tooling, matching the web
24
- // input's `aria-label`.
1
+ // `<DateTimePicker>` (native). Binds ./datetimepicker-view.js to React Native
2
+ // with the operating system's own picker as its surface, via
3
+ // `@react-native-community/datetimepicker`. That library is imperative on
4
+ // Android — rendering it opens the dialog — so it mounts only while open.
25
5
 
26
- import React, { useMemo, useState } from "react";
6
+ import React from "react";
27
7
  // eslint-disable-next-line no-restricted-syntax
28
8
  import RNDateTimePicker from "@react-native-community/datetimepicker";
29
- import { Pressable, Text, StyleSheet } from "react-native";
30
- import {
31
- parseToDate,
32
- formatToIso,
33
- formatDisplayLabel,
34
- } from "./datetimepicker-format.js";
35
- import { useHostTheme } from "./hooks.js";
36
- import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
37
-
38
- const styles = StyleSheet.create({
39
- trigger: {
40
- minHeight: 44,
41
- paddingVertical: 8,
42
- paddingHorizontal: 12,
43
- borderWidth: 1,
44
- borderRadius: 6,
45
- justifyContent: "center",
46
- },
47
- triggerDisabled: { opacity: 0.5 },
48
- label: { fontSize: 16 },
49
- placeholder: { fontSize: 16, opacity: 0.5 },
50
- });
51
-
52
- export function DateTimePicker({
53
- value,
54
- onChange,
55
- mode,
56
- minimumDate,
57
- maximumDate,
58
- disabled,
59
- accessibilityLabel,
60
- }) {
61
- const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
62
- const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
63
- const [open, setOpen] = useState(false);
64
- const dateValue = useMemo(() => parseToDate(value), [value]);
65
- const min = useMemo(
66
- () => (minimumDate ? parseToDate(minimumDate) : undefined),
67
- [minimumDate],
68
- );
69
- const max = useMemo(
70
- () => (maximumDate ? parseToDate(maximumDate) : undefined),
71
- [maximumDate],
72
- );
73
-
74
- const isEmpty = value == null || value === "";
75
-
76
- const handleChange = (event, picked) => {
77
- // Android fires "dismissed" when the user cancels; close without emitting.
78
- setOpen(false);
79
- if (event?.type === "dismissed") return;
80
- if (typeof onChange !== "function") return;
81
- if (!(picked instanceof Date)) return;
82
- const iso = formatToIso(picked, effectiveMode);
83
- if (iso != null) onChange(iso);
84
- };
9
+ import { View, Text, Pressable, Platform } from "react-native";
10
+ import { makeDateTimePicker } from "./datetimepicker-view.js";
11
+ import { nowParts, partsToDate, valueToParts } from "./datetimepicker-format.js";
12
+ import { clampToBounds } from "./datetimepicker-calendar.js";
13
+
14
+ function boundDate(parts, mode) {
15
+ if (!parts || mode === "time") return undefined;
16
+ return partsToDate(parts);
17
+ }
85
18
 
86
- const trigger = React.createElement(
87
- Pressable,
88
- {
89
- onPress: () => { if (!disabled) setOpen(true); },
90
- disabled: !!disabled,
91
- accessibilityRole: "button",
92
- accessibilityLabel,
93
- accessibilityState: { disabled: !!disabled },
94
- // Themed border + text so the trigger is legible on any surface (sc-3370).
95
- style: [
96
- styles.trigger,
97
- { borderColor: theme.colors.border },
98
- disabled && styles.triggerDisabled,
99
- ],
19
+ function OsPicker({ open, onClose, mode, parts, bounds, onPick }) {
20
+ if (!open) return null;
21
+ const start = parts ? { ...nowParts(), ...dropEmpty(parts) } : clampToBounds(nowParts(), bounds);
22
+ return React.createElement(RNDateTimePicker, {
23
+ value: partsToDate(start),
24
+ // iOS honours "datetime"; Android shows its date dialog for it.
25
+ mode,
26
+ minimumDate: boundDate(bounds.min, mode),
27
+ maximumDate: boundDate(bounds.max, mode),
28
+ onChange: (event, picked) => {
29
+ onClose();
30
+ // Android reports a cancelled dialog as "dismissed".
31
+ if (event && event.type === "dismissed") return;
32
+ if (!(picked instanceof Date)) return;
33
+ // Android's time dialog cannot be bounded, so a pick is clamped here.
34
+ onPick(clampToBounds(valueToParts(picked.toISOString(), "datetime"), bounds));
100
35
  },
101
- React.createElement(
102
- Text,
103
- {
104
- style: [
105
- isEmpty ? styles.placeholder : styles.label,
106
- { color: theme.colors.onSurface },
107
- ],
108
- },
109
- formatDisplayLabel(value, effectiveMode),
110
- ),
111
- );
112
-
113
- if (!open) return trigger;
36
+ });
37
+ }
114
38
 
115
- // The RN library's `mode` is "date" / "time"; iOS also honours "datetime",
116
- // Android falls back to date-only for it (a pre-existing limit, unchanged
117
- // here). The web build uses datetimepicker.js, so this file is never web.
118
- return React.createElement(
119
- React.Fragment,
120
- null,
121
- trigger,
122
- React.createElement(RNDateTimePicker, {
123
- value: dateValue,
124
- mode: effectiveMode,
125
- minimumDate: min,
126
- maximumDate: max,
127
- disabled: !!disabled,
128
- onChange: handleChange,
129
- }),
130
- );
39
+ function dropEmpty(parts) {
40
+ return Object.fromEntries(Object.entries(parts).filter(([, v]) => v != null));
131
41
  }
42
+
43
+ export const DateTimePicker = makeDateTimePicker({ View, Text, Pressable, Platform, Surface: OsPicker });
package/dist/hooks.js CHANGED
@@ -238,6 +238,14 @@ export function useHostNavigation() {
238
238
  return ctx ? ctx.navigation : undefined;
239
239
  }
240
240
 
241
+ // @internal — the app's language, read WITHOUT asserting a widget context so
242
+ // `<DateTimePicker>` can name months in it wherever it renders.
243
+ export function useHostLocale() {
244
+ const ctx = useContext(HostWidgetContext);
245
+ const locale = ctx && ctx.i18n ? ctx.i18n.locale : undefined;
246
+ return typeof locale === "string" && locale ? locale : undefined;
247
+ }
248
+
241
249
  // A host translation, or null on a miss: both hosts answer a miss with the
242
250
  // key itself or a `{{t:…}}` placeholder.
243
251
  function hostTranslation(hostT, key) {
package/dist/host.d.ts CHANGED
@@ -275,9 +275,11 @@ export interface FooterTokens {
275
275
  shape: "floating" | "docked";
276
276
  /** A floating bar's corner radius, px (0–40). */
277
277
  radius: number;
278
- /** A tab's corner radius, px: a floating bar's curve one row inset in
278
+ /** A tab's corner radius, px: a floating bar's curve one padding in
279
279
  * (min 6) for a filled tab; 6 for an accent tab or a docked bar. */
280
280
  itemRadius: number;
281
+ /** sc-11703: the space around the tabs, px (0–12, default 4). */
282
+ padding: number;
281
283
  /** A floating bar's distance from the screen's sides, px (0–48). */
282
284
  sideMargin: number;
283
285
  /** Space below the tabs, px (0–32), on top of the system's clearance. */
@@ -286,14 +288,14 @@ export interface FooterTokens {
286
288
 
287
289
  /** sc-11678: each bar measurement's upper bound and default, px (min 0). */
288
290
  export const FOOTER_SIZES: Readonly<
289
- Record<"radius" | "sideMargin" | "bottomSpacing", Readonly<{ max: number; fallback: number }>>
291
+ Record<"radius" | "sideMargin" | "bottomSpacing" | "padding", Readonly<{ max: number; fallback: number }>>
290
292
  >;
291
293
 
292
294
  /** sc-11678: one bar measurement clamped onto its `FOOTER_SIZES` range, or
293
295
  * `undefined` when the value is not a finite number. */
294
296
  export function clampFooterSize(
295
297
  value: unknown,
296
- key: "radius" | "sideMargin" | "bottomSpacing",
298
+ key: "radius" | "sideMargin" | "bottomSpacing" | "padding",
297
299
  ): number | undefined;
298
300
 
299
301
  /** sc-11678: the part of the iOS bottom inset a bar keeps clear (the home
@@ -624,10 +626,12 @@ export function isoToDateTimeLocalInput(value: unknown): string;
624
626
  export function dateTimeLocalInputToIso(raw: unknown): string;
625
627
 
626
628
  /**
627
- * sc-8448 — the `<DateTimePicker>` trigger label: for `"datetime"` the stored
628
- * instant as `YYYY-MM-DD HH:mm` local time, falling back to the raw string.
629
+ * The `<DateTimePicker>` field's label: the value in `locale` (default: the
630
+ * runtime's), a `"datetime"` instant on the viewer's clock (sc-8448, sc-11777).
631
+ * Empty gives the mode's placeholder; an unreadable value is returned as is.
629
632
  */
630
633
  export function formatDateTimeLabel(
631
634
  value: unknown,
632
635
  mode: "date" | "time" | "datetime",
636
+ locale?: string,
633
637
  ): string;
@@ -87,7 +87,7 @@ function menuItemCap(menuType) {
87
87
  // `backgroundColor`, `textColor`, `tabActiveBackgroundColor`,
88
88
  // `tabActiveTextColor`, `borderColor`, `borderWidth`, `activeStyle`,
89
89
  // `tabLayout`, (sc-11678) `shape`, `radius`, `sideMargin`, `bottomSpacing`
90
- // and (sc-11692) `glass`.
90
+ // (sc-11692) `glass` and (sc-11703) `padding`.
91
91
  function resolveFooterTokens(theme) {
92
92
  const config = isPlainObject(theme) ? theme : {};
93
93
  const footer = isPlainObject(config.footer) ? config.footer : {};
@@ -106,6 +106,7 @@ function resolveFooterTokens(theme) {
106
106
  const divider = borderWidth > 0;
107
107
  const floats = footer.shape !== "docked";
108
108
  const radius = footerSizeOr(footer, "radius");
109
+ const padding = footerSizeOr(footer, "padding");
109
110
  const backgroundColor = hexOr(footer.backgroundColor, DEFAULT_FOOTER_SURFACE);
110
111
  // sc-11692: frosted unless switched off; only a floating bar has a page
111
112
  // behind it to frost.
@@ -141,11 +142,12 @@ function resolveFooterTokens(theme) {
141
142
  // sc-11678: unset floats, so every app gets the rounded, inset bar.
142
143
  shape: floats ? "floating" : "docked",
143
144
  radius,
144
- // A filled tab follows a floating bar's curve one row inset (8px) in,
145
- // never sharper than the docked 6px chip. An accent tab paints no surface,
145
+ // A filled tab follows a floating bar's curve one padding in, never
146
+ // sharper than the docked 6px chip. An accent tab paints no surface,
146
147
  // and a curved top edge would bend its marker into an arc.
147
148
  itemRadius:
148
- floats && !accent ? Math.max(DOCKED_TAB_RADIUS, radius - 8) : DOCKED_TAB_RADIUS,
149
+ floats && !accent ? Math.max(DOCKED_TAB_RADIUS, radius - padding) : DOCKED_TAB_RADIUS,
150
+ padding,
149
151
  sideMargin: footerSizeOr(footer, "sideMargin"),
150
152
  bottomSpacing: footerSizeOr(footer, "bottomSpacing"),
151
153
  };
@@ -157,6 +159,8 @@ const FOOTER_SIZES = Object.freeze({
157
159
  radius: Object.freeze({ max: 40, fallback: 28 }),
158
160
  sideMargin: Object.freeze({ max: 48, fallback: 12 }),
159
161
  bottomSpacing: Object.freeze({ max: 32, fallback: 8 }),
162
+ // sc-11703: the space around the tabs; 4 makes a 48pt bar (40pt tabs).
163
+ padding: Object.freeze({ max: 12, fallback: 4 }),
160
164
  });
161
165
  const DOCKED_TAB_RADIUS = 6;
162
166