@lotics/ui 22.3.0 → 23.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -2
- package/MIGRATION.md +142 -2
- package/docs/ai_patterns.md +22 -22
- package/docs/catalog.md +131 -99
- package/docs/composition.md +134 -42
- package/docs/data_entry.md +67 -31
- package/docs/templates.md +86 -68
- package/examples/tpl_allocate.tsx +5 -5
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +6 -6
- package/examples/tpl_dashboard.tsx +9 -9
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_item_list.tsx +64 -37
- package/examples/tpl_lookup.tsx +5 -5
- package/examples/tpl_pick.tsx +6 -6
- package/examples/tpl_pivot.tsx +3 -3
- package/examples/tpl_record.tsx +919 -611
- package/examples/tpl_report.tsx +2 -2
- package/examples/tpl_rollup.tsx +5 -5
- package/examples/tpl_shifts.tsx +5 -5
- package/examples/tpl_statements.tsx +7 -7
- package/examples/tpl_stock.tsx +2 -2
- package/examples/tpl_task_board.tsx +42 -26
- package/examples/tpl_tower.tsx +6 -6
- package/package.json +4 -3
- package/src/agent_run.tsx +4 -4
- package/src/breakdown.tsx +1 -1
- package/src/calendar/calendar_view.tsx +1 -1
- package/src/change_review.tsx +9 -8
- package/src/chip_group.tsx +12 -2
- package/src/choice_list.tsx +2 -2
- package/src/confidence.tsx +2 -2
- package/src/data_grid.tsx +1 -1
- package/src/detail_row.tsx +50 -58
- package/src/file_dropzone.tsx +1 -1
- package/src/file_gallery_modal.tsx +3 -3
- package/src/file_row.tsx +29 -4
- package/src/file_rows.tsx +1 -1
- package/src/finding.tsx +4 -4
- package/src/form_field.tsx +1 -1
- package/src/format_date.ts +2 -2
- package/src/heatmap.tsx +1 -1
- package/src/inline_button.tsx +84 -0
- package/src/inline_date_picker.tsx +17 -10
- package/src/inline_edit.tsx +341 -60
- package/src/inline_member_select.tsx +8 -3
- package/src/inline_number_input.tsx +11 -4
- package/src/inline_select.tsx +26 -13
- package/src/inline_text_input.tsx +27 -6
- package/src/inline_time_picker.tsx +10 -4
- package/src/input_metrics.ts +17 -0
- package/src/ledger.tsx +2 -2
- package/src/locale.tsx +7 -3
- package/src/matrix.tsx +1 -1
- package/src/number_input.tsx +18 -7
- package/src/pipeline.tsx +1 -1
- package/src/popover.tsx +1 -1
- package/src/press_door.tsx +1 -1
- package/src/pressable_highlight.tsx +1 -1
- package/src/progress_bar.tsx +3 -3
- package/src/record_summary.tsx +2 -2
- package/src/result_header.tsx +2 -2
- package/src/sequence.tsx +170 -0
- package/src/share_or_download.ts +2 -2
- package/src/step_progress.tsx +7 -5
- package/src/stepper.tsx +1 -1
- package/src/task.tsx +6 -6
- package/src/text_input_field.tsx +21 -4
- package/src/text_utils.ts +7 -1
- package/src/linked_record_box.tsx +0 -157
package/src/inline_edit.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
|
-
import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
|
|
2
|
+
import { Pressable, View, StyleSheet, type GestureResponderEvent, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { useLoticsLocale } from "./locale";
|
|
@@ -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, getTextColor, type TextColor } from "./text_utils";
|
|
10
|
+
import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, getTextColor, type TextColor } from "./text_utils";
|
|
11
11
|
import { getInteractionModality } from "./interaction_modality";
|
|
12
12
|
import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
13
13
|
import { trackCommit } from "./pending_commits";
|
|
@@ -118,15 +118,25 @@ export function useInlineEditFocusRestore(editing: boolean, restore: () => void)
|
|
|
118
118
|
}, [editing, restore]);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
121
|
+
/**
|
|
122
|
+
* How much of the field's frame shows AT REST — an axis of WEIGHT, not of use.
|
|
123
|
+
*
|
|
124
|
+
* - `"framed"` (default): the pill surface always visible — white, 1px border.
|
|
125
|
+
* Required wherever editable and static values MIX (a record's `DetailTable`),
|
|
126
|
+
* because there the frame is the only thing saying which values you can change.
|
|
127
|
+
* - `"bare"`: nothing at rest; the same border fades in on hover. For a surface
|
|
128
|
+
* where EVERY value is editable — a register/`DataGrid` column, a task row —
|
|
129
|
+
* the per-field frame states what the whole surface already promises, and
|
|
130
|
+
* repeated down a column it draws the grid a second time.
|
|
131
|
+
*
|
|
132
|
+
* Open is identical either way (the 2px ring). HOVER is not, and deliberately:
|
|
133
|
+
* hover must be a CHANGE you can see, so a `bare` field has its border arrive out
|
|
134
|
+
* of nothing, while a `framed` one — already bordered — deepens that border AND
|
|
135
|
+
* tints, since zinc-200 → zinc-400 on a 1px line is too quiet on its own.
|
|
136
|
+
*
|
|
137
|
+
* Either way this changes what a field looks like, never what it does.
|
|
138
|
+
*/
|
|
139
|
+
export type InlineEditVariant = "framed" | "bare";
|
|
130
140
|
|
|
131
141
|
interface InlineEditFrameProps {
|
|
132
142
|
editing: boolean;
|
|
@@ -148,12 +158,143 @@ interface InlineEditFrameProps {
|
|
|
148
158
|
accessibilityLabel?: string;
|
|
149
159
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
150
160
|
struck?: boolean;
|
|
151
|
-
/**
|
|
161
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
152
162
|
variant?: InlineEditVariant;
|
|
163
|
+
/**
|
|
164
|
+
* How many lines the RESTING value may occupy before it truncates. Default 1.
|
|
165
|
+
* Raise it for a value a reader has to read whole — a payment term, an
|
|
166
|
+
* address — rather than recognise at a glance; the editor should open to the
|
|
167
|
+
* same height so the row does not reflow on click.
|
|
168
|
+
*/
|
|
169
|
+
numberOfLines?: number;
|
|
170
|
+
|
|
153
171
|
/** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
|
|
154
172
|
* field) — a hover-independent cue the field opens a picker. Omit for plain
|
|
155
173
|
* text/number inputs (typing IS the affordance). */
|
|
156
174
|
affordance?: ReactNode;
|
|
175
|
+
/** Verbs on the field's surface — see `InlineEditView.actions`. Rendered in BOTH
|
|
176
|
+
* modes (inside the chip at rest, beside the input while editing) so the field
|
|
177
|
+
* never changes width on focus or on commit. Pass it UNCONDITIONALLY and
|
|
178
|
+
* `disabled` the verb when it has nothing to act on: a slot that appears once
|
|
179
|
+
* the value is non-empty is the same layout shift by another route. */
|
|
180
|
+
actions?: ReactNode;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The field's VALUE REGION — what a reader sees where the value is, in the one
|
|
185
|
+
* form both modes agree on.
|
|
186
|
+
*
|
|
187
|
+
* Empty string display → the placeholder (a node renders as-is, a string reads
|
|
188
|
+
* muted); a node display renders as-is; a value string wears the value style
|
|
189
|
+
* (+ tone). Shared so a field that swaps to an input and back cannot change how
|
|
190
|
+
* its resting value is drawn on the way through.
|
|
191
|
+
*/
|
|
192
|
+
function fieldContent(o: {
|
|
193
|
+
display: string | ReactNode;
|
|
194
|
+
placeholder?: string | ReactNode;
|
|
195
|
+
struck?: boolean;
|
|
196
|
+
tone?: TextColor;
|
|
197
|
+
trailing?: ReactNode;
|
|
198
|
+
numberOfLines?: number;
|
|
199
|
+
}) {
|
|
200
|
+
const { display, placeholder, struck, tone, trailing, numberOfLines } = o;
|
|
201
|
+
const isEmptyString = typeof display === "string" && display === "";
|
|
202
|
+
return (
|
|
203
|
+
<>
|
|
204
|
+
{isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
|
|
205
|
+
<View style={styles.viewNode}>{placeholder}</View>
|
|
206
|
+
) : typeof display === "string" ? (
|
|
207
|
+
<Text numberOfLines={numberOfLines ?? 1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
|
|
208
|
+
{display || (typeof placeholder === "string" ? placeholder : "") || "—"}
|
|
209
|
+
</Text>
|
|
210
|
+
) : (
|
|
211
|
+
<View style={styles.viewNode}>{display}</View>
|
|
212
|
+
)}
|
|
213
|
+
{trailing != null ? trailing : null}
|
|
214
|
+
</>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* THE field's resting/hover/open styling, in ONE place.
|
|
220
|
+
*
|
|
221
|
+
* It is a function rather than four style arrays because both modes need the
|
|
222
|
+
* SAME answer: a field that hovers one way at rest and another way while editing
|
|
223
|
+
* flashes as the two disagree — which is exactly what a hand-assembled second
|
|
224
|
+
* copy did (it painted the border but dropped the tint, so clicking into a
|
|
225
|
+
* framed field made its hover fill vanish for the length of the swap).
|
|
226
|
+
*
|
|
227
|
+
* `variant` decides what hover has left to SAY: a `bare` field has its border
|
|
228
|
+
* ARRIVE (loud on its own), a `framed` field already has one, so it takes the
|
|
229
|
+
* tint as well.
|
|
230
|
+
*/
|
|
231
|
+
function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; active?: boolean; editing?: boolean }) {
|
|
232
|
+
return (hovered: boolean): StyleProp<ViewStyle>[] => [
|
|
233
|
+
styles.view,
|
|
234
|
+
(o.variant === "bare" || o.disabled) && styles.viewBare,
|
|
235
|
+
CONTROL_TRANSITION,
|
|
236
|
+
o.active && styles.viewActive,
|
|
237
|
+
!o.active && hovered && !o.disabled && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
|
|
238
|
+
// While an input is mounted inside, it brings its own height — the surface
|
|
239
|
+
// must not add its resting vertical padding on top.
|
|
240
|
+
o.editing && styles.editHost,
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* THE FIELD SURFACE — the one implementation of "a bordered box holding a
|
|
246
|
+
* control and, optionally, the verbs that act on its value".
|
|
247
|
+
*
|
|
248
|
+
* It exists because that box used to be assembled by hand in four places (the
|
|
249
|
+
* resting view with and without verbs, the editing frame with and without), and
|
|
250
|
+
* the copies drifted: one drew a border inside a border, one added its resting
|
|
251
|
+
* padding to an input that already had a height, one killed the focus ring.
|
|
252
|
+
* Every one of those was a call site re-deriving the same anatomy.
|
|
253
|
+
*
|
|
254
|
+
* The anatomy it owns, once:
|
|
255
|
+
* - the surface + its `variant` / hover / open / disabled states;
|
|
256
|
+
* - HOVER TRACKED HERE, on the box, not on the control inside it —
|
|
257
|
+
* react-native-web hands a parent's hover to the innermost pressable, so a
|
|
258
|
+
* control-tracked border drops out the moment the pointer crosses a verb;
|
|
259
|
+
* - FOCUS-WITHIN, so the ring paints on the whole field rather than on the text
|
|
260
|
+
* region inside it: React bubbles focus, so the box hears the control it
|
|
261
|
+
* contains, and the box is what a reader perceives as "the field";
|
|
262
|
+
* - the verbs as SIBLINGS of the control, never nested in it (a button must not
|
|
263
|
+
* contain a button), on the surface's own right edge.
|
|
264
|
+
*
|
|
265
|
+
* The CONTROL it wraps must draw no surface of its own — that is what
|
|
266
|
+
* `seamless` means on `TextInputField` / `NumberInput`. One box, one owner.
|
|
267
|
+
*/
|
|
268
|
+
function InlineFieldShell(props: {
|
|
269
|
+
surface: (hovered: boolean) => StyleProp<ViewStyle>[];
|
|
270
|
+
actions: ReactNode;
|
|
271
|
+
/** The outer box, for a caller that anchors an overlay to the FIELD (a
|
|
272
|
+
* select's popover inherits this width — the control inside is narrower). */
|
|
273
|
+
anchorRef?: Ref<View>;
|
|
274
|
+
/** Set while the control inside holds focus; the ring paints on the box. */
|
|
275
|
+
focusable?: boolean;
|
|
276
|
+
children: ReactNode;
|
|
277
|
+
}) {
|
|
278
|
+
const { surface, actions, anchorRef, focusable = true, children } = props;
|
|
279
|
+
const [hovered, setHovered] = useState(false);
|
|
280
|
+
const [focused, setFocused] = useState(false);
|
|
281
|
+
// RN's types declare neither; react-native-web forwards both to the DOM node
|
|
282
|
+
// (the same boundary cast `PressableRow` uses for its own hover).
|
|
283
|
+
const domProps = {
|
|
284
|
+
onMouseEnter: () => setHovered(true),
|
|
285
|
+
onMouseLeave: () => setHovered(false),
|
|
286
|
+
...(focusable ? { onFocus: () => setFocused(true), onBlur: () => setFocused(false) } : {}),
|
|
287
|
+
} as object;
|
|
288
|
+
return (
|
|
289
|
+
<View
|
|
290
|
+
ref={anchorRef}
|
|
291
|
+
style={[...surface(hovered), styles.shell, focused && styles.shellFocused]}
|
|
292
|
+
{...domProps}
|
|
293
|
+
>
|
|
294
|
+
<View style={styles.shellControl}>{children}</View>
|
|
295
|
+
{actions}
|
|
296
|
+
</View>
|
|
297
|
+
);
|
|
157
298
|
}
|
|
158
299
|
|
|
159
300
|
interface InlineEditViewProps {
|
|
@@ -167,8 +308,42 @@ interface InlineEditViewProps {
|
|
|
167
308
|
onPress?: () => void;
|
|
168
309
|
disabled?: boolean;
|
|
169
310
|
accessibilityLabel?: string;
|
|
170
|
-
/**
|
|
311
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
312
|
+
variant?: InlineEditVariant;
|
|
313
|
+
/**
|
|
314
|
+
* How many lines the RESTING value may occupy before it truncates. Default 1.
|
|
315
|
+
* Raise it for a value a reader has to read whole — a payment term, an
|
|
316
|
+
* address — rather than recognise at a glance; the editor should open to the
|
|
317
|
+
* same height so the row does not reflow on click.
|
|
318
|
+
*/
|
|
319
|
+
numberOfLines?: number;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A handle on the FIELD's outer box, for a caller that anchors an overlay to
|
|
323
|
+
* it — a select's popover inherits the trigger's width, and with `actions` the
|
|
324
|
+
* control inside the field is narrower than the field, so anchoring to `ref`
|
|
325
|
+
* would open a dropdown too small for the row it belongs to.
|
|
326
|
+
*
|
|
327
|
+
* Distinct from `ref` on purpose: `ref` means "the focusable control" (the
|
|
328
|
+
* thing `.focus()` restores focus to), this means "the box you see". They are
|
|
329
|
+
* the same element only when the field carries no verbs.
|
|
330
|
+
*/
|
|
331
|
+
anchorRef?: Ref<View>;
|
|
332
|
+
/** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving.
|
|
333
|
+
* DECORATION only: it renders inside the press target, so it may never hold a
|
|
334
|
+
* control (a button must not contain a button). A control goes in `actions`. */
|
|
171
335
|
trailing?: ReactNode;
|
|
336
|
+
/** Verbs that live ON the field's surface — an `InlineButton` Copy, Open… Use
|
|
337
|
+
* this when the act is ABOUT THE VALUE, so it travels with what it acts on
|
|
338
|
+
* instead of pairing across the row to a trailing column.
|
|
339
|
+
*
|
|
340
|
+
* Passing it changes the ANATOMY, because it has to: the surface moves to a
|
|
341
|
+
* wrapper and the press target becomes the value region beside these verbs, so
|
|
342
|
+
* each keeps its own press and neither nests in the other. The focus ring then
|
|
343
|
+
* rings the value region — the element that actually took focus — rather than
|
|
344
|
+
* the whole field. Everything else (hover on the full surface, the ref, the
|
|
345
|
+
* active/open edge) is unchanged. */
|
|
346
|
+
actions?: ReactNode;
|
|
172
347
|
/** True while the field's popover (select/date) is open: wears the 2px active
|
|
173
348
|
* ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
|
|
174
349
|
active?: boolean;
|
|
@@ -179,8 +354,6 @@ interface InlineEditViewProps {
|
|
|
179
354
|
onFocus?: () => void;
|
|
180
355
|
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
181
356
|
struck?: boolean;
|
|
182
|
-
/** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
|
|
183
|
-
variant?: InlineEditVariant;
|
|
184
357
|
/** Colours the resting VALUE by semantic state — an overdue date red, a soon-due
|
|
185
358
|
* amber (the caller owns the rule). Applies only to a set, non-struck string value;
|
|
186
359
|
* the placeholder + struck styles win. Default: the standard value ink. */
|
|
@@ -189,18 +362,41 @@ interface InlineEditViewProps {
|
|
|
189
362
|
}
|
|
190
363
|
|
|
191
364
|
/**
|
|
192
|
-
* The view-mode box of an inline-editable field: the value as plain text
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* the
|
|
196
|
-
*
|
|
197
|
-
* swapping to an input, or floating a
|
|
198
|
-
* layout. Used by `InlineEditFrame`, and as
|
|
199
|
-
* select/date inline editors (it forwards ref +
|
|
365
|
+
* The view-mode box of an inline-editable field: the value as plain text on the
|
|
366
|
+
* shared control surface — white with a 1px border when `framed`, frameless
|
|
367
|
+
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and the
|
|
368
|
+
* cursor stays the default arrow: the surface itself is the affordance, and a
|
|
369
|
+
* pointer would read as a button. The border is present at every state (merely
|
|
370
|
+
* transparent when `bare` rests), so swapping to an input, or floating a
|
|
371
|
+
* dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
|
|
372
|
+
* the overlay trigger for the select/date inline editors (it forwards ref +
|
|
373
|
+
* onPress to a `PopoverTrigger`).
|
|
374
|
+
*/
|
|
375
|
+
/**
|
|
376
|
+
* The resting box for a MULTI-LINE field.
|
|
377
|
+
*
|
|
378
|
+
* A field that changes size when you click it moves everything below it, and on
|
|
379
|
+
* a record surface that is the whole form jumping under the pointer. So the box
|
|
380
|
+
* must be identical in both modes — which means the RESTING box reserves the
|
|
381
|
+
* editor's full height, not the height this particular value happens to need.
|
|
382
|
+
* `TextInputField` sizes a multiline input to `numberOfLines * lineHeight + 16`;
|
|
383
|
+
* this reproduces that number exactly, and top-aligns the text the way a
|
|
384
|
+
* textarea does, so neither the box nor the first line moves.
|
|
385
|
+
*
|
|
386
|
+
* Single-line fields are untouched: `INLINE_CONTROL_HEIGHT` already equals the
|
|
387
|
+
* input's own height, which is why they never had this bug.
|
|
200
388
|
*/
|
|
389
|
+
function multilineBox(numberOfLines: number | undefined): ViewStyle | null {
|
|
390
|
+
if (numberOfLines == null || numberOfLines <= 1) return null;
|
|
391
|
+
return {
|
|
392
|
+
minHeight: getMultilineInputHeight(numberOfLines, false),
|
|
393
|
+
alignItems: "flex-start",
|
|
394
|
+
paddingVertical: 8,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
201
398
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
202
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, variant = "
|
|
203
|
-
const isEmptyString = typeof display === "string" && display === "";
|
|
399
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef, numberOfLines } = props;
|
|
204
400
|
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
205
401
|
// row that expands on press) edits the field instead of triggering the row.
|
|
206
402
|
const handlePress = onPress
|
|
@@ -209,33 +405,51 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
209
405
|
onPress();
|
|
210
406
|
}
|
|
211
407
|
: undefined;
|
|
408
|
+
const content = fieldContent({ display, placeholder, struck, tone, trailing, numberOfLines });
|
|
409
|
+
const surface = fieldSurface({ variant, disabled, active });
|
|
410
|
+
|
|
411
|
+
// WITH VERBS: the shell owns the surface, the pressable is just the value
|
|
412
|
+
// region inside it. `ref` stays on the PRESSABLE — it means "the focusable
|
|
413
|
+
// control", which `InlineEditFrame` calls `.focus()` on to restore focus after
|
|
414
|
+
// a keyboard close; `anchorRef` is the separate handle for the outer box.
|
|
415
|
+
if (actions != null) {
|
|
416
|
+
return (
|
|
417
|
+
<InlineFieldShell surface={surface} actions={actions} anchorRef={anchorRef}>
|
|
418
|
+
{/* A PLAIN Pressable, not `FocusRingPressable`: the SHELL paints the ring
|
|
419
|
+
(focus-within), and a control that rang as well would draw a second
|
|
420
|
+
2px box tight around the value — which reads as the value jumping the
|
|
421
|
+
moment the field takes focus. The ring has exactly one owner, for the
|
|
422
|
+
same reason the surface and the text inset each do. */}
|
|
423
|
+
<Pressable
|
|
424
|
+
ref={ref}
|
|
425
|
+
disabled={disabled}
|
|
426
|
+
onPress={handlePress}
|
|
427
|
+
onFocus={onFocus}
|
|
428
|
+
accessibilityRole="button"
|
|
429
|
+
accessibilityLabel={accessibilityLabel}
|
|
430
|
+
style={[styles.viewInner, multilineBox(numberOfLines)]}
|
|
431
|
+
>
|
|
432
|
+
{content}
|
|
433
|
+
</Pressable>
|
|
434
|
+
</InlineFieldShell>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
212
438
|
return (
|
|
213
439
|
<FocusRingPressable
|
|
214
|
-
ref
|
|
440
|
+
// No verbs: the box and the control are the same element, so one ref
|
|
441
|
+
// serves both jobs. `anchorRef` wins when both are passed — a caller that
|
|
442
|
+
// asked for the box gets the box.
|
|
443
|
+
ref={anchorRef ?? ref}
|
|
215
444
|
disabled={disabled}
|
|
216
445
|
onPress={handlePress}
|
|
217
446
|
onFocus={onFocus}
|
|
218
447
|
accessibilityRole="button"
|
|
219
448
|
accessibilityLabel={accessibilityLabel}
|
|
220
449
|
userSelect="none"
|
|
221
|
-
|
|
222
|
-
// (an input's edge); a `cell` field lives in a dense grid/row/task context,
|
|
223
|
-
// so it takes the background-tint WASH — the same `PressableHighlight`
|
|
224
|
-
// language as the cells beside it. Both suppressed while open (fill + ring lead).
|
|
225
|
-
style={(state) => [styles.view, (variant === "cell" || disabled) && styles.viewTransparent, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && (variant === "cell" ? styles.viewHoveredWash : styles.viewHovered)]}
|
|
450
|
+
style={(state) => [surface(state.hovered ?? false), multilineBox(numberOfLines)]}
|
|
226
451
|
>
|
|
227
|
-
{
|
|
228
|
-
a node display renders as-is; a value string wears the value style (+ tone). */}
|
|
229
|
-
{isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
|
|
230
|
-
<View style={styles.viewNode}>{placeholder}</View>
|
|
231
|
-
) : typeof display === "string" ? (
|
|
232
|
-
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
|
|
233
|
-
{display || (typeof placeholder === "string" ? placeholder : "") || "—"}
|
|
234
|
-
</Text>
|
|
235
|
-
) : (
|
|
236
|
-
<View style={styles.viewNode}>{display}</View>
|
|
237
|
-
)}
|
|
238
|
-
{trailing != null ? trailing : null}
|
|
452
|
+
{content}
|
|
239
453
|
</FocusRingPressable>
|
|
240
454
|
);
|
|
241
455
|
}
|
|
@@ -267,6 +481,8 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
267
481
|
struck,
|
|
268
482
|
variant,
|
|
269
483
|
affordance,
|
|
484
|
+
actions,
|
|
485
|
+
numberOfLines,
|
|
270
486
|
} = props;
|
|
271
487
|
|
|
272
488
|
const viewRef = useRef<View>(null);
|
|
@@ -292,7 +508,9 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
292
508
|
}, []),
|
|
293
509
|
);
|
|
294
510
|
|
|
295
|
-
|
|
511
|
+
// WITHOUT verbs the two modes are genuinely different elements (the resting
|
|
512
|
+
// button, then the input), and each owns its own surface — unchanged.
|
|
513
|
+
if (actions == null && !editing) {
|
|
296
514
|
return (
|
|
297
515
|
<InlineEditView
|
|
298
516
|
ref={viewRef}
|
|
@@ -305,6 +523,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
305
523
|
accessibilityLabel={accessibilityLabel}
|
|
306
524
|
struck={struck}
|
|
307
525
|
trailing={affordance}
|
|
526
|
+
numberOfLines={numberOfLines}
|
|
308
527
|
/>
|
|
309
528
|
);
|
|
310
529
|
}
|
|
@@ -312,6 +531,46 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
312
531
|
return (
|
|
313
532
|
<View>
|
|
314
533
|
<View style={styles.editRow}>
|
|
534
|
+
{/* ONE SHELL, ACROSS BOTH MODES — not "the same shape rebuilt", the same
|
|
535
|
+
ELEMENT. Only its contents swap.
|
|
536
|
+
|
|
537
|
+
Rebuilding it per mode looked equivalent and was not: the new shell
|
|
538
|
+
mounted under a stationary pointer, which fires no `mouseenter`, so it
|
|
539
|
+
came up UN-hovered and the hover styling dropped for the length of the
|
|
540
|
+
swap — the flash. Anything the shell tracks would have been lost the
|
|
541
|
+
same way. Persisting it also means the verbs are not remounted, so
|
|
542
|
+
they cannot move, which is the contract this whole path exists for. */}
|
|
543
|
+
{actions != null ? (
|
|
544
|
+
<View style={styles.editControl}>
|
|
545
|
+
<InlineFieldShell
|
|
546
|
+
surface={fieldSurface({ variant: variant ?? "framed", disabled, editing })}
|
|
547
|
+
actions={actions}
|
|
548
|
+
>
|
|
549
|
+
{editing ? (
|
|
550
|
+
<>
|
|
551
|
+
{children}
|
|
552
|
+
{saving ? (
|
|
553
|
+
<View style={styles.savingOverlay} pointerEvents="none">
|
|
554
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
555
|
+
</View>
|
|
556
|
+
) : null}
|
|
557
|
+
</>
|
|
558
|
+
) : (
|
|
559
|
+
<Pressable
|
|
560
|
+
ref={viewRef}
|
|
561
|
+
disabled={disabled}
|
|
562
|
+
onPress={onBegin}
|
|
563
|
+
onFocus={handleViewFocus}
|
|
564
|
+
accessibilityRole="button"
|
|
565
|
+
accessibilityLabel={accessibilityLabel}
|
|
566
|
+
style={styles.viewInner}
|
|
567
|
+
>
|
|
568
|
+
{fieldContent({ display, placeholder, struck, trailing: affordance, numberOfLines })}
|
|
569
|
+
</Pressable>
|
|
570
|
+
)}
|
|
571
|
+
</InlineFieldShell>
|
|
572
|
+
</View>
|
|
573
|
+
) : (
|
|
315
574
|
<View style={styles.editControl}>
|
|
316
575
|
{children}
|
|
317
576
|
{/* The saving spinner floats inside the input's right edge — it must
|
|
@@ -322,6 +581,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
322
581
|
</View>
|
|
323
582
|
) : null}
|
|
324
583
|
</View>
|
|
584
|
+
)}
|
|
325
585
|
{controls === "buttons" ? (
|
|
326
586
|
<View style={styles.buttons}>
|
|
327
587
|
<IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
|
|
@@ -353,16 +613,17 @@ export const inlineValueTextStyle: TextStyle = {
|
|
|
353
613
|
const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
|
|
354
614
|
|
|
355
615
|
const styles = StyleSheet.create({
|
|
356
|
-
// At REST an editor
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
616
|
+
// At REST an editor wears THE pill surface — white, 1px border — the same one
|
|
617
|
+
// `Chip`, `ChipGroup` and a secondary `Button` wear, so every control a record
|
|
618
|
+
// row can hold reads as one family. It is still THE editability affordance: a
|
|
619
|
+
// read-only `InlineStatic` stays flat and borderless, and a bordered box beside
|
|
620
|
+
// bare text is a plainer signal than a tint ever was.
|
|
360
621
|
view: {
|
|
361
622
|
minHeight: INLINE_CONTROL_HEIGHT,
|
|
362
623
|
borderRadius: CONTROL_RADIUS,
|
|
363
624
|
borderWidth: 1,
|
|
364
|
-
borderColor:
|
|
365
|
-
backgroundColor: colors.
|
|
625
|
+
borderColor: colors.border,
|
|
626
|
+
backgroundColor: colors.white,
|
|
366
627
|
paddingHorizontal: 8,
|
|
367
628
|
// Vertical padding matters only when the display content is TALLER than
|
|
368
629
|
// one line (a rich select showing title + description at rest): minHeight
|
|
@@ -377,30 +638,50 @@ const styles = StyleSheet.create({
|
|
|
377
638
|
// as a button press). Overrides the Pressable's pointer default.
|
|
378
639
|
cursor: "auto",
|
|
379
640
|
},
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
// the
|
|
641
|
+
// Nothing at rest — the frame arrives on hover. Also how a DISABLED field
|
|
642
|
+
// rests, whatever its variant: an inert value must not promise a press.
|
|
643
|
+
viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
|
|
644
|
+
// The shell's own inset: the right edge tightens to 4 so an `InlineButton` sits
|
|
645
|
+
// on the surface's edge with the same air a `Chip`'s dismiss gets.
|
|
646
|
+
shell: { paddingRight: 4 },
|
|
647
|
+
// Focus paints on the FIELD, not on the value region inside it.
|
|
648
|
+
shellFocused: { borderColor: colors.border, boxShadow: FOCUS_RING },
|
|
649
|
+
// The control's slot: it takes the slack so the verbs keep the right edge.
|
|
650
|
+
shellControl: { flex: 1, minWidth: 0, alignSelf: "stretch", justifyContent: "center", position: "relative" },
|
|
651
|
+
// The value region inside a verb-hosting resting field: carries the row layout
|
|
652
|
+
// the surface would otherwise own. `userSelect` here rather than via the
|
|
653
|
+
// `FocusRingPressable` prop — a drag across a value must edit, not select text.
|
|
654
|
+
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none", cursor: "auto" } as ViewStyle) },
|
|
655
|
+
// Open (popover showing): the resting surface plus the 2px active ring — the
|
|
656
|
+
// one thing that separates open from at-rest, and identical to a focused input.
|
|
385
657
|
viewActive: {
|
|
386
658
|
borderColor: colors.border,
|
|
387
659
|
backgroundColor: colors.white,
|
|
388
660
|
boxShadow: FOCUS_RING,
|
|
389
661
|
},
|
|
390
|
-
// Hover
|
|
391
|
-
// a transparent CELL/dense field takes the background-tint wash (zinc-100), the
|
|
392
|
-
// same language as the cells beside it.
|
|
662
|
+
// Hover on a BARE field: the border arrives out of nothing — the whole signal.
|
|
393
663
|
viewHovered: {
|
|
394
664
|
borderColor: HOVER_BORDER,
|
|
395
665
|
},
|
|
396
|
-
|
|
397
|
-
|
|
666
|
+
// Hover on a FRAMED field: the border deepens AND the surface tints. The border
|
|
667
|
+
// alone would be zinc-200 → zinc-400, a shade shift on a 1px line — too quiet to
|
|
668
|
+
// mark the field you are about to act on. The tint is the old resting chip,
|
|
669
|
+
// promoted to the state it always read as: "this one".
|
|
670
|
+
//
|
|
671
|
+
// It is deliberately NOT applied to `bare`: those live inside rows that wash on
|
|
672
|
+
// hover themselves, and a cell lighter than the row under it reads backwards.
|
|
673
|
+
viewHoveredFramed: {
|
|
674
|
+
borderColor: HOVER_BORDER,
|
|
675
|
+
backgroundColor: colors.zinc[50],
|
|
398
676
|
},
|
|
399
677
|
placeholder: { color: colors.zinc[400] },
|
|
400
678
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
401
679
|
viewNode: { flex: 1, minWidth: 0 },
|
|
402
680
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
403
681
|
editControl: { flex: 1, position: "relative" },
|
|
682
|
+
// The input brings its own 40px height, so the surface must not add its resting
|
|
683
|
+
// vertical padding on top — that was a 12px jump the moment the field focused.
|
|
684
|
+
editHost: { paddingVertical: 0 },
|
|
404
685
|
savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
|
|
405
686
|
buttons: { flexDirection: "row", gap: 6, height: INLINE_CONTROL_HEIGHT, alignItems: "center" },
|
|
406
687
|
error: { marginTop: 4 },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo } from "react";
|
|
1
|
+
import { useCallback, useMemo, type ReactNode } from "react";
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import type { InlineEditVariant } from "./inline_edit";
|
|
4
4
|
import { InlineSelect } from "./inline_select";
|
|
@@ -26,9 +26,14 @@ interface InlineMemberSelectProps {
|
|
|
26
26
|
onClear?: () => void | Promise<void>;
|
|
27
27
|
placeholder?: string;
|
|
28
28
|
disabled?: boolean;
|
|
29
|
-
|
|
30
|
-
/** Form field (default) or grid cell — see {@link InlineEditVariant}. */
|
|
29
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
31
30
|
variant?: InlineEditVariant;
|
|
31
|
+
/** Verbs on the field's surface — an `InlineButton` Copy, Open… See
|
|
32
|
+
* `InlineEditView.actions`: they sit INSIDE the field, in the same place in
|
|
33
|
+
* every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
|
|
34
|
+
* has nothing to act on) or the field resizes as the value fills. */
|
|
35
|
+
actions?: ReactNode;
|
|
36
|
+
accessibilityLabel?: string;
|
|
32
37
|
/** Show an in-menu search box to filter the roster; default false. */
|
|
33
38
|
searchable?: boolean;
|
|
34
39
|
/** Resting display = the bare AVATAR only (no name), a dashed "add" ghost when
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
3
|
import { NumberInput } from "./number_input";
|
|
4
4
|
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
@@ -14,9 +14,13 @@ export interface InlineNumberInputProps {
|
|
|
14
14
|
max?: number;
|
|
15
15
|
controls?: InlineEditControls;
|
|
16
16
|
disabled?: boolean;
|
|
17
|
-
|
|
18
|
-
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
17
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
19
18
|
variant?: InlineEditVariant;
|
|
19
|
+
/** Verbs on the field's surface — see `InlineEditView.actions`. Rendered in
|
|
20
|
+
* BOTH modes so the field never changes width on focus; pass it
|
|
21
|
+
* UNCONDITIONALLY and `disabled` the verb when it has nothing to act on. */
|
|
22
|
+
actions?: ReactNode;
|
|
23
|
+
accessibilityLabel?: string;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -25,7 +29,7 @@ export interface InlineNumberInputProps {
|
|
|
25
29
|
* the bare numeric field at the same height, so the form never reflows.
|
|
26
30
|
*/
|
|
27
31
|
export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
28
|
-
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel
|
|
32
|
+
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
|
|
29
33
|
const edit = useInlineEdit<number | null>({ value, onSave });
|
|
30
34
|
|
|
31
35
|
const onKeyDown = useCallback(
|
|
@@ -46,6 +50,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
46
50
|
return (
|
|
47
51
|
<InlineEditFrame
|
|
48
52
|
variant={variant}
|
|
53
|
+
actions={actions}
|
|
49
54
|
editing={edit.editing}
|
|
50
55
|
display={display}
|
|
51
56
|
placeholder={placeholder}
|
|
@@ -67,6 +72,8 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
67
72
|
min={min}
|
|
68
73
|
max={max}
|
|
69
74
|
accessibilityLabel={accessibilityLabel}
|
|
75
|
+
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
76
|
+
seamless={actions != null}
|
|
70
77
|
/>
|
|
71
78
|
</InlineEditFrame>
|
|
72
79
|
);
|