@lotics/ui 44.11.0 → 44.13.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 +2 -2
- package/docs/catalog.md +13 -2
- package/docs/data_entry.md +19 -0
- package/docs/reviewing.md +15 -0
- package/package.json +1 -1
- package/src/filter_chip.tsx +6 -2
- package/src/locale.tsx +3 -3
- package/src/option_list.tsx +25 -3
- package/src/sort_header.tsx +14 -2
package/AGENTS.md
CHANGED
|
@@ -15,10 +15,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
|
-
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then
|
|
21
|
+
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), FIRST PAINT (an entry screen showing zero inputs is gated by something, usually a mode chooser), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
|
24
24
|
|
package/docs/catalog.md
CHANGED
|
@@ -959,6 +959,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
959
959
|
It opens SCROLLED to its seat (the selected row in `search.mode:"none"`, else the first real
|
|
960
960
|
option), which only shows on a list longer than its own box and is the difference between a
|
|
961
961
|
96-row list opening on your value and opening at the top.
|
|
962
|
+
`indeterminateValues` (multi) draws a MIXED tick on values only SOME of the things being
|
|
963
|
+
edited carry — the shape for labelling a selection, where an empty box would say "none of
|
|
964
|
+
these" and be wrong. Presentational and one-directional: a mixed row is not in `value`, so
|
|
965
|
+
pressing it selects (the caller applies to all), and it announces itself in the row label
|
|
966
|
+
because `role="option"` has no ARIA state for a third value.
|
|
962
967
|
- **`combobox`** — COMPOUND single-select editable search: `Combobox` root +
|
|
963
968
|
`ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`,
|
|
964
969
|
`useCombobox()`; over the shared option-list engine; browses on focus; no `multi` —
|
|
@@ -1485,7 +1490,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1485
1490
|
- **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot). Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
|
|
1486
1491
|
- **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
|
|
1487
1492
|
`SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
|
|
1488
|
-
`DataGrid` consumers drive.
|
|
1493
|
+
`DataGrid` consumers drive. `sortBy` copies rather than mutates, and compares strings with
|
|
1494
|
+
`localeCompare` (case- and accent-folded, `numeric`) rather than `<`/`>` — code-point order
|
|
1495
|
+
exiles every accented letter past the whole unaccented alphabet, so a Vietnamese register
|
|
1496
|
+
reads correctly until its first Đ and looks fine to a reviewer forever. `getValue` returns a
|
|
1497
|
+
NUMBER for anything ordered numerically (a timestamp, a count).
|
|
1489
1498
|
- **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
|
|
1490
1499
|
[Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
|
|
1491
1500
|
a11y via `SortHeaderLabels`.
|
|
@@ -1501,7 +1510,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1501
1510
|
on the pill is the clear, and the editor inside brings whatever actions it has, so a
|
|
1502
1511
|
multi-select's "select all / deselect all" is the only rule in the panel. `footer` is for an
|
|
1503
1512
|
editor that must COMMIT (Cancel / Save); reach for it only then, since it adds a second band
|
|
1504
|
-
under an editor that already has one.
|
|
1513
|
+
under an editor that already has one. `testID` names the pill: a register's bulk verbs and
|
|
1514
|
+
its facets routinely share a word ("Tags" is a filter AND a selection action), so a driver
|
|
1515
|
+
reaching by visible name gets whichever one it finds.
|
|
1505
1516
|
- **`summary_line`** — `SummaryLine`: the light inline summary of a register/list's FILTERED
|
|
1506
1517
|
view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band. Every item is an
|
|
1507
1518
|
AGGREGATE over the rows in view (a count, a sum, a fill), and the strip goes with the set it
|
package/docs/data_entry.md
CHANGED
|
@@ -392,6 +392,25 @@ neighbour, and variation in row WEIGHT reads as noise long before row COUNT does
|
|
|
392
392
|
| More options, or long labels, or the value is set once and read after | **`InlineSelect`** | scales to any count; costs two presses (open, pick) and hides the alternatives until you do — put each option's gloss in `renderOptionContent`, where it is needed while CHOOSING rather than on every later read |
|
|
393
393
|
| The choice IS the task — a wizard step, a short focused form | **`RadioPicker`** | full-width choices with their descriptions permanently visible. In a grid of many fields this is ~4 rows of height for one value; never use it as a row in a record's field table |
|
|
394
394
|
|
|
395
|
+
**A choice that GATES the form is a fourth answer, and the answer is usually "don't ask".** The
|
|
396
|
+
table above is for a choice that IS a value. A choice about how to proceed — which sign-in method,
|
|
397
|
+
which import format, which template — is not a field at all, and putting it on its own screen in
|
|
398
|
+
front of the form costs every reader a press to reach the thing they came for. Ask whether the
|
|
399
|
+
options are different SUBJECTS (a shipment vs an invoice — genuinely different forms) or the same
|
|
400
|
+
subject reached two ways (a password vs a mailed link — one account, one destination). Only the
|
|
401
|
+
first earns a chooser. For the second, render the path most readers take and offer the other as a
|
|
402
|
+
secondary action beside that form's submit, where it is visible without being in the way.
|
|
403
|
+
|
|
404
|
+
The tell is that people press the chooser far more than once each — a real decision is made once
|
|
405
|
+
and remembered, so repeat presses mean they are bouncing between doors rather than choosing. The
|
|
406
|
+
measurement is in [reviewing.md](./reviewing.md) probe 8b.
|
|
407
|
+
|
|
408
|
+
And when you do offer the alternative beside the submit, **give it its own treatment**: an
|
|
409
|
+
alternative WAY IN and a recovery link ("forgot your password") are different roles, and a reader
|
|
410
|
+
parses shape before meaning, so two identical full-width buttons read as two equal ways to proceed
|
|
411
|
+
however different their consequences. One primary, one secondary, and anything remedial drops to a
|
|
412
|
+
quiet text link.
|
|
413
|
+
|
|
395
414
|
**"No selection" is the PLACEHOLDER, never an option whose value is the empty string.** A picker
|
|
396
415
|
reads `""` as *nothing chosen* — it is what its own clear affordance emits back through
|
|
397
416
|
`onValueChange`, so the two readings cannot both hold. Model the unfiltered/unset state as an
|
package/docs/reviewing.md
CHANGED
|
@@ -334,6 +334,21 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
334
334
|
- **Ask of every value: could this exist in the data?** Name the column. No answer means the block
|
|
335
335
|
goes.
|
|
336
336
|
|
|
337
|
+
### 8b. First paint — count the controls the task actually needs
|
|
338
|
+
- **On any screen whose job is ENTRY, count `input`/`select`/`textarea` on first paint.** Zero is
|
|
339
|
+
the finding: something is gating the task behind a press, and every probe above reads whatever
|
|
340
|
+
that gate rendered instead — a two-item menu measures perfectly as a two-item menu.
|
|
341
|
+
- **The usual gate is a mode chooser** — pick a method, pick a type, pick a template — sitting
|
|
342
|
+
where the form should be. Ask whether the options are different SUBJECTS or the same subject
|
|
343
|
+
reached two ways. Two doors to one room is not a choice worth a screen; show the dominant path
|
|
344
|
+
and offer the other beside its submit. → [data_entry.md](./data_entry.md).
|
|
345
|
+
- **Telemetry settles it when you have it.** A real choice is pressed about once per person: they
|
|
346
|
+
decide, and afterwards they know. Presses-per-person climbing well above one means people are
|
|
347
|
+
bouncing between the options rather than choosing between them, and the split will also be
|
|
348
|
+
lopsided — the minority path is not paying for the click the majority spends every visit.
|
|
349
|
+
- **The same count catches the inverse**: a screen that opens on twelve inputs where the task
|
|
350
|
+
needs three. Count what the FIRST decision requires, not what the record can hold.
|
|
351
|
+
|
|
337
352
|
### 9. The absence pass — what catches "bland"
|
|
338
353
|
Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
|
|
339
354
|
nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
|
package/package.json
CHANGED
package/src/filter_chip.tsx
CHANGED
|
@@ -42,6 +42,10 @@ export interface FilterChipProps {
|
|
|
42
42
|
* editor that already carries bottom actions of its own gets a second
|
|
43
43
|
* bordered band from this. */
|
|
44
44
|
footer?: ReactNode;
|
|
45
|
+
/** Names the TRIGGER, so a driver reaches this facet rather than whichever
|
|
46
|
+
* other control on the band happens to share its word — a register's bulk
|
|
47
|
+
* verbs and its filters routinely do ("Tags" is both). */
|
|
48
|
+
testID?: string;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -76,7 +80,7 @@ export function selectSummary(
|
|
|
76
80
|
* toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
|
|
77
81
|
*/
|
|
78
82
|
export function FilterChip(props: FilterChipProps) {
|
|
79
|
-
const { label, summary, onClear, children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
|
|
83
|
+
const { label, summary, onClear, children, side = "bottom", align = "start", open, onOpenChange, footer, testID } = props;
|
|
80
84
|
const clearLabel = props.clearLabel ?? useLoticsLocale().filterChip.clear;
|
|
81
85
|
const active = summary != null && (typeof summary !== "string" || summary.length > 0);
|
|
82
86
|
// THE × IS THE CLEAR, and it is the only one. A valued, non-clearable pill
|
|
@@ -110,7 +114,7 @@ export function FilterChip(props: FilterChipProps) {
|
|
|
110
114
|
return (
|
|
111
115
|
<Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
|
|
112
116
|
<PopoverTrigger>
|
|
113
|
-
<Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
|
|
117
|
+
<Chip testID={testID} onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
|
|
114
118
|
{active && typeof summary !== "string" ? (
|
|
115
119
|
<View style={styles.summaryRow}>
|
|
116
120
|
{/* This branch only renders when ACTIVE, so it takes the accent
|
package/src/locale.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export interface LoticsLocale {
|
|
|
47
47
|
/** `OptionList` (and everything built on it — `Select`, `Combobox`, the
|
|
48
48
|
* in-cell editors): the select-all/deselect-all links, the empty state, the
|
|
49
49
|
* internal search-field placeholder, and the `Combobox` recents header. */
|
|
50
|
-
optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string };
|
|
50
|
+
optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string; someSelected: string };
|
|
51
51
|
/** `Picker`'s empty option, when the caller declares empty a CHOICE
|
|
52
52
|
* (`includeEmptyOption`). A value, not an action — hence "None" rather than
|
|
53
53
|
* the option list's "Clear": in a native `<select>` the reader picks it the
|
|
@@ -298,7 +298,7 @@ export const en: LoticsLocale = {
|
|
|
298
298
|
descending: ", descending",
|
|
299
299
|
},
|
|
300
300
|
referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
|
|
301
|
-
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
301
|
+
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…", someSelected: "some selected" },
|
|
302
302
|
picker: { emptyOption: "None" },
|
|
303
303
|
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", chooseTime: "Choose a time", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
|
|
304
304
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
@@ -485,7 +485,7 @@ export const vi: LoticsLocale = {
|
|
|
485
485
|
descending: " (giảm dần)",
|
|
486
486
|
},
|
|
487
487
|
referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
|
|
488
|
-
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
488
|
+
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…", someSelected: "một số đã chọn" },
|
|
489
489
|
picker: { emptyOption: "Không có" },
|
|
490
490
|
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
491
491
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
package/src/option_list.tsx
CHANGED
|
@@ -23,6 +23,17 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
|
|
|
23
23
|
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
24
24
|
loading?: boolean;
|
|
25
25
|
emptyText?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Multi only: values that SOME of the things being edited carry, drawn as a
|
|
28
|
+
* mixed tick rather than an empty one.
|
|
29
|
+
*
|
|
30
|
+
* What it is for is editing a SET at once — five documents, three of which
|
|
31
|
+
* carry a label. An empty box there says "none of them", which is false, and
|
|
32
|
+
* the reader who ticks it to fix that cannot tell afterwards what they
|
|
33
|
+
* changed. Purely presentational: a mixed row is not in `value`, so pressing
|
|
34
|
+
* it selects (adds to all) and the caller decides what that means.
|
|
35
|
+
*/
|
|
36
|
+
indeterminateValues?: readonly T[];
|
|
26
37
|
accessibilityLabel?: string;
|
|
27
38
|
selectAllLabel?: string;
|
|
28
39
|
deselectAllLabel?: string;
|
|
@@ -45,7 +56,7 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
|
|
|
45
56
|
export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
|
|
46
57
|
props: OptionListProps<T, MULTI, D>,
|
|
47
58
|
) {
|
|
48
|
-
const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search, onClear } = props;
|
|
59
|
+
const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search, onClear, indeterminateValues } = props;
|
|
49
60
|
const loc = useLoticsLocale().optionList;
|
|
50
61
|
const emptyText = props.emptyText ?? loc.noResults;
|
|
51
62
|
const selectAllLabel = props.selectAllLabel ?? loc.selectAll;
|
|
@@ -122,6 +133,11 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
122
133
|
list.rows.map((row) => {
|
|
123
134
|
const isCustom = row.kind === "custom";
|
|
124
135
|
const opt = row.option;
|
|
136
|
+
// A mixed row is PAINTED mixed, and `role="option"` has no ARIA state
|
|
137
|
+
// for it — `aria-checked="mixed"` belongs to checkbox roles, and this
|
|
138
|
+
// row carries `aria-selected`. So the third state is said in the
|
|
139
|
+
// label, or it exists for sighted readers only.
|
|
140
|
+
const mixed = props.multi && !row.selected && indeterminateValues?.includes(opt.value) === true;
|
|
125
141
|
const desc = row.kind === "option" ? getOptionDescription?.(opt) : undefined;
|
|
126
142
|
const label = opt.label ?? opt.value;
|
|
127
143
|
const content = row.kind === "option" ? renderOptionContent?.(opt) : undefined;
|
|
@@ -129,11 +145,17 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
129
145
|
nativeID: row.nativeID,
|
|
130
146
|
testID: isCustom ? "option-list-custom" : opt.testID || `picker-option-${opt.value}`,
|
|
131
147
|
role: "option" as const,
|
|
132
|
-
accessibilityLabel: isCustom
|
|
148
|
+
accessibilityLabel: isCustom
|
|
149
|
+
? label
|
|
150
|
+
: mixed
|
|
151
|
+
? `${opt.label ?? String(opt.value)}, ${loc.someSelected}`
|
|
152
|
+
: (opt.label ?? String(opt.value)),
|
|
133
153
|
icon: isCustom ? (
|
|
134
154
|
<Icon name="plus" size={16} color={colors.zinc["600"]} />
|
|
135
155
|
) : props.multi && row.kind === "option" ? (
|
|
136
|
-
|
|
156
|
+
// A row already in `value` is fully selected — "some carry it"
|
|
157
|
+
// cannot also be "all carry it", so selection wins.
|
|
158
|
+
<Checkbox checked={row.selected} indeterminate={mixed} />
|
|
137
159
|
) : undefined,
|
|
138
160
|
right:
|
|
139
161
|
!props.multi && row.selected ? (
|
package/src/sort_header.tsx
CHANGED
|
@@ -23,8 +23,13 @@ export function cycleSort(current: SortState | null, key: string): SortState | n
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Order a COPY of `items` by the active column. `getValue` maps (item, key) to a
|
|
26
|
-
* comparable
|
|
27
|
-
*
|
|
26
|
+
* comparable. Returns `items` unchanged when nothing is sorted.
|
|
27
|
+
*
|
|
28
|
+
* Strings are compared with `localeCompare`, NOT `<`/`>`. Those operators order
|
|
29
|
+
* by code point, which puts every accented letter after the whole unaccented
|
|
30
|
+
* alphabet — so a Vietnamese register sorted by name reads correctly until the
|
|
31
|
+
* first Đ, and then does not. Numbers keep numeric order; a mixed pair falls
|
|
32
|
+
* back to the operators, which is the only defined thing left to do.
|
|
28
33
|
*/
|
|
29
34
|
export function sortBy<T>(
|
|
30
35
|
items: T[],
|
|
@@ -36,6 +41,13 @@ export function sortBy<T>(
|
|
|
36
41
|
return [...items].sort((a, b) => {
|
|
37
42
|
const va = getValue(a, sort.key);
|
|
38
43
|
const vb = getValue(b, sort.key);
|
|
44
|
+
if (typeof va === "string" && typeof vb === "string") {
|
|
45
|
+
// Undefined locale = the reader's own, which is the right collation for a
|
|
46
|
+
// register they are reading. `sensitivity: "base"` folds case and accent
|
|
47
|
+
// for ORDERING only, so "Đơn" and "don" land beside each other instead of
|
|
48
|
+
// in different halves of the list.
|
|
49
|
+
return va.localeCompare(vb, undefined, { sensitivity: "base", numeric: true }) * dir;
|
|
50
|
+
}
|
|
39
51
|
return va < vb ? -dir : va > vb ? dir : 0;
|
|
40
52
|
});
|
|
41
53
|
}
|