@lotics/ui 17.0.2 → 18.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 +20 -0
- package/docs/catalog.md +45 -17
- package/docs/composition.md +23 -12
- package/docs/data_entry.md +3 -1
- package/docs/templates.md +3 -2
- package/examples/tpl_record.tsx +2 -2
- package/package.json +1 -1
- package/src/combobox.tsx +24 -14
- package/src/date_calendar.tsx +6 -19
- package/src/date_filter.tsx +95 -102
- package/src/date_picker.tsx +5 -0
- package/src/date_range_selection.test.ts +60 -0
- package/src/date_range_selection.ts +32 -0
- package/src/date_segments.test.ts +48 -0
- package/src/date_segments.ts +34 -0
- package/src/inline_time_picker.tsx +11 -12
- package/src/linked_record_box.tsx +77 -21
- package/src/locale.tsx +2 -2
- package/src/menu_button.tsx +32 -5
- package/src/pressable_highlight.tsx +7 -0
- package/src/section_heading.tsx +2 -1
- package/src/section_stack.tsx +14 -8
- package/src/text_input_field.tsx +46 -7
- package/src/time_picker.tsx +97 -39
- package/src/use_section_nav.test.ts +103 -1
- package/src/use_section_nav.ts +47 -20
|
@@ -6,21 +6,21 @@ import { Text } from "./text";
|
|
|
6
6
|
import { Icon, type IconName } from "./icon";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
|
|
10
|
+
interface LinkedRecordFact<V = ReactNode> {
|
|
11
|
+
label: string;
|
|
12
|
+
value: V;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface LinkedRecordBoxBaseProps {
|
|
10
16
|
/** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
|
|
11
17
|
icon: IconName;
|
|
12
18
|
/** The linked record's display name. */
|
|
13
19
|
name: string;
|
|
14
20
|
/** A secondary identity line — a code, tax id, country, or "<desk> record". */
|
|
15
21
|
subtitle?: string;
|
|
16
|
-
/** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). */
|
|
17
|
-
facts?: { label: string; value: string }[];
|
|
18
22
|
/** Width of the facts' label column (default 80). Widen for longer labels. */
|
|
19
23
|
factLabelWidth?: number;
|
|
20
|
-
/** Accessible name for opening the record (e.g. "Acme Corp — details"). */
|
|
21
|
-
doorLabel: string;
|
|
22
|
-
/** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
|
|
23
|
-
onOpen: () => void;
|
|
24
24
|
/** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
|
|
25
25
|
* hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
|
|
26
26
|
* spacer between). The divider is drawn automatically when actions are present. Supplied by
|
|
@@ -28,22 +28,61 @@ interface LinkedRecordBoxProps {
|
|
|
28
28
|
actions?: ReactNode;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** The box that OPENS — the whole surface is the door into the linked record's own detail. */
|
|
32
|
+
interface LinkedRecordBoxDoorProps extends LinkedRecordBoxBaseProps {
|
|
33
|
+
/** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
|
|
34
|
+
onOpen: () => void;
|
|
35
|
+
/** Accessible name for opening the record (e.g. "Acme Corp — details"). */
|
|
36
|
+
doorLabel: string;
|
|
37
|
+
/** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). TEXT only
|
|
38
|
+
* (`""` renders "—"): the box is one press target, so the only controls it may hold are the
|
|
39
|
+
* `actions` verbs, which the footer fences off and lifts above the door. A fact the reader
|
|
40
|
+
* must EDIT belongs on the linked record's own page — that is what the door is for. */
|
|
41
|
+
facts?: LinkedRecordFact<string>[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The box with nothing to open — the linked record has no detail surface of its own. */
|
|
45
|
+
interface LinkedRecordBoxStaticProps extends LinkedRecordBoxBaseProps {
|
|
46
|
+
/** Absent by construction. Typed `never` rather than left off so `doorLabel` can never arrive
|
|
47
|
+
* without a handler — a named door that goes nowhere. */
|
|
48
|
+
onOpen?: never;
|
|
49
|
+
doorLabel?: never;
|
|
50
|
+
/** Reference facts, stacked VERTICALLY. A string renders as text (`""` → "—"); a NODE renders
|
|
51
|
+
* as given (no placeholder), so a fact can carry its own inline editor — safe here precisely
|
|
52
|
+
* because nothing about this box presses. */
|
|
53
|
+
facts?: LinkedRecordFact[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type LinkedRecordBoxProps = LinkedRecordBoxDoorProps | LinkedRecordBoxStaticProps;
|
|
57
|
+
|
|
31
58
|
/**
|
|
32
59
|
* A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
|
|
33
|
-
* (identity glyph · name · facts)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
60
|
+
* (identity glyph · name · facts). Use it wherever one record points at another (a shipment's
|
|
61
|
+
* customer, an invoice's party, a case's sibling); the picker / empty-state that REASSIGNS the
|
|
62
|
+
* link is the consumer's, shown in place of the box.
|
|
63
|
+
*
|
|
64
|
+
* `onOpen` picks the variant:
|
|
65
|
+
*
|
|
66
|
+
* - **With `onOpen`** (+ `doorLabel`) the WHOLE box is a keyboard-accessible door into the
|
|
67
|
+
* record's detail. Its facts are TEXT, and the `actions` footer is the one place a control
|
|
68
|
+
* may live — everything else is press-through to the door.
|
|
69
|
+
* - **Without it** the box is a STATIC reference card: no door, no tab stop, no pointer, nothing
|
|
70
|
+
* announced as a button; the only interactive parts are `actions` and whatever a fact node
|
|
71
|
+
* holds. This is the shape for a record with NO page of its own — there is nothing to open, so
|
|
72
|
+
* the box IS where its values are read and edited, and an inline editor in a fact is honest.
|
|
37
73
|
*
|
|
38
|
-
* The a11y contract
|
|
39
|
-
* interactive descendants must NEVER be `role="button"` (invalid HTML). So the
|
|
40
|
-
* role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
|
|
41
|
-
* focus ring, and the interior verbs lift above it via `zIndex`.
|
|
74
|
+
* The a11y contract the door variant enforces (the error-prone part, hence a primitive): a
|
|
75
|
+
* container with interactive descendants must NEVER be `role="button"` (invalid HTML). So the
|
|
76
|
+
* box is a role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
|
|
77
|
+
* name / focus ring, and the interior verbs lift above it via `zIndex`.
|
|
42
78
|
*/
|
|
43
|
-
export function LinkedRecordBox(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
79
|
+
export function LinkedRecordBox(props: LinkedRecordBoxProps) {
|
|
80
|
+
const { icon, name, subtitle, factLabelWidth = 80, actions } = props;
|
|
81
|
+
// A door box's facts are strings, and a string IS a ReactNode — so both variants render
|
|
82
|
+
// through one path typed at the wider value.
|
|
83
|
+
const facts: LinkedRecordFact[] = props.facts ?? [];
|
|
84
|
+
const body = (
|
|
85
|
+
<>
|
|
47
86
|
<View style={styles.identity}>
|
|
48
87
|
<View style={styles.glyph}>
|
|
49
88
|
<Icon name={icon} size={17} color={colors.zinc[600]} />
|
|
@@ -53,12 +92,18 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
|
|
|
53
92
|
{subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
|
|
54
93
|
</View>
|
|
55
94
|
</View>
|
|
56
|
-
{facts
|
|
95
|
+
{facts.length > 0 ? (
|
|
57
96
|
<View style={{ gap: 6 }}>
|
|
58
97
|
{facts.map((f) => (
|
|
59
98
|
<View key={f.label} style={styles.fact}>
|
|
60
99
|
<Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
|
|
61
|
-
|
|
100
|
+
{typeof f.value === "string" ? (
|
|
101
|
+
<Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
|
|
102
|
+
) : (
|
|
103
|
+
// A node prints as authored — the "—" placeholder is a TEXT affordance, and a
|
|
104
|
+
// control renders its own empty state.
|
|
105
|
+
<View style={{ flex: 1, minWidth: 0 }}>{f.value}</View>
|
|
106
|
+
)}
|
|
62
107
|
</View>
|
|
63
108
|
))}
|
|
64
109
|
</View>
|
|
@@ -70,6 +115,17 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
|
|
|
70
115
|
<View style={styles.actions}>{actions}</View>
|
|
71
116
|
</>
|
|
72
117
|
) : null}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Nothing to open → a plain View, so the box takes no tab stop, announces no role, and paints
|
|
122
|
+
// no pointer/hover promise of a destination it doesn't have.
|
|
123
|
+
if (!props.onOpen) return <View style={styles.box}>{body}</View>;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<PressableRow onPress={props.onOpen} style={styles.box}>
|
|
127
|
+
<PressDoor accessibilityLabel={props.doorLabel} onPress={props.onOpen} />
|
|
128
|
+
{body}
|
|
73
129
|
</PressableRow>
|
|
74
130
|
);
|
|
75
131
|
}
|
package/src/locale.tsx
CHANGED
|
@@ -162,7 +162,7 @@ export const en: LoticsLocale = {
|
|
|
162
162
|
descending: ", descending",
|
|
163
163
|
},
|
|
164
164
|
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
165
|
-
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
|
|
165
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
|
|
166
166
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
167
167
|
filterChip: { clear: "Clear" },
|
|
168
168
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -260,7 +260,7 @@ export const vi: LoticsLocale = {
|
|
|
260
260
|
descending: " (giảm dần)",
|
|
261
261
|
},
|
|
262
262
|
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
263
|
-
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
|
|
263
|
+
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
264
264
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
265
265
|
filterChip: { clear: "Xóa" },
|
|
266
266
|
floatingActionBar: { clear: "Bỏ chọn" },
|
package/src/menu_button.tsx
CHANGED
|
@@ -12,7 +12,23 @@ export interface MenuButtonProps {
|
|
|
12
12
|
right?: React.ReactNode;
|
|
13
13
|
onPress?: () => void;
|
|
14
14
|
onHoverIn?: () => void;
|
|
15
|
+
/**
|
|
16
|
+
* Persistent SELECTION highlight — the listbox pattern, where the row is an
|
|
17
|
+
* `option` and `aria-selected` is the property that carries its state. On any
|
|
18
|
+
* other role it is the highlight only: `aria-selected` is valid on
|
|
19
|
+
* option/tab/row/gridcell and nowhere else, so it is not emitted there (a menu
|
|
20
|
+
* carries state in the `right` slot — a check glyph).
|
|
21
|
+
*/
|
|
15
22
|
selected?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* This row is the CURRENT item in a set of NAVIGATION items — the section an
|
|
25
|
+
* outline rail sits on (`useSectionNav`'s `activeKey`), the open pane of a
|
|
26
|
+
* settings dialog. Renders `aria-current`, which is global (valid on any
|
|
27
|
+
* role): `true` is the generic "this is the current one", or name the set it
|
|
28
|
+
* is current WITHIN. Navigation is not selection — a rail item is `current`,
|
|
29
|
+
* a listbox row is `selected`. Both paint the same highlight.
|
|
30
|
+
*/
|
|
31
|
+
current?: boolean | "page" | "step" | "location";
|
|
16
32
|
focused?: boolean;
|
|
17
33
|
disabled?: boolean;
|
|
18
34
|
danger?: boolean;
|
|
@@ -42,6 +58,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
42
58
|
onPress,
|
|
43
59
|
onHoverIn,
|
|
44
60
|
selected,
|
|
61
|
+
current,
|
|
45
62
|
focused,
|
|
46
63
|
disabled,
|
|
47
64
|
danger,
|
|
@@ -84,18 +101,26 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
84
101
|
</>
|
|
85
102
|
);
|
|
86
103
|
|
|
104
|
+
// Selection and current-ness are different states with the same visual weight
|
|
105
|
+
// — a row is at most one of them, and either owns the resting highlight.
|
|
106
|
+
const highlighted = !!selected || !!current;
|
|
87
107
|
const containerStyle = [
|
|
88
108
|
styles.container,
|
|
89
|
-
|
|
90
|
-
focused && !
|
|
109
|
+
highlighted && styles.highlighted,
|
|
110
|
+
focused && !highlighted && styles.focused,
|
|
91
111
|
style,
|
|
92
112
|
];
|
|
93
113
|
|
|
114
|
+
// `aria-current` takes a TOKEN, and absence — never `false` — is how the other
|
|
115
|
+
// items in the set say "not this one".
|
|
116
|
+
const ariaCurrent = current === true ? "true" : current === false ? undefined : current;
|
|
117
|
+
|
|
94
118
|
if (onPress) {
|
|
95
119
|
return (
|
|
96
120
|
<PressableHighlight
|
|
97
121
|
focusRing
|
|
98
|
-
ref={ref}
|
|
122
|
+
ref={ref}
|
|
123
|
+
testID={testID}
|
|
99
124
|
nativeID={nativeID}
|
|
100
125
|
onPress={() => {
|
|
101
126
|
onPress?.();
|
|
@@ -106,7 +131,9 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
106
131
|
style={containerStyle}
|
|
107
132
|
role={role}
|
|
108
133
|
accessibilityLabel={resolvedLabel}
|
|
109
|
-
aria-selected={!!selected
|
|
134
|
+
aria-selected={role === "option" ? !!selected : undefined}
|
|
135
|
+
aria-current={ariaCurrent}
|
|
136
|
+
aria-disabled={disabled || undefined}
|
|
110
137
|
>
|
|
111
138
|
{inner}
|
|
112
139
|
</PressableHighlight>
|
|
@@ -133,7 +160,7 @@ const styles = StyleSheet.create({
|
|
|
133
160
|
flex: 1,
|
|
134
161
|
alignItems: "flex-start",
|
|
135
162
|
},
|
|
136
|
-
|
|
163
|
+
highlighted: {
|
|
137
164
|
backgroundColor: colors.zinc["100"],
|
|
138
165
|
},
|
|
139
166
|
focused: {
|
|
@@ -50,6 +50,13 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
50
50
|
* explicitly because the base React Native `PressableProps` type omits it.
|
|
51
51
|
*/
|
|
52
52
|
onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
|
|
53
|
+
/**
|
|
54
|
+
* The item is the current one in a set of navigation items. Exposed
|
|
55
|
+
* explicitly because React Native's accessibility props carry only the ARIA
|
|
56
|
+
* attributes with a native counterpart, and `aria-current` has none —
|
|
57
|
+
* react-native-web forwards it to the DOM like any other `aria-*` prop.
|
|
58
|
+
*/
|
|
59
|
+
"aria-current"?: "true" | "page" | "step" | "location";
|
|
53
60
|
/**
|
|
54
61
|
* Pass "none" on row/card surfaces: a pressable surface is a button, not a
|
|
55
62
|
* text-selection surface — drag jitter on selectable text starts a
|
package/src/section_heading.tsx
CHANGED
|
@@ -53,7 +53,8 @@ export interface SubsectionProps {
|
|
|
53
53
|
* One named group INSIDE a `Section` — the level below `Section` on a long
|
|
54
54
|
* record surface (`###` in the heading ramp). Compose a `SubsectionHeading`
|
|
55
55
|
* then the group's rows; sibling subsections stack in a `SubsectionStack`
|
|
56
|
-
* (
|
|
56
|
+
* (32, space-only while the groups are short — no margins, no hand-rolled
|
|
57
|
+
* dividers; see `SubsectionStack` for when length earns `divided`). A
|
|
57
58
|
* headingless `Subsection` is fine for the section's lead group.
|
|
58
59
|
*
|
|
59
60
|
* <Section>
|
package/src/section_stack.tsx
CHANGED
|
@@ -4,8 +4,9 @@ import { Divider } from "./divider";
|
|
|
4
4
|
|
|
5
5
|
interface StackProps {
|
|
6
6
|
children: React.ReactNode;
|
|
7
|
-
/** Hairline between blocks
|
|
8
|
-
*
|
|
7
|
+
/** Hairline between blocks. Each stack defaults to what its altitude wants
|
|
8
|
+
* (on for sections, off for subsections); the rule for overriding it is on
|
|
9
|
+
* `SubsectionStack`. */
|
|
9
10
|
divided?: boolean;
|
|
10
11
|
style?: StyleProp<ViewStyle>;
|
|
11
12
|
}
|
|
@@ -49,12 +50,17 @@ export function SectionStack(props: SectionStackProps) {
|
|
|
49
50
|
export type SubsectionStackProps = StackProps;
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
* The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat.
|
|
54
|
+
* Compose it as the section's body (after the `SectionHeading`); a section's
|
|
55
|
+
* groups divide without hand-rolled gaps.
|
|
56
|
+
*
|
|
57
|
+
* SPACE-ONLY while the groups are SHORT (a handful of rows, taken in at a
|
|
58
|
+
* glance): the titles carry the grouping, and hairlines belong to the SECTION
|
|
59
|
+
* level (`SectionStack`), one rule per altitude. Turn `divided` on once the
|
|
60
|
+
* groups are LONG — past a screenful of rows, 32px reads as one more row gap
|
|
61
|
+
* and the next title arrives with nothing marking that a new group started, so
|
|
62
|
+
* the hairline becomes the boundary. LENGTH is the discriminator, not whether
|
|
63
|
+
* the groups are titled: a headingless stack follows the same rule.
|
|
58
64
|
*
|
|
59
65
|
* <Section>
|
|
60
66
|
* <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
|
package/src/text_input_field.tsx
CHANGED
|
@@ -3,12 +3,13 @@ import {
|
|
|
3
3
|
TextInput as RNTextInput,
|
|
4
4
|
TextInputProps as RNTextInputProps,
|
|
5
5
|
View,
|
|
6
|
+
type LayoutChangeEvent,
|
|
6
7
|
} from "react-native";
|
|
7
8
|
import { colors } from "./colors";
|
|
8
9
|
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
9
10
|
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
10
11
|
import { useHover } from "./use_hover";
|
|
11
|
-
import { Ref, useCallback } from "react";
|
|
12
|
+
import { Ref, useCallback, useState } from "react";
|
|
12
13
|
import { Icon, IconName } from "./icon";
|
|
13
14
|
import { IconButton } from "./icon_button";
|
|
14
15
|
import { ShortcutBadge } from "./shortcut_badge";
|
|
@@ -99,6 +100,23 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
99
100
|
|
|
100
101
|
const editable = !!(!disabled || inputProps.editable);
|
|
101
102
|
|
|
103
|
+
// The ONE source for what occupies the trailing slot — the same flags decide
|
|
104
|
+
// which affordance renders AND how much the text reserves for it. Derived once
|
|
105
|
+
// so the two can never disagree (a gutter gated on anything else is how a long
|
|
106
|
+
// value ends up running underneath the ✕).
|
|
107
|
+
const showClear = !!clearable && !!value;
|
|
108
|
+
// The badge is a hint for reaching an EMPTY field; once there's a value the
|
|
109
|
+
// clear ✕ owns the slot and the hint is spent.
|
|
110
|
+
const showShortcut = !!shortcut && !value;
|
|
111
|
+
// The badge's width is content- AND screen-dependent (⌘B vs Ctrl+Shift+S;
|
|
112
|
+
// `ShortcutBadge` renders NOTHING on small screens), so unlike the fixed-size
|
|
113
|
+
// ✕ its gutter cannot be a constant — it is measured, which also makes the
|
|
114
|
+
// small-screen reserve correctly zero.
|
|
115
|
+
const [shortcutWidth, setShortcutWidth] = useState(0);
|
|
116
|
+
const onShortcutLayout = useCallback((e: LayoutChangeEvent) => {
|
|
117
|
+
setShortcutWidth(e.nativeEvent.layout.width);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
102
120
|
const { measure, inputRef } = autoGrowResult;
|
|
103
121
|
|
|
104
122
|
const handleChangeText = useCallback(
|
|
@@ -145,6 +163,8 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
145
163
|
autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
|
|
146
164
|
!editable && styles.disabled,
|
|
147
165
|
icon && styles.withIcon,
|
|
166
|
+
showClear && styles.withClear,
|
|
167
|
+
showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
|
|
148
168
|
hovered && editable && { borderColor: HOVER_BORDER },
|
|
149
169
|
style,
|
|
150
170
|
focusVisible && { boxShadow: FOCUS_RING },
|
|
@@ -157,7 +177,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
157
177
|
scrollEnabled={autoGrow ? autoGrowResult.scrollEnabled : undefined}
|
|
158
178
|
onContentSizeChange={autoGrow ? autoGrowResult.onContentSizeChange : undefined}
|
|
159
179
|
/>
|
|
160
|
-
{
|
|
180
|
+
{showClear ? (
|
|
161
181
|
<IconButton
|
|
162
182
|
icon="x"
|
|
163
183
|
tooltip={clearLabel}
|
|
@@ -167,8 +187,8 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
167
187
|
}}
|
|
168
188
|
style={styles.clear}
|
|
169
189
|
/>
|
|
170
|
-
) :
|
|
171
|
-
<View style={styles.shortcut}>
|
|
190
|
+
) : showShortcut ? (
|
|
191
|
+
<View style={styles.shortcut} onLayout={onShortcutLayout}>
|
|
172
192
|
<ShortcutBadge shortcut={shortcut} />
|
|
173
193
|
</View>
|
|
174
194
|
) : null}
|
|
@@ -176,6 +196,20 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
176
196
|
);
|
|
177
197
|
}
|
|
178
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The trailing slot (the clear ✕, the shortcut badge) is ABSOLUTELY POSITIONED —
|
|
201
|
+
* out of flow, so the input's text does not stop at it. Whichever affordance is
|
|
202
|
+
* up, the text reserves the span it occupies; these constants drive BOTH the
|
|
203
|
+
* affordance's `right` offset and the reserved padding, so the two cannot drift
|
|
204
|
+
* apart. A slot standing empty reserves nothing, so an input with no trailing
|
|
205
|
+
* affordance is untouched.
|
|
206
|
+
*/
|
|
207
|
+
const CLEAR_INSET = 6;
|
|
208
|
+
/** `IconButton size="md"` is a 28px box around an 18px glyph, so the button's own
|
|
209
|
+
* interior padding IS the visual gap — text may sit against the box edge. */
|
|
210
|
+
const CLEAR_GUTTER = CLEAR_INSET + 28;
|
|
211
|
+
const SHORTCUT_INSET = 12;
|
|
212
|
+
|
|
179
213
|
const styles = StyleSheet.create({
|
|
180
214
|
input: {
|
|
181
215
|
borderRadius: CONTROL_RADIUS,
|
|
@@ -200,19 +234,24 @@ const styles = StyleSheet.create({
|
|
|
200
234
|
withIcon: {
|
|
201
235
|
paddingLeft: 34,
|
|
202
236
|
},
|
|
237
|
+
withClear: {
|
|
238
|
+
paddingRight: CLEAR_GUTTER,
|
|
239
|
+
},
|
|
203
240
|
icon: {
|
|
204
241
|
top: 9,
|
|
205
242
|
left: 8,
|
|
206
243
|
position: "absolute",
|
|
207
244
|
},
|
|
208
245
|
clear: {
|
|
209
|
-
|
|
210
|
-
|
|
246
|
+
// Pinned to the TOP, not centred: on a multiline/auto-grow field the ✕ must
|
|
247
|
+
// stay reachable at the first line, not float in the middle of a tall box.
|
|
248
|
+
top: CLEAR_INSET,
|
|
249
|
+
right: CLEAR_INSET,
|
|
211
250
|
position: "absolute",
|
|
212
251
|
},
|
|
213
252
|
shortcut: {
|
|
214
253
|
position: "absolute",
|
|
215
|
-
right:
|
|
254
|
+
right: SHORTCUT_INSET,
|
|
216
255
|
top: 0,
|
|
217
256
|
bottom: 0,
|
|
218
257
|
justifyContent: "center",
|
package/src/time_picker.tsx
CHANGED
|
@@ -1,56 +1,114 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
4
|
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
5
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
6
5
|
import { useHover } from "./use_hover";
|
|
6
|
+
import { DateSegments } from "./date_segments_field";
|
|
7
|
+
import { SegmentLabels, timeSegmentsConfig } from "./date_segments";
|
|
8
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
9
|
+
|
|
7
10
|
export interface TimePickerProps {
|
|
11
|
+
/** Canonical 24-hour "HH:mm", `""` when empty — independent of how it displays. */
|
|
8
12
|
value?: string;
|
|
9
13
|
onValueChange: (value: string) => void;
|
|
10
14
|
onBlur?: () => void;
|
|
11
|
-
/** Web key handler — e.g. an inline editor committing on Enter / reverting on Escape. */
|
|
12
|
-
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
13
15
|
autoFocus?: boolean;
|
|
14
16
|
disabled?: boolean;
|
|
15
17
|
accessibilityLabel?: string;
|
|
18
|
+
/**
|
|
19
|
+
* BCP-47 locale deciding 12- vs 24-hour DISPLAY. Defaults to the active
|
|
20
|
+
* `LoticsLocaleProvider` locale.
|
|
21
|
+
*
|
|
22
|
+
* This is the whole reason the field is segments and not `<input type="time">`:
|
|
23
|
+
* a native time input takes its 12/24-hour form from the BROWSER's UI locale
|
|
24
|
+
* and ignores `lang` entirely, so a Vietnamese screen on an en-US browser read
|
|
25
|
+
* "01:45 PM" next to its own 24-hour "13:45 02/01/2023".
|
|
26
|
+
*/
|
|
27
|
+
locale?: string;
|
|
28
|
+
/** Accessible names per segment. Defaults to the `datePicker` locale slice. */
|
|
29
|
+
segmentLabels?: SegmentLabels;
|
|
30
|
+
/** Escape pressed in a segment — an inline editor cancels its session. */
|
|
31
|
+
onEscape?: () => void;
|
|
32
|
+
/** True while the entry is non-empty but still half-typed (an hour, no minute).
|
|
33
|
+
* An inline editor blocks its commit on this. */
|
|
34
|
+
onIncompleteChange?: (incomplete: boolean) => void;
|
|
35
|
+
testID?: string;
|
|
16
36
|
}
|
|
17
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A time of day. The value is always canonical 24-hour "HH:mm"; the locale only
|
|
40
|
+
* decides what the user sees and types (an AM/PM segment appears only where the
|
|
41
|
+
* locale uses one).
|
|
42
|
+
*/
|
|
18
43
|
export function TimePicker(props: TimePickerProps) {
|
|
19
|
-
const {
|
|
20
|
-
|
|
44
|
+
const {
|
|
45
|
+
value = "",
|
|
46
|
+
onValueChange,
|
|
47
|
+
onBlur,
|
|
48
|
+
autoFocus,
|
|
49
|
+
disabled,
|
|
50
|
+
accessibilityLabel,
|
|
51
|
+
locale,
|
|
52
|
+
onEscape,
|
|
53
|
+
onIncompleteChange,
|
|
54
|
+
testID,
|
|
55
|
+
} = props;
|
|
56
|
+
const localeTag = useLocaleTag(locale);
|
|
57
|
+
const loc = useLoticsLocale().datePicker;
|
|
58
|
+
const segmentLabels = props.segmentLabels ?? loc;
|
|
59
|
+
const config = useMemo(() => timeSegmentsConfig(localeTag), [localeTag]);
|
|
21
60
|
const { hovered, hoverProps } = useHover();
|
|
61
|
+
const [focused, setFocused] = useState(false);
|
|
22
62
|
|
|
23
63
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
boxSizing: "border-box",
|
|
52
|
-
transition: "border-color 0.12s, box-shadow 0.12s",
|
|
53
|
-
}}
|
|
54
|
-
/>
|
|
64
|
+
<View
|
|
65
|
+
{...(hoverProps as object)}
|
|
66
|
+
style={[
|
|
67
|
+
styles.frame,
|
|
68
|
+
hovered && !disabled && styles.frameHovered,
|
|
69
|
+
focused && !disabled && styles.frameFocused,
|
|
70
|
+
disabled && styles.frameDisabled,
|
|
71
|
+
]}
|
|
72
|
+
testID={testID}
|
|
73
|
+
>
|
|
74
|
+
<DateSegments
|
|
75
|
+
value={value}
|
|
76
|
+
onChange={onValueChange}
|
|
77
|
+
config={config}
|
|
78
|
+
segmentLabels={segmentLabels}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
autoFocus={autoFocus}
|
|
81
|
+
accessibilityLabel={accessibilityLabel}
|
|
82
|
+
onFocus={() => setFocused(true)}
|
|
83
|
+
onBlur={() => {
|
|
84
|
+
setFocused(false);
|
|
85
|
+
onBlur?.();
|
|
86
|
+
}}
|
|
87
|
+
onEscape={onEscape}
|
|
88
|
+
onIncompleteChange={onIncompleteChange}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
55
91
|
);
|
|
56
92
|
}
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
frame: {
|
|
96
|
+
flexDirection: "row",
|
|
97
|
+
alignItems: "center",
|
|
98
|
+
height: 40,
|
|
99
|
+
paddingHorizontal: 8,
|
|
100
|
+
borderRadius: CONTROL_RADIUS,
|
|
101
|
+
borderWidth: 1,
|
|
102
|
+
borderColor: colors.border,
|
|
103
|
+
backgroundColor: colors.background,
|
|
104
|
+
},
|
|
105
|
+
frameHovered: {
|
|
106
|
+
borderColor: HOVER_BORDER,
|
|
107
|
+
},
|
|
108
|
+
frameFocused: {
|
|
109
|
+
boxShadow: FOCUS_RING,
|
|
110
|
+
},
|
|
111
|
+
frameDisabled: {
|
|
112
|
+
backgroundColor: colors.zinc[50],
|
|
113
|
+
},
|
|
114
|
+
});
|