@lotics/ui 44.12.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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.12.0",
3
+ "version": "44.13.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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" },
@@ -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 ? label : (opt.label ?? String(opt.value)),
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
- <Checkbox checked={row.selected} />
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 ? (
@@ -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 (lowercase strings for case-insensitive order). Returns `items`
27
- * unchanged when nothing is sorted.
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
  }