@lotics/ui 12.1.2 → 13.7.1
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/AGENTS.md +5 -0
- package/docs/catalog.md +75 -33
- package/docs/composition.md +61 -24
- package/docs/data_entry.md +19 -17
- package/docs/templates.md +71 -76
- package/examples/tpl_item_list.tsx +6 -7
- package/examples/tpl_record.tsx +131 -188
- package/examples/tpl_task_board.tsx +20 -22
- package/package.json +3 -2
- package/src/badge.tsx +26 -10
- package/src/checklist.tsx +1 -1
- package/src/detail_row.tsx +24 -8
- package/src/drawer.tsx +10 -2
- package/src/form_date_picker.tsx +2 -1
- package/src/form_field.tsx +23 -3
- package/src/form_picker.tsx +2 -1
- package/src/form_switch.tsx +16 -2
- package/src/form_text_input.tsx +4 -4
- package/src/inline_date_picker.tsx +19 -9
- package/src/inline_edit.tsx +43 -24
- package/src/inline_member_select.tsx +43 -10
- package/src/inline_number_input.tsx +4 -4
- package/src/inline_select.tsx +193 -87
- package/src/inline_text_input.tsx +4 -4
- package/src/inline_time_picker.tsx +4 -4
- package/src/inset.tsx +38 -0
- package/src/linked_record_box.tsx +102 -0
- package/src/number_input.tsx +1 -1
- package/src/radio_picker.tsx +18 -28
- package/src/text_input_field.tsx +3 -3
- package/examples/tpl_tasks.tsx +0 -456
- package/src/inline_tag_select.tsx +0 -140
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
3
|
import { NumberInput } from "./number_input";
|
|
4
|
-
import { type
|
|
4
|
+
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
5
|
|
|
6
6
|
export interface InlineNumberInputProps {
|
|
7
7
|
value: number | null;
|
|
@@ -16,7 +16,7 @@ export interface InlineNumberInputProps {
|
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
accessibilityLabel?: string;
|
|
18
18
|
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
19
|
-
|
|
19
|
+
variant?: InlineEditVariant;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -25,7 +25,7 @@ export interface InlineNumberInputProps {
|
|
|
25
25
|
* the bare numeric field at the same height, so the form never reflows.
|
|
26
26
|
*/
|
|
27
27
|
export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
28
|
-
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel ,
|
|
28
|
+
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel , variant } = props;
|
|
29
29
|
const edit = useInlineEdit<number | null>({ value, onSave });
|
|
30
30
|
|
|
31
31
|
const onKeyDown = useCallback(
|
|
@@ -45,7 +45,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
47
|
<InlineEditFrame
|
|
48
|
-
|
|
48
|
+
variant={variant}
|
|
49
49
|
editing={edit.editing}
|
|
50
50
|
display={display}
|
|
51
51
|
placeholder={placeholder}
|
package/src/inline_select.tsx
CHANGED
|
@@ -1,125 +1,231 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { View } from "react-native";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
+
import { Badge } from "./badge";
|
|
5
6
|
import { colors } from "./colors";
|
|
6
7
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
8
|
import { OptionList } from "./option_list";
|
|
8
9
|
import type { PickerOption } from "./picker";
|
|
9
10
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
-
import { type
|
|
11
|
+
import { type InlineEditVariant, InlineEditView } from "./inline_edit";
|
|
11
12
|
import { useLoticsLocale } from "./locale";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
value: T | null;
|
|
15
|
-
onSave: (next: T) => void | Promise<void>;
|
|
16
|
-
/** Unset the field to empty. Provide it to make the value clearable: a compact
|
|
17
|
-
* "Clear" button appears below the options whenever there IS a value. Kept
|
|
18
|
-
* separate from `onSave` (whose next is a non-null `T`) so an app opts in
|
|
19
|
-
* without every caller having to handle null. */
|
|
20
|
-
onClear?: () => void | Promise<void>;
|
|
14
|
+
interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
21
15
|
options: PickerOption<T, D>[];
|
|
22
16
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
23
17
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
24
|
-
renderOptionContent?: (option: PickerOption<T, D>) =>
|
|
25
|
-
/** Render
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
19
|
+
/** Render ONE selected option as its resting chip — single: the value; multi:
|
|
20
|
+
* each tag. Falls back to `renderOptionContent`, then the plain label (single) /
|
|
21
|
+
* a zinc `Badge` (multi). The one seam for "how the selection looks". */
|
|
22
|
+
renderSelected?: (option: PickerOption<T, D>) => ReactNode;
|
|
23
|
+
/** Empty-state content: a string reads as muted placeholder text, a NODE renders
|
|
24
|
+
* as-is (e.g. an avatar "add" ghost for an `avatarOnly` member cell). */
|
|
25
|
+
placeholder?: string | ReactNode;
|
|
31
26
|
disabled?: boolean;
|
|
32
27
|
accessibilityLabel?: string;
|
|
33
|
-
/**
|
|
34
|
-
|
|
28
|
+
/** Form field (default) or grid cell — see {@link InlineEditVariant}. */
|
|
29
|
+
variant?: InlineEditVariant;
|
|
35
30
|
/** Show an in-menu search box; default false. */
|
|
36
31
|
searchable?: boolean;
|
|
32
|
+
/** Offer a "create" row when the query matches no option — picking it commits the
|
|
33
|
+
* typed value (single) / adds it to the set (multi). Implies `searchable`. */
|
|
34
|
+
allowCustom?: boolean;
|
|
35
|
+
/** Label for the create row (default: `Add "<query>"`). */
|
|
36
|
+
customOptionLabel?: (query: string) => string | null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* The inline-editable select — single by default, `multi` for a tag SET. Mirrors
|
|
41
|
+
* `Select`'s one-component/`multi` axis (there is no separate tag component). Value
|
|
42
|
+
* + `onSave` are typed by the mode: single is `T | null` and commits on pick; multi
|
|
43
|
+
* is `T[]` and commits the new set when the popover CLOSES (the inline blur-commit,
|
|
44
|
+
* applied to a set).
|
|
45
45
|
*/
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
setError(null);
|
|
61
|
-
try {
|
|
62
|
-
await onSave(next);
|
|
63
|
-
} catch (e) {
|
|
64
|
-
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
65
|
-
} finally {
|
|
66
|
-
setSaving(false);
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
[value, onSave],
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
const clear = useCallback(async () => {
|
|
73
|
-
setOpen(false);
|
|
74
|
-
if (value == null || !onClear) return;
|
|
75
|
-
setSaving(true);
|
|
76
|
-
setError(null);
|
|
77
|
-
try {
|
|
78
|
-
await onClear();
|
|
79
|
-
} catch (e) {
|
|
80
|
-
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
81
|
-
} finally {
|
|
82
|
-
setSaving(false);
|
|
83
|
-
}
|
|
84
|
-
}, [value, onClear]);
|
|
46
|
+
export type InlineSelectProps<T extends string, D = unknown> =
|
|
47
|
+
| (InlineSelectBaseProps<T, D> & {
|
|
48
|
+
multi?: false;
|
|
49
|
+
value: T | null;
|
|
50
|
+
onSave: (next: T) => void | Promise<void>;
|
|
51
|
+
/** Unset the field. Provide it for a "Clear" row whenever there IS a value —
|
|
52
|
+
* kept separate from `onSave` (whose next is a non-null `T`). Single only. */
|
|
53
|
+
onClear?: () => void | Promise<void>;
|
|
54
|
+
})
|
|
55
|
+
| (InlineSelectBaseProps<T, D> & {
|
|
56
|
+
multi: true;
|
|
57
|
+
value: T[];
|
|
58
|
+
onSave: (next: T[]) => void | Promise<void>;
|
|
59
|
+
});
|
|
85
60
|
|
|
61
|
+
/** The shared shell: the resting view (a `Popover` trigger) that floats an
|
|
62
|
+
* `OptionList` — identical for single and multi; only the display + list differ. */
|
|
63
|
+
function InlineSelectShell(props: {
|
|
64
|
+
open: boolean;
|
|
65
|
+
onOpenChange: (next: boolean) => void;
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
display: string | ReactNode;
|
|
68
|
+
placeholder?: string | ReactNode;
|
|
69
|
+
accessibilityLabel?: string;
|
|
70
|
+
variant?: InlineEditVariant;
|
|
71
|
+
saving: boolean;
|
|
72
|
+
error: string | null;
|
|
73
|
+
children: ReactNode;
|
|
74
|
+
}) {
|
|
86
75
|
return (
|
|
87
76
|
<View>
|
|
88
|
-
<Popover open={open && !disabled} onOpenChange={
|
|
77
|
+
<Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} side="bottom" align="start" inheritTriggerWidth>
|
|
89
78
|
<PopoverTrigger>
|
|
90
79
|
<InlineEditView
|
|
91
|
-
|
|
92
|
-
display={
|
|
93
|
-
placeholder={placeholder}
|
|
94
|
-
disabled={disabled}
|
|
95
|
-
active={open && !disabled}
|
|
96
|
-
accessibilityLabel={accessibilityLabel}
|
|
80
|
+
variant={props.variant}
|
|
81
|
+
display={props.display}
|
|
82
|
+
placeholder={props.placeholder}
|
|
83
|
+
disabled={props.disabled}
|
|
84
|
+
active={props.open && !props.disabled}
|
|
85
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
86
|
+
// A `cell` reads value-first: it drops the resting chevron (the column
|
|
87
|
+
// header + the uniformly-editable grid are the affordance), like the date
|
|
88
|
+
// cell drops its calendar glyph. A `form` field keeps the chevron. The
|
|
89
|
+
// saving spinner shows in both.
|
|
97
90
|
trailing={
|
|
98
|
-
saving ? (
|
|
91
|
+
props.saving ? (
|
|
99
92
|
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
100
|
-
) : (
|
|
93
|
+
) : props.variant === "cell" ? undefined : (
|
|
101
94
|
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
102
95
|
)
|
|
103
96
|
}
|
|
104
97
|
/>
|
|
105
98
|
</PopoverTrigger>
|
|
106
|
-
<PopoverContent disableBodyScroll>
|
|
107
|
-
<OptionList
|
|
108
|
-
search={{ mode: searchable ? "internal" : "none" }}
|
|
109
|
-
options={options}
|
|
110
|
-
value={value}
|
|
111
|
-
onValueChange={(next) => void pick(next)}
|
|
112
|
-
onClear={onClear ? () => void clear() : undefined}
|
|
113
|
-
onRequestClose={() => setOpen(false)}
|
|
114
|
-
renderOptionContent={renderOptionContent}
|
|
115
|
-
/>
|
|
116
|
-
</PopoverContent>
|
|
99
|
+
<PopoverContent disableBodyScroll>{props.children}</PopoverContent>
|
|
117
100
|
</Popover>
|
|
118
|
-
{error ? (
|
|
119
|
-
<Text size="xs" color="danger" style={
|
|
120
|
-
{error}
|
|
101
|
+
{props.error ? (
|
|
102
|
+
<Text size="xs" color="danger" style={styles.error}>
|
|
103
|
+
{props.error}
|
|
121
104
|
</Text>
|
|
122
105
|
) : null}
|
|
123
106
|
</View>
|
|
124
107
|
);
|
|
125
108
|
}
|
|
109
|
+
|
|
110
|
+
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
111
|
+
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, variant, searchable = false, allowCustom = false, customOptionLabel } = props;
|
|
112
|
+
const labels = useLoticsLocale().inline;
|
|
113
|
+
const [open, setOpen] = useState(false);
|
|
114
|
+
const [saving, setSaving] = useState(false);
|
|
115
|
+
const [error, setError] = useState<string | null>(null);
|
|
116
|
+
const [draft, setDraft] = useState<T[]>(props.multi ? props.value : []);
|
|
117
|
+
|
|
118
|
+
const runSave = async (fn: () => void | Promise<void>) => {
|
|
119
|
+
setSaving(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
try {
|
|
122
|
+
await fn();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
125
|
+
} finally {
|
|
126
|
+
setSaving(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const searchMode = searchable || allowCustom ? "internal" : "none";
|
|
131
|
+
|
|
132
|
+
if (props.multi) {
|
|
133
|
+
const { value, onSave } = props;
|
|
134
|
+
// Resolve each value to its option IN SELECTION ORDER; an `allowCustom` value not in
|
|
135
|
+
// `options` (a just-created tag) renders from a {value,label} fallback so it still shows
|
|
136
|
+
// (mirrors `Select`). Without allowCustom, an unknown value is dropped (→ nothing), as before.
|
|
137
|
+
const selected = value
|
|
138
|
+
.map((v) => options.find((o) => o.value === v) ?? (allowCustom ? { value: v, label: v } : null))
|
|
139
|
+
.filter((o): o is PickerOption<T, D> => o != null);
|
|
140
|
+
const chip = (o: PickerOption<T, D>) =>
|
|
141
|
+
renderSelected ? renderSelected(o) : renderOptionContent ? renderOptionContent(o) : <Badge label={o.label ?? String(o.value)} color="zinc" />;
|
|
142
|
+
const onOpenChange = (next: boolean) => {
|
|
143
|
+
if (next) {
|
|
144
|
+
setDraft(value);
|
|
145
|
+
setOpen(true);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setOpen(false);
|
|
149
|
+
const changed = draft.length !== value.length || draft.some((v) => !value.includes(v));
|
|
150
|
+
if (changed) void runSave(() => onSave(draft));
|
|
151
|
+
};
|
|
152
|
+
return (
|
|
153
|
+
<InlineSelectShell
|
|
154
|
+
open={open}
|
|
155
|
+
onOpenChange={onOpenChange}
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
display={selected.length > 0 ? <View style={styles.tags}>{selected.map((o) => <View key={o.value}>{chip(o)}</View>)}</View> : ""}
|
|
158
|
+
placeholder={placeholder}
|
|
159
|
+
accessibilityLabel={accessibilityLabel}
|
|
160
|
+
variant={variant}
|
|
161
|
+
saving={saving}
|
|
162
|
+
error={error}
|
|
163
|
+
>
|
|
164
|
+
<OptionList<T, true, D>
|
|
165
|
+
multi
|
|
166
|
+
search={{ mode: searchMode }}
|
|
167
|
+
options={options}
|
|
168
|
+
value={draft}
|
|
169
|
+
onValueChange={setDraft}
|
|
170
|
+
onRequestClose={() => onOpenChange(false)}
|
|
171
|
+
allowCustom={allowCustom}
|
|
172
|
+
customOptionLabel={customOptionLabel}
|
|
173
|
+
onCustomCommit={(raw) => {
|
|
174
|
+
const created = raw.trim() as T;
|
|
175
|
+
if (created && !draft.includes(created)) setDraft([...draft, created]);
|
|
176
|
+
}}
|
|
177
|
+
renderOptionContent={renderOptionContent ?? renderSelected}
|
|
178
|
+
/>
|
|
179
|
+
</InlineSelectShell>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { value, onSave, onClear } = props;
|
|
184
|
+
// An `allowCustom` value not in `options` renders from a fallback (like `Select`); a plain
|
|
185
|
+
// unknown value (a removed member/status) resolves to nothing → the placeholder shows.
|
|
186
|
+
const selected = options.find((o) => o.value === value) ?? (allowCustom && value != null ? { value, label: value } : undefined);
|
|
187
|
+
const pick = (next: T) => {
|
|
188
|
+
setOpen(false);
|
|
189
|
+
if (next === value) return;
|
|
190
|
+
void runSave(() => onSave(next));
|
|
191
|
+
};
|
|
192
|
+
const clear = () => {
|
|
193
|
+
setOpen(false);
|
|
194
|
+
if (value == null || !onClear) return;
|
|
195
|
+
void runSave(() => onClear());
|
|
196
|
+
};
|
|
197
|
+
return (
|
|
198
|
+
<InlineSelectShell
|
|
199
|
+
open={open}
|
|
200
|
+
onOpenChange={setOpen}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
203
|
+
placeholder={placeholder}
|
|
204
|
+
accessibilityLabel={accessibilityLabel}
|
|
205
|
+
variant={variant}
|
|
206
|
+
saving={saving}
|
|
207
|
+
error={error}
|
|
208
|
+
>
|
|
209
|
+
<OptionList<T, false, D>
|
|
210
|
+
search={{ mode: searchMode }}
|
|
211
|
+
options={options}
|
|
212
|
+
value={value}
|
|
213
|
+
onValueChange={(next) => pick(next)}
|
|
214
|
+
onClear={onClear ? () => clear() : undefined}
|
|
215
|
+
onRequestClose={() => setOpen(false)}
|
|
216
|
+
allowCustom={allowCustom}
|
|
217
|
+
customOptionLabel={customOptionLabel}
|
|
218
|
+
onCustomCommit={(raw) => {
|
|
219
|
+
const created = raw.trim() as T;
|
|
220
|
+
if (created) pick(created);
|
|
221
|
+
}}
|
|
222
|
+
renderOptionContent={renderOptionContent}
|
|
223
|
+
/>
|
|
224
|
+
</InlineSelectShell>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const styles = StyleSheet.create({
|
|
229
|
+
tags: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 4, rowGap: 2 },
|
|
230
|
+
error: { marginTop: 4 },
|
|
231
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
|
3
3
|
import { TextInputField } from "./text_input_field";
|
|
4
|
-
import { type
|
|
4
|
+
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
5
|
|
|
6
6
|
export interface InlineTextInputProps {
|
|
7
7
|
value: string;
|
|
@@ -16,7 +16,7 @@ export interface InlineTextInputProps {
|
|
|
16
16
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
17
17
|
struck?: boolean;
|
|
18
18
|
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
19
|
-
|
|
19
|
+
variant?: InlineEditVariant;
|
|
20
20
|
accessibilityLabel?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -27,7 +27,7 @@ export interface InlineTextInputProps {
|
|
|
27
27
|
* value in a dense record / detail surface.
|
|
28
28
|
*/
|
|
29
29
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
30
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel ,
|
|
30
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant } = props;
|
|
31
31
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
32
32
|
|
|
33
33
|
const onKeyPress = useCallback(
|
|
@@ -49,7 +49,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
51
|
<InlineEditFrame
|
|
52
|
-
|
|
52
|
+
variant={variant}
|
|
53
53
|
editing={edit.editing}
|
|
54
54
|
display={value}
|
|
55
55
|
placeholder={placeholder}
|
|
@@ -3,7 +3,7 @@ import type { KeyboardEvent } from "react";
|
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { TimePicker } from "./time_picker";
|
|
6
|
-
import { type
|
|
6
|
+
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
7
7
|
|
|
8
8
|
export interface InlineTimePickerProps {
|
|
9
9
|
/** Canonical 24-hour "HH:mm", "" when empty. */
|
|
@@ -16,7 +16,7 @@ export interface InlineTimePickerProps {
|
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
accessibilityLabel?: string;
|
|
18
18
|
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
19
|
-
|
|
19
|
+
variant?: InlineEditVariant;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -25,7 +25,7 @@ export interface InlineTimePickerProps {
|
|
|
25
25
|
* field at the same height, so the form never reflows.
|
|
26
26
|
*/
|
|
27
27
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
28
|
-
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel ,
|
|
28
|
+
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
|
|
29
29
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
30
30
|
|
|
31
31
|
const onKeyDown = useCallback(
|
|
@@ -43,7 +43,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
45
|
<InlineEditFrame
|
|
46
|
-
|
|
46
|
+
variant={variant}
|
|
47
47
|
editing={edit.editing}
|
|
48
48
|
display={value}
|
|
49
49
|
placeholder={placeholder}
|
package/src/inset.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
interface InsetProps {
|
|
6
|
+
/** The grouped content — a sub-form's fields, an inline fill editor, a nested block. */
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Test id on the container. */
|
|
9
|
+
testID?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A tinted, recessed content surface — the "well" INSIDE a Card or Section for a grouped
|
|
14
|
+
* sub-form, an inline fill editor, or a nested detail block. It carries the kit's
|
|
15
|
+
* nested-surface tokens (zinc-50 fill, 10 radius, 14 padding) and a 12 gap so stacked
|
|
16
|
+
* fields keep the form rhythm.
|
|
17
|
+
*
|
|
18
|
+
* `Inset` is NOT a `Callout`. A Callout is a status BAND — it announces a message, and
|
|
19
|
+
* for `warning`/`error` it is an ARIA `alert`. Wrapping form fields in an alert is wrong:
|
|
20
|
+
* a screen reader announces the whole form as one time-sensitive alert. The law is:
|
|
21
|
+
* **content + fields go in an `Inset`; a message goes in a `Callout`.**
|
|
22
|
+
*/
|
|
23
|
+
export function Inset({ children, testID }: InsetProps) {
|
|
24
|
+
return (
|
|
25
|
+
<View style={styles.inset} testID={testID}>
|
|
26
|
+
{children}
|
|
27
|
+
</View>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const styles = StyleSheet.create({
|
|
32
|
+
inset: {
|
|
33
|
+
backgroundColor: colors.zinc[50],
|
|
34
|
+
borderRadius: 10,
|
|
35
|
+
padding: 14,
|
|
36
|
+
gap: 12,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View, Pressable, StyleSheet } from "react-native";
|
|
3
|
+
import { PressableRow } from "./pressable_row";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon, type IconName } from "./icon";
|
|
6
|
+
import { colors } from "./colors";
|
|
7
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
8
|
+
import { FOCUS_RING } from "./control_surface";
|
|
9
|
+
|
|
10
|
+
interface LinkedRecordBoxProps {
|
|
11
|
+
/** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
|
|
12
|
+
icon: IconName;
|
|
13
|
+
/** The linked record's display name. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** A secondary identity line — a code, tax id, country, or "<desk> record". */
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
/** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). */
|
|
18
|
+
facts?: { label: string; value: string }[];
|
|
19
|
+
/** Width of the facts' label column (default 80). Widen for longer labels. */
|
|
20
|
+
factLabelWidth?: number;
|
|
21
|
+
/** Accessible name for opening the record (e.g. "Acme Corp — details"). */
|
|
22
|
+
doorLabel: string;
|
|
23
|
+
/** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
|
|
24
|
+
onOpen: () => void;
|
|
25
|
+
/** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
|
|
26
|
+
* hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
|
|
27
|
+
* spacer between). The divider is drawn automatically when actions are present. Supplied by
|
|
28
|
+
* the consumer so the labels stay localized and the verb set fits the link. */
|
|
29
|
+
actions?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
|
|
34
|
+
* (identity glyph · name · facts), the WHOLE box a keyboard-accessible door into that record's
|
|
35
|
+
* detail. Use it wherever one record points at another (a shipment's customer, an invoice's
|
|
36
|
+
* party, a case's sibling); the picker / empty-state that REASSIGNS the link is the consumer's,
|
|
37
|
+
* shown in place of the box.
|
|
38
|
+
*
|
|
39
|
+
* The a11y contract it enforces (the error-prone part, hence a primitive): a container with
|
|
40
|
+
* interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
|
|
41
|
+
* role-less `PressableRow`, an empty absolutely-positioned door sibling carries the tab stop /
|
|
42
|
+
* accessible name / focus ring, and the interior verbs lift above it via `zIndex`.
|
|
43
|
+
*/
|
|
44
|
+
export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
|
|
45
|
+
return (
|
|
46
|
+
<PressableRow onPress={onOpen} style={styles.box}>
|
|
47
|
+
<Door label={doorLabel} onPress={onOpen} />
|
|
48
|
+
<View style={styles.identity}>
|
|
49
|
+
<View style={styles.glyph}>
|
|
50
|
+
<Icon name={icon} size={17} color={colors.zinc[600]} />
|
|
51
|
+
</View>
|
|
52
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
53
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{name}</Text>
|
|
54
|
+
{subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
|
|
55
|
+
</View>
|
|
56
|
+
</View>
|
|
57
|
+
{facts && facts.length > 0 ? (
|
|
58
|
+
<View style={{ gap: 6 }}>
|
|
59
|
+
{facts.map((f) => (
|
|
60
|
+
<View key={f.label} style={styles.fact}>
|
|
61
|
+
<Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
|
|
62
|
+
<Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
|
|
63
|
+
</View>
|
|
64
|
+
))}
|
|
65
|
+
</View>
|
|
66
|
+
) : null}
|
|
67
|
+
{actions ? (
|
|
68
|
+
<>
|
|
69
|
+
{/* full-bleed hairline fencing the verb footer off from the facts */}
|
|
70
|
+
<View style={styles.divider} />
|
|
71
|
+
<View style={styles.actions}>{actions}</View>
|
|
72
|
+
</>
|
|
73
|
+
) : null}
|
|
74
|
+
</PressableRow>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The keyboard DOOR: an empty absolutely-positioned sibling beneath the box content — a
|
|
79
|
+
// button must NOT wrap the box's interactive descendants, so the door alone carries the tab
|
|
80
|
+
// stop, accessible name, and focus ring; mouse presses ride the PressableRow surface.
|
|
81
|
+
function Door({ label, onPress }: { label: string; onPress: () => void }) {
|
|
82
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
83
|
+
return (
|
|
84
|
+
<Pressable
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
accessibilityLabel={label}
|
|
87
|
+
onPress={onPress}
|
|
88
|
+
{...focusProps}
|
|
89
|
+
style={[styles.door, focusVisible ? { boxShadow: FOCUS_RING } : null]}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
|
|
96
|
+
door: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
|
|
97
|
+
identity: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
98
|
+
glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
|
|
99
|
+
fact: { flexDirection: "row", gap: 10 },
|
|
100
|
+
divider: { height: 1, backgroundColor: colors.zinc[100], marginHorizontal: -16 },
|
|
101
|
+
actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 },
|
|
102
|
+
});
|
package/src/number_input.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export interface NumberInputProps {
|
|
|
28
28
|
export function NumberInput(props: NumberInputProps) {
|
|
29
29
|
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
|
|
30
30
|
const binding = useFormField();
|
|
31
|
-
const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
31
|
+
const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
32
32
|
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
33
33
|
const { hovered, hoverProps } = useHover();
|
|
34
34
|
|