@lotics/ui 42.4.0 → 43.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +74 -0
- package/docs/catalog.md +38 -6
- package/docs/composition.md +45 -3
- package/docs/data_entry.md +9 -4
- package/package.json +12 -2
- package/src/back_button.tsx +4 -1
- package/src/badge.tsx +10 -2
- package/src/button.tsx +69 -8
- package/src/check_circle.tsx +1 -2
- package/src/checkbox_input.tsx +2 -2
- package/src/choice_list.tsx +2 -2
- package/src/color_tokens.ts +27 -3
- package/src/colors.web.ts +4 -2
- package/src/comments_button.tsx +3 -1
- package/src/control_surface.ts +27 -0
- package/src/data_grid.tsx +1 -1
- package/src/date_calendar.tsx +210 -58
- package/src/date_filter.tsx +1 -5
- package/src/date_picker.tsx +2 -0
- package/src/date_range_selection.ts +18 -0
- package/src/date_segments.ts +15 -1
- package/src/display_font.ts +27 -0
- package/src/display_font.web.ts +31 -0
- package/src/file_dropzone.tsx +2 -1
- package/src/file_row.tsx +2 -2
- package/src/file_thumbnail.tsx +4 -1
- package/src/filter_chip.tsx +12 -2
- package/src/focus_ring_pressable.tsx +6 -1
- package/src/font_family.ts +26 -0
- package/src/font_family.web.ts +29 -0
- package/src/icon_button.tsx +3 -1
- package/src/image_gallery.tsx +1 -1
- package/src/index.css +0 -2
- package/src/inline_button.tsx +3 -1
- package/src/inline_time_picker.tsx +97 -48
- package/src/list_item.tsx +109 -11
- package/src/locale.tsx +2 -2
- package/src/menu_button.tsx +19 -0
- package/src/option_list.tsx +8 -0
- package/src/pressable_highlight.tsx +20 -8
- package/src/pressable_row.tsx +7 -3
- package/src/scroll_to_bottom.tsx +3 -0
- package/src/slider.tsx +2 -2
- package/src/summary.tsx +28 -4
- package/src/switch.tsx +3 -1
- package/src/table.tsx +46 -4
- package/src/text.tsx +32 -11
- package/src/text_utils.ts +3 -11
- package/src/theme.web.tsx +16 -1
- package/src/theme_context.ts +63 -1
- package/src/time_columns.tsx +225 -0
- package/src/time_options.ts +138 -0
- package/src/time_picker.tsx +102 -64
- package/src/use_option_list.ts +19 -0
package/src/slider.tsx
CHANGED
|
@@ -263,7 +263,7 @@ const styles = StyleSheet.create({
|
|
|
263
263
|
trackArea: {
|
|
264
264
|
height: 28,
|
|
265
265
|
justifyContent: "center",
|
|
266
|
-
...({ cursor: "
|
|
266
|
+
...({ cursor: "auto", touchAction: "none" } as ViewStyle),
|
|
267
267
|
},
|
|
268
268
|
trackBg: {
|
|
269
269
|
position: "absolute",
|
|
@@ -289,7 +289,7 @@ const styles = StyleSheet.create({
|
|
|
289
289
|
backgroundColor: colors.white,
|
|
290
290
|
borderWidth: 2,
|
|
291
291
|
...({
|
|
292
|
-
cursor: "
|
|
292
|
+
cursor: "auto",
|
|
293
293
|
boxShadow: "0 1px 3px rgba(38,38,38,0.18)",
|
|
294
294
|
} as ViewStyle),
|
|
295
295
|
},
|
package/src/summary.tsx
CHANGED
|
@@ -28,6 +28,16 @@ export interface SummaryBucket {
|
|
|
28
28
|
color: string;
|
|
29
29
|
/** What this bucket means — the ⓘ beside its count. */
|
|
30
30
|
info?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Valence on the bucket's COUNT, for a bucket that represents work someone has
|
|
33
|
+
* to do. Reserve it: a screen where every bucket is toned has said nothing, and
|
|
34
|
+
* the bucket holding the largest number is usually the one that needs it least
|
|
35
|
+
* (work that has left the building is not a queue).
|
|
36
|
+
*
|
|
37
|
+
* It colours the figure only. The segment keeps the bucket's own `color`, so
|
|
38
|
+
* the bar goes on reading as a distribution rather than as a heat map.
|
|
39
|
+
*/
|
|
40
|
+
tone?: MetricTone;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
interface SummaryContext {
|
|
@@ -99,13 +109,26 @@ function Header(props: { children: ReactNode }) {
|
|
|
99
109
|
/**
|
|
100
110
|
* The set's SIZE, at display scale — the band's headline.
|
|
101
111
|
*
|
|
102
|
-
*
|
|
103
|
-
* restates its own total eventually restates it wrongly
|
|
112
|
+
* By DEFAULT it is the sum of the buckets, and that default is the point: a page
|
|
113
|
+
* that restates its own total eventually restates it wrongly — a bucket is added,
|
|
104
114
|
* the constant does not move, and the headline quietly disagrees with the bar
|
|
105
115
|
* directly beneath it.
|
|
116
|
+
*
|
|
117
|
+
* `value` overrides it, for the one case that is not a restatement: a total with
|
|
118
|
+
* its OWN authority, counted by the same source that counted the buckets. A
|
|
119
|
+
* server-paginated register is the example — the buckets are per-stage `COUNT`s
|
|
120
|
+
* over the filtered view and the size is a fourth count over that same view, so
|
|
121
|
+
* summing the three client-side would replace an authoritative number with a
|
|
122
|
+
* derived one that can differ (a bucket query filtered a shade differently, or
|
|
123
|
+
* landed a moment later) and would be believed because it is the biggest figure
|
|
124
|
+
* on the screen.
|
|
125
|
+
*
|
|
126
|
+
* The line, then: pass `value` when something ELSE counted it, never to write a
|
|
127
|
+
* literal. A number typed into this prop is the defect the default prevents.
|
|
106
128
|
*/
|
|
107
|
-
function Total(props: { label: string; formatValue?: (n: number) => string }) {
|
|
108
|
-
const { total } = useSummary("Total");
|
|
129
|
+
function Total(props: { label: string; value?: number; formatValue?: (n: number) => string }) {
|
|
130
|
+
const { total: summed } = useSummary("Total");
|
|
131
|
+
const total = props.value ?? summed;
|
|
109
132
|
const tag = useLocaleTag();
|
|
110
133
|
// Grouped by the reader's locale by default — a set of 12,345 was rendering
|
|
111
134
|
// "12345" in the one place on the screen sized to be read first, because the
|
|
@@ -187,6 +210,7 @@ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
|
|
|
187
210
|
value: b.value,
|
|
188
211
|
color: b.color,
|
|
189
212
|
info: b.info,
|
|
213
|
+
tone: b.tone,
|
|
190
214
|
}));
|
|
191
215
|
return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
|
|
192
216
|
}
|
package/src/switch.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { Animated, StyleSheet, Pressable, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
|
-
import { FOCUS_RING } from "./control_surface";
|
|
5
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
6
6
|
import { useFocusRing } from "./use_focus_ring";
|
|
7
7
|
export interface SwitchProps {
|
|
8
8
|
testID?: string;
|
|
@@ -89,6 +89,8 @@ export function Switch(props: SwitchProps) {
|
|
|
89
89
|
|
|
90
90
|
const styles = StyleSheet.create({
|
|
91
91
|
root: {
|
|
92
|
+
// A switch SETS A VALUE — the arrow, see `CURSOR_DEFAULT`.
|
|
93
|
+
cursor: CURSOR_DEFAULT,
|
|
92
94
|
borderRadius: 999,
|
|
93
95
|
width: 48,
|
|
94
96
|
height: 32,
|
package/src/table.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import { Divider } from "./divider";
|
|
|
17
17
|
import { DetailRow } from "./detail_row";
|
|
18
18
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
19
19
|
import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
20
|
+
import { ROW_WASH_BLEED } from "./control_surface";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* One column of a register — its width/flex/align/label/sortability defined ONCE,
|
|
@@ -120,8 +121,36 @@ export interface TableProps {
|
|
|
120
121
|
* Positions are yours for the same reason — see `TableRowProps.ordinal`. This
|
|
121
122
|
* prop only says "this register is counted", which is what reserves the gutter
|
|
122
123
|
* on the header and on every row at once, so they cannot disagree.
|
|
124
|
+
*
|
|
125
|
+
* Pass {@link TableProps.counted} INSTEAD when the page already states the
|
|
126
|
+
* total somewhere the reader will see first.
|
|
123
127
|
*/
|
|
124
128
|
count?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Number the rows WITHOUT heading the gutter with a total.
|
|
131
|
+
*
|
|
132
|
+
* The total earns its place in the header by answering "how many" without
|
|
133
|
+
* scrolling to the last row — so it stops earning it the moment the page
|
|
134
|
+
* answers that louder. A register whose band opens with the count in display
|
|
135
|
+
* type was printing the same figure twice within a hundred pixels, at the same
|
|
136
|
+
* left edge, one huge and one muted, which reads as a mistake rather than as
|
|
137
|
+
* two deliberate statements.
|
|
138
|
+
*
|
|
139
|
+
* Ignored when `count` is given; the gutter is reserved either way, so header
|
|
140
|
+
* and rows can never disagree about its width.
|
|
141
|
+
*/
|
|
142
|
+
counted?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Paint the column-header band with the brand's wash.
|
|
145
|
+
*
|
|
146
|
+
* OPT-IN, and deliberately not the default: a filled header is a real restyle
|
|
147
|
+
* of every table in every app, and the kit's language is a white canvas where
|
|
148
|
+
* a hairline does the separating. It earns its place on a register that is the
|
|
149
|
+
* page's whole content, where the band gives the columns a lid and the table
|
|
150
|
+
* stops floating; it is noise on a small table inside a card that already has
|
|
151
|
+
* edges of its own.
|
|
152
|
+
*/
|
|
153
|
+
headerFill?: boolean;
|
|
125
154
|
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
126
155
|
trailing?: number;
|
|
127
156
|
/** The `TableRow`s. */
|
|
@@ -143,8 +172,11 @@ export interface TableProps {
|
|
|
143
172
|
* keeps its current order). See `computeTableFit`.
|
|
144
173
|
*/
|
|
145
174
|
export function Table(props: TableProps) {
|
|
146
|
-
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
|
|
147
|
-
|
|
175
|
+
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, children } = props;
|
|
176
|
+
// Either prop reserves the gutter; only `count` puts a number in its header.
|
|
177
|
+
// `LeadGutter` already renders an empty box when it has no number — the same
|
|
178
|
+
// path a group band takes — so nothing new is needed to draw it.
|
|
179
|
+
const ordinal = count != null || counted ? ORDINAL_W : 0;
|
|
148
180
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
149
181
|
|
|
150
182
|
// Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
|
|
@@ -174,12 +206,12 @@ export function Table(props: TableProps) {
|
|
|
174
206
|
// select-all checkbox keeps its band: it's the bulk-select entry point,
|
|
175
207
|
// aligned over the rows' leading checkboxes.
|
|
176
208
|
(selectAll != null && leading > 0) || ordinal > 0 ? (
|
|
177
|
-
<View style={styles.headerBand}>
|
|
209
|
+
<View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
|
|
178
210
|
<LeadGutter ordinal={count}>{selectAll}</LeadGutter>
|
|
179
211
|
</View>
|
|
180
212
|
) : null
|
|
181
213
|
) : (
|
|
182
|
-
<View style={styles.headerBand}>
|
|
214
|
+
<View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
|
|
183
215
|
{/* The TOTAL heads the column of positions — the one number that
|
|
184
216
|
answers "how many" without the reader scrolling to the last row. */}
|
|
185
217
|
<LeadGutter ordinal={count}>{selectAll}</LeadGutter>
|
|
@@ -458,6 +490,16 @@ const styles = StyleSheet.create({
|
|
|
458
490
|
},
|
|
459
491
|
// A hairline under the column header anchors the columns; the rows below it are
|
|
460
492
|
// Divider-separated.
|
|
493
|
+
headerBandFilled: {
|
|
494
|
+
backgroundColor: colors.accent_wash,
|
|
495
|
+
// The band bleeds to the row wash's width so its edges line up with a hovered
|
|
496
|
+
// row beneath it; without this the lid is narrower than the rows it caps.
|
|
497
|
+
marginHorizontal: -ROW_WASH_BLEED,
|
|
498
|
+
paddingHorizontal: ROW_GUTTER + ROW_WASH_BLEED,
|
|
499
|
+
paddingTop: 8,
|
|
500
|
+
borderTopLeftRadius: 8,
|
|
501
|
+
borderTopRightRadius: 8,
|
|
502
|
+
},
|
|
461
503
|
headerBand: {
|
|
462
504
|
paddingHorizontal: ROW_GUTTER,
|
|
463
505
|
// NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
|
package/src/text.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import "./text.css";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet } from "react-native";
|
|
4
|
+
import { fontFamilyDisplay } from "./display_font";
|
|
4
5
|
import {
|
|
5
6
|
fontFamilyMedium,
|
|
6
7
|
fontFamilyRegular,
|
|
@@ -14,6 +15,16 @@ export interface TextProps {
|
|
|
14
15
|
testID?: string;
|
|
15
16
|
size?: TextSize;
|
|
16
17
|
color?: TextColor;
|
|
18
|
+
/**
|
|
19
|
+
* `display` renders in the app's display face — a headline, a wordmark, a
|
|
20
|
+
* figure the page exists to show. Everything else stays the body face, which
|
|
21
|
+
* is not themeable (see `display_font.ts`).
|
|
22
|
+
*
|
|
23
|
+
* A ROLE, not a font name: the call site says what the text IS, and the theme
|
|
24
|
+
* decides what that looks like. Threading family strings through call sites is
|
|
25
|
+
* how one screen ends up in a different face from the next.
|
|
26
|
+
*/
|
|
27
|
+
family?: "display";
|
|
17
28
|
align?: TextAlign;
|
|
18
29
|
weight?: TextWeight;
|
|
19
30
|
numberOfLines?: number;
|
|
@@ -62,13 +73,14 @@ type TextUserSelect = "none" | "auto" | "text";
|
|
|
62
73
|
*/
|
|
63
74
|
export function Text(props: TextProps) {
|
|
64
75
|
const {
|
|
65
|
-
userSelect
|
|
76
|
+
userSelect,
|
|
66
77
|
align = "left",
|
|
67
78
|
children,
|
|
68
79
|
testID,
|
|
69
80
|
color = "default",
|
|
70
81
|
size = "sm",
|
|
71
82
|
weight = "regular",
|
|
83
|
+
family,
|
|
72
84
|
numberOfLines,
|
|
73
85
|
decoration,
|
|
74
86
|
tabular,
|
|
@@ -107,7 +119,14 @@ export function Text(props: TextProps) {
|
|
|
107
119
|
styles.text,
|
|
108
120
|
Platform.OS !== "web" && styles[size],
|
|
109
121
|
styles[weight],
|
|
110
|
-
|
|
122
|
+
// AFTER the weight, because the weight ladder sets a family too — each
|
|
123
|
+
// rung is a distinct Inter family, not a `font-weight` — so the display
|
|
124
|
+
// face has to win the family while leaving the numeric weight alone.
|
|
125
|
+
family === "display" && { fontFamily: fontFamilyDisplay },
|
|
126
|
+
// Only when ASKED for. Left to inherit, a label inside a button is
|
|
127
|
+
// unselectable and a paragraph is selectable, each from its container —
|
|
128
|
+
// which is what a container setting `userSelect` is trying to say.
|
|
129
|
+
userSelect && selectStyles[userSelect],
|
|
111
130
|
styles[align],
|
|
112
131
|
decoration && styles[decoration],
|
|
113
132
|
tabular && styles.tabular,
|
|
@@ -123,10 +142,20 @@ export function Text(props: TextProps) {
|
|
|
123
142
|
);
|
|
124
143
|
}
|
|
125
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Selectability, applied only when a caller states it. Its own sheet because the
|
|
147
|
+
* value `"text"` shares a name with the BASE text style — mapped through the main
|
|
148
|
+
* sheet, asking for `userSelect="text"` resolved to that base and set nothing.
|
|
149
|
+
*/
|
|
150
|
+
const selectStyles = StyleSheet.create({
|
|
151
|
+
text: { userSelect: "text" },
|
|
152
|
+
auto: { userSelect: "auto" },
|
|
153
|
+
none: { userSelect: "none" },
|
|
154
|
+
});
|
|
155
|
+
|
|
126
156
|
const styles = StyleSheet.create({
|
|
127
157
|
text: {
|
|
128
158
|
letterSpacing: -0.4,
|
|
129
|
-
userSelect: "text",
|
|
130
159
|
},
|
|
131
160
|
|
|
132
161
|
// Text size styles
|
|
@@ -197,14 +226,6 @@ const styles = StyleSheet.create({
|
|
|
197
226
|
textDecorationLine: "underline line-through",
|
|
198
227
|
},
|
|
199
228
|
|
|
200
|
-
// User select styles
|
|
201
|
-
auto: {
|
|
202
|
-
userSelect: "auto",
|
|
203
|
-
},
|
|
204
|
-
none: {
|
|
205
|
-
userSelect: "none",
|
|
206
|
-
},
|
|
207
|
-
|
|
208
229
|
tabular: {
|
|
209
230
|
fontVariant: ["tabular-nums"],
|
|
210
231
|
},
|
package/src/text_utils.ts
CHANGED
|
@@ -47,17 +47,9 @@ export function getTextColor(color?: TextColor): string {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export const fontFamilyMedium = `Inter_500Medium, "apple-system", "BlinkMacSystemFont",
|
|
55
|
-
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
56
|
-
"Droid Sans", "Helvetica Neue", sans-serif`;
|
|
57
|
-
|
|
58
|
-
export const fontFamilySemiBold = `Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
|
|
59
|
-
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
|
60
|
-
"Droid Sans", "Helvetica Neue", sans-serif`;
|
|
50
|
+
// The three rungs live in their own platform-split module — see `font_family.ts`.
|
|
51
|
+
// Re-exported here so every existing consumer keeps one import.
|
|
52
|
+
export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
|
|
61
53
|
|
|
62
54
|
/** Line height for mobile web (prevents Safari iOS auto-zoom) */
|
|
63
55
|
export const INPUT_LINE_HEIGHT_MOBILE = 24;
|
package/src/theme.web.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import { LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
2
|
+
import { ACCENT_WASH_VAR, deriveAccentWash, FONT_MEDIUM_VAR, FONT_SEMIBOLD_VAR, LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
3
3
|
|
|
4
4
|
export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
5
5
|
|
|
@@ -46,6 +46,21 @@ export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
|
|
|
46
46
|
const value = theme[role as keyof LoticsTheme];
|
|
47
47
|
if (value !== undefined) vars[name] = value;
|
|
48
48
|
}
|
|
49
|
+
// The wash is DERIVED here, where the accent is still a literal — see
|
|
50
|
+
// `deriveAccentWash`. An accent the parser cannot read leaves it undefined,
|
|
51
|
+
// so those surfaces keep the neutral default instead of breaking.
|
|
52
|
+
if (theme.accent !== undefined) {
|
|
53
|
+
const wash = deriveAccentWash(theme.accent);
|
|
54
|
+
if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
|
|
55
|
+
}
|
|
56
|
+
// ONE font value, THREE rungs. `THEME_VARS` already mapped `bodyFont` to the
|
|
57
|
+
// regular rung; medium and semibold follow it here, because a family set on
|
|
58
|
+
// only one rung would leave every medium and semibold run in Inter — the
|
|
59
|
+
// screen would come out in two typefaces and look like a loading bug.
|
|
60
|
+
if (theme.bodyFont !== undefined) {
|
|
61
|
+
vars[FONT_MEDIUM_VAR] = theme.bodyFont;
|
|
62
|
+
vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
|
|
63
|
+
}
|
|
49
64
|
// `display: contents` — the provider generates NO box. Custom properties
|
|
50
65
|
// inherit down the DOM tree rather than the box tree, so the variables still
|
|
51
66
|
// reach every descendant while the element itself adds no layout at all.
|
package/src/theme_context.ts
CHANGED
|
@@ -19,8 +19,40 @@ export interface LoticsTheme {
|
|
|
19
19
|
* on almost every app; set it only for a deliberately toned surface. */
|
|
20
20
|
background?: string;
|
|
21
21
|
/** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
|
|
22
|
-
* small change here is felt
|
|
22
|
+
* small change here is felt against a whole screen. */
|
|
23
23
|
border?: string;
|
|
24
|
+
/**
|
|
25
|
+
* THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
|
|
26
|
+
* app follows it.
|
|
27
|
+
*
|
|
28
|
+
* It fans out to the three weight rungs (`font_family.ts`), because weight is
|
|
29
|
+
* a FAMILY in this kit rather than a `font-weight`. Themed, all three resolve
|
|
30
|
+
* to this family and the numeric weight already on every style makes the
|
|
31
|
+
* ladder; unthemed, each keeps its own Inter face.
|
|
32
|
+
*
|
|
33
|
+
* Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
|
|
34
|
+
* through to system sans, which is the one failure here that looks like
|
|
35
|
+
* nothing happening. And check Vietnamese: a face without the diacritics turns
|
|
36
|
+
* "Thẩm định" into tofu.
|
|
37
|
+
*
|
|
38
|
+
* The kit's letter-spacing curve is tuned for Inter's glyphs, so another face
|
|
39
|
+
* inherits tracking chosen for a different drawing. It is a refinement, not a
|
|
40
|
+
* defect — worth an eye, not a blocker.
|
|
41
|
+
*/
|
|
42
|
+
bodyFont?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The typeface for DISPLAY text only — what `<Text family="display">` renders
|
|
45
|
+
* in. A headline, a wordmark, a figure the page exists to show.
|
|
46
|
+
*
|
|
47
|
+
* Defaults to `bodyFont` when that is set, so ONE parameter changes the whole
|
|
48
|
+
* app and a SECOND one is only needed to make display type differ from body —
|
|
49
|
+
* a serif masthead over a sans register, say.
|
|
50
|
+
*/
|
|
51
|
+
displayFont?: string;
|
|
52
|
+
/** The PRIMARY action's fill. Defaults to the kit's near-black, so an app that
|
|
53
|
+
* themes nothing is unchanged; set it to put the brand on the one filled
|
|
54
|
+
* button a surface gets. Distinct from `accent`, which marks identity. */
|
|
55
|
+
primary?: string;
|
|
24
56
|
}
|
|
25
57
|
|
|
26
58
|
/**
|
|
@@ -31,8 +63,38 @@ export const THEME_VARS: Record<keyof LoticsTheme, string> = {
|
|
|
31
63
|
accent: "--lotics-accent",
|
|
32
64
|
background: "--lotics-background",
|
|
33
65
|
border: "--lotics-border",
|
|
66
|
+
primary: "--lotics-primary",
|
|
67
|
+
bodyFont: "--lotics-font-regular",
|
|
68
|
+
displayFont: "--lotics-display-font",
|
|
34
69
|
};
|
|
35
70
|
|
|
71
|
+
/** The extra variable the provider DERIVES — not a role an app sets, because it
|
|
72
|
+
* is the accent seen through alpha and two sources for one colour drift. */
|
|
73
|
+
export const ACCENT_WASH_VAR = "--lotics-accent-wash";
|
|
74
|
+
|
|
75
|
+
/** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
|
|
76
|
+
export const FONT_MEDIUM_VAR = "--lotics-font-medium";
|
|
77
|
+
export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The accent at wash strength.
|
|
81
|
+
*
|
|
82
|
+
* Computed from the literal hex the app passed, because it CANNOT be computed
|
|
83
|
+
* downstream: on web the token is a `var()`, and the kit's alpha helpers parse
|
|
84
|
+
* `rgba()` strings. Returns undefined for a value it cannot parse (a named
|
|
85
|
+
* colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
|
|
86
|
+
* rather than painting rows in garbage.
|
|
87
|
+
*/
|
|
88
|
+
export function deriveAccentWash(accent: string): string | undefined {
|
|
89
|
+
const hex = accent.trim();
|
|
90
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex);
|
|
91
|
+
if (!m) return undefined;
|
|
92
|
+
const n = parseInt(m[1], 16);
|
|
93
|
+
// 0.07 — heavy enough to read as a tint on white, light enough that body text
|
|
94
|
+
// on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
|
|
95
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
|
|
96
|
+
}
|
|
97
|
+
|
|
36
98
|
export const LoticsThemeContext = createContext<LoticsTheme>({});
|
|
37
99
|
|
|
38
100
|
/**
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
+
import { MenuButton } from "./menu_button";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
8
|
+
import {
|
|
9
|
+
composeTime,
|
|
10
|
+
dayPeriodOptions,
|
|
11
|
+
decomposeTime,
|
|
12
|
+
hourOptions,
|
|
13
|
+
minuteOptions,
|
|
14
|
+
} from "./time_options";
|
|
15
|
+
import type { PickerOption } from "./picker";
|
|
16
|
+
|
|
17
|
+
/** Row height, and the unit the seat scroll counts in. */
|
|
18
|
+
const ROW_HEIGHT = 36;
|
|
19
|
+
/** How many rows a column shows before it scrolls. Odd, so the seated row can
|
|
20
|
+
* sit near the middle with context above and below it rather than at an edge. */
|
|
21
|
+
const VISIBLE_ROWS = 7;
|
|
22
|
+
|
|
23
|
+
export interface TimeColumnsProps {
|
|
24
|
+
/** Canonical 24-hour `"HH:mm"`, `""` when empty (the columns then sit on midnight). */
|
|
25
|
+
value: string;
|
|
26
|
+
/** Fires on every pick, with a complete canonical time — one column at a time,
|
|
27
|
+
* so the value is live rather than staged behind a confirm. */
|
|
28
|
+
onValueChange: (value: string) => void;
|
|
29
|
+
/** BCP-47 locale deciding 12- vs 24-hour, and whether there is a period column
|
|
30
|
+
* at all. Defaults to the active `LoticsLocaleProvider` locale. */
|
|
31
|
+
locale?: string;
|
|
32
|
+
/** Accessible names for the three columns. Defaults to the `datePicker` slice. */
|
|
33
|
+
labels?: { hour: string; minute: string; dayPeriod: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The picking face of a time: an hour column, a minute column, and — only where
|
|
38
|
+
* the locale uses one — a day period.
|
|
39
|
+
*
|
|
40
|
+
* Split by UNIT rather than offered as composed times, which is what a native
|
|
41
|
+
* picker does and what makes every time reachable: a list of whole times has to
|
|
42
|
+
* choose a step, and any step makes the times between its rungs pickable only by
|
|
43
|
+
* typing. It also puts AM/PM where it belongs. A period is a choice between two
|
|
44
|
+
* named things and never a thing to spell — leaving it on a keyboard was the
|
|
45
|
+
* clearest sign the composed list was the wrong model.
|
|
46
|
+
*
|
|
47
|
+
* Each column commits on its own, so the value is always a real time and the
|
|
48
|
+
* face can be read back from it. There is no draft to confirm and nothing
|
|
49
|
+
* half-entered to guard against.
|
|
50
|
+
*/
|
|
51
|
+
export function TimeColumns(props: TimeColumnsProps) {
|
|
52
|
+
const { value, onValueChange } = props;
|
|
53
|
+
const localeTag = useLocaleTag(props.locale);
|
|
54
|
+
const loc = useLoticsLocale().datePicker;
|
|
55
|
+
const labels = props.labels ?? loc;
|
|
56
|
+
|
|
57
|
+
const parts = useMemo(() => decomposeTime(value, localeTag), [value, localeTag]);
|
|
58
|
+
const hours = useMemo(() => hourOptions(localeTag), [localeTag]);
|
|
59
|
+
const minutes = useMemo(() => minuteOptions(), []);
|
|
60
|
+
const periods = useMemo(() => dayPeriodOptions(localeTag), [localeTag]);
|
|
61
|
+
|
|
62
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View style={styles.columns}>
|
|
66
|
+
<TimeColumn
|
|
67
|
+
accessibilityLabel={labels.hour}
|
|
68
|
+
options={hours}
|
|
69
|
+
value={pad(parts.displayHour)}
|
|
70
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, displayHour: Number(next) }))}
|
|
71
|
+
/>
|
|
72
|
+
<TimeColumn
|
|
73
|
+
accessibilityLabel={labels.minute}
|
|
74
|
+
options={minutes}
|
|
75
|
+
value={pad(parts.minute)}
|
|
76
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, minute: Number(next) }))}
|
|
77
|
+
/>
|
|
78
|
+
{periods.length > 0 && (
|
|
79
|
+
<TimeColumn
|
|
80
|
+
accessibilityLabel={labels.dayPeriod}
|
|
81
|
+
options={periods}
|
|
82
|
+
value={parts.pm ? "pm" : "am"}
|
|
83
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, pm: next === "pm" }))}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TimeColumnProps {
|
|
91
|
+
options: PickerOption<string>[];
|
|
92
|
+
value: string;
|
|
93
|
+
onValueChange: (value: string) => void;
|
|
94
|
+
accessibilityLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One unit's column: a listbox that scrolls to its selection on open and moves
|
|
99
|
+
* on arrows.
|
|
100
|
+
*
|
|
101
|
+
* Roving tabindex, per the composite-widget rule — the selected row is the one
|
|
102
|
+
* tab stop and the rest are reached with arrows, so Tab crosses the three
|
|
103
|
+
* columns in three stops instead of sixty. Not `OptionList`: that body owns a
|
|
104
|
+
* hidden autofocus input for its typeahead, and three of them side by side would
|
|
105
|
+
* fight over focus the moment the popover opened.
|
|
106
|
+
*/
|
|
107
|
+
function TimeColumn(props: TimeColumnProps) {
|
|
108
|
+
const { options, value, onValueChange, accessibilityLabel } = props;
|
|
109
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
110
|
+
const rowRefs = useRef<(View | null)[]>([]);
|
|
111
|
+
|
|
112
|
+
const selectedIndex = options.findIndex((option) => option.value === value);
|
|
113
|
+
// A value the column does not offer still needs a tab stop, or the column
|
|
114
|
+
// becomes unreachable by keyboard.
|
|
115
|
+
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
116
|
+
|
|
117
|
+
const scrollToIndex = useCallback((index: number) => {
|
|
118
|
+
if (index < 0) return;
|
|
119
|
+
// Centre the row: subtract half the visible window, floored at the top.
|
|
120
|
+
const offset = index * ROW_HEIGHT - ((VISIBLE_ROWS - 1) / 2) * ROW_HEIGHT;
|
|
121
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, offset), animated: false });
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// Seat on OPEN only. The column mounts with the popover, so this runs once per
|
|
125
|
+
// opening; re-running it on every pick would yank the list back under the
|
|
126
|
+
// pointer while someone is scanning it.
|
|
127
|
+
const seatedRef = useRef(false);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (seatedRef.current) return;
|
|
130
|
+
seatedRef.current = true;
|
|
131
|
+
scrollToIndex(tabStopIndex);
|
|
132
|
+
}, [tabStopIndex, scrollToIndex]);
|
|
133
|
+
|
|
134
|
+
const handleKeyDown = useCallback(
|
|
135
|
+
(event: { key: string; preventDefault?: () => void }, index: number) => {
|
|
136
|
+
const last = options.length - 1;
|
|
137
|
+
let next = index;
|
|
138
|
+
switch (event.key) {
|
|
139
|
+
case "ArrowDown":
|
|
140
|
+
next = index === last ? 0 : index + 1;
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowUp":
|
|
143
|
+
next = index === 0 ? last : index - 1;
|
|
144
|
+
break;
|
|
145
|
+
case "Home":
|
|
146
|
+
next = 0;
|
|
147
|
+
break;
|
|
148
|
+
case "End":
|
|
149
|
+
next = last;
|
|
150
|
+
break;
|
|
151
|
+
case "Enter":
|
|
152
|
+
case " ":
|
|
153
|
+
event.preventDefault?.();
|
|
154
|
+
onValueChange(options[index].value);
|
|
155
|
+
return;
|
|
156
|
+
default:
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
event.preventDefault?.();
|
|
160
|
+
// Selection follows focus, the listbox pattern for a single-select column:
|
|
161
|
+
// arrowing IS choosing, so the value under the cursor is always the value.
|
|
162
|
+
onValueChange(options[next].value);
|
|
163
|
+
scrollToIndex(next);
|
|
164
|
+
rowRefs.current[next]?.focus();
|
|
165
|
+
},
|
|
166
|
+
[options, onValueChange, scrollToIndex],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<ScrollView
|
|
171
|
+
ref={scrollRef}
|
|
172
|
+
style={styles.column}
|
|
173
|
+
contentContainerStyle={styles.columnContent}
|
|
174
|
+
showsVerticalScrollIndicator={false}
|
|
175
|
+
accessibilityLabel={accessibilityLabel}
|
|
176
|
+
// `listbox` is valid ARIA but absent from RN's Role enum; rn-web forwards it.
|
|
177
|
+
role={"listbox" as "list"}
|
|
178
|
+
>
|
|
179
|
+
{options.map((option, index) => (
|
|
180
|
+
<MenuButton
|
|
181
|
+
key={option.value}
|
|
182
|
+
ref={(node: View | null) => {
|
|
183
|
+
rowRefs.current[index] = node;
|
|
184
|
+
}}
|
|
185
|
+
role="option"
|
|
186
|
+
selected={index === selectedIndex}
|
|
187
|
+
title={
|
|
188
|
+
<Text size="sm" userSelect="none">
|
|
189
|
+
{option.label}
|
|
190
|
+
</Text>
|
|
191
|
+
}
|
|
192
|
+
accessibilityLabel={option.label}
|
|
193
|
+
onPress={() => onValueChange(option.value)}
|
|
194
|
+
onKeyDown={(event) => handleKeyDown(event, index)}
|
|
195
|
+
// Exactly one stop per column; the rest are reached with arrows.
|
|
196
|
+
// tabIndex, NOT focusable — RN-Web's Pressable ignores `focusable`.
|
|
197
|
+
tabIndex={index === tabStopIndex ? 0 : -1}
|
|
198
|
+
style={styles.row}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</ScrollView>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const styles = StyleSheet.create({
|
|
206
|
+
columns: {
|
|
207
|
+
flexDirection: "row",
|
|
208
|
+
gap: 4,
|
|
209
|
+
},
|
|
210
|
+
column: {
|
|
211
|
+
maxHeight: ROW_HEIGHT * VISIBLE_ROWS,
|
|
212
|
+
borderRadius: CONTROL_RADIUS,
|
|
213
|
+
backgroundColor: colors.white,
|
|
214
|
+
},
|
|
215
|
+
columnContent: {
|
|
216
|
+
// Padding rather than a spacer row, so the first and last values can still
|
|
217
|
+
// reach the middle of the window when the column is scrolled to an end.
|
|
218
|
+
paddingVertical: 2,
|
|
219
|
+
},
|
|
220
|
+
row: {
|
|
221
|
+
height: ROW_HEIGHT,
|
|
222
|
+
minWidth: 56,
|
|
223
|
+
justifyContent: "center",
|
|
224
|
+
},
|
|
225
|
+
});
|