@lotics/ui 22.2.0 → 23.0.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 +27 -25
- package/docs/catalog.md +119 -98
- 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 +3 -2
- package/src/agent_run.tsx +40 -25
- 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_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 +298 -59
- 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 +12 -4
- package/src/inline_time_picker.tsx +10 -4
- 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 +19 -1
- package/src/text_utils.ts +1 -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";
|
|
@@ -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,134 @@ 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;
|
|
153
163
|
/** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
|
|
154
164
|
* field) — a hover-independent cue the field opens a picker. Omit for plain
|
|
155
165
|
* text/number inputs (typing IS the affordance). */
|
|
156
166
|
affordance?: ReactNode;
|
|
167
|
+
/** Verbs on the field's surface — see `InlineEditView.actions`. Rendered in BOTH
|
|
168
|
+
* modes (inside the chip at rest, beside the input while editing) so the field
|
|
169
|
+
* never changes width on focus or on commit. Pass it UNCONDITIONALLY and
|
|
170
|
+
* `disabled` the verb when it has nothing to act on: a slot that appears once
|
|
171
|
+
* the value is non-empty is the same layout shift by another route. */
|
|
172
|
+
actions?: ReactNode;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The field's VALUE REGION — what a reader sees where the value is, in the one
|
|
177
|
+
* form both modes agree on.
|
|
178
|
+
*
|
|
179
|
+
* Empty string display → the placeholder (a node renders as-is, a string reads
|
|
180
|
+
* muted); a node display renders as-is; a value string wears the value style
|
|
181
|
+
* (+ tone). Shared so a field that swaps to an input and back cannot change how
|
|
182
|
+
* its resting value is drawn on the way through.
|
|
183
|
+
*/
|
|
184
|
+
function fieldContent(o: {
|
|
185
|
+
display: string | ReactNode;
|
|
186
|
+
placeholder?: string | ReactNode;
|
|
187
|
+
struck?: boolean;
|
|
188
|
+
tone?: TextColor;
|
|
189
|
+
trailing?: ReactNode;
|
|
190
|
+
}) {
|
|
191
|
+
const { display, placeholder, struck, tone, trailing } = o;
|
|
192
|
+
const isEmptyString = typeof display === "string" && display === "";
|
|
193
|
+
return (
|
|
194
|
+
<>
|
|
195
|
+
{isEmptyString && placeholder != null && typeof placeholder !== "string" ? (
|
|
196
|
+
<View style={styles.viewNode}>{placeholder}</View>
|
|
197
|
+
) : typeof display === "string" ? (
|
|
198
|
+
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null, tone && display && !struck ? { color: getTextColor(tone) } : null]}>
|
|
199
|
+
{display || (typeof placeholder === "string" ? placeholder : "") || "—"}
|
|
200
|
+
</Text>
|
|
201
|
+
) : (
|
|
202
|
+
<View style={styles.viewNode}>{display}</View>
|
|
203
|
+
)}
|
|
204
|
+
{trailing != null ? trailing : null}
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* THE field's resting/hover/open styling, in ONE place.
|
|
211
|
+
*
|
|
212
|
+
* It is a function rather than four style arrays because both modes need the
|
|
213
|
+
* SAME answer: a field that hovers one way at rest and another way while editing
|
|
214
|
+
* flashes as the two disagree — which is exactly what a hand-assembled second
|
|
215
|
+
* copy did (it painted the border but dropped the tint, so clicking into a
|
|
216
|
+
* framed field made its hover fill vanish for the length of the swap).
|
|
217
|
+
*
|
|
218
|
+
* `variant` decides what hover has left to SAY: a `bare` field has its border
|
|
219
|
+
* ARRIVE (loud on its own), a `framed` field already has one, so it takes the
|
|
220
|
+
* tint as well.
|
|
221
|
+
*/
|
|
222
|
+
function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; active?: boolean; editing?: boolean }) {
|
|
223
|
+
return (hovered: boolean): StyleProp<ViewStyle>[] => [
|
|
224
|
+
styles.view,
|
|
225
|
+
(o.variant === "bare" || o.disabled) && styles.viewBare,
|
|
226
|
+
CONTROL_TRANSITION,
|
|
227
|
+
o.active && styles.viewActive,
|
|
228
|
+
!o.active && hovered && !o.disabled && (o.variant === "bare" ? styles.viewHovered : styles.viewHoveredFramed),
|
|
229
|
+
// While an input is mounted inside, it brings its own height — the surface
|
|
230
|
+
// must not add its resting vertical padding on top.
|
|
231
|
+
o.editing && styles.editHost,
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* THE FIELD SURFACE — the one implementation of "a bordered box holding a
|
|
237
|
+
* control and, optionally, the verbs that act on its value".
|
|
238
|
+
*
|
|
239
|
+
* It exists because that box used to be assembled by hand in four places (the
|
|
240
|
+
* resting view with and without verbs, the editing frame with and without), and
|
|
241
|
+
* the copies drifted: one drew a border inside a border, one added its resting
|
|
242
|
+
* padding to an input that already had a height, one killed the focus ring.
|
|
243
|
+
* Every one of those was a call site re-deriving the same anatomy.
|
|
244
|
+
*
|
|
245
|
+
* The anatomy it owns, once:
|
|
246
|
+
* - the surface + its `variant` / hover / open / disabled states;
|
|
247
|
+
* - HOVER TRACKED HERE, on the box, not on the control inside it —
|
|
248
|
+
* react-native-web hands a parent's hover to the innermost pressable, so a
|
|
249
|
+
* control-tracked border drops out the moment the pointer crosses a verb;
|
|
250
|
+
* - FOCUS-WITHIN, so the ring paints on the whole field rather than on the text
|
|
251
|
+
* region inside it: React bubbles focus, so the box hears the control it
|
|
252
|
+
* contains, and the box is what a reader perceives as "the field";
|
|
253
|
+
* - the verbs as SIBLINGS of the control, never nested in it (a button must not
|
|
254
|
+
* contain a button), on the surface's own right edge.
|
|
255
|
+
*
|
|
256
|
+
* The CONTROL it wraps must draw no surface of its own — that is what
|
|
257
|
+
* `seamless` means on `TextInputField` / `NumberInput`. One box, one owner.
|
|
258
|
+
*/
|
|
259
|
+
function InlineFieldShell(props: {
|
|
260
|
+
surface: (hovered: boolean) => StyleProp<ViewStyle>[];
|
|
261
|
+
actions: ReactNode;
|
|
262
|
+
/** The outer box, for a caller that anchors an overlay to the FIELD (a
|
|
263
|
+
* select's popover inherits this width — the control inside is narrower). */
|
|
264
|
+
anchorRef?: Ref<View>;
|
|
265
|
+
/** Set while the control inside holds focus; the ring paints on the box. */
|
|
266
|
+
focusable?: boolean;
|
|
267
|
+
children: ReactNode;
|
|
268
|
+
}) {
|
|
269
|
+
const { surface, actions, anchorRef, focusable = true, children } = props;
|
|
270
|
+
const [hovered, setHovered] = useState(false);
|
|
271
|
+
const [focused, setFocused] = useState(false);
|
|
272
|
+
// RN's types declare neither; react-native-web forwards both to the DOM node
|
|
273
|
+
// (the same boundary cast `PressableRow` uses for its own hover).
|
|
274
|
+
const domProps = {
|
|
275
|
+
onMouseEnter: () => setHovered(true),
|
|
276
|
+
onMouseLeave: () => setHovered(false),
|
|
277
|
+
...(focusable ? { onFocus: () => setFocused(true), onBlur: () => setFocused(false) } : {}),
|
|
278
|
+
} as object;
|
|
279
|
+
return (
|
|
280
|
+
<View
|
|
281
|
+
ref={anchorRef}
|
|
282
|
+
style={[...surface(hovered), styles.shell, focused && styles.shellFocused]}
|
|
283
|
+
{...domProps}
|
|
284
|
+
>
|
|
285
|
+
<View style={styles.shellControl}>{children}</View>
|
|
286
|
+
{actions}
|
|
287
|
+
</View>
|
|
288
|
+
);
|
|
157
289
|
}
|
|
158
290
|
|
|
159
291
|
interface InlineEditViewProps {
|
|
@@ -167,8 +299,34 @@ interface InlineEditViewProps {
|
|
|
167
299
|
onPress?: () => void;
|
|
168
300
|
disabled?: boolean;
|
|
169
301
|
accessibilityLabel?: string;
|
|
170
|
-
/**
|
|
302
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
303
|
+
variant?: InlineEditVariant;
|
|
304
|
+
/**
|
|
305
|
+
* A handle on the FIELD's outer box, for a caller that anchors an overlay to
|
|
306
|
+
* it — a select's popover inherits the trigger's width, and with `actions` the
|
|
307
|
+
* control inside the field is narrower than the field, so anchoring to `ref`
|
|
308
|
+
* would open a dropdown too small for the row it belongs to.
|
|
309
|
+
*
|
|
310
|
+
* Distinct from `ref` on purpose: `ref` means "the focusable control" (the
|
|
311
|
+
* thing `.focus()` restores focus to), this means "the box you see". They are
|
|
312
|
+
* the same element only when the field carries no verbs.
|
|
313
|
+
*/
|
|
314
|
+
anchorRef?: Ref<View>;
|
|
315
|
+
/** Right-aligned adornment — e.g. a chevron for a select, a spinner while saving.
|
|
316
|
+
* DECORATION only: it renders inside the press target, so it may never hold a
|
|
317
|
+
* control (a button must not contain a button). A control goes in `actions`. */
|
|
171
318
|
trailing?: ReactNode;
|
|
319
|
+
/** Verbs that live ON the field's surface — an `InlineButton` Copy, Open… Use
|
|
320
|
+
* this when the act is ABOUT THE VALUE, so it travels with what it acts on
|
|
321
|
+
* instead of pairing across the row to a trailing column.
|
|
322
|
+
*
|
|
323
|
+
* Passing it changes the ANATOMY, because it has to: the surface moves to a
|
|
324
|
+
* wrapper and the press target becomes the value region beside these verbs, so
|
|
325
|
+
* each keeps its own press and neither nests in the other. The focus ring then
|
|
326
|
+
* rings the value region — the element that actually took focus — rather than
|
|
327
|
+
* the whole field. Everything else (hover on the full surface, the ref, the
|
|
328
|
+
* active/open edge) is unchanged. */
|
|
329
|
+
actions?: ReactNode;
|
|
172
330
|
/** True while the field's popover (select/date) is open: wears the 2px active
|
|
173
331
|
* ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
|
|
174
332
|
active?: boolean;
|
|
@@ -179,8 +337,6 @@ interface InlineEditViewProps {
|
|
|
179
337
|
onFocus?: () => void;
|
|
180
338
|
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
181
339
|
struck?: boolean;
|
|
182
|
-
/** Form field or grid cell — see {@link InlineEditVariant}. Default "form". */
|
|
183
|
-
variant?: InlineEditVariant;
|
|
184
340
|
/** Colours the resting VALUE by semantic state — an overdue date red, a soon-due
|
|
185
341
|
* amber (the caller owns the rule). Applies only to a set, non-struck string value;
|
|
186
342
|
* the placeholder + struck styles win. Default: the standard value ink. */
|
|
@@ -189,18 +345,18 @@ interface InlineEditViewProps {
|
|
|
189
345
|
}
|
|
190
346
|
|
|
191
347
|
/**
|
|
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 +
|
|
348
|
+
* The view-mode box of an inline-editable field: the value as plain text on the
|
|
349
|
+
* shared control surface — white with a 1px border when `framed`, frameless
|
|
350
|
+
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and the
|
|
351
|
+
* cursor stays the default arrow: the surface itself is the affordance, and a
|
|
352
|
+
* pointer would read as a button. The border is present at every state (merely
|
|
353
|
+
* transparent when `bare` rests), so swapping to an input, or floating a
|
|
354
|
+
* dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
|
|
355
|
+
* the overlay trigger for the select/date inline editors (it forwards ref +
|
|
356
|
+
* onPress to a `PopoverTrigger`).
|
|
200
357
|
*/
|
|
201
358
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
202
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, variant = "
|
|
203
|
-
const isEmptyString = typeof display === "string" && display === "";
|
|
359
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, actions, active, struck, variant = "framed", tone, onFocus, ref, anchorRef } = props;
|
|
204
360
|
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
205
361
|
// row that expands on press) edits the field instead of triggering the row.
|
|
206
362
|
const handlePress = onPress
|
|
@@ -209,33 +365,51 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
209
365
|
onPress();
|
|
210
366
|
}
|
|
211
367
|
: undefined;
|
|
368
|
+
const content = fieldContent({ display, placeholder, struck, tone, trailing });
|
|
369
|
+
const surface = fieldSurface({ variant, disabled, active });
|
|
370
|
+
|
|
371
|
+
// WITH VERBS: the shell owns the surface, the pressable is just the value
|
|
372
|
+
// region inside it. `ref` stays on the PRESSABLE — it means "the focusable
|
|
373
|
+
// control", which `InlineEditFrame` calls `.focus()` on to restore focus after
|
|
374
|
+
// a keyboard close; `anchorRef` is the separate handle for the outer box.
|
|
375
|
+
if (actions != null) {
|
|
376
|
+
return (
|
|
377
|
+
<InlineFieldShell surface={surface} actions={actions} anchorRef={anchorRef}>
|
|
378
|
+
{/* A PLAIN Pressable, not `FocusRingPressable`: the SHELL paints the ring
|
|
379
|
+
(focus-within), and a control that rang as well would draw a second
|
|
380
|
+
2px box tight around the value — which reads as the value jumping the
|
|
381
|
+
moment the field takes focus. The ring has exactly one owner, for the
|
|
382
|
+
same reason the surface and the text inset each do. */}
|
|
383
|
+
<Pressable
|
|
384
|
+
ref={ref}
|
|
385
|
+
disabled={disabled}
|
|
386
|
+
onPress={handlePress}
|
|
387
|
+
onFocus={onFocus}
|
|
388
|
+
accessibilityRole="button"
|
|
389
|
+
accessibilityLabel={accessibilityLabel}
|
|
390
|
+
style={styles.viewInner}
|
|
391
|
+
>
|
|
392
|
+
{content}
|
|
393
|
+
</Pressable>
|
|
394
|
+
</InlineFieldShell>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
212
398
|
return (
|
|
213
399
|
<FocusRingPressable
|
|
214
|
-
ref
|
|
400
|
+
// No verbs: the box and the control are the same element, so one ref
|
|
401
|
+
// serves both jobs. `anchorRef` wins when both are passed — a caller that
|
|
402
|
+
// asked for the box gets the box.
|
|
403
|
+
ref={anchorRef ?? ref}
|
|
215
404
|
disabled={disabled}
|
|
216
405
|
onPress={handlePress}
|
|
217
406
|
onFocus={onFocus}
|
|
218
407
|
accessibilityRole="button"
|
|
219
408
|
accessibilityLabel={accessibilityLabel}
|
|
220
409
|
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)]}
|
|
410
|
+
style={(state) => surface(state.hovered ?? false)}
|
|
226
411
|
>
|
|
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}
|
|
412
|
+
{content}
|
|
239
413
|
</FocusRingPressable>
|
|
240
414
|
);
|
|
241
415
|
}
|
|
@@ -267,6 +441,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
267
441
|
struck,
|
|
268
442
|
variant,
|
|
269
443
|
affordance,
|
|
444
|
+
actions,
|
|
270
445
|
} = props;
|
|
271
446
|
|
|
272
447
|
const viewRef = useRef<View>(null);
|
|
@@ -292,7 +467,9 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
292
467
|
}, []),
|
|
293
468
|
);
|
|
294
469
|
|
|
295
|
-
|
|
470
|
+
// WITHOUT verbs the two modes are genuinely different elements (the resting
|
|
471
|
+
// button, then the input), and each owns its own surface — unchanged.
|
|
472
|
+
if (actions == null && !editing) {
|
|
296
473
|
return (
|
|
297
474
|
<InlineEditView
|
|
298
475
|
ref={viewRef}
|
|
@@ -312,6 +489,46 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
312
489
|
return (
|
|
313
490
|
<View>
|
|
314
491
|
<View style={styles.editRow}>
|
|
492
|
+
{/* ONE SHELL, ACROSS BOTH MODES — not "the same shape rebuilt", the same
|
|
493
|
+
ELEMENT. Only its contents swap.
|
|
494
|
+
|
|
495
|
+
Rebuilding it per mode looked equivalent and was not: the new shell
|
|
496
|
+
mounted under a stationary pointer, which fires no `mouseenter`, so it
|
|
497
|
+
came up UN-hovered and the hover styling dropped for the length of the
|
|
498
|
+
swap — the flash. Anything the shell tracks would have been lost the
|
|
499
|
+
same way. Persisting it also means the verbs are not remounted, so
|
|
500
|
+
they cannot move, which is the contract this whole path exists for. */}
|
|
501
|
+
{actions != null ? (
|
|
502
|
+
<View style={styles.editControl}>
|
|
503
|
+
<InlineFieldShell
|
|
504
|
+
surface={fieldSurface({ variant: variant ?? "framed", disabled, editing })}
|
|
505
|
+
actions={actions}
|
|
506
|
+
>
|
|
507
|
+
{editing ? (
|
|
508
|
+
<>
|
|
509
|
+
{children}
|
|
510
|
+
{saving ? (
|
|
511
|
+
<View style={styles.savingOverlay} pointerEvents="none">
|
|
512
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
513
|
+
</View>
|
|
514
|
+
) : null}
|
|
515
|
+
</>
|
|
516
|
+
) : (
|
|
517
|
+
<Pressable
|
|
518
|
+
ref={viewRef}
|
|
519
|
+
disabled={disabled}
|
|
520
|
+
onPress={onBegin}
|
|
521
|
+
onFocus={handleViewFocus}
|
|
522
|
+
accessibilityRole="button"
|
|
523
|
+
accessibilityLabel={accessibilityLabel}
|
|
524
|
+
style={styles.viewInner}
|
|
525
|
+
>
|
|
526
|
+
{fieldContent({ display, placeholder, struck, trailing: affordance })}
|
|
527
|
+
</Pressable>
|
|
528
|
+
)}
|
|
529
|
+
</InlineFieldShell>
|
|
530
|
+
</View>
|
|
531
|
+
) : (
|
|
315
532
|
<View style={styles.editControl}>
|
|
316
533
|
{children}
|
|
317
534
|
{/* The saving spinner floats inside the input's right edge — it must
|
|
@@ -322,6 +539,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
322
539
|
</View>
|
|
323
540
|
) : null}
|
|
324
541
|
</View>
|
|
542
|
+
)}
|
|
325
543
|
{controls === "buttons" ? (
|
|
326
544
|
<View style={styles.buttons}>
|
|
327
545
|
<IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
|
|
@@ -353,16 +571,17 @@ export const inlineValueTextStyle: TextStyle = {
|
|
|
353
571
|
const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
|
|
354
572
|
|
|
355
573
|
const styles = StyleSheet.create({
|
|
356
|
-
// At REST an editor
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
574
|
+
// At REST an editor wears THE pill surface — white, 1px border — the same one
|
|
575
|
+
// `Chip`, `ChipGroup` and a secondary `Button` wear, so every control a record
|
|
576
|
+
// row can hold reads as one family. It is still THE editability affordance: a
|
|
577
|
+
// read-only `InlineStatic` stays flat and borderless, and a bordered box beside
|
|
578
|
+
// bare text is a plainer signal than a tint ever was.
|
|
360
579
|
view: {
|
|
361
580
|
minHeight: INLINE_CONTROL_HEIGHT,
|
|
362
581
|
borderRadius: CONTROL_RADIUS,
|
|
363
582
|
borderWidth: 1,
|
|
364
|
-
borderColor:
|
|
365
|
-
backgroundColor: colors.
|
|
583
|
+
borderColor: colors.border,
|
|
584
|
+
backgroundColor: colors.white,
|
|
366
585
|
paddingHorizontal: 8,
|
|
367
586
|
// Vertical padding matters only when the display content is TALLER than
|
|
368
587
|
// one line (a rich select showing title + description at rest): minHeight
|
|
@@ -377,30 +596,50 @@ const styles = StyleSheet.create({
|
|
|
377
596
|
// as a button press). Overrides the Pressable's pointer default.
|
|
378
597
|
cursor: "auto",
|
|
379
598
|
},
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
// the
|
|
599
|
+
// Nothing at rest — the frame arrives on hover. Also how a DISABLED field
|
|
600
|
+
// rests, whatever its variant: an inert value must not promise a press.
|
|
601
|
+
viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
|
|
602
|
+
// The shell's own inset: the right edge tightens to 4 so an `InlineButton` sits
|
|
603
|
+
// on the surface's edge with the same air a `Chip`'s dismiss gets.
|
|
604
|
+
shell: { paddingRight: 4 },
|
|
605
|
+
// Focus paints on the FIELD, not on the value region inside it.
|
|
606
|
+
shellFocused: { borderColor: colors.border, boxShadow: FOCUS_RING },
|
|
607
|
+
// The control's slot: it takes the slack so the verbs keep the right edge.
|
|
608
|
+
shellControl: { flex: 1, minWidth: 0, alignSelf: "stretch", justifyContent: "center", position: "relative" },
|
|
609
|
+
// The value region inside a verb-hosting resting field: carries the row layout
|
|
610
|
+
// the surface would otherwise own. `userSelect` here rather than via the
|
|
611
|
+
// `FocusRingPressable` prop — a drag across a value must edit, not select text.
|
|
612
|
+
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none", cursor: "auto" } as ViewStyle) },
|
|
613
|
+
// Open (popover showing): the resting surface plus the 2px active ring — the
|
|
614
|
+
// one thing that separates open from at-rest, and identical to a focused input.
|
|
385
615
|
viewActive: {
|
|
386
616
|
borderColor: colors.border,
|
|
387
617
|
backgroundColor: colors.white,
|
|
388
618
|
boxShadow: FOCUS_RING,
|
|
389
619
|
},
|
|
390
|
-
// Hover
|
|
391
|
-
// a transparent CELL/dense field takes the background-tint wash (zinc-100), the
|
|
392
|
-
// same language as the cells beside it.
|
|
620
|
+
// Hover on a BARE field: the border arrives out of nothing — the whole signal.
|
|
393
621
|
viewHovered: {
|
|
394
622
|
borderColor: HOVER_BORDER,
|
|
395
623
|
},
|
|
396
|
-
|
|
397
|
-
|
|
624
|
+
// Hover on a FRAMED field: the border deepens AND the surface tints. The border
|
|
625
|
+
// alone would be zinc-200 → zinc-400, a shade shift on a 1px line — too quiet to
|
|
626
|
+
// mark the field you are about to act on. The tint is the old resting chip,
|
|
627
|
+
// promoted to the state it always read as: "this one".
|
|
628
|
+
//
|
|
629
|
+
// It is deliberately NOT applied to `bare`: those live inside rows that wash on
|
|
630
|
+
// hover themselves, and a cell lighter than the row under it reads backwards.
|
|
631
|
+
viewHoveredFramed: {
|
|
632
|
+
borderColor: HOVER_BORDER,
|
|
633
|
+
backgroundColor: colors.zinc[50],
|
|
398
634
|
},
|
|
399
635
|
placeholder: { color: colors.zinc[400] },
|
|
400
636
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
401
637
|
viewNode: { flex: 1, minWidth: 0 },
|
|
402
638
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
403
639
|
editControl: { flex: 1, position: "relative" },
|
|
640
|
+
// The input brings its own 40px height, so the surface must not add its resting
|
|
641
|
+
// vertical padding on top — that was a 12px jump the moment the field focused.
|
|
642
|
+
editHost: { paddingVertical: 0 },
|
|
404
643
|
savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
|
|
405
644
|
buttons: { flexDirection: "row", gap: 6, height: INLINE_CONTROL_HEIGHT, alignItems: "center" },
|
|
406
645
|
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
|
);
|
package/src/inline_select.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
1
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { Icon } from "./icon";
|
|
4
4
|
import { Text } from "./text";
|
|
@@ -24,9 +24,14 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
|
24
24
|
* as-is (e.g. an avatar "add" ghost for an `avatarOnly` member cell). */
|
|
25
25
|
placeholder?: string | ReactNode;
|
|
26
26
|
disabled?: boolean;
|
|
27
|
-
|
|
28
|
-
/** Form field (default) or grid cell — see {@link InlineEditVariant}. */
|
|
27
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
29
28
|
variant?: InlineEditVariant;
|
|
29
|
+
/** Verbs on the field's surface — an `InlineButton` Copy, Open… See
|
|
30
|
+
* `InlineEditView.actions`: they sit INSIDE the field, in the same place in
|
|
31
|
+
* every mode, and must be passed UNCONDITIONALLY (`disabled` the verb when it
|
|
32
|
+
* has nothing to act on) or the field resizes as the value fills. */
|
|
33
|
+
actions?: ReactNode;
|
|
34
|
+
accessibilityLabel?: string;
|
|
30
35
|
/** Show an in-menu search box; default false. */
|
|
31
36
|
searchable?: boolean;
|
|
32
37
|
/** Offer a "create" row when the query matches no option — picking it commits the
|
|
@@ -67,30 +72,36 @@ function InlineSelectShell(props: {
|
|
|
67
72
|
display: string | ReactNode;
|
|
68
73
|
placeholder?: string | ReactNode;
|
|
69
74
|
accessibilityLabel?: string;
|
|
70
|
-
variant?: InlineEditVariant;
|
|
71
75
|
saving: boolean;
|
|
72
76
|
error: string | null;
|
|
73
77
|
children: ReactNode;
|
|
78
|
+
variant?: InlineEditVariant;
|
|
79
|
+
actions?: ReactNode;
|
|
74
80
|
}) {
|
|
81
|
+
// The popover INHERITS THE TRIGGER'S WIDTH, so it has to measure the FIELD —
|
|
82
|
+
// with verbs on the surface the value region inside it is narrower, and
|
|
83
|
+
// anchoring there would open a list too small for the row it belongs to.
|
|
84
|
+
const anchorRef = useRef<View>(null);
|
|
75
85
|
return (
|
|
76
86
|
<View>
|
|
77
|
-
<Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} side="bottom" align="start" inheritTriggerWidth>
|
|
87
|
+
<Popover open={props.open && !props.disabled} onOpenChange={props.onOpenChange} triggerRef={anchorRef} side="bottom" align="start" inheritTriggerWidth>
|
|
78
88
|
<PopoverTrigger>
|
|
79
89
|
<InlineEditView
|
|
90
|
+
anchorRef={anchorRef}
|
|
91
|
+
actions={props.actions}
|
|
80
92
|
variant={props.variant}
|
|
81
93
|
display={props.display}
|
|
82
94
|
placeholder={props.placeholder}
|
|
83
95
|
disabled={props.disabled}
|
|
84
96
|
active={props.open && !props.disabled}
|
|
85
97
|
accessibilityLabel={props.accessibilityLabel}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// saving spinner shows in both.
|
|
98
|
+
// The resting chevron says "this opens a list" — the same promise on
|
|
99
|
+
// every surface, now that a field looks the same everywhere. The
|
|
100
|
+
// saving spinner replaces it while a write is in flight.
|
|
90
101
|
trailing={
|
|
91
102
|
props.saving ? (
|
|
92
103
|
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
93
|
-
) :
|
|
104
|
+
) : (
|
|
94
105
|
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
95
106
|
)
|
|
96
107
|
}
|
|
@@ -108,7 +119,7 @@ function InlineSelectShell(props: {
|
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
111
|
-
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel,
|
|
122
|
+
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions } = props;
|
|
112
123
|
const labels = useLoticsLocale().inline;
|
|
113
124
|
const [open, setOpen] = useState(false);
|
|
114
125
|
const [saving, setSaving] = useState(false);
|
|
@@ -151,13 +162,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
151
162
|
};
|
|
152
163
|
return (
|
|
153
164
|
<InlineSelectShell
|
|
165
|
+
variant={variant}
|
|
166
|
+
actions={actions}
|
|
154
167
|
open={open}
|
|
155
168
|
onOpenChange={onOpenChange}
|
|
156
169
|
disabled={disabled}
|
|
157
170
|
display={selected.length > 0 ? <View style={styles.tags}>{selected.map((o) => <View key={o.value}>{chip(o)}</View>)}</View> : ""}
|
|
158
171
|
placeholder={placeholder}
|
|
159
172
|
accessibilityLabel={accessibilityLabel}
|
|
160
|
-
variant={variant}
|
|
161
173
|
saving={saving}
|
|
162
174
|
error={error}
|
|
163
175
|
>
|
|
@@ -196,13 +208,14 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
196
208
|
};
|
|
197
209
|
return (
|
|
198
210
|
<InlineSelectShell
|
|
211
|
+
variant={variant}
|
|
212
|
+
actions={actions}
|
|
199
213
|
open={open}
|
|
200
214
|
onOpenChange={setOpen}
|
|
201
215
|
disabled={disabled}
|
|
202
216
|
display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
|
|
203
217
|
placeholder={placeholder}
|
|
204
218
|
accessibilityLabel={accessibilityLabel}
|
|
205
|
-
variant={variant}
|
|
206
219
|
saving={saving}
|
|
207
220
|
error={error}
|
|
208
221
|
>
|