@lotics/ui 44.7.0 → 44.8.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 +11 -1
- package/MIGRATION.md +110 -5
- package/docs/catalog.md +35 -7
- package/docs/composition.md +105 -5
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +46 -3
- package/examples/tpl_item_list.tsx +8 -4
- package/package.json +2 -1
- package/src/accordion.tsx +9 -1
- package/src/agent_run_pane.tsx +7 -5
- package/src/card.tsx +7 -1
- package/src/card_select_item.tsx +15 -2
- package/src/control_surface.ts +18 -4
- package/src/data_grid.tsx +9 -1
- package/src/date_range_filter_field.tsx +9 -2
- package/src/date_stamp.tsx +8 -2
- package/src/detail_row.tsx +17 -12
- package/src/empty_state.tsx +9 -2
- package/src/error_state.tsx +9 -2
- package/src/field_annotations.tsx +25 -2
- package/src/finding.tsx +19 -10
- package/src/format_money.ts +16 -2
- package/src/inline_edit.tsx +29 -9
- package/src/inline_select.tsx +8 -1
- package/src/inline_text_input.tsx +6 -7
- package/src/locale.tsx +24 -1
- package/src/number_input.tsx +8 -1
- package/src/reference_field.tsx +9 -2
- package/src/section_heading.tsx +17 -5
- package/src/select.tsx +16 -16
- package/src/sequence.tsx +41 -25
- package/src/step_progress.tsx +6 -4
- package/src/table_fit.ts +18 -8
- package/src/tabs.tsx +19 -0
- package/src/text_input_field.tsx +35 -1
package/src/inline_edit.tsx
CHANGED
|
@@ -259,6 +259,7 @@ function fieldSurface(o: { variant: InlineEditVariant; disabled?: boolean; activ
|
|
|
259
259
|
return (hovered: boolean): StyleProp<ViewStyle>[] => [
|
|
260
260
|
styles.view,
|
|
261
261
|
(o.variant === "bare" || o.disabled) && styles.viewBare,
|
|
262
|
+
o.disabled === true && styles.viewInert,
|
|
262
263
|
// Keyed on the VARIANT alone, never on `disabled` — a disabled `framed`
|
|
263
264
|
// field shares the line above (it must not promise a press) but stays in its
|
|
264
265
|
// column with the enabled fields beside it. Bleeding that one would pull a
|
|
@@ -396,9 +397,9 @@ interface InlineEditViewProps {
|
|
|
396
397
|
/**
|
|
397
398
|
* The view-mode box of an inline-editable field: the value as plain text on the
|
|
398
399
|
* shared control surface — white with a 1px border when `framed`, frameless
|
|
399
|
-
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and
|
|
400
|
-
*
|
|
401
|
-
*
|
|
400
|
+
* until hover when `bare`. It hovers via its BORDER, never a grey wash, and it
|
|
401
|
+
* takes the pointer cursor every other control takes. The border is present at
|
|
402
|
+
* every state (merely
|
|
402
403
|
* transparent when `bare` rests), so swapping to an input, or floating a
|
|
403
404
|
* dropdown above it, never shifts the layout. Used by `InlineEditFrame`, and as
|
|
404
405
|
* the overlay trigger for the select/date inline editors (it forwards ref +
|
|
@@ -459,7 +460,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
459
460
|
onFocus={onFocus}
|
|
460
461
|
accessibilityRole="button"
|
|
461
462
|
accessibilityLabel={accessibilityLabel}
|
|
462
|
-
style={[styles.viewInner, multilineBox(numberOfLines)]}
|
|
463
|
+
style={[styles.viewInner, disabled === true && styles.viewInert, multilineBox(numberOfLines)]}
|
|
463
464
|
>
|
|
464
465
|
{content}
|
|
465
466
|
</Pressable>
|
|
@@ -678,14 +679,33 @@ const styles = StyleSheet.create({
|
|
|
678
679
|
flexDirection: "row",
|
|
679
680
|
alignItems: "center",
|
|
680
681
|
gap: 6,
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
682
|
+
// THE POINTER, like every other control in the kit.
|
|
683
|
+
//
|
|
684
|
+
// This box used to force the arrow back, on the reasoning that an inline
|
|
685
|
+
// editor is an input rather than a button and the chip plus hover-border
|
|
686
|
+
// already carried the affordance. Both halves fail. `bare` HAS no chip —
|
|
687
|
+
// nothing at rest by design — so the whole affordance was a 1px edge that
|
|
688
|
+
// appears on hover, which is invisible to touch and to anyone who never
|
|
689
|
+
// happens to sweep the value; a member's actual question was "how am I
|
|
690
|
+
// supposed to edit this?". And the arrow does not read as "input", it
|
|
691
|
+
// reads as INERT: it is what the page's own prose gets, so the one thing
|
|
692
|
+
// on the row that is theirs to change looked like the one thing that
|
|
693
|
+
// wasn't.
|
|
694
|
+
//
|
|
695
|
+
// The I-beam is not the alternative. It promises a caret and a selection,
|
|
696
|
+
// and `userSelect: "none"` below deliberately denies both so a drag edits
|
|
697
|
+
// instead of selecting — a false promise in the same family as the arrow.
|
|
698
|
+
// What is true of every variant is that pressing DOES something, which is
|
|
699
|
+
// exactly what the pointer says.
|
|
700
|
+
cursor: "pointer",
|
|
685
701
|
},
|
|
686
702
|
// Nothing at rest — the frame arrives on hover. Also how a DISABLED field
|
|
687
703
|
// rests, whatever its variant: an inert value must not promise a press.
|
|
688
704
|
viewBare: { backgroundColor: "transparent", borderColor: "transparent" },
|
|
705
|
+
// ...and the cursor is half of that promise, so it goes back to the arrow
|
|
706
|
+
// for a disabled field only. `viewBare` cannot carry it: a `bare` field is
|
|
707
|
+
// enabled and wears the same style.
|
|
708
|
+
viewInert: { cursor: "auto" },
|
|
689
709
|
// A bare field at rest looks like TEXT, so it aligns like text: its glyphs sit
|
|
690
710
|
// on the column, not its invisible box. The frame's 8px inset is real padding
|
|
691
711
|
// the eye cannot see at rest, and it put every bare value 8px right of the
|
|
@@ -716,7 +736,7 @@ const styles = StyleSheet.create({
|
|
|
716
736
|
// The value region inside a verb-hosting resting field: carries the row layout
|
|
717
737
|
// the surface would otherwise own. `userSelect` here rather than via the
|
|
718
738
|
// `FocusRingPressable` prop — a drag across a value must edit, not select text.
|
|
719
|
-
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none"
|
|
739
|
+
viewInner: { flex: 1, minWidth: 0, alignSelf: "stretch", flexDirection: "row", alignItems: "center", gap: 6, ...({ userSelect: "none" } as ViewStyle) },
|
|
720
740
|
// Open (popover showing): the resting surface plus the 2px active ring — the
|
|
721
741
|
// one thing that separates open from at-rest, and identical to a focused input.
|
|
722
742
|
viewActive: {
|
package/src/inline_select.tsx
CHANGED
|
@@ -26,6 +26,11 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
|
26
26
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
27
27
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
28
28
|
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
29
|
+
/** A second line under an option's label in the dropdown — a driver's phone
|
|
30
|
+
* under their name, a code under a description. `OptionList` has always
|
|
31
|
+
* rendered this two-line `MenuListItem`; this only forwards it, which is why
|
|
32
|
+
* callers were hand-rebuilding the anatomy through `renderOptionContent`. */
|
|
33
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
29
34
|
/** Render ONE selected option as its resting chip — single: the value; multi:
|
|
30
35
|
* each tag. Falls back to `renderOptionContent`, then the plain label (single) /
|
|
31
36
|
* a zinc `Badge` (multi). The one seam for "how the selection looks". */
|
|
@@ -135,7 +140,7 @@ function InlineSelectShell(props: {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
138
|
-
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
|
|
143
|
+
const { options, renderOptionContent, getOptionDescription, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
|
|
139
144
|
const labels = useLoticsLocale().inline;
|
|
140
145
|
const [open, setOpen] = useState(autoFocus);
|
|
141
146
|
const [saving, setSaving] = useState(false);
|
|
@@ -191,6 +196,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
191
196
|
>
|
|
192
197
|
<OptionList<T, true, D>
|
|
193
198
|
multi
|
|
199
|
+
getOptionDescription={getOptionDescription}
|
|
194
200
|
search={{ mode: searchMode }}
|
|
195
201
|
options={options}
|
|
196
202
|
value={draft}
|
|
@@ -237,6 +243,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
|
|
|
237
243
|
error={error}
|
|
238
244
|
>
|
|
239
245
|
<OptionList<T, false, D>
|
|
246
|
+
getOptionDescription={getOptionDescription}
|
|
240
247
|
search={{ mode: searchMode }}
|
|
241
248
|
options={options}
|
|
242
249
|
value={value}
|
|
@@ -184,19 +184,18 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
184
184
|
// Everything the resting VIEW used to draw, the input now draws, because
|
|
185
185
|
// there is no resting view left to draw it. Both of these were its job:
|
|
186
186
|
//
|
|
187
|
-
// `
|
|
188
|
-
//
|
|
189
|
-
//
|
|
187
|
+
// `variant` — the resting frame, or none. It goes over as the PROP, not
|
|
188
|
+
// as a `style` override: the two are not equivalent, because `style` is
|
|
189
|
+
// applied after the hover rule and so erased the hover edge that is a
|
|
190
|
+
// bare field's only affordance. The field owns its own variants.
|
|
190
191
|
//
|
|
191
192
|
// `struck` — a completed item's value, line-through and muted. This was
|
|
192
193
|
// applied in `fieldContent`, which only ever runs in the resting view, so
|
|
193
194
|
// moving to one element silently dropped it: a done task on the board kept
|
|
194
195
|
// its title upright. Anything else the view used to render has to move the
|
|
195
196
|
// same way, or it goes the same way — quietly.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null,
|
|
199
|
-
]}
|
|
197
|
+
variant={variant}
|
|
198
|
+
style={struck ? { textDecorationLine: "line-through" as const, color: colors.zinc[500] } : null}
|
|
200
199
|
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
201
200
|
seamless={actions != null}
|
|
202
201
|
/>
|
package/src/locale.tsx
CHANGED
|
@@ -74,11 +74,20 @@ export interface LoticsLocale {
|
|
|
74
74
|
clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
|
|
75
75
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
76
76
|
ledger: { rowDetails: (label: string) => string };
|
|
77
|
-
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
78
77
|
/** `Step`/`ChecklistItem`'s toggleable MARKER, when the caller names no
|
|
79
78
|
* label of its own. A pipeline stage always passes its title, so this is the
|
|
80
79
|
* bare-`Step` fallback. */
|
|
81
80
|
stepper: { complete: string; progress: string };
|
|
81
|
+
/** `StepProgress`: the built-in caption over NAMED stages, and the
|
|
82
|
+
* screen-reader position when there is no caption to borrow. The counts are
|
|
83
|
+
* interpolated by the pack rather than concatenated at the call site, because
|
|
84
|
+
* where the number sits in the sentence is a property of the language. */
|
|
85
|
+
stepProgress: {
|
|
86
|
+
complete: (total: number) => string;
|
|
87
|
+
stage: (name: string, index: number, total: number) => string;
|
|
88
|
+
none: (total: number) => string;
|
|
89
|
+
position: (index: number, total: number) => string;
|
|
90
|
+
};
|
|
82
91
|
/** `ChecklistGroup`'s disclosure — the small pressable text that shows or hides
|
|
83
92
|
* a phase's rows. The kit owns the wording so one app does not say "Show" while
|
|
84
93
|
* the next says "Expand" for the same gesture. */
|
|
@@ -89,6 +98,8 @@ export interface LoticsLocale {
|
|
|
89
98
|
* because word order is not shared: English puts the verb first, and a
|
|
90
99
|
* language that does not would otherwise be stuck with a prefix. */
|
|
91
100
|
textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
|
|
101
|
+
/** The info-popover trigger's screen-reader name on a heading — every level of
|
|
102
|
+
* `SectionHeading*`, and `CardHeaderTitle`, which names the same altitude. */
|
|
92
103
|
sectionHeading: { info: string };
|
|
93
104
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
94
105
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -293,6 +304,12 @@ export const en: LoticsLocale = {
|
|
|
293
304
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
294
305
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
295
306
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
307
|
+
stepProgress: {
|
|
308
|
+
complete: (total) => `Complete (${total}/${total})`,
|
|
309
|
+
stage: (name, index, total) => `${name} (${index}/${total})`,
|
|
310
|
+
none: (total) => `0/${total}`,
|
|
311
|
+
position: (index, total) => `${index} of ${total}`,
|
|
312
|
+
},
|
|
296
313
|
checklist: { expand: "Show", collapse: "Hide" },
|
|
297
314
|
textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
|
|
298
315
|
sectionHeading: { info: "About this data" },
|
|
@@ -472,6 +489,12 @@ export const vi: LoticsLocale = {
|
|
|
472
489
|
clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
|
|
473
490
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
474
491
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
492
|
+
stepProgress: {
|
|
493
|
+
complete: (total) => `Hoàn tất (${total}/${total})`,
|
|
494
|
+
stage: (name, index, total) => `${name} (${index}/${total})`,
|
|
495
|
+
none: (total) => `0/${total}`,
|
|
496
|
+
position: (index, total) => `${index} trên ${total}`,
|
|
497
|
+
},
|
|
475
498
|
checklist: { expand: "Mở", collapse: "Thu gọn" },
|
|
476
499
|
textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
|
|
477
500
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
package/src/number_input.tsx
CHANGED
|
@@ -18,6 +18,12 @@ export interface NumberInputProps {
|
|
|
18
18
|
disabled?: boolean;
|
|
19
19
|
testID?: string;
|
|
20
20
|
accessibilityLabel?: string;
|
|
21
|
+
/** Hint text shown while the field is empty — a unit, a shape, an example
|
|
22
|
+
* ("0,00", "kg"). `InlineNumberInput` has always had one; without it here the
|
|
23
|
+
* only hint slot left on a `FormField` is `description`, which renders
|
|
24
|
+
* BETWEEN label and input and so drops this field's input a line below its
|
|
25
|
+
* neighbour's in a two-column form grid. */
|
|
26
|
+
placeholder?: string;
|
|
21
27
|
/** The HOST paints the surface — drop this input's own border, fill, focus ring
|
|
22
28
|
* AND horizontal padding, so the page draws one box and the text lands on the
|
|
23
29
|
* host's inset rather than 8px further in. Set by an inline editor whose field
|
|
@@ -37,7 +43,7 @@ export interface NumberInputProps {
|
|
|
37
43
|
* number in place on a record use `InlineNumberInput`.
|
|
38
44
|
*/
|
|
39
45
|
export function NumberInput(props: NumberInputProps) {
|
|
40
|
-
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, seamless } = props;
|
|
46
|
+
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel, placeholder, seamless } = props;
|
|
41
47
|
const binding = useFormField();
|
|
42
48
|
const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
43
49
|
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
@@ -52,6 +58,7 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
52
58
|
aria-describedby={describedBy}
|
|
53
59
|
aria-invalid={binding?.invalid || undefined}
|
|
54
60
|
value={value ?? ""}
|
|
61
|
+
placeholder={placeholder}
|
|
55
62
|
onChange={(e) =>
|
|
56
63
|
e.target.value !== "" ? onValueChange(Number(e.target.value)) : onValueChange(null)
|
|
57
64
|
}
|
package/src/reference_field.tsx
CHANGED
|
@@ -423,10 +423,17 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
423
423
|
accessibilityLabel={`${t.change} — ${name}`}
|
|
424
424
|
onPress={() => { setPeekOpen(false); onChange(); }}
|
|
425
425
|
/>
|
|
426
|
-
{/*
|
|
427
|
-
|
|
426
|
+
{/* Same resting surface as `change` beside it. It had NO
|
|
427
|
+
color — which renders transparent, borderless and
|
|
428
|
+
undecorated, i.e. the hover-only affordance composition.md
|
|
429
|
+
bans: invisible to keyboard and touch, and a singleton
|
|
430
|
+
no-ground among grounded siblings, which a reader parses
|
|
431
|
+
before they read any label. Being the least-reached verb is
|
|
432
|
+
said by PLACEMENT — second in the left group, far from the
|
|
433
|
+
primary — not by drawing nothing. */}
|
|
428
434
|
<Button
|
|
429
435
|
title={t.clear}
|
|
436
|
+
color="secondary"
|
|
430
437
|
accessibilityLabel={`${t.clear} — ${name}`}
|
|
431
438
|
onPress={() => { setPeekOpen(false); onClear(); }}
|
|
432
439
|
/>
|
package/src/section_heading.tsx
CHANGED
|
@@ -77,9 +77,21 @@ export interface SubsectionHeadingProps {
|
|
|
77
77
|
style?: StyleProp<ViewStyle>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The narrowest a heading's TITLE column may be before its row wraps and the
|
|
82
|
+
* trailing slot (an ADD verb, a meta, a badge) drops to the next line.
|
|
83
|
+
*
|
|
84
|
+
* Needed because RN-Web resolves `flex: 1` to `flex-basis: 0%` with
|
|
85
|
+
* `min-width: 0`: with no floor, a title beside a button that holds its
|
|
86
|
+
* intrinsic width is the half that gives way, so the HEADING breaks — one word
|
|
87
|
+
* per line — while the verb beside it sits untouched. That is backwards. The
|
|
88
|
+
* title names the section; the verb is the thing that can move.
|
|
89
|
+
*/
|
|
90
|
+
const HEADING_TITLE_MIN_WIDTH = 200;
|
|
91
|
+
|
|
80
92
|
export function SubsectionHeading(props: SubsectionHeadingProps) {
|
|
81
93
|
return (
|
|
82
|
-
<View style={[{ flexDirection: "row", alignItems: "center", gap: 10 }, props.style]}>
|
|
94
|
+
<View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 10 }, props.style]}>
|
|
83
95
|
{props.children}
|
|
84
96
|
</View>
|
|
85
97
|
);
|
|
@@ -116,7 +128,7 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
|
116
128
|
// wrapper carries the `flex: 1` that lets a heading row push meta to its right
|
|
117
129
|
// edge. Without the wrapper the description would land on the title's LINE.
|
|
118
130
|
return (
|
|
119
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
131
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
120
132
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
121
133
|
<Text level={level} size="lg" weight="semibold">
|
|
122
134
|
{children}
|
|
@@ -173,7 +185,7 @@ export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps)
|
|
|
173
185
|
const { children, description, icon, level = 4, info } = props;
|
|
174
186
|
const words = useLoticsLocale();
|
|
175
187
|
return (
|
|
176
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
188
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
177
189
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
178
190
|
{icon ? <Icon name={icon} size={16} /> : null}
|
|
179
191
|
<Text level={level} size="md" weight="semibold">
|
|
@@ -197,7 +209,7 @@ export interface SectionHeadingProps {
|
|
|
197
209
|
|
|
198
210
|
export function SectionHeading(props: SectionHeadingProps) {
|
|
199
211
|
return (
|
|
200
|
-
<View style={[{ flexDirection: "row", alignItems: "center", gap: 12 }, props.style]}>
|
|
212
|
+
<View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12 }, props.style]}>
|
|
201
213
|
{props.children}
|
|
202
214
|
</View>
|
|
203
215
|
);
|
|
@@ -229,7 +241,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
229
241
|
// render `info` appears or disappears.
|
|
230
242
|
const words = useLoticsLocale();
|
|
231
243
|
return (
|
|
232
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
244
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
233
245
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
234
246
|
{icon ? <Icon name={icon} size={20} /> : null}
|
|
235
247
|
<Text level={level} weight={weight} size="xl">
|
package/src/select.tsx
CHANGED
|
@@ -8,8 +8,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
|
8
8
|
import { OptionList } from "./option_list";
|
|
9
9
|
import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
10
10
|
|
|
11
|
-
export interface SelectProps<T extends string = string, MULTI extends boolean = false> {
|
|
12
|
-
options?: (PickerOption<T> | undefined | false)[];
|
|
11
|
+
export interface SelectProps<T extends string = string, MULTI extends boolean = false, D = unknown> {
|
|
12
|
+
options?: (PickerOption<T, D> | undefined | false)[];
|
|
13
13
|
placeholder?: string;
|
|
14
14
|
/** Accessible name for the control. Required in spirit whenever there's no
|
|
15
15
|
* visible label beside it — the selected text describes the value, not the control. */
|
|
@@ -17,15 +17,15 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
|
|
|
17
17
|
/** How each OPTION renders in the menu (a colour-dot badge, a member chip).
|
|
18
18
|
* Also the default for the trigger's selection display when `renderSelected`
|
|
19
19
|
* is omitted. */
|
|
20
|
-
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
20
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
21
21
|
/** How each SELECTED item renders in the anchor — a render FUNCTION, not a preset.
|
|
22
22
|
* Use `controls.remove` to detach it in place: render `<Chip onDismiss={remove}>`
|
|
23
23
|
* for a removable chip box, an `OptionBadge`, or any custom display. Omit it for
|
|
24
24
|
* a comma summary (single: the one value, `remove` clears it). The single seam
|
|
25
25
|
* for "how the selection looks" — there's no `display` mode. */
|
|
26
|
-
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
26
|
+
renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
|
|
27
27
|
/** A single-line subtitle under each option's label in the menu. */
|
|
28
|
-
getOptionDescription?: (option: PickerOption<T>) => string | undefined;
|
|
28
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
29
29
|
/** Show a search field in the menu to filter the options. */
|
|
30
30
|
searchable?: boolean;
|
|
31
31
|
/** Offer a "create" row when the query matches no option — picking it adds the
|
|
@@ -59,7 +59,7 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
|
|
|
59
59
|
* select use the native `Picker`; to SEARCH a large/remote set with the input AS
|
|
60
60
|
* the control (type-in-place, free text reflected in the field) use `Combobox`.
|
|
61
61
|
*/
|
|
62
|
-
export function Select<T extends string, MULTI extends boolean = false>(props: SelectProps<T, MULTI>) {
|
|
62
|
+
export function Select<T extends string, MULTI extends boolean = false, D = unknown>(props: SelectProps<T, MULTI, D>) {
|
|
63
63
|
const {
|
|
64
64
|
testID,
|
|
65
65
|
options = [],
|
|
@@ -139,7 +139,7 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
|
|
|
139
139
|
field/footer OUTSIDE it. The default body ScrollView would be a redundant
|
|
140
140
|
second scroll that also clips the autofocused search field's outset ring. */}
|
|
141
141
|
<PopoverContent disableBodyScroll>
|
|
142
|
-
<OptionList<T, MULTI>
|
|
142
|
+
<OptionList<T, MULTI, D>
|
|
143
143
|
search={{ mode: searchable || allowCustom ? "internal" : "none" }}
|
|
144
144
|
options={options}
|
|
145
145
|
multi={multi}
|
|
@@ -163,18 +163,18 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function useSelectedItems<T extends string>(
|
|
166
|
+
function useSelectedItems<T extends string, D = unknown>(
|
|
167
167
|
multi: boolean,
|
|
168
168
|
value: T | T[] | undefined | null,
|
|
169
|
-
options: (PickerOption<T> | undefined | false)[],
|
|
170
|
-
): PickerOption<T>[] {
|
|
169
|
+
options: (PickerOption<T, D> | undefined | false)[],
|
|
170
|
+
): PickerOption<T, D>[] {
|
|
171
171
|
return useMemo(() => {
|
|
172
172
|
const optionsMap = new Map(
|
|
173
|
-
options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
|
|
173
|
+
options.filter((opt): opt is PickerOption<T, D> => !!opt).map((opt) => [opt.value, opt]),
|
|
174
174
|
);
|
|
175
175
|
// A value not in `options` (e.g. an `allowCustom` creation) still shows —
|
|
176
176
|
// fall back to a {value, label} so it renders.
|
|
177
|
-
const resolve = (val: T): PickerOption<T> => optionsMap.get(val) ?? { value: val, label: val };
|
|
177
|
+
const resolve = (val: T): PickerOption<T, D> => optionsMap.get(val) ?? { value: val, label: val };
|
|
178
178
|
if (multi) {
|
|
179
179
|
const multiValue = Array.isArray(value) ? value : [];
|
|
180
180
|
return multiValue.map(resolve);
|
|
@@ -187,7 +187,7 @@ function useSelectedItems<T extends string>(
|
|
|
187
187
|
}, [multi, value, options]);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function SelectTrigger<T extends string>({
|
|
190
|
+
function SelectTrigger<T extends string, D = unknown>({
|
|
191
191
|
ref,
|
|
192
192
|
testID,
|
|
193
193
|
open,
|
|
@@ -206,10 +206,10 @@ function SelectTrigger<T extends string>({
|
|
|
206
206
|
open: boolean;
|
|
207
207
|
style?: StyleProp<ViewStyle>;
|
|
208
208
|
onPress: () => void;
|
|
209
|
-
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
210
|
-
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
209
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
210
|
+
renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
|
|
211
211
|
onRemove?: (value: T) => void;
|
|
212
|
-
selectedItems: PickerOption<T>[];
|
|
212
|
+
selectedItems: PickerOption<T, D>[];
|
|
213
213
|
placeholder?: string;
|
|
214
214
|
accessibilityLabel?: string;
|
|
215
215
|
disabled?: boolean;
|
package/src/sequence.tsx
CHANGED
|
@@ -77,8 +77,11 @@ export interface SequenceItemProps {
|
|
|
77
77
|
role?: string;
|
|
78
78
|
/** The item's editors / content. */
|
|
79
79
|
children: ReactNode;
|
|
80
|
-
/** Swap with the item above.
|
|
81
|
-
*
|
|
80
|
+
/** Swap with the item above. On an item that is editable at all, omitting
|
|
81
|
+
* this (or leaving it undefined on the first item) renders the control
|
|
82
|
+
* DISABLED rather than absent, so the column never changes width between
|
|
83
|
+
* items. Omit all three and the item is not editable: the control column
|
|
84
|
+
* goes away entirely — see the note on the component. */
|
|
82
85
|
onMoveUp?: () => void;
|
|
83
86
|
onMoveDown?: () => void;
|
|
84
87
|
onRemove?: () => void;
|
|
@@ -98,6 +101,13 @@ export interface SequenceItemProps {
|
|
|
98
101
|
* phone, and a list of three-to-six positions does not need the expressiveness.
|
|
99
102
|
* They render even where they cannot act (first item, last item) so the row's
|
|
100
103
|
* right edge never shifts between items.
|
|
104
|
+
*
|
|
105
|
+
* That steadiness is between items that CAN be edited. An item passing NO
|
|
106
|
+
* handler at all is not an edge case of editing, it is a read-only row, and it
|
|
107
|
+
* renders no control column: three permanently dead buttons on every row of a
|
|
108
|
+
* sequence nobody can reorder are dead affordances, which the kit's own
|
|
109
|
+
* reviewing gates count as a defect. A read-only `Sequence` is a legitimate and
|
|
110
|
+
* common shape — an ordered list of legs already run, a route as recorded.
|
|
101
111
|
*/
|
|
102
112
|
export function SequenceItem(props: SequenceItemProps) {
|
|
103
113
|
const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
|
|
@@ -106,6 +116,10 @@ export function SequenceItem(props: SequenceItemProps) {
|
|
|
106
116
|
const first = pos == null || pos.index === 0;
|
|
107
117
|
const last = pos == null || pos.index === pos.count - 1;
|
|
108
118
|
const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
|
|
119
|
+
// Any ONE handler makes the item editable and brings the whole cluster, so a
|
|
120
|
+
// row that can be removed but not reordered still holds the column steady
|
|
121
|
+
// against its neighbours. Only "no handler at all" drops it.
|
|
122
|
+
const editable = onMoveUp != null || onMoveDown != null || onRemove != null;
|
|
109
123
|
return (
|
|
110
124
|
<View style={styles.item}>
|
|
111
125
|
{/* THE RAIL — a leading segment, the dot on the control line, then a
|
|
@@ -126,29 +140,31 @@ export function SequenceItem(props: SequenceItemProps) {
|
|
|
126
140
|
</View>
|
|
127
141
|
) : null}
|
|
128
142
|
<View style={styles.content}>{children}</View>
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
{editable ? (
|
|
144
|
+
<View style={styles.controls}>
|
|
145
|
+
<IconButton
|
|
146
|
+
icon="chevron-up"
|
|
147
|
+
tooltip={labels.moveUp}
|
|
148
|
+
accessibilityLabel={named(labels.moveUp)}
|
|
149
|
+
disabled={first || onMoveUp == null}
|
|
150
|
+
onPress={() => onMoveUp?.()}
|
|
151
|
+
/>
|
|
152
|
+
<IconButton
|
|
153
|
+
icon="chevron-down"
|
|
154
|
+
tooltip={labels.moveDown}
|
|
155
|
+
accessibilityLabel={named(labels.moveDown)}
|
|
156
|
+
disabled={last || onMoveDown == null}
|
|
157
|
+
onPress={() => onMoveDown?.()}
|
|
158
|
+
/>
|
|
159
|
+
<IconButton
|
|
160
|
+
icon="x"
|
|
161
|
+
tooltip={labels.remove}
|
|
162
|
+
accessibilityLabel={named(labels.remove)}
|
|
163
|
+
disabled={onRemove == null}
|
|
164
|
+
onPress={() => onRemove?.()}
|
|
165
|
+
/>
|
|
166
|
+
</View>
|
|
167
|
+
) : null}
|
|
152
168
|
</View>
|
|
153
169
|
</View>
|
|
154
170
|
);
|
package/src/step_progress.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { colors } from "./colors";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Eyebrow } from "./eyebrow";
|
|
5
5
|
import { useTooltip } from "./tooltip";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface StepProgressProps {
|
|
8
9
|
/** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
|
|
@@ -50,6 +51,7 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
50
51
|
// much is done", the same claim the one filled button makes. Still a prop, so a
|
|
51
52
|
// caller that means a specific hue (a status, a series) passes one.
|
|
52
53
|
const { steps, current, color = colors.primary, title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
|
|
54
|
+
const words = useLoticsLocale();
|
|
53
55
|
const captionColor = captionTone === "danger" ? "danger" : "muted";
|
|
54
56
|
const names = typeof steps === "number" ? null : steps;
|
|
55
57
|
const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
|
|
@@ -59,10 +61,10 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
59
61
|
label ??
|
|
60
62
|
(names
|
|
61
63
|
? isComplete
|
|
62
|
-
?
|
|
64
|
+
? words.stepProgress.complete(count)
|
|
63
65
|
: safe >= 0
|
|
64
|
-
?
|
|
65
|
-
:
|
|
66
|
+
? words.stepProgress.stage(names[safe], safe + 1, count)
|
|
67
|
+
: words.stepProgress.none(count)
|
|
66
68
|
: undefined);
|
|
67
69
|
|
|
68
70
|
// `progressbar` IS right here — the segments are decoration over one quantity,
|
|
@@ -73,7 +75,7 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
73
75
|
const bar = (
|
|
74
76
|
<View
|
|
75
77
|
accessibilityRole="progressbar"
|
|
76
|
-
accessibilityLabel={accessibilityLabel ?? caption ??
|
|
78
|
+
accessibilityLabel={accessibilityLabel ?? caption ?? words.stepProgress.position(Math.max(0, safe + 1), count)}
|
|
77
79
|
aria-valuenow={isComplete ? count : Math.max(0, safe + 1)}
|
|
78
80
|
aria-valuemin={0}
|
|
79
81
|
aria-valuemax={count}
|
package/src/table_fit.ts
CHANGED
|
@@ -21,9 +21,17 @@ export interface TableFitColumn {
|
|
|
21
21
|
/** Fixed width in px; omit for a flexible column. */
|
|
22
22
|
width?: number;
|
|
23
23
|
/** Drop precedence when the container can't fit every column: HIGHER numbers
|
|
24
|
-
* drop first, ties drop right-to-left.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* drop first, ties drop right-to-left. The FIRST column is the row's identity
|
|
25
|
+
* — it never drops.
|
|
26
|
+
*
|
|
27
|
+
* An unannotated column defaults to `columns.length + index`, which is above
|
|
28
|
+
* every hand-written priority rather than interleaved with them, so an
|
|
29
|
+
* unannotated register still sheds right-to-left AND an explicit priority is
|
|
30
|
+
* strictly safer than none. Defaulting to the bare index made annotation
|
|
31
|
+
* actively harmful: marking your most important column `priority: 1` TIED it
|
|
32
|
+
* with the unannotated column at index 1, and the right-to-left tie-break
|
|
33
|
+
* then dropped yours — losing the amount column, the fact the register exists
|
|
34
|
+
* for, on a screen that reviewed as correctly annotated. */
|
|
27
35
|
priority?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
@@ -59,10 +67,12 @@ export const ROW_HEIGHT = 72;
|
|
|
59
67
|
*/
|
|
60
68
|
export const ROW_GUTTER = 0;
|
|
61
69
|
const ROW_H_PADDING = ROW_GUTTER * 2;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
70
|
+
/** Width a flexible column needs to stay usable — below this it is crushed to
|
|
71
|
+
* ellipsis soup. `Table` counts a flex column as this wide when deciding what
|
|
72
|
+
* fits; `DataGrid`, which sheds nothing, uses it as the column's hard floor so
|
|
73
|
+
* the squeeze overflows the grid instead of erasing its identity column.
|
|
74
|
+
* Exported so the two cannot drift. */
|
|
75
|
+
export const FLEX_MIN_WIDTH = 120;
|
|
66
76
|
/** Fewer side-by-side columns than this stops being a register — stack instead. */
|
|
67
77
|
const MIN_VISIBLE_COLUMNS = 2;
|
|
68
78
|
|
|
@@ -91,7 +101,7 @@ export function computeTableFit(
|
|
|
91
101
|
};
|
|
92
102
|
|
|
93
103
|
const allKeys = new Set(columns.map((c) => c.key));
|
|
94
|
-
const priorityOf = (c: TableFitColumn) => c.priority ?? columns.indexOf(c);
|
|
104
|
+
const priorityOf = (c: TableFitColumn) => c.priority ?? columns.length + columns.indexOf(c);
|
|
95
105
|
// Column 0 never enters the drop order — it's the row's identity.
|
|
96
106
|
const dropOrder = columns
|
|
97
107
|
.slice(1)
|
package/src/tabs.tsx
CHANGED
|
@@ -12,6 +12,19 @@ export interface TabOption<T extends string> {
|
|
|
12
12
|
/** A small status dot before the label — set only when the tab's area needs
|
|
13
13
|
* attention (a blocker / missing item). Omit for the resting state. */
|
|
14
14
|
status?: ColorName;
|
|
15
|
+
/** How many rows the tab's band holds, shown after the label.
|
|
16
|
+
*
|
|
17
|
+
* A PROP, never a count formatted into `label`. A tab band partitions a
|
|
18
|
+
* register exactly as `TableGroup` does, and that component states its size
|
|
19
|
+
* the same way for the same reason: a hand-built `"Trên tàu (4)"` is a second
|
|
20
|
+
* copy of a number the screen already computes, and it is the copy that goes
|
|
21
|
+
* stale. Pressing the tab is the filter, so the count is also the reason to
|
|
22
|
+
* press it.
|
|
23
|
+
*
|
|
24
|
+
* Yours to pass because it is rarely the child count — a paged or filtered
|
|
25
|
+
* band counts what matched, not what rendered. Omit when every band is
|
|
26
|
+
* always whole. */
|
|
27
|
+
count?: number;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
interface TabsProps<T extends string> {
|
|
@@ -113,6 +126,12 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
113
126
|
>
|
|
114
127
|
{option.label}
|
|
115
128
|
</Text>
|
|
129
|
+
{option.count != null ? (
|
|
130
|
+
<Text color="zinc-500" tabular userSelect="none">
|
|
131
|
+
{option.count}
|
|
132
|
+
</Text>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
116
135
|
</View>
|
|
117
136
|
);
|
|
118
137
|
|