@lotics/ui 12.1.1 → 13.7.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/AGENTS.md +5 -0
- package/docs/catalog.md +78 -35
- 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 -6
- 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/choice_list.tsx +47 -36
- package/src/clarify.tsx +14 -24
- package/src/detail_row.tsx +24 -8
- 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
package/src/inline_edit.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { ActivityIndicator } from "./activity_indicator";
|
|
|
7
7
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
8
8
|
import { colors } from "./colors";
|
|
9
9
|
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
10
|
-
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
10
|
+
import { fontFamilyRegular, getInputTextStyle, getTextColor, type TextColor } from "./text_utils";
|
|
11
11
|
import { getInteractionModality } from "./interaction_modality";
|
|
12
12
|
import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
13
13
|
|
|
@@ -85,12 +85,15 @@ export function useInlineEdit<T>(opts: {
|
|
|
85
85
|
return { editing, draft, setDraft, saving, error, begin, cancel, commit };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
88
|
+
/** Which surface an inline editor lives on — the ONE axis that separates a FORM
|
|
89
|
+
* field from a data-grid CELL:
|
|
90
|
+
* - `"form"` (default): the zinc-50 chip at rest + a hover-BORDER. THE editability
|
|
91
|
+
* affordance on surfaces that MIX editable and static values (a `DetailTable`).
|
|
92
|
+
* - `"cell"`: transparent at rest + a background-tint WASH on hover (the same
|
|
93
|
+
* `PressableHighlight` language as the row/cell it sits in). For DENSE,
|
|
94
|
+
* uniformly-editable surfaces where every cell edits — a register/`DataGrid`
|
|
95
|
+
* column, a task row — where the repeated chip + per-field border are noise. */
|
|
96
|
+
export type InlineEditVariant = "form" | "cell";
|
|
94
97
|
|
|
95
98
|
interface InlineEditFrameProps {
|
|
96
99
|
editing: boolean;
|
|
@@ -112,8 +115,8 @@ interface InlineEditFrameProps {
|
|
|
112
115
|
accessibilityLabel?: string;
|
|
113
116
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
114
117
|
struck?: boolean;
|
|
115
|
-
/**
|
|
116
|
-
|
|
118
|
+
/** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
|
|
119
|
+
variant?: InlineEditVariant;
|
|
117
120
|
/** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
|
|
118
121
|
* field) — a hover-independent cue the field opens a picker. Omit for plain
|
|
119
122
|
* text/number inputs (typing IS the affordance). */
|
|
@@ -125,7 +128,9 @@ interface InlineEditViewProps {
|
|
|
125
128
|
* rendered as-is — e.g. a `MemberChip` / `OptionBadge` for a rich resting
|
|
126
129
|
* value. A node takes the flex slot; the trailing adornment still right-aligns. */
|
|
127
130
|
display: string | ReactNode;
|
|
128
|
-
placeholder
|
|
131
|
+
/** Empty-state content. A string reads as muted placeholder text; a NODE (a dashed
|
|
132
|
+
* avatar "add" ghost, a placeholder chip) renders as-is in the value slot. */
|
|
133
|
+
placeholder?: string | ReactNode;
|
|
129
134
|
onPress?: () => void;
|
|
130
135
|
disabled?: boolean;
|
|
131
136
|
accessibilityLabel?: string;
|
|
@@ -141,8 +146,12 @@ interface InlineEditViewProps {
|
|
|
141
146
|
onFocus?: () => void;
|
|
142
147
|
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
143
148
|
struck?: boolean;
|
|
144
|
-
/**
|
|
145
|
-
|
|
149
|
+
/** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
|
|
150
|
+
variant?: InlineEditVariant;
|
|
151
|
+
/** Colours the resting VALUE by semantic state — an overdue date red, a soon-due
|
|
152
|
+
* amber (the caller owns the rule). Applies only to a set, non-struck string value;
|
|
153
|
+
* the placeholder + struck styles win. Default: the standard value ink. */
|
|
154
|
+
tone?: TextColor;
|
|
146
155
|
ref?: Ref<View>;
|
|
147
156
|
}
|
|
148
157
|
|
|
@@ -157,7 +166,8 @@ interface InlineEditViewProps {
|
|
|
157
166
|
* select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
|
|
158
167
|
*/
|
|
159
168
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
160
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck,
|
|
169
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, variant = "form", tone, onFocus, ref } = props;
|
|
170
|
+
const isEmptyString = typeof display === "string" && display === "";
|
|
161
171
|
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
162
172
|
// row that expands on press) edits the field instead of triggering the row.
|
|
163
173
|
const handlePress = onPress
|
|
@@ -175,14 +185,19 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
175
185
|
accessibilityRole="button"
|
|
176
186
|
accessibilityLabel={accessibilityLabel}
|
|
177
187
|
userSelect="none"
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
188
|
+
// Hover language follows the variant: a `form` field reveals its BORDER
|
|
189
|
+
// (an input's edge); a `cell` field lives in a dense grid/row/task context,
|
|
190
|
+
// so it takes the background-tint WASH — the same `PressableHighlight`
|
|
191
|
+
// language as the cells beside it. Both suppressed while open (fill + ring lead).
|
|
192
|
+
style={(state) => [styles.view, (variant === "cell" || disabled) && styles.viewTransparent, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && (variant === "cell" ? styles.viewHoveredWash : styles.viewHovered)]}
|
|
182
193
|
>
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
194
|
+
{/* Empty string display → the placeholder (a node renders as-is, a string reads muted);
|
|
195
|
+
a node display renders as-is; a value string wears the value style (+ tone). */}
|
|
196
|
+
{isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
|
|
197
|
+
<View style={styles.viewNode}>{placeholder}</View>
|
|
198
|
+
) : typeof display === "string" ? (
|
|
199
|
+
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
|
|
200
|
+
{display || (typeof placeholder === "string" ? placeholder : "") || "—"}
|
|
186
201
|
</Text>
|
|
187
202
|
) : (
|
|
188
203
|
<View style={styles.viewNode}>{display}</View>
|
|
@@ -217,7 +232,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
217
232
|
disabled,
|
|
218
233
|
accessibilityLabel,
|
|
219
234
|
struck,
|
|
220
|
-
|
|
235
|
+
variant,
|
|
221
236
|
affordance,
|
|
222
237
|
} = props;
|
|
223
238
|
|
|
@@ -266,7 +281,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
266
281
|
return (
|
|
267
282
|
<InlineEditView
|
|
268
283
|
ref={viewRef}
|
|
269
|
-
|
|
284
|
+
variant={variant}
|
|
270
285
|
display={display}
|
|
271
286
|
placeholder={placeholder}
|
|
272
287
|
onPress={onBegin}
|
|
@@ -357,11 +372,15 @@ const styles = StyleSheet.create({
|
|
|
357
372
|
backgroundColor: colors.white,
|
|
358
373
|
boxShadow: FOCUS_RING,
|
|
359
374
|
},
|
|
360
|
-
// Hover (not open):
|
|
361
|
-
//
|
|
375
|
+
// Hover (not open): a tint FORM field reveals the kit hover-border (input family);
|
|
376
|
+
// a transparent CELL/dense field takes the background-tint wash (zinc-100), the
|
|
377
|
+
// same language as the cells beside it.
|
|
362
378
|
viewHovered: {
|
|
363
379
|
borderColor: HOVER_BORDER,
|
|
364
380
|
},
|
|
381
|
+
viewHoveredWash: {
|
|
382
|
+
backgroundColor: colors.zinc[100],
|
|
383
|
+
},
|
|
365
384
|
placeholder: { color: colors.zinc[400] },
|
|
366
385
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
367
386
|
viewNode: { flex: 1, minWidth: 0 },
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useCallback, useMemo } from "react";
|
|
2
|
-
import
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import type { InlineEditVariant } from "./inline_edit";
|
|
3
4
|
import { InlineSelect } from "./inline_select";
|
|
4
5
|
import { MemberChip } from "./member_chip";
|
|
6
|
+
import { Avatar } from "./avatar";
|
|
7
|
+
import { Icon } from "./icon";
|
|
8
|
+
import { colors } from "./colors";
|
|
5
9
|
import type { MemberSelectMember } from "./member_select";
|
|
6
10
|
import type { PickerOption } from "./picker";
|
|
7
11
|
|
|
@@ -23,19 +27,25 @@ interface InlineMemberSelectProps {
|
|
|
23
27
|
placeholder?: string;
|
|
24
28
|
disabled?: boolean;
|
|
25
29
|
accessibilityLabel?: string;
|
|
26
|
-
/**
|
|
27
|
-
|
|
30
|
+
/** Form field (default) or grid cell — see {@link InlineEditVariant}. */
|
|
31
|
+
variant?: InlineEditVariant;
|
|
28
32
|
/** Show an in-menu search box to filter the roster; default false. */
|
|
29
33
|
searchable?: boolean;
|
|
34
|
+
/** Resting display = the bare AVATAR only (no name), a dashed "add" ghost when
|
|
35
|
+
* empty — for a DENSE row/cell where the name won't fit (a task-row assignee).
|
|
36
|
+
* The dropdown rows still show avatar + name. Default false (avatar + name). */
|
|
37
|
+
avatarOnly?: boolean;
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
const AVATAR_ONLY_SIZE = 24;
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* Edit a `select_member` field in place: the assigned member shows as a
|
|
34
|
-
* {@link MemberChip} (avatar + name)
|
|
35
|
-
*
|
|
36
|
-
* layout shift. The inline-edit twin
|
|
37
|
-
* form); built on {@link InlineSelect}. PURE:
|
|
38
|
-
* app feeds `useMembers`); this fetches nothing.
|
|
44
|
+
* {@link MemberChip} (avatar + name) — or the bare avatar with `avatarOnly` for a
|
|
45
|
+
* dense cell — at the kit's control height; clicking floats a member picker (every
|
|
46
|
+
* option a `MemberChip`) and picking commits, no layout shift. The inline-edit twin
|
|
47
|
+
* of {@link MemberSelect} (use that in a form); built on {@link InlineSelect}. PURE:
|
|
48
|
+
* pass the candidate `members` (an app feeds `useMembers`); this fetches nothing.
|
|
39
49
|
*
|
|
40
50
|
* ```tsx
|
|
41
51
|
* const { members } = useMembers();
|
|
@@ -43,7 +53,7 @@ interface InlineMemberSelectProps {
|
|
|
43
53
|
* ```
|
|
44
54
|
*/
|
|
45
55
|
export function InlineMemberSelect(props: InlineMemberSelectProps) {
|
|
46
|
-
const { members, ...inline } = props;
|
|
56
|
+
const { members, avatarOnly, placeholder, ...inline } = props;
|
|
47
57
|
|
|
48
58
|
const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
|
|
49
59
|
const options = useMemo<PickerOption<string>[]>(
|
|
@@ -60,12 +70,35 @@ export function InlineMemberSelect(props: InlineMemberSelectProps) {
|
|
|
60
70
|
[byId],
|
|
61
71
|
);
|
|
62
72
|
|
|
73
|
+
const renderAvatar = useCallback(
|
|
74
|
+
(option: PickerOption<string>) => {
|
|
75
|
+
const member = byId.get(option.value);
|
|
76
|
+
if (!member) return null;
|
|
77
|
+
return <Avatar size={AVATAR_ONLY_SIZE} name={member.name || member.email || member.id} source={member.image ? { uri: member.image } : undefined} announce />;
|
|
78
|
+
},
|
|
79
|
+
[byId],
|
|
80
|
+
);
|
|
81
|
+
|
|
63
82
|
return (
|
|
64
83
|
<InlineSelect<string>
|
|
65
84
|
options={options}
|
|
66
85
|
renderOptionContent={renderMember}
|
|
67
|
-
renderSelected={renderMember}
|
|
86
|
+
renderSelected={avatarOnly ? renderAvatar : renderMember}
|
|
87
|
+
placeholder={avatarOnly ? <View style={styles.ghost}><Icon name="plus" size={13} color={colors.zinc[400]} /></View> : placeholder}
|
|
68
88
|
{...inline}
|
|
69
89
|
/>
|
|
70
90
|
);
|
|
71
91
|
}
|
|
92
|
+
|
|
93
|
+
const styles = StyleSheet.create({
|
|
94
|
+
ghost: {
|
|
95
|
+
width: AVATAR_ONLY_SIZE,
|
|
96
|
+
height: AVATAR_ONLY_SIZE,
|
|
97
|
+
borderRadius: AVATAR_ONLY_SIZE / 2,
|
|
98
|
+
borderWidth: 1,
|
|
99
|
+
borderStyle: "dashed",
|
|
100
|
+
borderColor: colors.zinc[300],
|
|
101
|
+
alignItems: "center",
|
|
102
|
+
justifyContent: "center",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -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}
|