@lotics/ui 46.14.1 → 47.1.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.
Files changed (49) hide show
  1. package/AGENTS.md +20 -0
  2. package/MIGRATION.md +58 -0
  3. package/docs/catalog.md +72 -13
  4. package/docs/composition.md +94 -6
  5. package/docs/reviewing.md +56 -5
  6. package/docs/templates.md +8 -2
  7. package/examples/tpl_calendar.tsx +22 -29
  8. package/examples/tpl_item_list.tsx +7 -2
  9. package/package.json +1 -1
  10. package/src/accordion.tsx +11 -5
  11. package/src/bar_chart.tsx +3 -1
  12. package/src/board.tsx +1 -2
  13. package/src/breakdown.tsx +3 -1
  14. package/src/calendar/agenda_view.tsx +136 -0
  15. package/src/calendar/calendar_toolbar.tsx +84 -0
  16. package/src/calendar/calendar_view.tsx +196 -103
  17. package/src/calendar/context.ts +47 -0
  18. package/src/calendar/dates.ts +36 -6
  19. package/src/calendar/event_chip.tsx +141 -0
  20. package/src/calendar/index.ts +31 -8
  21. package/src/calendar/layout.ts +113 -11
  22. package/src/calendar/month_view.tsx +194 -150
  23. package/src/calendar/repeat.ts +174 -0
  24. package/src/calendar/time_grid_view.tsx +182 -202
  25. package/src/calendar/types.ts +37 -11
  26. package/src/charge_lines.tsx +24 -5
  27. package/src/control_surface.ts +28 -0
  28. package/src/deadline.ts +10 -0
  29. package/src/file_row.tsx +26 -3
  30. package/src/finding.tsx +1 -1
  31. package/src/form_text_input.tsx +9 -2
  32. package/src/gantt/gantt_view.tsx +212 -119
  33. package/src/gantt/index.ts +2 -2
  34. package/src/gantt/scale.ts +38 -2
  35. package/src/gantt/types.ts +34 -7
  36. package/src/inline_slot.tsx +16 -2
  37. package/src/inline_static.tsx +18 -4
  38. package/src/legend_item.tsx +14 -1
  39. package/src/locale.tsx +30 -0
  40. package/src/matrix.tsx +19 -5
  41. package/src/member_chip.tsx +18 -1
  42. package/src/menu_button.tsx +12 -2
  43. package/src/option_list.tsx +11 -1
  44. package/src/progress_bar.tsx +14 -1
  45. package/src/record_summary.tsx +5 -0
  46. package/src/stacked_bar_chart.tsx +4 -1
  47. package/src/table_fit.ts +18 -1
  48. package/src/thumbnail_stack.tsx +18 -3
  49. package/src/use_option_list.ts +13 -1
@@ -6,6 +6,10 @@ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
6
  export interface InlineSlotProps {
7
7
  /** Whatever sits in the value column — a `DiffValue`, a chip, a pair. */
8
8
  children: ReactNode;
9
+ /** Verbs about the value, pinned right ON the slot's surface — the same place
10
+ * an editable `Inline*` puts its `actions`, so a static row and an editable
11
+ * one carry their verbs on one edge. */
12
+ actions?: ReactNode;
9
13
  style?: StyleProp<ViewStyle>;
10
14
  }
11
15
 
