@lotics/ui 11.7.0 → 11.7.2

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
@@ -408,7 +408,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
408
408
  `selected`/`focused`/`danger`; `role` menuitem|button|option) — popover menus, outline
409
409
  rails, section pickers.
410
410
  - **`menu_list_item`** — `MenuListItem`: the richer listbox/menu row (title + description +
411
- `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`.
412
415
  - **`floating_action_bar`** — `FloatingActionBar`: the floating bulk-select action bar.
413
416
  - **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
414
417
  `selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
@@ -254,7 +254,8 @@ an empty overlay with no content to derive a name from.
254
254
 
255
255
  Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
256
256
  `CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
257
- rows `marked`, paired with a `FloatingActionBar`. The selection state is the **`useSelection()`**
257
+ rows `marked`, paired with a `FloatingActionBar` (its "Clear" escape is locale-resolved — pass
258
+ `clearLabel` only to override it). The selection state is the **`useSelection()`**
258
259
  hook (`@lotics/ui/use_selection`: `selected` / `has` / `count` / `toggle` / `setAll` /
259
260
  `allSelected` / `indeterminate` / `clear`) — gating which rows CAN be ticked stays with you: pass
260
261
  only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selectable row gets a
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.7.0",
3
+ "version": "11.7.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
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 title =
512
- !isCustom && renderOptionContent ? (
513
- renderOptionContent(opt)
514
- ) : desc ? (
515
- <View>
516
- <Text userSelect="none" numberOfLines={1}>
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
- <Text size="xs" color="zinc-500" numberOfLines={1}>
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
  })
@@ -1,6 +1,7 @@
1
1
  import { ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { Button } from "./button";
4
+ import { useLoticsLocale } from "./locale";
4
5
  import { Card } from "./card";
5
6
  import { Text } from "./text";
6
7
 
@@ -11,6 +12,7 @@ export interface FloatingActionBarProps {
11
12
  label: string;
12
13
  /** Clears the selection — the always-present escape. */
13
14
  onClear: () => void;
15
+ /** Overrides the localized default ("Clear" / "Bỏ chọn"). */
14
16
  clearLabel?: string;
15
17
  /** The bulk action(s) — a primary `Button` or an "Assign to…" `Popover`. */
16
18
  children: ReactNode;
@@ -25,7 +27,8 @@ export interface FloatingActionBarProps {
25
27
  * `box-none` wrapper lets clicks fall through everywhere except the bar.
26
28
  */
27
29
  export function FloatingActionBar(props: FloatingActionBarProps) {
28
- const { count, label, onClear, clearLabel = "Clear", children } = props;
30
+ const loc = useLoticsLocale().floatingActionBar;
31
+ const { count, label, onClear, clearLabel = loc.clear, children } = props;
29
32
  if (count <= 0) return null;
30
33
  return (
31
34
  <View pointerEvents="box-none" style={styles.wrap}>
package/src/locale.tsx CHANGED
@@ -36,6 +36,9 @@ export interface LoticsLocale {
36
36
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
37
37
  * a call site doesn't pass a dimension-specific `clearLabel`. */
38
38
  filterChip: { clear: string };
39
+ /** `FloatingActionBar`: the escape that drops the selection. The COUNT's noun is
40
+ * the call site's `label` (it names the rows), so only the escape lives here. */
41
+ floatingActionBar: { clear: string };
39
42
  /** `FormField`: the "Optional" marker shown next to the label when `optional`. */
40
43
  formField: { optional: string };
41
44
  /** `Drawer`: the record prev/next + close controls (screen-reader names). */
@@ -107,6 +110,7 @@ export const en: LoticsLocale = {
107
110
  datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM" },
108
111
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
109
112
  filterChip: { clear: "Clear" },
113
+ floatingActionBar: { clear: "Clear" },
110
114
  formField: { optional: "Optional" },
111
115
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
112
116
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
@@ -186,6 +190,7 @@ export const vi: LoticsLocale = {
186
190
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ 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" },
187
191
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
188
192
  filterChip: { clear: "Xóa" },
193
+ floatingActionBar: { clear: "Bỏ chọn" },
189
194
  formField: { optional: "Tùy chọn" },
190
195
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
191
196
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
@@ -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: string;
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
- <Text weight="medium" color={titleColor} numberOfLines={1} userSelect="none">
62
- {title}
63
- </Text>
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={description ? `${title}, ${description}` : title}
107
+ accessibilityLabel={resolvedLabel}
93
108
  aria-selected={selected || undefined}
94
109
  aria-disabled={disabled || undefined}
95
110
  >
@@ -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 title =
123
- row.kind === "option" && renderOptionContent ? (
124
- renderOptionContent(opt)
125
- ) : desc ? (
126
- <View>
127
- <Text userSelect="none" numberOfLines={1}>
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
  })