@lotics/ui 44.7.1 → 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 +91 -5
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +36 -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
|
@@ -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
|
|
package/src/text_input_field.tsx
CHANGED
|
@@ -45,6 +45,24 @@ interface TextInputFieldProps extends RNTextInputProps {
|
|
|
45
45
|
* because the host already spent `CONTROL_TEXT_INSET` on it and two insets
|
|
46
46
|
* stacked is a visible jump to the right on focus. */
|
|
47
47
|
seamless?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Frameless at rest — no ground, no visible edge — for a field that must read
|
|
50
|
+
* as its own value until touched: a table cell, a title in place. The edge
|
|
51
|
+
* arrives on hover and the ring on focus, exactly as in the framed variant.
|
|
52
|
+
*
|
|
53
|
+
* It is a PROP rather than a `style` the caller passes because of the order
|
|
54
|
+
* below. Hover is applied before `style`, so a caller-supplied
|
|
55
|
+
* `borderColor: "transparent"` landed on top of it and erased the hover edge
|
|
56
|
+
* on every bare field in the kit — leaving a control whose entire affordance
|
|
57
|
+
* was that edge with no affordance at all, and no way to discover it was a
|
|
58
|
+
* control short of clicking. Nothing measured wrong: the field had a real
|
|
59
|
+
* focus ring, a real role, correct type and spacing, and the hover rule was
|
|
60
|
+
* right there in the array being silently overwritten one line later.
|
|
61
|
+
*
|
|
62
|
+
* Distinct from `seamless`, which surrenders the surface to a HOST that draws
|
|
63
|
+
* it. Bare has no host — it draws its own frame, just not at rest.
|
|
64
|
+
*/
|
|
65
|
+
variant?: "framed" | "bare";
|
|
48
66
|
// DOM-only ARIA attrs not declared on React Native's TextInputProps. They
|
|
49
67
|
// are forwarded verbatim to the underlying web input.
|
|
50
68
|
"aria-controls"?: string;
|
|
@@ -63,6 +81,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
63
81
|
const {
|
|
64
82
|
style,
|
|
65
83
|
seamless,
|
|
84
|
+
variant = "framed",
|
|
66
85
|
icon,
|
|
67
86
|
clearable,
|
|
68
87
|
onClear,
|
|
@@ -202,8 +221,20 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
202
221
|
icon && styles.withIcon,
|
|
203
222
|
showClear && styles.withClear,
|
|
204
223
|
showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
|
|
205
|
-
|
|
224
|
+
// RESTING FRAME first, so a caller's `style` can still override it.
|
|
225
|
+
variant === "bare" && styles.bare,
|
|
206
226
|
style,
|
|
227
|
+
// ...and HOVER after `style`, which is the grammar's rule for fields
|
|
228
|
+
// (composition.md § the surface table) and was inverted here. A caller
|
|
229
|
+
// passing a resting `borderColor` — which is exactly how `bare` used
|
|
230
|
+
// to be expressed — silently erased the hover edge one line after it
|
|
231
|
+
// was computed. Order is the whole bug: every property was correct.
|
|
232
|
+
//
|
|
233
|
+
// Still BEFORE the last two. The ring is a shadow, so it does not
|
|
234
|
+
// compete; `seamless` must outrank hover, because there the border
|
|
235
|
+
// belongs to the host shell and the input drawing its own would put
|
|
236
|
+
// two edges on one field.
|
|
237
|
+
hovered && editable && { borderColor: HOVER_BORDER },
|
|
207
238
|
focusVisible && !seamless && { boxShadow: FOCUS_RING },
|
|
208
239
|
seamless && styles.seamless,
|
|
209
240
|
]}
|
|
@@ -277,6 +308,9 @@ const styles = StyleSheet.create({
|
|
|
277
308
|
// Safe for height because the box is `border-box`: dropping the border changes
|
|
278
309
|
// the content box, never the field's 40px outer size.
|
|
279
310
|
seamless: { borderColor: "transparent", borderWidth: 0, backgroundColor: "transparent", paddingHorizontal: 0 },
|
|
311
|
+
// Nothing at rest — but the 1px border STAYS, transparent, so the hover edge
|
|
312
|
+
// costs no reflow when it arrives.
|
|
313
|
+
bare: { borderColor: "transparent", backgroundColor: "transparent" },
|
|
280
314
|
disabled: {
|
|
281
315
|
color: colors.zinc["400"],
|
|
282
316
|
outlineStyle: "none" as unknown as "solid",
|