@lotics/ui 11.6.0 → 11.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -0
- package/docs/catalog.md +10 -3
- package/docs/composition.md +74 -1
- package/docs/data_entry.md +5 -0
- package/examples/tpl_record.tsx +4 -4
- package/package.json +1 -1
- package/src/combobox.tsx +25 -29
- package/src/detail_row.tsx +40 -7
- package/src/menu_list_item.tsx +20 -5
- package/src/option_list.tsx +33 -41
- package/src/section_heading.tsx +11 -3
package/AGENTS.md
CHANGED
|
@@ -39,6 +39,9 @@ doc before building any screen, **never from memory**. Exact props are the shipp
|
|
|
39
39
|
- **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
|
|
40
40
|
callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
|
|
41
41
|
- **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
|
|
42
|
+
- **Copy states content, not mechanics** — a `description` names the section's content or one
|
|
43
|
+
domain rule; widget behavior ("edits in place, saves on its own") and selling points never
|
|
44
|
+
appear. Instructions live in empty states / form-field hints. → [composition.md §Microcopy](./docs/composition.md).
|
|
42
45
|
|
|
43
46
|
## Keeping this reference current
|
|
44
47
|
|
package/docs/catalog.md
CHANGED
|
@@ -335,7 +335,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
335
335
|
(12, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
|
|
336
336
|
`weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
|
|
337
337
|
same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
|
|
338
|
-
title of a named group inside a section
|
|
338
|
+
title of a named group inside a section (same `info` ⓘ affordance as the section title) —
|
|
339
|
+
heading-row siblings ride its right edge; the
|
|
339
340
|
heading ramp is FIXED: `#` xxl / `##` xl / `###` lg, no size props.
|
|
340
341
|
- **`section`** — a SECOND, standalone `Section`: a self-contained titled block
|
|
341
342
|
(`title`/`description`/`collapsible`/`icon`/`titleRight`, imperative
|
|
@@ -407,7 +408,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
407
408
|
`selected`/`focused`/`danger`; `role` menuitem|button|option) — popover menus, outline
|
|
408
409
|
rails, section pickers.
|
|
409
410
|
- **`menu_list_item`** — `MenuListItem`: the richer listbox/menu row (title + description +
|
|
410
|
-
`right`; `selected` vs roving `focused` via `aria-activedescendant`).
|
|
411
|
+
`right`; `selected` vs roving `focused` via `aria-activedescendant`). No fixed height — it
|
|
412
|
+
GROWS with its content, and `title` takes a node, so it is what `OptionList`/`Combobox` render
|
|
413
|
+
for any rich option (custom `renderOptionContent`, or a label over its description); plain
|
|
414
|
+
label-only rows stay on `MenuButton`.
|
|
411
415
|
- **`floating_action_bar`** — `FloatingActionBar`: the floating bulk-select action bar.
|
|
412
416
|
- **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
|
|
413
417
|
`selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
|
|
@@ -551,7 +555,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
551
555
|
label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column
|
|
552
556
|
FILLS the row so a stack of inline editors all span the same width + none jumps wider on
|
|
553
557
|
edit; optional `trailing` slot renders a right-side action/badge after the value (units
|
|
554
|
-
belong IN the value via `InlineNumberInput format`)
|
|
558
|
+
belong IN the value via `InlineNumberInput format`); optional `info` renders an ⓘ popover
|
|
559
|
+
hugging the label — the row-label member of the one `info` vocabulary (`CardHeaderTitle` /
|
|
560
|
+
`SectionHeadingTitle` / `SubsectionHeadingTitle`); pass `undefined` to hide it (callers may
|
|
561
|
+
gate it on their own state). `DetailTable`: the compound parent of
|
|
555
562
|
a row STACK — `labelWidth` (default 130) / `trailingWidth` / `minHeight` (default 40, the
|
|
556
563
|
inline-control grid) declared ONCE + the 6px row gap; with `trailingWidth` every row
|
|
557
564
|
reserves the trailing column so value cells share one width and trailing items align at
|
package/docs/composition.md
CHANGED
|
@@ -60,7 +60,8 @@ restyle a heading level per-page.
|
|
|
60
60
|
`Section` with a `title` string prop — a different component. Import from `section_heading` for
|
|
61
61
|
the grammar described here.
|
|
62
62
|
- **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
|
|
63
|
-
`SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold
|
|
63
|
+
`SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
|
|
64
|
+
affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
|
|
64
65
|
`SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections stack
|
|
65
66
|
in a **`SubsectionStack`** (the same hairline law one step tighter: a fixed 24px beat + hairline
|
|
66
67
|
between groups — no margins, no hand-rolled dividers); a headingless `Subsection` is the
|
|
@@ -437,3 +438,75 @@ disabled ink).
|
|
|
437
438
|
Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px between cards, 24–28
|
|
438
439
|
canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
|
|
439
440
|
hierarchy, not cramming.
|
|
441
|
+
|
|
442
|
+
## Microcopy — content, not mechanics
|
|
443
|
+
|
|
444
|
+
UI copy states WHAT something is — domain content or one real rule — never how the widget
|
|
445
|
+
behaves, and never a selling point. The control demonstrates its own behavior; copy that
|
|
446
|
+
narrates it ("edits in place, saves on its own", "sửa tại chỗ, lưu ngay") is noise, and copy
|
|
447
|
+
that praises it is marketing.
|
|
448
|
+
|
|
449
|
+
- **Heading `description`s** name the section's content ("Các bên và điều khoản thương mại
|
|
450
|
+
của lô.") or one domain invariant ("Each invoice owns its charge lines — a charge never
|
|
451
|
+
lives apart from the document it bills on."). Never widget mechanics, never usage narration
|
|
452
|
+
("drop to add, click to preview"), never feature-brag adjectives.
|
|
453
|
+
- **Instructions live where action is needed** — empty states, callouts, form-field
|
|
454
|
+
descriptions: imperative, one concrete step ("Drop the CI + PL here."). A heading never
|
|
455
|
+
instructs.
|
|
456
|
+
- **Restatement is the second noise mode.** A description that repeats what the screen already
|
|
457
|
+
shows — the title, the subsection names below it, the table's columns — carries zero
|
|
458
|
+
information and fails even though it "states content". Useful copy states an **invisible
|
|
459
|
+
truth**: an invariant of the data, a boundary ("P&L lives in the accounting app, not here"),
|
|
460
|
+
a linkage ("the HS code here feeds the customs declaration"), or a consequence ("past the
|
|
461
|
+
cut-off, the box misses the sailing").
|
|
462
|
+
- **The deletion test**: if removing the line changes nothing for the reader, remove the line —
|
|
463
|
+
`description` is optional for a reason; when no invisible truth is worth stating, omit it.
|
|
464
|
+
- **Plain language over jargon; effect over mechanism** ("Record visibility", not "Row-Level
|
|
465
|
+
Privacy") — if a term needs explaining, replace it. **One concept = one word everywhere** —
|
|
466
|
+
pick the term once and reuse it on every surface.
|
|
467
|
+
- **Native language**: match the workspace language with native vocabulary — a Vietnamese ops
|
|
468
|
+
app reads like a Vietnamese ops tool, not a translated brochure. Sentence case; no
|
|
469
|
+
exclamation marks.
|
|
470
|
+
|
|
471
|
+
### Error messages — what happened → what it means → what to do
|
|
472
|
+
|
|
473
|
+
The consensus anatomy (GOV.UK · NN/g · Microsoft): state what happened, what it means for the
|
|
474
|
+
user's data, and the one next step. Field errors go INLINE (`FormField error=`); a page-level
|
|
475
|
+
failure is a `Callout tone="error"` (title = what happened, text = what to do, an action when
|
|
476
|
+
one exists).
|
|
477
|
+
|
|
478
|
+
- Never blame ("invalid", "you forgot"), never bare "An error occurred", never an error code
|
|
479
|
+
as the headline (put codes at the end, for support). No ALL-CAPS, no exclamation.
|
|
480
|
+
- Say whether the user's input survived — and PRESERVE it (re-entering data is the real cost).
|
|
481
|
+
- Empty required field → instruct ("Enter the hours you work a week"); constraint violation →
|
|
482
|
+
describe the constraint ("First name must be 35 characters or less").
|
|
483
|
+
- Apology is reserved for genuine system failure — never for validation.
|
|
484
|
+
- BAD: `Error: invalid input (0x643).` · GOOD: `Không tải được chứng từ lên — tệp vẫn còn trên
|
|
485
|
+
máy bạn. Thử lại; nếu vẫn lỗi, kiểm tra kết nối mạng.`
|
|
486
|
+
|
|
487
|
+
### Empty states — confirm, orient, one action
|
|
488
|
+
|
|
489
|
+
An empty region has three jobs (NN/g): confirm this is EMPTY (not loading, not broken), say
|
|
490
|
+
what belongs here, and offer ONE next action — the `EmptyState` props are this anatomy
|
|
491
|
+
(`message` = what's empty, `hint` = what to do about it, `action` = the one CTA). The three
|
|
492
|
+
empties get DIFFERENT copy:
|
|
493
|
+
|
|
494
|
+
- **First use** → teach + invite: message names what will live here, `action` creates the first
|
|
495
|
+
one ("Chưa có khoản phí" + "Thêm khoản thu/chi cho lô" + a Thêm phí button).
|
|
496
|
+
- **User cleared it** → stay quiet: the user knows why it's empty; confirmation only, no tutorial.
|
|
497
|
+
- **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
|
|
498
|
+
'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
|
|
499
|
+
|
|
500
|
+
### Buttons & action labels — the outcome, verb-first
|
|
501
|
+
|
|
502
|
+
A label names the OUTCOME of pressing — verb + object, 2–4 words (GOV.UK: "Save and continue",
|
|
503
|
+
"Add another address"). Universal across every source; only casing forks by platform (this kit:
|
|
504
|
+
sentence case, always).
|
|
505
|
+
|
|
506
|
+
- Never "OK" / "Yes" / "Submit" / "Click here" — a `Button title` says what happens ("Tạo lô
|
|
507
|
+
hàng", "Xóa 3 tệp").
|
|
508
|
+
- A dialog's confirm button RESTATES the action: "Xóa lô?" → `Xóa` / `Hủy` — never `OK` / `Hủy`
|
|
509
|
+
(the pair must answer the question without reading the body).
|
|
510
|
+
- Destructive buttons name the object (`color="danger"` + "Xóa lô", not "Xóa") — the label is
|
|
511
|
+
the last safeguard.
|
|
512
|
+
|
package/docs/data_entry.md
CHANGED
|
@@ -40,6 +40,11 @@ One per type:
|
|
|
40
40
|
- **`InlineSelect`** — plain options OR `renderOptionContent`; floats an `OptionList` in a popover
|
|
41
41
|
so the row never grows; the RESTING value renders like its option (`renderOptionContent` by
|
|
42
42
|
default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label.
|
|
43
|
+
An option ROW may be taller than one line — a rich row (custom `renderOptionContent`, or a
|
|
44
|
+
label over a `getOptionDescription` line) renders as `MenuListItem`, the listbox row that
|
|
45
|
+
GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short
|
|
46
|
+
code + the full official name underneath is a supported option shape, not a hack. Reach for it
|
|
47
|
+
whenever the real name is too long to READ in a list but must stay exact somewhere.
|
|
43
48
|
- **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
|
|
44
49
|
`MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
|
|
45
50
|
- **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -707,7 +707,7 @@ export function TplRecord() {
|
|
|
707
707
|
<View onLayout={nav.register("details")}>
|
|
708
708
|
<Section>
|
|
709
709
|
<SectionHeading>
|
|
710
|
-
<SectionHeadingTitle description="
|
|
710
|
+
<SectionHeadingTitle description="The order's core facts — reference, dates, and the amounts every desk reads first.">Details</SectionHeadingTitle>
|
|
711
711
|
<OwnerTag stage="sales" />
|
|
712
712
|
</SectionHeading>
|
|
713
713
|
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
@@ -757,7 +757,7 @@ export function TplRecord() {
|
|
|
757
757
|
<View onLayout={nav.register("customer")}>
|
|
758
758
|
<Section>
|
|
759
759
|
<SectionHeading>
|
|
760
|
-
<SectionHeadingTitle description="The bill-to
|
|
760
|
+
<SectionHeadingTitle description="The bill-to party — its tax ID gates invoicing below.">Customer</SectionHeadingTitle>
|
|
761
761
|
<OwnerTag stage="sales" />
|
|
762
762
|
{customer && !editingCustomer ? (
|
|
763
763
|
<Button title="Edit" color="secondary" icon="pencil" onPress={() => setEditingCustomer(true)} />
|
|
@@ -964,7 +964,7 @@ export function TplRecord() {
|
|
|
964
964
|
<View onLayout={nav.register("files")}>
|
|
965
965
|
<Section>
|
|
966
966
|
<SectionHeading>
|
|
967
|
-
<SectionHeadingTitle description="The signed quote, the PO, photos —
|
|
967
|
+
<SectionHeadingTitle description="The signed quote, the PO, photos — everything filed on the order.">Files</SectionHeadingTitle>
|
|
968
968
|
<OwnerTag stage="operations" />
|
|
969
969
|
{files.length > 0 ? <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta> : null}
|
|
970
970
|
</SectionHeading>
|
|
@@ -1194,7 +1194,7 @@ export function TplRecord() {
|
|
|
1194
1194
|
<View onLayout={nav.register("preferences")}>
|
|
1195
1195
|
<Section>
|
|
1196
1196
|
<SectionHeading>
|
|
1197
|
-
<SectionHeadingTitle description="
|
|
1197
|
+
<SectionHeadingTitle description="Per-order handling and notification preferences.">Preferences</SectionHeadingTitle>
|
|
1198
1198
|
</SectionHeading>
|
|
1199
1199
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
|
1200
1200
|
<View style={{ flex: 1, gap: 1 }}>
|
package/package.json
CHANGED
package/src/combobox.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { Text } from "./text";
|
|
|
24
24
|
import { Icon, type IconName } from "./icon";
|
|
25
25
|
import { TextInputField } from "./text_input_field";
|
|
26
26
|
import { MenuButton } from "./menu_button";
|
|
27
|
+
import { MenuListItem } from "./menu_list_item";
|
|
27
28
|
import { ActivityIndicator } from "./activity_indicator";
|
|
28
29
|
import { Popover, PopoverContent } from "./popover";
|
|
29
30
|
import type { PickerOption } from "./picker";
|
|
@@ -508,37 +509,32 @@ export function ComboboxContent(props: ComboboxContentProps) {
|
|
|
508
509
|
const opt = row.option;
|
|
509
510
|
const desc = !isCustom ? getOptionDescription?.(opt) : undefined;
|
|
510
511
|
const label = opt.label ?? opt.value;
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
512
|
+
const content = !isCustom ? renderOptionContent?.(opt) : undefined;
|
|
513
|
+
const shared = {
|
|
514
|
+
key: `${row.kind}-${opt.value}`,
|
|
515
|
+
nativeID: row.nativeID,
|
|
516
|
+
testID: isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`,
|
|
517
|
+
role: "option" as const,
|
|
518
|
+
accessibilityLabel: label,
|
|
519
|
+
icon: isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined,
|
|
520
|
+
focused: row.index === list.activeIndex,
|
|
521
|
+
selected: row.selected,
|
|
522
|
+
disabled: opt.disabled,
|
|
523
|
+
onPress: () => list.pickRow(row.index),
|
|
524
|
+
onHoverIn: () => list.setActiveIndex(row.index, false),
|
|
525
|
+
};
|
|
526
|
+
// Rich row (custom content, or a label over its description) → the growing
|
|
527
|
+
// `MenuListItem`; a plain option stays the fixed-height `MenuButton`.
|
|
528
|
+
return content || desc ? (
|
|
529
|
+
<MenuListItem {...shared} title={content ?? label} description={content ? undefined : desc} />
|
|
530
|
+
) : (
|
|
531
|
+
<MenuButton
|
|
532
|
+
{...shared}
|
|
533
|
+
title={
|
|
534
|
+
<Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
|
|
517
535
|
{label}
|
|
518
536
|
</Text>
|
|
519
|
-
|
|
520
|
-
{desc}
|
|
521
|
-
</Text>
|
|
522
|
-
</View>
|
|
523
|
-
) : (
|
|
524
|
-
<Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
|
|
525
|
-
{label}
|
|
526
|
-
</Text>
|
|
527
|
-
);
|
|
528
|
-
return (
|
|
529
|
-
<MenuButton
|
|
530
|
-
key={`${row.kind}-${opt.value}`}
|
|
531
|
-
nativeID={row.nativeID}
|
|
532
|
-
testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
|
|
533
|
-
role="option"
|
|
534
|
-
accessibilityLabel={label}
|
|
535
|
-
icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
|
|
536
|
-
title={title}
|
|
537
|
-
focused={row.index === list.activeIndex}
|
|
538
|
-
selected={row.selected}
|
|
539
|
-
disabled={opt.disabled}
|
|
540
|
-
onPress={() => list.pickRow(row.index)}
|
|
541
|
-
onHoverIn={() => list.setActiveIndex(row.index, false)}
|
|
537
|
+
}
|
|
542
538
|
/>
|
|
543
539
|
);
|
|
544
540
|
})
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
4
|
+
import { InfoPopover } from "./info_popover";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
4
6
|
import { Text } from "./text";
|
|
5
7
|
|
|
6
8
|
interface DetailTableColumns {
|
|
@@ -103,6 +105,11 @@ export interface DetailRowProps {
|
|
|
103
105
|
labelSize?: "xs" | "sm";
|
|
104
106
|
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
105
107
|
minHeight?: number;
|
|
108
|
+
/** An ⓘ popover hugging the label — a short "what this field means" gloss,
|
|
109
|
+
* the row-label member of the one `info` vocabulary
|
|
110
|
+
* (`CardHeaderTitle` / `SectionHeadingTitle` / `SubsectionHeadingTitle`).
|
|
111
|
+
* Pass `undefined` to hide the ⓘ (a caller can gate it on its own state). */
|
|
112
|
+
info?: string;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/**
|
|
@@ -116,10 +123,21 @@ export interface DetailRowProps {
|
|
|
116
123
|
*/
|
|
117
124
|
export function DetailRow(props: DetailRowProps) {
|
|
118
125
|
const table = useContext(DetailTableContext);
|
|
119
|
-
const { label, children, trailing, labelSize = "sm" } = props;
|
|
126
|
+
const { label, children, trailing, labelSize = "sm", info } = props;
|
|
120
127
|
const labelWidth = props.labelWidth ?? table?.labelWidth;
|
|
121
128
|
const minHeight = props.minHeight ?? table?.minHeight ?? 28;
|
|
122
129
|
const form = labelWidth != null;
|
|
130
|
+
const words = useLoticsLocale();
|
|
131
|
+
// The ⓘ hugs the label text (flexShrink keeps long labels from pushing it
|
|
132
|
+
// out of the cell) — the label cell keeps its exact width either way.
|
|
133
|
+
const infoLabel = (cellStyle: StyleProp<ViewStyle>) => (
|
|
134
|
+
<View style={[styles.labelWithInfo, cellStyle]}>
|
|
135
|
+
<Text size={labelSize} color="muted" numberOfLines={1} style={styles.labelShrink}>
|
|
136
|
+
{label}
|
|
137
|
+
</Text>
|
|
138
|
+
<InfoPopover text={info ?? ""} accessibilityLabel={words.sectionHeading.info} />
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
123
141
|
if (table?.stacked) {
|
|
124
142
|
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
125
143
|
// `FormField` label (medium, default ink), so a narrow record surface
|
|
@@ -128,9 +146,18 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
128
146
|
// inline editor self-persists — only the look converges.
|
|
129
147
|
return (
|
|
130
148
|
<View style={styles.stackedRow}>
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
|
|
149
|
+
{info != null ? (
|
|
150
|
+
<View style={styles.labelWithInfo}>
|
|
151
|
+
<Text weight="medium" numberOfLines={1} style={styles.labelShrink}>
|
|
152
|
+
{label}
|
|
153
|
+
</Text>
|
|
154
|
+
<InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} />
|
|
155
|
+
</View>
|
|
156
|
+
) : (
|
|
157
|
+
<Text weight="medium" numberOfLines={1}>
|
|
158
|
+
{label}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
134
161
|
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
135
162
|
<View style={styles.value}>{children}</View>
|
|
136
163
|
{trailing != null ? trailing : null}
|
|
@@ -140,9 +167,13 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
140
167
|
}
|
|
141
168
|
return (
|
|
142
169
|
<View style={[styles.row, { minHeight }]}>
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
170
|
+
{info != null ? (
|
|
171
|
+
infoLabel(form ? { width: labelWidth } : styles.flexLabel)
|
|
172
|
+
) : (
|
|
173
|
+
<Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
|
|
174
|
+
{label}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
146
177
|
{form ? <View style={styles.value}>{children}</View> : children}
|
|
147
178
|
{table?.trailingWidth != null ? (
|
|
148
179
|
<View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
|
|
@@ -171,6 +202,8 @@ const styles = StyleSheet.create({
|
|
|
171
202
|
gap: 12,
|
|
172
203
|
},
|
|
173
204
|
flexLabel: { flex: 1 },
|
|
205
|
+
labelWithInfo: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
206
|
+
labelShrink: { flexShrink: 1 },
|
|
174
207
|
// Form mode (labelWidth set): the value column FILLS the row so every inline
|
|
175
208
|
// editor spans the SAME width — and none jumps wider when it swaps to the
|
|
176
209
|
// (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -10,7 +10,10 @@ import type { TextColor } from "./text_utils";
|
|
|
10
10
|
export interface MenuListItemProps {
|
|
11
11
|
ref?: Ref<View>;
|
|
12
12
|
icon?: IconName | React.ReactNode;
|
|
13
|
-
title
|
|
13
|
+
/** A plain string gets the row's own title type; a node renders as-is — an option
|
|
14
|
+
* whose label is richer than one line of text (a code + its full official name).
|
|
15
|
+
* Pass `accessibilityLabel` when the title is a node. */
|
|
16
|
+
title: React.ReactNode;
|
|
14
17
|
/** Title color. Default ink — set it to mark the row as an action (e.g. a
|
|
15
18
|
* leading "Browse all" tinted to match its icon). */
|
|
16
19
|
titleColor?: TextColor;
|
|
@@ -28,6 +31,8 @@ export interface MenuListItemProps {
|
|
|
28
31
|
testID?: string;
|
|
29
32
|
/** DOM id, referenced by a combobox input's `aria-activedescendant`. */
|
|
30
33
|
nativeID?: string;
|
|
34
|
+
/** Required when `title` is a node — the row's accessible name. */
|
|
35
|
+
accessibilityLabel?: string;
|
|
31
36
|
/** ARIA role. Defaults to `button`; pass `option` for a listbox row. */
|
|
32
37
|
role?: "menuitem" | "button" | "option";
|
|
33
38
|
}
|
|
@@ -48,9 +53,15 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
48
53
|
style,
|
|
49
54
|
testID,
|
|
50
55
|
nativeID,
|
|
56
|
+
accessibilityLabel,
|
|
51
57
|
role = "button",
|
|
52
58
|
} = props;
|
|
53
59
|
|
|
60
|
+
const titleText = typeof title === "string" ? title : undefined;
|
|
61
|
+
const resolvedLabel =
|
|
62
|
+
accessibilityLabel ??
|
|
63
|
+
(titleText && description ? `${titleText}, ${description}` : titleText);
|
|
64
|
+
|
|
54
65
|
const resolvedIcon =
|
|
55
66
|
typeof icon === "string" ? <Icon size={20} name={icon as IconName} /> : icon;
|
|
56
67
|
|
|
@@ -58,9 +69,13 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
58
69
|
<>
|
|
59
70
|
{resolvedIcon}
|
|
60
71
|
<View style={styles.textContainer}>
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
|
|
72
|
+
{titleText !== undefined ? (
|
|
73
|
+
<Text weight="medium" color={titleColor} numberOfLines={1} userSelect="none">
|
|
74
|
+
{titleText}
|
|
75
|
+
</Text>
|
|
76
|
+
) : (
|
|
77
|
+
title
|
|
78
|
+
)}
|
|
64
79
|
{!!description && (
|
|
65
80
|
<Text size="xs" color="zinc-500" numberOfLines={1} userSelect="none">
|
|
66
81
|
{description}
|
|
@@ -89,7 +104,7 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
89
104
|
disabled={disabled}
|
|
90
105
|
style={containerStyle}
|
|
91
106
|
role={role}
|
|
92
|
-
accessibilityLabel={
|
|
107
|
+
accessibilityLabel={resolvedLabel}
|
|
93
108
|
aria-selected={selected || undefined}
|
|
94
109
|
aria-disabled={disabled || undefined}
|
|
95
110
|
>
|
package/src/option_list.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Text } from "./text";
|
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
7
|
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { MenuListItem } from "./menu_list_item";
|
|
8
9
|
import { Button } from "./button";
|
|
9
10
|
import { TextLink } from "./text_link";
|
|
10
11
|
import { ActivityIndicator } from "./activity_indicator";
|
|
@@ -119,50 +120,41 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
119
120
|
const opt = row.option;
|
|
120
121
|
const desc = row.kind === "option" ? getOptionDescription?.(opt) : undefined;
|
|
121
122
|
const label = opt.label ?? opt.value;
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
const content = row.kind === "option" ? renderOptionContent?.(opt) : undefined;
|
|
124
|
+
const shared = {
|
|
125
|
+
key: `${row.kind}-${opt.value}`,
|
|
126
|
+
nativeID: row.nativeID,
|
|
127
|
+
testID: isCustom ? "option-list-custom" : opt.testID || `picker-option-${opt.value}`,
|
|
128
|
+
role: "option" as const,
|
|
129
|
+
accessibilityLabel: isCustom ? label : (opt.label ?? String(opt.value)),
|
|
130
|
+
icon: isCustom ? (
|
|
131
|
+
<Icon name="plus" size={16} color={colors.zinc["600"]} />
|
|
132
|
+
) : props.multi && row.kind === "option" ? (
|
|
133
|
+
<Checkbox checked={row.selected} />
|
|
134
|
+
) : undefined,
|
|
135
|
+
right:
|
|
136
|
+
!props.multi && row.selected ? (
|
|
137
|
+
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
138
|
+
) : undefined,
|
|
139
|
+
focused: row.index === list.activeIndex,
|
|
140
|
+
disabled: opt.disabled,
|
|
141
|
+
onPress: () => list.pickRow(row.index),
|
|
142
|
+
onHoverIn: () => list.setActiveIndex(row.index, false),
|
|
143
|
+
style: styles.option,
|
|
144
|
+
};
|
|
145
|
+
// A row that carries more than a label — custom content, or a label over its
|
|
146
|
+
// description — is `MenuListItem`: the listbox row that GROWS. `MenuButton` is
|
|
147
|
+
// the fixed-height single-line menu row, and stays that for plain options.
|
|
148
|
+
return content || desc ? (
|
|
149
|
+
<MenuListItem {...shared} title={content ?? label} description={content ? undefined : desc} />
|
|
150
|
+
) : (
|
|
151
|
+
<MenuButton
|
|
152
|
+
{...shared}
|
|
153
|
+
title={
|
|
154
|
+
<Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
|
|
128
155
|
{label}
|
|
129
156
|
</Text>
|
|
130
|
-
<Text size="xs" color="zinc-500" numberOfLines={1}>
|
|
131
|
-
{desc}
|
|
132
|
-
</Text>
|
|
133
|
-
</View>
|
|
134
|
-
) : (
|
|
135
|
-
<Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
|
|
136
|
-
{label}
|
|
137
|
-
</Text>
|
|
138
|
-
);
|
|
139
|
-
return (
|
|
140
|
-
<MenuButton
|
|
141
|
-
key={`${row.kind}-${opt.value}`}
|
|
142
|
-
nativeID={row.nativeID}
|
|
143
|
-
testID={
|
|
144
|
-
isCustom ? "option-list-custom" : opt.testID || `picker-option-${opt.value}`
|
|
145
|
-
}
|
|
146
|
-
role="option"
|
|
147
|
-
accessibilityLabel={isCustom ? label : (opt.label ?? String(opt.value))}
|
|
148
|
-
icon={
|
|
149
|
-
isCustom ? (
|
|
150
|
-
<Icon name="plus" size={16} color={colors.zinc["600"]} />
|
|
151
|
-
) : props.multi && row.kind === "option" ? (
|
|
152
|
-
<Checkbox checked={row.selected} />
|
|
153
|
-
) : undefined
|
|
154
|
-
}
|
|
155
|
-
title={title}
|
|
156
|
-
right={
|
|
157
|
-
!props.multi && row.selected ? (
|
|
158
|
-
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
159
|
-
) : undefined
|
|
160
157
|
}
|
|
161
|
-
focused={row.index === list.activeIndex}
|
|
162
|
-
disabled={opt.disabled}
|
|
163
|
-
onPress={() => list.pickRow(row.index)}
|
|
164
|
-
onHoverIn={() => list.setActiveIndex(row.index, false)}
|
|
165
|
-
style={styles.option}
|
|
166
158
|
/>
|
|
167
159
|
);
|
|
168
160
|
})
|
package/src/section_heading.tsx
CHANGED
|
@@ -88,18 +88,23 @@ export interface SubsectionHeadingTitleProps {
|
|
|
88
88
|
children: React.ReactNode;
|
|
89
89
|
/** Heading rank. Defaults to 3 — one level under `SectionHeadingTitle`'s 2. */
|
|
90
90
|
level?: HeadingLevel;
|
|
91
|
+
/** An ⓘ popover after the title — a short "what this is / where it came from"
|
|
92
|
+
* gloss, mirroring `SectionHeadingTitle`'s `info`. */
|
|
93
|
+
info?: string;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
|
|
94
97
|
* xl section title and above the md/sm body. Grows to push siblings (a
|
|
95
98
|
* `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
|
|
96
99
|
export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
97
|
-
const { children, level = 3 } = props;
|
|
100
|
+
const { children, level = 3, info } = props;
|
|
101
|
+
const words = useLoticsLocale();
|
|
98
102
|
return (
|
|
99
|
-
<View style={{ flex: 1 }}>
|
|
103
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
100
104
|
<Text level={level} size="lg" weight="semibold">
|
|
101
105
|
{children}
|
|
102
106
|
</Text>
|
|
107
|
+
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
103
108
|
</View>
|
|
104
109
|
);
|
|
105
110
|
}
|
|
@@ -139,6 +144,9 @@ export interface SectionHeadingTitleProps {
|
|
|
139
144
|
* push any sibling actions to the right edge. */
|
|
140
145
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
141
146
|
const { children, description, icon, level = 2, weight = "semibold", info } = props;
|
|
147
|
+
// Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
|
|
148
|
+
// render `info` appears or disappears.
|
|
149
|
+
const words = useLoticsLocale();
|
|
142
150
|
return (
|
|
143
151
|
<View style={{ flex: 1, gap: 2 }}>
|
|
144
152
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
@@ -146,7 +154,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
146
154
|
<Text level={level} weight={weight} size="xl">
|
|
147
155
|
{children}
|
|
148
156
|
</Text>
|
|
149
|
-
{info ? <InfoPopover text={info} accessibilityLabel={
|
|
157
|
+
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
150
158
|
</View>
|
|
151
159
|
{description ? (
|
|
152
160
|
<Text color="zinc-500" size="sm">
|