@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
package/src/badge.tsx
CHANGED
|
@@ -9,15 +9,16 @@ interface BadgeProps {
|
|
|
9
9
|
label?: string;
|
|
10
10
|
color?: ColorName;
|
|
11
11
|
/**
|
|
12
|
-
* The status-indicator weight
|
|
13
|
-
* - "
|
|
14
|
-
* register
|
|
15
|
-
*
|
|
16
|
-
* - "
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* The status-indicator weight — DEFAULT to `dot`:
|
|
13
|
+
* - "dot": pill-less — a colored dot + label. The everyday status indicator:
|
|
14
|
+
* legends, register cells, inline/secondary status, a metric's quality cue.
|
|
15
|
+
* Light enough to sit anywhere without shouting. Reach for this by default.
|
|
16
|
+
* - "tonal": a filled pill — HEAVY, and easy to overuse into clutter. Reserve
|
|
17
|
+
* it for the ONE prominent status of a surface (a drawer header, a register's
|
|
18
|
+
* primary Status column). One per view, a deliberate choice — not sprinkled
|
|
19
|
+
* through rows or option lists. If in doubt, use `dot`.
|
|
20
|
+
* (StatusGrid/StatusLegend keep their own dot — raw hex coupled to the grid
|
|
21
|
+
* cells' tint, a separate data-viz concern.)
|
|
21
22
|
*/
|
|
22
23
|
variant?: "tonal" | "dot";
|
|
23
24
|
style?: StyleProp<ViewStyle>;
|
|
@@ -25,8 +26,23 @@ interface BadgeProps {
|
|
|
25
26
|
userSelect?: "none" | "auto";
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A STATUS indicator — a lifecycle state, a risk level, a quality cue — that reads at a
|
|
31
|
+
* GLANCE. That is the ONLY thing a Badge is for; keep the vocabulary scarce so a colored
|
|
32
|
+
* chip always MEANS state.
|
|
33
|
+
*
|
|
34
|
+
* NOT for a type / category / attribute / count (a party's type, a document kind, a city,
|
|
35
|
+
* "3 files") — that is not a status, and a pill for it is noise. Render it as inline TEXT
|
|
36
|
+
* (a muted metadata line; a taller row is fine). If you're adding a Badge "to show more
|
|
37
|
+
* info" in a row or an option list, STOP — that's text, not a badge.
|
|
38
|
+
*
|
|
39
|
+
* Default `variant="dot"`; `tonal` is the heavy exception (see the prop doc).
|
|
40
|
+
*/
|
|
41
|
+
|
|
28
42
|
export function Badge(props: BadgeProps) {
|
|
29
|
-
|
|
43
|
+
// Default `dot` (the light indicator) — `tonal` is the heavy pill you opt INTO
|
|
44
|
+
// deliberately, so a bare <Badge> never silently clutters a row with a filled chip.
|
|
45
|
+
const { label, color, variant = "dot", style, tooltip, userSelect = "none" } = props;
|
|
30
46
|
const tooltipProps = useTooltip(tooltip);
|
|
31
47
|
const hue = color && colors[color] ? colors[color] : null;
|
|
32
48
|
|
package/src/checklist.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
|
21
21
|
// <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
|
|
22
22
|
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
23
23
|
// accessibilityLabel: "Task options: …" }}>
|
|
24
|
-
// <InlineTextInput
|
|
24
|
+
// <InlineTextInput variant="cell" struck={done} … />
|
|
25
25
|
// </ChecklistRow>
|
|
26
26
|
// <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
|
|
27
27
|
// <CaptureRow … />
|
package/src/detail_row.tsx
CHANGED
|
@@ -111,6 +111,11 @@ export interface DetailRowProps {
|
|
|
111
111
|
* hidden ⓘ gloss. Invalid STATE → `error` or a co-located `Callout`. Same
|
|
112
112
|
* name, same meaning as `FormField.description`. */
|
|
113
113
|
description?: string;
|
|
114
|
+
/** A consequence the user should WEIGH before acting — renders in the same
|
|
115
|
+
* annotation slot in amber (`Text color="warning"`), one tone between the
|
|
116
|
+
* muted `description` and the danger `error`, announced on appearance. Same
|
|
117
|
+
* name, same meaning as `FormField.warning`. */
|
|
118
|
+
warning?: string;
|
|
114
119
|
/** Field-level failure, under the value in danger ink with `FormField`'s
|
|
115
120
|
* alert semantics (announced on appearance). For CONSUMER-validated state —
|
|
116
121
|
* the `Inline*` editors already render their own transient save errors;
|
|
@@ -136,13 +141,13 @@ export interface DetailRowProps {
|
|
|
136
141
|
*/
|
|
137
142
|
export function DetailRow(props: DetailRowProps) {
|
|
138
143
|
const table = useContext(DetailTableContext);
|
|
139
|
-
const { label, children, trailing, labelSize = "sm", description, error, flat } = props;
|
|
144
|
+
const { label, children, trailing, labelSize = "sm", description, warning, error, flat } = props;
|
|
140
145
|
const labelWidth = props.labelWidth ?? table?.labelWidth;
|
|
141
146
|
const minHeight = props.minHeight ?? table?.minHeight ?? 28;
|
|
142
147
|
const form = labelWidth != null;
|
|
143
|
-
// Annotations (description / error) live UNDER THE VALUE — the row
|
|
144
|
-
// so the label + trailing center against the CONTROL LINE, not the block.
|
|
145
|
-
const annotated = description != null || error != null;
|
|
148
|
+
// Annotations (description / warning / error) live UNDER THE VALUE — the row
|
|
149
|
+
// grows, so the label + trailing center against the CONTROL LINE, not the block.
|
|
150
|
+
const annotated = description != null || warning != null || error != null;
|
|
146
151
|
// A FLAT value (no painted chip) centers its text in the control band,
|
|
147
152
|
// leaving invisible slack below it — tuck the annotations up by that slack
|
|
148
153
|
// so the perceived gap matches a chip row's. Horizontal mode only (stacked
|
|
@@ -155,9 +160,10 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
155
160
|
</Text>
|
|
156
161
|
</View>
|
|
157
162
|
);
|
|
158
|
-
//
|
|
159
|
-
// semantics), then the guidance.
|
|
160
|
-
// inset (paddingHorizontal 8), so
|
|
163
|
+
// Severity descending from the control: error first (adjacent to the control
|
|
164
|
+
// that failed, FormField's alert semantics), then warning, then the guidance.
|
|
165
|
+
// Indented to the inline chip's OWN text inset (paddingHorizontal 8), so
|
|
166
|
+
// annotations align with the value's text.
|
|
161
167
|
const annotations = annotated ? (
|
|
162
168
|
<>
|
|
163
169
|
{error != null ? (
|
|
@@ -165,6 +171,11 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
165
171
|
{error}
|
|
166
172
|
</Text>
|
|
167
173
|
) : null}
|
|
174
|
+
{warning != null ? (
|
|
175
|
+
<Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
|
|
176
|
+
{warning}
|
|
177
|
+
</Text>
|
|
178
|
+
) : null}
|
|
168
179
|
{description != null ? (
|
|
169
180
|
<Text size="xs" color="muted" style={styles.annotation}>
|
|
170
181
|
{description}
|
|
@@ -184,12 +195,17 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
184
195
|
{label}
|
|
185
196
|
</Text>
|
|
186
197
|
{/* stacked wears the FORM grammar exactly: label · description ·
|
|
187
|
-
control · error (the `FormField` order) */}
|
|
198
|
+
control · warning · error (the `FormField` order) */}
|
|
188
199
|
{description != null ? <Text color="muted">{description}</Text> : null}
|
|
189
200
|
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
190
201
|
<View style={styles.value}>{children}</View>
|
|
191
202
|
{trailing != null ? trailing : null}
|
|
192
203
|
</View>
|
|
204
|
+
{warning != null ? (
|
|
205
|
+
<Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
|
|
206
|
+
{warning}
|
|
207
|
+
</Text>
|
|
208
|
+
) : null}
|
|
193
209
|
{error != null ? (
|
|
194
210
|
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
|
|
195
211
|
{error}
|
package/src/drawer.tsx
CHANGED
|
@@ -13,7 +13,11 @@ export interface DrawerProps {
|
|
|
13
13
|
onOpenChange: (open: boolean) => void;
|
|
14
14
|
/** Header title. A string renders as the standard title; a node renders as-is. */
|
|
15
15
|
title?: ReactNode;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Panel width on non-small screens (number = px, or a `%` string). Rarely set —
|
|
18
|
+
* the ONE standard width (600) fits record side-panels and lighter detail alike;
|
|
19
|
+
* override only for a genuinely exceptional panel. Full-width on small screens.
|
|
20
|
+
*/
|
|
17
21
|
width?: number | `${number}%`;
|
|
18
22
|
/**
|
|
19
23
|
* Record sequencing — step to the previous/next record WITHOUT closing the
|
|
@@ -43,8 +47,12 @@ export interface DrawerProps {
|
|
|
43
47
|
* the scrim and the header close button both dismiss it. The panel goes
|
|
44
48
|
* full-width on small screens.
|
|
45
49
|
*/
|
|
50
|
+
/** The ONE standard drawer width — every drawer is this wide unless it passes an
|
|
51
|
+
* explicit `width` for a genuinely exceptional case. */
|
|
52
|
+
const DRAWER_WIDTH = 600;
|
|
53
|
+
|
|
46
54
|
export function Drawer(props: DrawerProps) {
|
|
47
|
-
const { open, onOpenChange, title, width =
|
|
55
|
+
const { open, onOpenChange, title, width = DRAWER_WIDTH, onPrev, onNext, position, children, testID } = props;
|
|
48
56
|
const loc = useLoticsLocale().drawer;
|
|
49
57
|
const screenSize = useScreenSize();
|
|
50
58
|
useOverlayScope(open);
|
package/src/form_date_picker.tsx
CHANGED
|
@@ -8,12 +8,13 @@ export interface FormDatePickerProps
|
|
|
8
8
|
/** `FormField` wrapping a `DatePicker` — one labeled date field. (Omits `style`; for a grid cell
|
|
9
9
|
* use a bare `FormField style={half}` around `DatePicker`.) */
|
|
10
10
|
export function FormDatePicker(props: FormDatePickerProps) {
|
|
11
|
-
const { label, description, optional, optionalLabel, error, ...datePickerProps } = props;
|
|
11
|
+
const { label, description, warning, optional, optionalLabel, error, ...datePickerProps } = props;
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
14
|
<FormField
|
|
15
15
|
label={label}
|
|
16
16
|
description={description}
|
|
17
|
+
warning={warning}
|
|
17
18
|
error={error}
|
|
18
19
|
optional={optional}
|
|
19
20
|
optionalLabel={optionalLabel}
|
package/src/form_field.tsx
CHANGED
|
@@ -8,6 +8,10 @@ export interface FormFieldProps {
|
|
|
8
8
|
label: string;
|
|
9
9
|
optional?: boolean;
|
|
10
10
|
description?: string;
|
|
11
|
+
/** A consequence the user should WEIGH before acting — amber, one tone between
|
|
12
|
+
* the muted `description` and the danger `error`. Same name/meaning on
|
|
13
|
+
* `DetailRow`. */
|
|
14
|
+
warning?: string;
|
|
11
15
|
error?: string;
|
|
12
16
|
style?: StyleProp<ViewStyle>;
|
|
13
17
|
/** Label shown next to `label` when `optional` is true. Defaults to the locale
|
|
@@ -18,12 +22,13 @@ export interface FormFieldProps {
|
|
|
18
22
|
/**
|
|
19
23
|
* Binding emitted by `FormField` to the single input it wraps. Descendants
|
|
20
24
|
* that call `useFormField()` apply these props to the native input so screen
|
|
21
|
-
* readers know the label,
|
|
25
|
+
* readers know the label, description, warning, and error apply to it.
|
|
22
26
|
*/
|
|
23
27
|
export interface FormFieldBinding {
|
|
24
28
|
inputId: string;
|
|
25
29
|
labelId: string;
|
|
26
30
|
descriptionId: string | undefined;
|
|
31
|
+
warningId: string | undefined;
|
|
27
32
|
errorId: string | undefined;
|
|
28
33
|
invalid: boolean;
|
|
29
34
|
}
|
|
@@ -33,7 +38,7 @@ const FormFieldContext = createContext<FormFieldBinding | null>(null);
|
|
|
33
38
|
/**
|
|
34
39
|
* Returns the association IDs set by the nearest enclosing `FormField`.
|
|
35
40
|
* Inputs spread the returned props onto their underlying element so label,
|
|
36
|
-
* description, and error are announced together. Returns `null` when the
|
|
41
|
+
* description, warning, and error are announced together. Returns `null` when the
|
|
37
42
|
* input is used outside a `FormField` — callers should then provide their
|
|
38
43
|
* own `accessibilityLabel`.
|
|
39
44
|
*/
|
|
@@ -42,18 +47,20 @@ export function useFormField(): FormFieldBinding | null {
|
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export function FormField(props: FormFieldProps & { children: React.ReactNode }) {
|
|
45
|
-
const { label, description, error, optional, style, children, optionalLabel } = props;
|
|
50
|
+
const { label, description, warning, error, optional, style, children, optionalLabel } = props;
|
|
46
51
|
const resolvedOptionalLabel = optionalLabel ?? useLoticsLocale().formField.optional;
|
|
47
52
|
|
|
48
53
|
const inputId = useId();
|
|
49
54
|
const labelId = `${inputId}-label`;
|
|
50
55
|
const descriptionId = description ? `${inputId}-description` : undefined;
|
|
56
|
+
const warningId = warning ? `${inputId}-warning` : undefined;
|
|
51
57
|
const errorId = error ? `${inputId}-error` : undefined;
|
|
52
58
|
|
|
53
59
|
const binding: FormFieldBinding = {
|
|
54
60
|
inputId,
|
|
55
61
|
labelId,
|
|
56
62
|
descriptionId,
|
|
63
|
+
warningId,
|
|
57
64
|
errorId,
|
|
58
65
|
invalid: !!error,
|
|
59
66
|
};
|
|
@@ -86,6 +93,19 @@ export function FormField(props: FormFieldProps & { children: React.ReactNode })
|
|
|
86
93
|
)}
|
|
87
94
|
<Spacer size={8} />
|
|
88
95
|
<View>{children}</View>
|
|
96
|
+
{warning && (
|
|
97
|
+
<>
|
|
98
|
+
<Spacer size={8} />
|
|
99
|
+
{/*
|
|
100
|
+
Below the control, above the error — the three tones read
|
|
101
|
+
description · warning · error, ascending severity. `role="alert"`
|
|
102
|
+
+ `aria-live="polite"` announce it politely on appearance.
|
|
103
|
+
*/}
|
|
104
|
+
<Text nativeID={warningId} color="warning" accessibilityRole="alert" aria-live="polite">
|
|
105
|
+
{warning}
|
|
106
|
+
</Text>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
89
109
|
{error && (
|
|
90
110
|
<>
|
|
91
111
|
<Spacer size={8} />
|
package/src/form_picker.tsx
CHANGED
|
@@ -5,12 +5,13 @@ import { Picker, PickerProps } from "./picker";
|
|
|
5
5
|
export function FormPicker<T extends string>(
|
|
6
6
|
props: PickerProps<T> & FormFieldProps,
|
|
7
7
|
) {
|
|
8
|
-
const { label, description, optional, optionalLabel, error, ...pickerProps } = props;
|
|
8
|
+
const { label, description, warning, optional, optionalLabel, error, ...pickerProps } = props;
|
|
9
9
|
|
|
10
10
|
return (
|
|
11
11
|
<FormField
|
|
12
12
|
label={label}
|
|
13
13
|
description={description}
|
|
14
|
+
warning={warning}
|
|
14
15
|
error={error}
|
|
15
16
|
optional={optional}
|
|
16
17
|
optionalLabel={optionalLabel}
|
package/src/form_switch.tsx
CHANGED
|
@@ -7,10 +7,11 @@ import { Spacer } from "./spacer";
|
|
|
7
7
|
|
|
8
8
|
export interface FormSwitchProps extends Omit<FormFieldProps, "optional">, SwitchProps {}
|
|
9
9
|
|
|
10
|
-
/** A labeled on/off toggle — a `Switch` + clickable label + optional `description`/`error`. */
|
|
10
|
+
/** A labeled on/off toggle — a `Switch` + clickable label + optional `description`/`warning`/`error`. */
|
|
11
11
|
export function FormSwitch(props: FormSwitchProps) {
|
|
12
|
-
const { label, description, error, value, onChange } = props;
|
|
12
|
+
const { label, description, warning, error, value, onChange } = props;
|
|
13
13
|
const labelId = useId();
|
|
14
|
+
const warningId = warning ? `${labelId}-warning` : undefined;
|
|
14
15
|
const errorId = error ? `${labelId}-error` : undefined;
|
|
15
16
|
|
|
16
17
|
return (
|
|
@@ -44,6 +45,19 @@ export function FormSwitch(props: FormSwitchProps) {
|
|
|
44
45
|
</View>
|
|
45
46
|
</View>
|
|
46
47
|
|
|
48
|
+
{!!warning && (
|
|
49
|
+
<>
|
|
50
|
+
<Spacer size={8} />
|
|
51
|
+
<Text
|
|
52
|
+
nativeID={warningId}
|
|
53
|
+
color="warning"
|
|
54
|
+
accessibilityRole="alert"
|
|
55
|
+
aria-live="polite"
|
|
56
|
+
>
|
|
57
|
+
{warning}
|
|
58
|
+
</Text>
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
47
61
|
{!!error && (
|
|
48
62
|
<>
|
|
49
63
|
<Spacer size={8} />
|
package/src/form_text_input.tsx
CHANGED
|
@@ -4,13 +4,13 @@ import { TextInputField } from "./text_input_field";
|
|
|
4
4
|
|
|
5
5
|
export interface FormTextInputProps extends Omit<FormFieldProps, "style">, TextInputProps {}
|
|
6
6
|
|
|
7
|
-
/** `FormField` (label / description / error / optional) wrapping a `TextInputField` — one
|
|
8
|
-
* text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
|
|
7
|
+
/** `FormField` (label / description / warning / error / optional) wrapping a `TextInputField` — one
|
|
8
|
+
* labeled text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
|
|
9
9
|
export function FormTextInput(props: FormTextInputProps) {
|
|
10
|
-
const { label, description, optional, error, ...textInputProps } = props;
|
|
10
|
+
const { label, description, warning, optional, error, ...textInputProps } = props;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<FormField label={label} description={description} error={error} optional={optional}>
|
|
13
|
+
<FormField label={label} description={description} warning={warning} error={error} optional={optional}>
|
|
14
14
|
<TextInputField {...textInputProps} />
|
|
15
15
|
</FormField>
|
|
16
16
|
);
|
|
@@ -9,7 +9,8 @@ import { DateField } from "./date_field";
|
|
|
9
9
|
import { isoHasTime, resolveDateCommit } from "./date_picker_value";
|
|
10
10
|
import { formatDate } from "./format_date";
|
|
11
11
|
import { ActivityIndicator } from "./activity_indicator";
|
|
12
|
-
import { type
|
|
12
|
+
import { type InlineEditVariant, InlineEditView } from "./inline_edit";
|
|
13
|
+
import { type TextColor } from "./text_utils";
|
|
13
14
|
import { useLoticsLocale } from "./locale";
|
|
14
15
|
import { getInteractionModality } from "./interaction_modality";
|
|
15
16
|
import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
@@ -34,8 +35,11 @@ export interface InlineDatePickerProps {
|
|
|
34
35
|
locale?: string;
|
|
35
36
|
disabled?: boolean;
|
|
36
37
|
accessibilityLabel?: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/** Form field (default) or grid cell — see `InlineEditVariant`. */
|
|
39
|
+
variant?: InlineEditVariant;
|
|
40
|
+
/** Colour the resting date by urgency — overdue red, soon-due amber (the caller owns
|
|
41
|
+
* the rule; e.g. a task's `dueTone`). Applies to a set value only. */
|
|
42
|
+
tone?: TextColor;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/**
|
|
@@ -54,7 +58,7 @@ export interface InlineDatePickerProps {
|
|
|
54
58
|
* The row never changes height in either mode.
|
|
55
59
|
*/
|
|
56
60
|
export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
57
|
-
const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel ,
|
|
61
|
+
const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , variant, tone } = props;
|
|
58
62
|
const locales = useLoticsLocale();
|
|
59
63
|
const inlineLabels = locales.inline;
|
|
60
64
|
const dateLabels = locales.datePicker;
|
|
@@ -228,6 +232,11 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
228
232
|
) : (
|
|
229
233
|
<Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />
|
|
230
234
|
);
|
|
235
|
+
// The resting calendar glyph is a FORM affordance (marks an empty field as a date
|
|
236
|
+
// control). A `cell` lives in a grid where the column header already says "Due" and
|
|
237
|
+
// every cell edits — the repeated glyph is noise, so it reads value-first (the saving
|
|
238
|
+
// spinner still shows). The typed-edit field keeps its glyph — that's the open-picker button.
|
|
239
|
+
const restTrailing = variant === "cell" && !saving ? undefined : trailing;
|
|
231
240
|
|
|
232
241
|
return (
|
|
233
242
|
<View>
|
|
@@ -254,17 +263,18 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
254
263
|
) : (
|
|
255
264
|
<PopoverTrigger>
|
|
256
265
|
<InlineEditView
|
|
257
|
-
|
|
266
|
+
variant={variant}
|
|
267
|
+
tone={tone}
|
|
258
268
|
display={display}
|
|
259
269
|
placeholder={placeholder}
|
|
260
270
|
disabled={disabled}
|
|
261
271
|
active={open && !disabled}
|
|
262
272
|
accessibilityLabel={accessibilityLabel}
|
|
263
273
|
onFocus={handleViewFocus}
|
|
264
|
-
// A rest affordance (like the select's chevron): a calendar glyph
|
|
265
|
-
// the field as a tappable date control even when empty
|
|
266
|
-
//
|
|
267
|
-
trailing={
|
|
274
|
+
// A rest affordance (like the select's chevron): in a FORM a calendar glyph
|
|
275
|
+
// marks the field as a tappable date control even when empty; a grid CELL
|
|
276
|
+
// drops it (see restTrailing) to read value-first.
|
|
277
|
+
trailing={restTrailing}
|
|
268
278
|
/>
|
|
269
279
|
</PopoverTrigger>
|
|
270
280
|
)}
|
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
|
+
});
|