@@ -32,11 +36,21 @@ export interface InlineSlotProps {
32
36
  * NOT for a table cell: a `Table` sets its own row rhythm, and a 40px control
33
37
  * band inside one only makes the rows taller.
34
38
  */
35
- export function InlineSlot({ children, style }: InlineSlotProps) {
36
- return <View style={[styles.box, style]}>{children}</View>;
39
+ export function InlineSlot({ children, actions, style }: InlineSlotProps) {
40
+ if (!actions) return <View style={[styles.box, style]}>{children}</View>;
41
+ return (
42
+ <View style={[styles.box, styles.withActions, style]}>
43
+ <View style={styles.grow}>{children}</View>
44
+ {actions}
45
+ </View>
46
+ );
37
47
  }
38
48
 
39
49
  const styles = StyleSheet.create({
50
+ // The value takes the slack and the verbs pin right, so a column of rows —
51
+ // some with a verb, some without — keeps one right edge.
52
+ withActions: { flexDirection: "row", alignItems: "center", gap: 8 },
53
+ grow: { flex: 1, minWidth: 0 },
40
54
  // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
41
55
  // transparent border) so the content's baseline aligns with the editors —
42
56
  // minus the FocusRingPressable, so it never reads as an interactive control.
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import { StyleSheet } from "react-native";
2
3
  import { Text } from "./text";
3
4
  import { colors } from "./colors";
@@ -28,6 +29,18 @@ export interface InlineStaticProps {
28
29
  * misalignment this component exists to prevent, just on the other axis.
29
30
  */
30
31
  multiline?: boolean;
32
+ /**
33
+ * Verbs about THIS value, on the value's own surface — an `InlineButton`, the
34
+ * same slot every editable `Inline*` exposes.
35
+ *
36
+ * A read-only value often still has an act attached to it: look this id up,
37
+ * copy it, request the change that is gated behind an approval. Without this
38
+ * slot that verb has nowhere to sit but the row's ground, and an `InlineButton`
39
+ * outside a field surface is the wrong control (see `catalog.md`
40
+ * §`inline_button`). A static value is still a field; it just is not editable
41
+ * in place.
42
+ */
43
+ actions?: ReactNode;
31
44
  }
32
45
 
33
46
  /**
@@ -37,15 +50,16 @@ export interface InlineStaticProps {
37
50
  * locked value — sits flush in the same column as the editable rows. It is
38
51
  * NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
39
52
  * value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
40
- * editors; wrap it in a `flex:1` View when the row also carries a `trailing`
41
- * slot, so the value takes the slack and the trailing pins right.
53
+ * editors. A verb about the value goes in `actions`, on the value's own surface
54
+ * `DetailRow` has no trailing slot, and a verb on the row's ground is an
55
+ * `InlineButton` without a field under it.
42
56
  */
43
57
  export function InlineStatic(props: InlineStaticProps) {
44
- const { value, placeholder, muted, tabular, align = "left", weight, multiline } = props;
58
+ const { value, placeholder, muted, tabular, align = "left", weight, multiline, actions } = props;
45
59
  const isEmpty = value.length === 0;
46
60
  const display = isEmpty ? (placeholder ?? "—") : value;
47
61
  return (
48
- <InlineSlot>
62
+ <InlineSlot actions={actions}>
49
63
  <Text
50
64
  numberOfLines={multiline ? undefined : 1}
51
65
  tabular={tabular}
@@ -24,7 +24,10 @@ export function LegendItem(props: LegendItemProps) {
24
24
  return (
25
25
  <View style={styles.row}>
26
26
  <View style={[styles.swatch, { backgroundColor: props.color }]} />
27
- <Text size="sm" color="muted">
27
+ {/* Nhãn phải CO ĐƯỢC. Không có `minWidth: 0` thì một hộp chữ trong hàng
28
+ flex không bao giờ nhỏ hơn nội dung của nó, nên nó đẩy cả hàng rộng ra
29
+ và ở màn hẹp hai mục chú giải chồng lên nhau thay vì xuống dòng. */}
30
+ <Text size="sm" color="muted" style={styles.label}>
28
31
  {props.label}
29
32
  </Text>
30
33
  {props.value !== undefined && <Metric value={props.value} size="sm" />}
@@ -37,7 +40,17 @@ const styles = StyleSheet.create({
37
40
  flexDirection: "row",
38
41
  alignItems: "center",
39
42
  gap: SPACE.sm,
43
+ // 130 là bề ngang MONG MUỐN để vài mục chú giải xếp thành cột đều nhau —
44
+ // không phải một sàn cứng. Thiếu `flexShrink` thì nó thành sàn thật: ở màn
45
+ // hẹp hàng không chịu co, và một chú giải bốn chữ vừa bị cắt vừa đè lên
46
+ // mục bên cạnh. Cùng nhầm lẫn mà `table_fit` đã gỡ một lần — hỏi "còn
47
+ // dùng được không" ở chỗ đáng ra phải hỏi "còn đọc được không".
40
48
  minWidth: 130,
49
+ flexShrink: 1,
50
+ },
51
+ label: {
52
+ flexShrink: 1,
53
+ minWidth: 0,
41
54
  },
42
55
  swatch: {
43
56
  width: 8,
package/src/locale.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import { type DatePickerLabels } from "./date_picker";
3
3
  import { type CalendarLabels } from "./date_calendar";
4
+ import { type CalendarViewLabels } from "./calendar/types";
5
+ import { type GanttLabels } from "./gantt/types";
4
6
  import { type PaginationLabels } from "./pagination";
5
7
  import { type SortHeaderLabels } from "./sort_header";
6
8
  import { type ConfidenceLabels } from "./confidence";
@@ -55,6 +57,14 @@ export interface LoticsLocale {
55
57
  picker: { emptyOption: string };
56
58
  datePicker: DatePickerLabels;
57
59
  calendar: CalendarLabels;
60
+ /** `CalendarView` and its parts — the SCHEDULING surface. Distinct from
61
+ * `calendar` above, which is the date PICKER's month grid: two different
62
+ * components, two different vocabularies, and the collision is why this one
63
+ * carries the longer name. */
64
+ calendarView: CalendarViewLabels;
65
+ /** `GanttView` — the zoom switch, the frozen column header, dependency
66
+ * announcements and the empty state. */
67
+ gantt: GanttLabels;
58
68
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
59
69
  * a call site doesn't pass a dimension-specific `clearLabel`. */
60
70
  filterChip: { clear: string };
@@ -327,6 +337,16 @@ export const en: LoticsLocale = {
327
337
  picker: { emptyOption: "None" },
328
338
  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" },
329
339
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
340
+ calendarView: {
341
+ today: "Today", month: "Month", week: "Week", day: "Day", agenda: "Agenda",
342
+ previous: "Previous", next: "Next", allDay: "all-day",
343
+ more: (n) => `+${n} more`, noEvents: "Nothing scheduled",
344
+ addAt: (when) => `Add at ${when}`,
345
+ },
346
+ gantt: {
347
+ day: "Day", week: "Week", month: "Month", task: "Task",
348
+ empty: "Nothing scheduled",
349
+ },
330
350
  filterChip: { clear: "Clear" },
331
351
  floatingActionBar: { clear: "Clear" },
332
352
  formField: { optional: "Optional" },
@@ -536,6 +556,16 @@ export const vi: LoticsLocale = {
536
556
  picker: { emptyOption: "Không có" },
537
557
  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 đủ" },
538
558
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
559
+ calendarView: {
560
+ today: "Hôm nay", month: "Tháng", week: "Tuần", day: "Ngày", agenda: "Danh sách",
561
+ previous: "Trước", next: "Sau", allDay: "cả ngày",
562
+ more: (n) => `+${n} nữa`, noEvents: "Không có lịch nào",
563
+ addAt: (when) => `Thêm vào ${when}`,
564
+ },
565
+ gantt: {
566
+ day: "Ngày", week: "Tuần", month: "Tháng", task: "Công việc",
567
+ empty: "Chưa có việc nào",
568
+ },
539
569
  filterChip: { clear: "Xóa" },
540
570
  floatingActionBar: { clear: "Bỏ chọn" },
541
571
  formField: { optional: "Tùy chọn" },
package/src/matrix.tsx CHANGED
@@ -28,6 +28,10 @@ interface MatrixContextValue {
28
28
  * column budget needs the same number the grid renders with — hard-coding it
29
29
  * is how a width budget and the layout drift apart. */
30
30
  export const MATRIX_COL_GAP = 3;
31
+ /** Chỗ thở hai bên con số bên trong một ô ma trận. Ô có NỀN, nên con số căn phải
32
+ * cần một khoảng với mép nền; dán sát thì nó đọc như bị cắt. Tiêu đề cột cộng
33
+ * đúng số này vào `paddingRight` của nó để hai bên kết thúc trên một đường. */
34
+ const MATRIX_CELL_PAD = 6;
31
35
 
32
36
  const MatrixContext = createContext<MatrixContextValue | null>(null);
33
37
 
@@ -319,10 +323,19 @@ const styles = StyleSheet.create({
319
323
  },
320
324
  cell: {
321
325
  flex: 1,
322
- minWidth: 52,
326
+ // 52 + hai lần MATRIX_CELL_PAD: cái đệm mới ăn vào bề ngang, nên sàn tối
327
+ // thiểu phải cộng thêm đúng ngần ấy, không thì ô chật đi và con số dài nhất
328
+ // là con số đầu tiên bị ép.
329
+ minWidth: 52 + MATRIX_CELL_PAD * 2,
323
330
  height: 36,
324
331
  borderRadius: 4,
325
332
  borderWidth: 2,
333
+ // Con số căn phải trong một ô CÓ NỀN, nên nó cần chỗ thở với mép nền. Trước
334
+ // đây ô không có đệm ngang nào: chữ số cuối dừng cách mép đúng 2px của cái
335
+ // viền, và trên một ô tô màu thì nó đọc ra như bị cắt cụt chứ không như căn
336
+ // phải. Một con số dán vào cạnh khối màu của chính nó là con số trông như
337
+ // còn nữa mà không hiện ra.
338
+ paddingHorizontal: MATRIX_CELL_PAD,
326
339
  // STRETCH, not center. The figure inside sets `align="right"`, and a Text
327
340
  // centred by its parent SHRINK-WRAPS — so it is exactly as wide as its own
328
341
  // glyphs and has nothing to align against. Right-aligning the text while
@@ -336,10 +349,11 @@ const styles = StyleSheet.create({
336
349
  },
337
350
  colCell: {
338
351
  flex: 1,
339
- minWidth: 52,
340
- // Matches the 2px the cell's own border insets its figure by, so a header
341
- // and the numbers under it end on ONE x rather than two pixels apart.
342
- paddingRight: 2,
352
+ minWidth: 52 + MATRIX_CELL_PAD * 2,
353
+ // Khớp với tổng phần ô đẩy con số vào: 2px viền cộng cái đệm ngang. Lệch
354
+ // một pixel đây tiêu đề cột dãy số dưới kết thúc ở hai đường x
355
+ // khác nhau — thứ mắt bắt được ngay dù không gọi được tên.
356
+ paddingRight: 2 + MATRIX_CELL_PAD,
343
357
  },
344
358
  totalCol: {
345
359
  // SIZED LIKE A DATA COLUMN, not to a fixed number. It was `width: 64`, and a
@@ -29,6 +29,20 @@ interface MemberChipProps {
29
29
  /** A rung on the shared avatar scale. Default `md`, which is sized to seat in a
30
30
  * 40px control band; drop to `sm` for a dense cell. See `AVATAR_PX`. */
31
31
  size?: AvatarSize;
32
+ /**
33
+ * Drop the face and render the person as TEXT.
34
+ *
35
+ * Pass it wherever the member is ONE FIELD AMONG COLUMNS rather than the row's
36
+ * subject — an owner column, an assignee cell, a "last edited by". Two reasons,
37
+ * both from `composition.md` §Identity marks: a second mark competes with the
38
+ * mark the row already carries for its own subject, and a handful of people
39
+ * owning hundreds of rows draws the same disc over and over, which "carries
40
+ * nothing and is the largest, brightest thing there".
41
+ *
42
+ * Keep the face where the member IS the subject — a roster, a picker option, a
43
+ * profile, a directory row.
44
+ */
45
+ showAvatar?: boolean;
32
46
  /** This person is no longer active — they left, their account was closed.
33
47
  * Mutes the name so a record that still names them reads as history rather
34
48
  * than as a current assignment. The chip keeps its height, because its usual
@@ -58,6 +72,7 @@ export function MemberChip({
58
72
  image,
59
73
  secondary,
60
74
  size = "md",
75
+ showAvatar = true,
61
76
  inactive,
62
77
  style,
63
78
  }: MemberChipProps) {
@@ -101,7 +116,9 @@ export function MemberChip({
101
116
  secondary && AVATAR_PX[size] < AVATAR_PX.lg ? "lg" : size;
102
117
  return (
103
118
  <View style={[styles.row, style]}>
104
- <Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
119
+ {showAvatar ? (
120
+ <Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
121
+ ) : null}
105
122
  <View style={styles.text}>
106
123
  <Text
107
124
  userSelect="none"
@@ -91,7 +91,11 @@ export function MenuButton(props: MenuButtonProps) {
91
91
 
92
92
  const resolvedIcon =
93
93
  typeof icon === "string" ? (
94
- <Icon size={20} name={icon as IconName} color={danger ? colors.red["900"] : undefined} />
94
+ <Icon
95
+ size={20}
96
+ name={icon as IconName}
97
+ color={disabled ? colors.zinc["400"] : danger ? colors.red["900"] : undefined}
98
+ />
95
99
  ) : (
96
100
  icon
97
101
  );
@@ -102,7 +106,7 @@ export function MenuButton(props: MenuButtonProps) {
102
106
  weight="medium"
103
107
  numberOfLines={1}
104
108
  userSelect="none"
105
- color={danger ? "danger" : undefined}
109
+ color={disabled ? "muted" : danger ? "danger" : undefined}
106
110
  >
107
111
  {title}
108
112
  </Text>
@@ -125,6 +129,7 @@ export function MenuButton(props: MenuButtonProps) {
125
129
  styles.container,
126
130
  highlighted && styles.highlighted,
127
131
  focused && !highlighted && styles.focused,
132
+ disabled && styles.disabled,
128
133
  style,
129
134
  ];
130
135
 
@@ -182,6 +187,11 @@ const styles = StyleSheet.create({
182
187
  highlighted: {
183
188
  backgroundColor: colors.zinc["100"],
184
189
  },
190
+ // A blocked row keeps its geometry and loses its weight — the label and icon
191
+ // go muted above, and the row itself stops inviting the press.
192
+ disabled: {
193
+ opacity: 0.6,
194
+ },
185
195
  focused: {
186
196
  backgroundColor: colors.zinc["50"],
187
197
  },
@@ -11,6 +11,7 @@ import { ActivityIndicator } from "./activity_indicator";
11
11
  import { TextInputField } from "./text_input_field";
12
12
  import { useScreenSize } from "./use_screen_size";
13
13
  import { useOptionList, type UseOptionListParams } from "./use_option_list";
14
+ import { pickerEmptyOptionLabel } from "./picker_empty_option";
14
15
  import { useLoticsLocale } from "./locale";
15
16
  import type { PickerOption } from "./picker";
16
17
 
@@ -61,7 +62,16 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
61
62
  const emptyText = props.emptyText ?? loc.noResults;
62
63
  const selectAllLabel = props.selectAllLabel ?? loc.selectAll;
63
64
  const deselectAllLabel = props.deselectAllLabel ?? loc.deselectAll;
64
- const list = useOptionList(props);
65
+ // `Picker` names its clear row from the locale; this list is the same row on
66
+ // the popover path, so it takes the same name from the same helper.
67
+ const emptyWord = useLoticsLocale().picker.emptyOption;
68
+ const list = useOptionList({
69
+ ...props,
70
+ emptyOptionLabel: pickerEmptyOptionLabel({
71
+ includeEmptyOption: props.includeEmptyOption,
72
+ localeEmptyOption: emptyWord,
73
+ }),
74
+ });
65
75
  const { small } = useScreenSize();
66
76
 
67
77
  // The option rows set `activeIndex` on hover but have no hover-OUT, so entering
@@ -92,8 +92,15 @@ export function ProgressBar(props: ProgressBarProps) {
92
92
  <View style={[styles.track, styles.compactTrack]}>
93
93
  <View style={[styles.fill, { width: `${percentage}%`, backgroundColor: isComplete ? completeColor : color }]} />
94
94
  </View>
95
+ {/* The caption is MUTED at every ratio, matching the non-compact anatomy.
96
+ A meter can mean "done" (a task, an upload) or "used up" (a credit
97
+ limit, a quota), and the same 100% is good in one and bad in the
98
+ other — so the component cannot colour the number without asserting
99
+ something it does not know. It used to draw a green caption beside a
100
+ red bar on every cap meter. The verdict stays where the caller states
101
+ it: the FILL, via `completeColor`. */}
95
102
  {format === "none" ? null : (
96
- <Text size="sm" tabular color={isComplete ? "success" : "muted"}>
103
+ <Text size="sm" tabular color="muted">
97
104
  {label}
98
105
  </Text>
99
106
  )}
@@ -151,6 +158,12 @@ const styles = StyleSheet.create({
151
158
  },
152
159
  compactTrack: {
153
160
  flex: 1,
161
+ // A shrink-to-fit parent — a stacked `Table` cell, a `DetailRow` with no
162
+ // `DetailTable` above it — gives `flex: 1` nothing to divide, so the track
163
+ // measures 0 and the meter renders as its caption alone. It passes every
164
+ // treatment probe because there is nothing left to measure. The floor is
165
+ // what makes it a bar rather than a number.
166
+ minWidth: 64,
154
167
  },
155
168
  header: {
156
169
  flexDirection: "row",
@@ -102,6 +102,11 @@ const styles = StyleSheet.create({
102
102
  flexDirection: "row",
103
103
  alignItems: "flex-start",
104
104
  gap: 16,
105
+ // The metric sizes to its content while `identity` is `flex: 1`, so without
106
+ // this the title is the only thing that can give — and it gave down to three
107
+ // wrapped lines in a 358px column while the metric's note kept its full width.
108
+ // A record's identity is the last thing on the surface that should yield.
109
+ flexWrap: "wrap",
105
110
  },
106
111
  identity: {
107
112
  flex: 1,
@@ -129,7 +129,10 @@ export function StackedBarChart(props: StackedBarChartProps) {
129
129
  <View style={styles.leading}>{row.leading}</View>
130
130
  ) : null}
131
131
  <View style={styles.headText}>
132
- <Text size="sm" weight="medium" numberOfLines={2} leading="tight">
132
+ {/* regular, matching `Breakdown`'s label: the two components draw
133
+ the same row and a card may sit them side by side. The figure
134
+ carries the weight; the series name does not. */}
135
+ <Text size="sm" numberOfLines={2} leading="tight">
133
136
  {row.label}
134
137
  </Text>
135
138
  {row.meta ? (
package/src/table_fit.ts CHANGED
@@ -26,6 +26,23 @@ export interface TableFitColumn {
26
26
  key: string;
27
27
  /** Fixed width in px; omit for a flexible column. */
28
28
  width?: number;
29
+ /**
30
+ * Fixed CHROME inside this flexible cell, in px — a leading mark, a badge, a
31
+ * swatch, plus its gap. Added to the read floor so the cell keeps
32
+ * {@link FLEX_READ_WIDTH} for the TEXT rather than spending part of it on
33
+ * furniture.
34
+ *
35
+ * `composition.md` requires every register row to carry a leading mark and
36
+ * `tpl_item_list` puts it INSIDE the subject cell (the `leading` slot holds
37
+ * the checkbox), so obeying the kit's own rule silently adds ~50px the fit
38
+ * could not see: a register reports "fitted" while its subject clips. Measured
39
+ * in the wild — a subject handed 154px for strings needing 283, with every
40
+ * other signal green.
41
+ *
42
+ * Ignored on a fixed-`width` column, which already states its total. Default
43
+ * 0, so a caller that passes nothing gets exactly the previous behaviour.
44
+ */
45
+ lead?: number;
29
46
  /** This column's claim on space, **`1` = highest**, the way P1/P2/P3 rank a
30
47
  * bug: the column you least want to lose gets the SMALLEST number. Said the
31
48
  * other way round it is the same rule — the larger the number, the sooner the
@@ -133,7 +150,7 @@ export function computeTableFit(
133
150
  const requiredWidth = (cols: TableFitColumn[]): number => {
134
151
  const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
135
152
  const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
136
- const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH), 0);
153
+ const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH + (c.lead ?? 0)), 0);
137
154
  return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
138
155
  };
139
156
 
@@ -62,6 +62,19 @@ export interface ThumbnailStackProps {
62
62
  items: readonly ThumbnailStackItem[];
63
63
  /** Pictures shown before the remainder becomes `+N`. Default 3. */
64
64
  max?: number;
65
+ /**
66
+ * Silence the marks for assistive tech — they become decoration.
67
+ *
68
+ * The DEFAULT is to announce each `label`, and that is deliberate: a stack is
69
+ * often the only statement of what is there, and a run of unlabelled pictures
70
+ * is unreadable. Pass this where the row ALREADY names the thing in text, so
71
+ * the pictures do not read it a second time on every row.
72
+ *
73
+ * Note the default is the opposite of `Avatar.announce`, and for the same
74
+ * reason: an avatar almost always sits beside its name, a stack often does
75
+ * not. The default follows where the component actually sits.
76
+ */
77
+ decorative?: boolean;
65
78
  /**
66
79
  * A rung on the shared avatar scale — the same scale the marks in the
67
80
  * neighbouring columns take, so a row of goods and a row of people sit on one
@@ -73,7 +86,7 @@ export interface ThumbnailStackProps {
73
86
  }
74
87
 
75
88
  export function ThumbnailStack(props: ThumbnailStackProps) {
76
- const { items, max = DEFAULT_MAX, size = "md", style, testID } = props;
89
+ const { items, max = DEFAULT_MAX, size = "md", decorative, style, testID } = props;
77
90
  const locale = useLoticsLocale();
78
91
 
79
92
  if (items.length === 0) return null;
@@ -92,8 +105,10 @@ export function ThumbnailStack(props: ThumbnailStackProps) {
92
105
  {shown.map((item, index) => (
93
106
  <View
94
107
  key={item.id}
95
- accessible
96
- accessibilityLabel={item.label}
108
+ // Announced by default — a standalone stack is unreadable otherwise.
109
+ // `decorative` silences it where the row already names the thing.
110
+ accessible={decorative ? undefined : true}
111
+ accessibilityLabel={decorative ? undefined : item.label}
97
112
  style={{
98
113
  width: px,
99
114
  height: px,
@@ -65,6 +65,10 @@ export interface UseOptionListParams<
65
65
  onCustomCommit?: (rawQuery: string) => void;
66
66
  /** Single-select: a leading "" row that clears the value. */
67
67
  includeEmptyOption?: boolean;
68
+ /** What the clear row is CALLED. `OptionList` supplies the locale's word via
69
+ * `pickerEmptyOptionLabel`; the default keeps older callers rendering as
70
+ * before rather than silently gaining a word they never asked for. */
71
+ emptyOptionLabel?: string;
68
72
  enableSelectAll?: boolean;
69
73
  }
70
74
 
@@ -130,6 +134,7 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
130
134
  customOptionPlacement = "bottom",
131
135
  onCustomCommit,
132
136
  includeEmptyOption = false,
137
+ emptyOptionLabel = "",
133
138
  enableSelectAll = false,
134
139
  } = params;
135
140
 
@@ -211,7 +216,13 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
211
216
  kind !== "custom" && (multi ? selectedSet.has(option.value) : option.value === singleValue);
212
217
  out.push({ kind, option, index: out.length, selected, nativeID: optionId(out.length) });
213
218
  };
214
- if (includeEmptyOption && !multi && !searching) push("empty", { value: "" as T, label: "" });
219
+ // The clear row is a CHOICE the reader picks, so it is NAMED `Picker` has
220
+ // always named it through `pickerEmptyOptionLabel`, and this list did not,
221
+ // so every `Select`/`InlineSelect` offering a way back to empty offered it
222
+ // as a blank row with an empty accessible name.
223
+ if (includeEmptyOption && !multi && !searching) {
224
+ push("empty", { value: "" as T, label: emptyOptionLabel });
225
+ }
215
226
  if (customRow && customOptionPlacement === "top") push("custom", customRow);
216
227
  for (const o of filtered) push("option", o);
217
228
  if (customRow && customOptionPlacement === "bottom") push("custom", customRow);
@@ -221,6 +232,7 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
221
232
  customRow,
222
233
  customOptionPlacement,
223
234
  includeEmptyOption,
235
+ emptyOptionLabel,
224
236
  multi,
225
237
  searching,
226
238
  selectedSet,