@lotics/ui 3.1.0 → 3.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/button.tsx CHANGED
@@ -22,6 +22,10 @@ interface ButtonPropsBase {
22
22
  icon?: IconName;
23
23
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
24
24
  color?: ButtonColor;
25
+ /** Corner shape. `pill` (default) is the fully-rounded capsule — keep it for
26
+ * icon-only and toolbar buttons, and to preserve existing screens. `rounded`
27
+ * is a 10px radius — use it for labelled CTAs / action buttons. */
28
+ shape?: "pill" | "rounded";
25
29
  loading?: boolean;
26
30
  disabled?: boolean;
27
31
  tooltip?: string | UseTooltipOptions;
@@ -49,6 +53,7 @@ export function Button(props: ButtonProps) {
49
53
  alignSelf,
50
54
  title,
51
55
  color,
56
+ shape = "pill",
52
57
  style,
53
58
  disabled,
54
59
  tooltip,
@@ -108,8 +113,11 @@ export function Button(props: ButtonProps) {
108
113
  disabled={disabledOrLoading}
109
114
  // @ts-ignore hovered is a react-native-web extension not in base RN types
110
115
  style={({ pressed, hovered }) => {
116
+ const isPrimary = color === "primary" && !disabled;
117
+ const restPrimary = isPrimary && !pressed && !hovered;
111
118
  return [
112
119
  {
120
+ borderRadius: shape === "rounded" ? 10 : 999,
113
121
  backgroundColor: disabled
114
122
  ? getButtonDisabledBackgroundColor(color)
115
123
  : pressed
@@ -118,6 +126,12 @@ export function Button(props: ButtonProps) {
118
126
  ? getButtonHoverColor(color)
119
127
  : getButtonBackgroundColor(color),
120
128
  },
129
+ // Subtle depth on the primary: a soft drop shadow lifts it off the
130
+ // surface and a whisper of top highlight + a gentle vertical shade (the
131
+ // app-icon technique) catch the light — premium, not skeuomorphic. The
132
+ // lift stays on hover/press; only the gradient drops so the wash shows.
133
+ isPrimary && { boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.18)" },
134
+ restPrimary && { backgroundImage: `linear-gradient(180deg, ${colors.zinc["700"]} 0%, ${colors.zinc["900"]} 100%)` },
121
135
  styles.button,
122
136
  style,
123
137
  alignSelf && { alignSelf },
@@ -228,7 +242,6 @@ const styles = StyleSheet.create({
228
242
  justifyContent: "center",
229
243
  height: 40,
230
244
  paddingHorizontal: 10,
231
- borderRadius: 999,
232
245
  // react-native-web extensions
233
246
  ...({ touchAction: "manipulation", cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
234
247
  },
@@ -5,27 +5,35 @@ import { PressableHighlight } from "./pressable_highlight";
5
5
  interface CardSelectItemProps {
6
6
  children: React.ReactNode;
7
7
  onPress: () => void;
8
+ /** Persistent selection — when true, the ring stays on (the current item in a
9
+ * list with one active selection: an org picker's current org, a switcher's
10
+ * open item). Reads as `aria-pressed`. */
11
+ selected?: boolean;
8
12
  testID?: string;
13
+ accessibilityLabel?: string;
9
14
  style?: StyleProp<ViewStyle>;
10
15
  }
11
16
 
12
17
  /**
13
- * A bordered, card-shaped button — the selectable row used on auth screens
14
- * (organization picker, login choices). Always interactive: a real focusable
15
- * `button` that shows a 2px ring on hover/press, matching the global keyboard
16
- * focus ring. For a static surface use Card instead.
18
+ * A bordered, card-shaped button — the selectable item used on auth screens
19
+ * (organization picker, login choices) and in entity switchers. Always
20
+ * interactive: a real focusable `button` that shows a 2px ring on hover/press,
21
+ * matching the global keyboard focus ring. Pass `selected` to keep that ring on
22
+ * for the current item in a single-selection list. For a static surface use Card.
17
23
  */
18
24
  export function CardSelectItem(props: CardSelectItemProps) {
19
- const { children, onPress, testID, style } = props;
25
+ const { children, onPress, selected = false, testID, accessibilityLabel, style } = props;
20
26
 
21
27
  return (
22
28
  <PressableHighlight
23
29
  testID={testID}
24
30
  accessibilityRole="button"
31
+ accessibilityLabel={accessibilityLabel}
32
+ aria-pressed={selected}
25
33
  onPress={onPress}
26
34
  style={(state: PressableStateCallbackType) => {
27
35
  const hovered = (state as { hovered?: boolean }).hovered;
28
- const active = hovered || state.pressed;
36
+ const active = selected || hovered || state.pressed;
29
37
  return [styles.container, active && styles.ring, style];
30
38
  }}
31
39
  >
package/src/combobox.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
11
11
  import { colors } from "./colors";
12
12
  import { Text } from "./text";
13
- import { Icon } from "./icon";
13
+ import { Icon, type IconName } from "./icon";
14
14
  import { TextInputField } from "./text_input_field";
15
15
  import { MenuButton } from "./menu_button";
16
16
  import { ActivityIndicator } from "./activity_indicator";
@@ -58,6 +58,11 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
58
58
  recentOptions?: PickerOption<T, D>[];
59
59
  loading?: boolean;
60
60
  searchDebounceMs?: number;
61
+ /** Leading icon inside the input. OMIT (the default) for a SELECT — no leading
62
+ * glyph, a trailing chevron — so a "pick a code/record" combobox reads as a
63
+ * picker, not a free-text search box. Opt IN to the search-box look explicitly
64
+ * with `icon="search"` (leading glyph, no chevron). Multi-select shows neither. */
65
+ icon?: IconName;
61
66
  placeholder?: string;
62
67
  recentsLabel?: string;
63
68
  emptyText?: string;
@@ -109,6 +114,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
109
114
  recentOptions,
110
115
  loading = false,
111
116
  searchDebounceMs = 200,
117
+ icon,
112
118
  placeholder,
113
119
  recentsLabel = "Recent",
114
120
  emptyText = "No results",
@@ -157,11 +163,14 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
157
163
  return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
158
164
  }, [isServer, searching, options, multi, reflectSelection, single, query]);
159
165
 
160
- // Free-entry row, appended when the query matches no option label exactly.
166
+ // Free-entry row, appended when the query matches no option exactly. Match
167
+ // both label AND value — a query equal to an option's value (e.g. a code typed
168
+ // in full) must NOT also offer "Add <code>", or the two share a key.
161
169
  const customRow = useMemo((): PickerOption<T, D> | null => {
162
170
  if (!allowCustom || !searching) return null;
163
171
  const q = query.trim();
164
- const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === q.toLowerCase());
172
+ const ql = q.toLowerCase();
173
+ const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === ql || String(o.value).toLowerCase() === ql);
165
174
  if (exact) return null;
166
175
  const label = customOptionLabel ? customOptionLabel(q) : `Add "${q}"`;
167
176
  if (label === null) return null;
@@ -257,6 +266,10 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
257
266
  const showList = open && !disabled && (searching || list.length > 0);
258
267
  const activeOptionId =
259
268
  activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
269
+ // Select mode (no leading icon): show a trailing chevron so the field reads as
270
+ // a picker, not a free-text search. The clear ✕ owns the right slot when
271
+ // present, so suppress the chevron then.
272
+ const showChevron = !multi && !icon && !clearable;
260
273
 
261
274
  return (
262
275
  <View style={style}>
@@ -281,7 +294,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
281
294
  <TextInputField
282
295
  ref={inputRef}
283
296
  testID={testID}
284
- icon={multi ? undefined : "search"}
297
+ icon={multi ? undefined : icon}
285
298
  value={query}
286
299
  clearable={!multi && clearable}
287
300
  clearLabel={clearLabel}
@@ -308,13 +321,18 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
308
321
  autoFocus={autoFocus}
309
322
  autoCapitalize="none"
310
323
  autoCorrect={false}
311
- style={multi ? styles.multiInput : undefined}
324
+ style={multi ? styles.multiInput : showChevron ? styles.selectInput : undefined}
312
325
  role="combobox"
313
326
  aria-expanded={showList}
314
327
  aria-controls={listboxId}
315
328
  aria-activedescendant={activeOptionId}
316
329
  aria-autocomplete="list"
317
330
  />
331
+ {showChevron ? (
332
+ <View style={styles.chevron}>
333
+ <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
334
+ </View>
335
+ ) : null}
318
336
  </View>
319
337
  <Popover
320
338
  open={showList}
@@ -421,6 +439,19 @@ const styles = StyleSheet.create({
421
439
  borderWidth: 0,
422
440
  height: 28,
423
441
  },
442
+ // Select mode: room on the right for the trailing chevron.
443
+ selectInput: {
444
+ paddingRight: 32,
445
+ },
446
+ chevron: {
447
+ position: "absolute",
448
+ right: 10,
449
+ top: 0,
450
+ bottom: 0,
451
+ justifyContent: "center",
452
+ pointerEvents: "none",
453
+ },
454
+
424
455
  chip: {
425
456
  flexDirection: "row",
426
457
  alignItems: "center",
@@ -3,6 +3,34 @@ import { View, StyleSheet } from "react-native";
3
3
  import { IconButton } from "./icon_button";
4
4
  import { Text } from "./text";
5
5
 
6
+ /**
7
+ * Localizable strings for `Pagination`. Every field is optional; an omitted
8
+ * field keeps the English default, so an app that needs another language passes
9
+ * only what it overrides (mirrors `DateRangeFilterField`'s `labels`). The
10
+ * formatters receive 1-indexed display numbers and the raw total, so a caller
11
+ * controls its own locale (e.g. `total.toLocaleString("vi-VN")`).
12
+ */
13
+ export interface PaginationLabels {
14
+ /** Range summary with a known total. Default `${start}–${end} of ${total}`. */
15
+ rangeWithTotal?: (start: number, end: number, total: number) => string;
16
+ /** Range summary without a total. Default `${start}–${end}`. */
17
+ range?: (start: number, end: number) => string;
18
+ /** Page indicator with a known total. Default `Page ${page} of ${pageCount}`. */
19
+ pageWithTotal?: (page: number, pageCount: number) => string;
20
+ /** Page indicator without a total. Default `Page ${page}`. */
21
+ page?: (page: number) => string;
22
+ /** Previous-page button tooltip. Default "Previous". */
23
+ previous?: string;
24
+ /** Next-page button tooltip. Default "Next". */
25
+ next?: string;
26
+ }
27
+
28
+ const defaultRangeWithTotal = (start: number, end: number, total: number): string =>
29
+ `${start}–${end} of ${total.toLocaleString()}`;
30
+ const defaultRange = (start: number, end: number): string => `${start}–${end}`;
31
+ const defaultPageWithTotal = (page: number, pageCount: number): string => `Page ${page} of ${pageCount}`;
32
+ const defaultPage = (page: number): string => `Page ${page}`;
33
+
6
34
  export interface PaginationProps {
7
35
  /** 0-indexed. */
8
36
  page: number;
@@ -18,6 +46,8 @@ export interface PaginationProps {
18
46
  total?: number;
19
47
  loading?: boolean;
20
48
  onPageChange: (page: number) => void;
49
+ /** Override the English strings for localization. Omit to keep defaults. */
50
+ labels?: PaginationLabels;
21
51
  }
22
52
 
23
53
  /**
@@ -28,21 +58,21 @@ export interface PaginationProps {
28
58
  * doubling up a border.
29
59
  */
30
60
  export function Pagination(props: PaginationProps): React.ReactNode {
31
- const { page, pageSize, rowCount, hasMore, total, loading, onPageChange } = props;
61
+ const { page, pageSize, rowCount, hasMore, total, loading, onPageChange, labels } = props;
32
62
  const start = page * pageSize + 1;
33
63
  const end = page * pageSize + rowCount;
34
64
  const showRange = !loading && rowCount > 0;
35
65
 
36
66
  const summary = showRange
37
67
  ? total !== undefined
38
- ? `${start}–${end} of ${total.toLocaleString()}`
39
- : `${start}–${end}`
68
+ ? (labels?.rangeWithTotal ?? defaultRangeWithTotal)(start, end, total)
69
+ : (labels?.range ?? defaultRange)(start, end)
40
70
  : "";
41
71
 
42
72
  const pageLabel =
43
73
  total !== undefined
44
- ? `Page ${page + 1} of ${Math.max(1, Math.ceil(total / pageSize))}`
45
- : `Page ${page + 1}`;
74
+ ? (labels?.pageWithTotal ?? defaultPageWithTotal)(page + 1, Math.max(1, Math.ceil(total / pageSize)))
75
+ : (labels?.page ?? defaultPage)(page + 1);
46
76
 
47
77
  return (
48
78
  <View style={styles.container}>
@@ -60,14 +90,14 @@ export function Pagination(props: PaginationProps): React.ReactNode {
60
90
  color="secondary"
61
91
  onPress={() => onPageChange(Math.max(0, page - 1))}
62
92
  disabled={page === 0 || !!loading}
63
- tooltip="Previous"
93
+ tooltip={labels?.previous ?? "Previous"}
64
94
  />
65
95
  <IconButton
66
96
  icon="chevron-right"
67
97
  color="secondary"
68
98
  onPress={() => onPageChange(page + 1)}
69
99
  disabled={!hasMore || !!loading}
70
- tooltip="Next"
100
+ tooltip={labels?.next ?? "Next"}
71
101
  />
72
102
  </View>
73
103
  </View>
@@ -186,7 +186,11 @@ const styles = StyleSheet.create({
186
186
  opacity: 0.55,
187
187
  },
188
188
  segment: {
189
- flex: 1,
189
+ // Grow to share a stretched track equally, but keep an `auto` basis so a
190
+ // hug-content track (the common toolbar case) sizes each segment to its
191
+ // label instead of collapsing it (flex-basis 0 → truncated text).
192
+ flexGrow: 1,
193
+ flexBasis: "auto",
190
194
  minHeight: 30,
191
195
  paddingVertical: 6,
192
196
  paddingHorizontal: 14,
@@ -39,6 +39,20 @@ export function sortBy<T>(
39
39
  });
40
40
  }
41
41
 
42
+ /**
43
+ * Localizable a11y fragments for `SortHeader`. The visible header is the app's
44
+ * own `label`; only the screen-reader announcement is built here, so these
45
+ * cover that string. Omit any field to keep the English default.
46
+ */
47
+ export interface SortHeaderLabels {
48
+ /** a11y prefix around the column label. Default `(label) => `Sort by ${label}``. */
49
+ sortBy?: (label: string) => string;
50
+ /** Appended when sorted ascending. Default ", ascending". */
51
+ ascending?: string;
52
+ /** Appended when sorted descending. Default ", descending". */
53
+ descending?: string;
54
+ }
55
+
42
56
  export interface SortHeaderProps {
43
57
  label: string;
44
58
  sortKey: string;
@@ -47,6 +61,8 @@ export interface SortHeaderProps {
47
61
  /** Right-align for numeric columns — the arrow then sits LEFT of the label. */
48
62
  align?: "left" | "right";
49
63
  style?: ViewStyle;
64
+ /** Override the English a11y strings for localization. Omit to keep defaults. */
65
+ labels?: SortHeaderLabels;
50
66
  }
51
67
 
52
68
  /**
@@ -57,15 +73,20 @@ export interface SortHeaderProps {
57
73
  * flush with the column content beneath it.
58
74
  */
59
75
  export function SortHeader(props: SortHeaderProps) {
60
- const { label, sortKey, sort, onSort, align = "left", style } = props;
76
+ const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
61
77
  const active = sort?.key === sortKey;
62
78
  const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
63
- const dirText = active ? (sort.dir === "asc" ? ", ascending" : ", descending") : "";
79
+ const dirText = active
80
+ ? sort.dir === "asc"
81
+ ? (labels?.ascending ?? ", ascending")
82
+ : (labels?.descending ?? ", descending")
83
+ : "";
84
+ const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
64
85
 
65
86
  return (
66
87
  <PressableHighlight
67
88
  accessibilityRole="button"
68
- accessibilityLabel={`Sort by ${label}${dirText}`}
89
+ accessibilityLabel={`${sortByLabel}${dirText}`}
69
90
  onPress={() => onSort(sortKey)}
70
91
  style={[styles.header, align === "right" ? styles.right : null, style]}
71
92
  >
package/src/table.tsx CHANGED
@@ -11,7 +11,7 @@ import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
11
11
  import { Text } from "./text";
12
12
  import { Divider } from "./divider";
13
13
  import { PressableRow } from "./pressable_row";
14
- import { SortHeader, type SortState } from "./sort_header";
14
+ import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
15
15
 
16
16
  /**
17
17
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
@@ -50,6 +50,8 @@ export interface TableProps {
50
50
  sort?: SortState | null;
51
51
  /** Cycles the sort (none→asc→desc→none) — pair with `cycleSort` in the parent. */
52
52
  onSort?: (key: string) => void;
53
+ /** Localized a11y strings for the sortable headers. Omit to keep English. */
54
+ sortLabels?: SortHeaderLabels;
53
55
  /** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
54
56
  leading?: number;
55
57
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
@@ -67,7 +69,7 @@ export interface TableProps {
67
69
  * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
68
70
  */
69
71
  export function Table(props: TableProps) {
70
- const { columns, sort, onSort, leading = 0, trailing = 0, children } = props;
72
+ const { columns, sort, onSort, sortLabels, leading = 0, trailing = 0, children } = props;
71
73
  const rows = Children.toArray(children).filter(isValidElement);
72
74
 
73
75
  return (
@@ -78,7 +80,7 @@ export function Table(props: TableProps) {
78
80
  <View key={col.key} style={colStyle(col)}>
79
81
  {col.label ? (
80
82
  col.sortable && onSort ? (
81
- <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} />
83
+ <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
82
84
  ) : (
83
85
  <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
84
86
  {col.label}