@lotics/ui 7.8.0 → 7.10.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 (63) hide show
  1. package/AGENTS.md +92 -43
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record_plain.tsx +121 -16
  26. package/examples/tpl_report.tsx +1 -1
  27. package/examples/tpl_rollup.tsx +1 -1
  28. package/examples/tpl_settings.tsx +1 -1
  29. package/examples/tpl_shifts.tsx +1 -1
  30. package/examples/tpl_stock.tsx +1 -1
  31. package/examples/tpl_task_board.tsx +2 -2
  32. package/examples/tpl_tasks.tsx +1 -1
  33. package/examples/tpl_timeline.tsx +1 -1
  34. package/examples/tpl_tower.tsx +1 -1
  35. package/examples/tpl_triage.tsx +1 -1
  36. package/examples/tpl_wizard.tsx +1 -1
  37. package/package.json +5 -1
  38. package/src/card.tsx +6 -13
  39. package/src/danger_zone.tsx +58 -0
  40. package/src/data_grid.tsx +1 -1
  41. package/src/detail_row.tsx +8 -1
  42. package/src/inline_edit.tsx +14 -4
  43. package/src/inline_select.tsx +9 -8
  44. package/src/inline_static.tsx +63 -0
  45. package/src/kpi_strip.tsx +5 -5
  46. package/src/pressable_row.tsx +22 -10
  47. package/src/sort_header.tsx +9 -4
  48. package/src/summary_line.tsx +76 -0
  49. package/src/table.tsx +27 -11
  50. package/src/use_selection.test.ts +66 -0
  51. package/src/use_selection.ts +75 -0
  52. package/examples/app_orders.tsx +0 -405
  53. package/examples/tpl_approvals.tsx +0 -375
  54. package/examples/tpl_batch.tsx +0 -234
  55. package/examples/tpl_callsheet.tsx +0 -481
  56. package/examples/tpl_convert.tsx +0 -478
  57. package/examples/tpl_crm_desk.tsx +0 -541
  58. package/examples/tpl_dispatch.tsx +0 -289
  59. package/examples/tpl_inventory.tsx +0 -299
  60. package/examples/tpl_pipeline.tsx +0 -400
  61. package/examples/tpl_reconcile.tsx +0 -275
  62. package/examples/tpl_record.tsx +0 -302
  63. package/examples/tpl_run.tsx +0 -235
package/src/kpi_strip.tsx CHANGED
@@ -34,11 +34,11 @@ export interface KPIStripProps {
34
34
  }
35
35
 
36
36
  /**
37
- * THE stat band at the top of a screen — one Card, hairline-divided columns
38
- * of KPICard with a uniform value scale. Screens must not hand-compose stat
39
- * strips: the same four-numbers-in-a-row should look identical on a
40
- * dashboard, an inventory register, or a payroll page. Wraps on narrow
41
- * screens (columns keep a 180px floor).
37
+ * THE dashboard stat band — one Card, hairline-divided columns of KPICard with a
38
+ * uniform value scale, at the TOP of a DASHBOARD page. It is NOT for register /
39
+ * list pages: a list summarizes the FILTERED view with the light inline
40
+ * `SummaryLine` below its toolbar, never this boxed metric grid. Screens must
41
+ * not hand-compose stat bands. Wraps on narrow screens (columns keep a 180px floor).
42
42
  */
43
43
  export function KPIStrip(props: KPIStripProps) {
44
44
  const { items, size = "lg" } = props;
@@ -11,16 +11,19 @@ export interface PressableRowProps {
11
11
  * it's the resting state of a ticked row in a bulk-select register. */
12
12
  marked?: boolean;
13
13
  /**
14
- * - "bleed" (default): the standalone REGISTER row px-20, square wash to
15
- * the card edges, separated by full-bleed `Divider`s. The one pattern for
16
- * top-level record lists.
14
+ * - "register" (THE record-list default): a rounded row whose hover/open/`marked`
15
+ * wash spans the FULL row width (incl. nested controls); content stays on the
16
+ * 20px column gutter so a `Table` header and its cells still align. The `Table`
17
+ * separates rows with a `Divider` (the wash is the hover/selection state, the
18
+ * hairline the resting separation) — every app inherits this register look.
19
+ * - "bleed" (legacy): px-20, square wash to the edges, `Divider`-separated.
20
+ * The pre-rounded register row — kept for an edge-to-edge data grid that
21
+ * genuinely wants hard rules, not the floating default.
17
22
  * - "inset": a rounded, contained row — the wash is a rounded pill pulled in
18
23
  * from the edge (radius 8, marginH -8). Lives inside a padded container (an
19
- * `Accordion`'s drill-down rows, or a gap-separated selectable list) so the
20
- * wash insets from the card edge instead of bleeding to it. Use when rows are
21
- * GAP-separated rather than `Divider`-separated.
24
+ * `Accordion`'s drill-down rows, or a gap-separated selectable list).
22
25
  */
23
- variant?: "bleed" | "inset";
26
+ variant?: "register" | "bleed" | "inset";
24
27
  /** Cells + trailing controls. Nested pressables (CTAs, checkboxes, ⋯)
25
28
  * claim their own presses; put the accessible door inside — a plain
26
29
  * `Pressable` with role="button" + "Open …" label around the row body. */
@@ -42,7 +45,7 @@ export interface PressableRowProps {
42
45
  * `variant` picks bleed (registers) or inset (Accordion drill-down rows).
43
46
  */
44
47
  export function PressableRow(props: PressableRowProps) {
45
- const { onPress, selected = false, marked = false, variant = "bleed", children, style } = props;
48
+ const { onPress, selected = false, marked = false, variant = "register", children, style } = props;
46
49
  const [hovered, setHovered] = useState(false);
47
50
 
48
51
  // RN's types don't declare mouse handlers; react-native-web forwards them
@@ -62,7 +65,7 @@ export function PressableRow(props: PressableRowProps) {
62
65
  {...mouseProps}
63
66
  style={({ pressed }) => [
64
67
  styles.row,
65
- variant === "inset" ? styles.inset : styles.bleed,
68
+ variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
66
69
  {
67
70
  backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
68
71
  },
@@ -81,7 +84,16 @@ const styles = StyleSheet.create({
81
84
  gap: 12,
82
85
  ...({ cursor: "pointer", userSelect: "none", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
83
86
  },
84
- // Square, full-bleedlines up with the Dividers between register rows.
87
+ // THE register row FULL-WIDTH rounded highlight. The hover/selected wash
88
+ // spans the entire row (so the pressable area === the row, never narrower than
89
+ // the dividers around it) and rounds (radius 10); content sits on the 20px
90
+ // column gutter so a Table header + cells align. The Divider between rows (the
91
+ // Table renders it) is the resting separation; this wash is the hover state.
92
+ register: {
93
+ borderRadius: 10,
94
+ paddingHorizontal: 20,
95
+ },
96
+ // Square, full-bleed — lines up with the Dividers between register rows. Legacy.
85
97
  bleed: {
86
98
  paddingHorizontal: 20,
87
99
  },
@@ -77,18 +77,23 @@ export function SortHeader(props: SortHeaderProps) {
77
77
  const { label, sortKey, sort, onSort, align = "left", style, labels } = props;
78
78
  const loc = useLoticsLocale().sortHeader;
79
79
  const active = sort?.key === sortKey;
80
- const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
80
+ // Always show a sort glyph so every sortable column READS as sortable — a faint
81
+ // up/down hint at rest, the solid direction chevron once active. (Before, the
82
+ // affordance only appeared after sorting, so columns looked un-sortable.)
83
+ const glyph = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down";
84
+ const glyphColor = active ? colors.zinc[600] : colors.zinc[400];
81
85
  const dirText = active ? (sort.dir === "asc" ? (labels?.ascending ?? loc.ascending) : (labels?.descending ?? loc.descending)) : "";
82
86
  const sortByLabel = (labels?.sortBy ?? loc.sortBy)(label);
83
87
 
84
88
  return (
85
89
  <PressableHighlight
86
- focusRing accessibilityRole="button"
90
+ focusRing
91
+ accessibilityRole="button"
87
92
  accessibilityLabel={`${sortByLabel}${dirText}`}
88
93
  onPress={() => onSort(sortKey)}
89
94
  style={[styles.header, align === "right" ? styles.right : null, style]}
90
95
  >
91
- {align === "right" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
96
+ {align === "right" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
92
97
  <Text
93
98
  size="xs"
94
99
  color={active ? "default" : "muted"}
@@ -98,7 +103,7 @@ export function SortHeader(props: SortHeaderProps) {
98
103
  >
99
104
  {label}
100
105
  </Text>
101
- {align === "left" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
106
+ {align === "left" ? <Icon name={glyph} size={12} color={glyphColor} /> : null}
102
107
  </PressableHighlight>
103
108
  );
104
109
  }
@@ -0,0 +1,76 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Metric, type MetricFormat, type MetricTone } from "./metric";
4
+ import { TrendChip } from "./trend_chip";
5
+ import { InfoPopover } from "./info_popover";
6
+
7
+ export interface SummaryLineItem {
8
+ /** Label that reads AFTER the value ("below minimum", "stock value"). */
9
+ label: string;
10
+ value: number | string | null | undefined;
11
+ format?: MetricFormat;
12
+ currency?: string;
13
+ locale?: string;
14
+ emptyLabel?: string;
15
+ tone?: MetricTone;
16
+ /** Abbreviate large values (≥1 triệu → "486 tr"). */
17
+ compact?: boolean;
18
+ /** Optional ±% trend chip. */
19
+ trend?: number | null;
20
+ /** What this metric means — the ⓘ next to the value opens it in a popover. */
21
+ info?: string;
22
+ }
23
+
24
+ export interface SummaryLineProps {
25
+ items: SummaryLineItem[];
26
+ }
27
+
28
+ /**
29
+ * A LIGHT, inline "value label" summary of the CURRENT view — sits BETWEEN the
30
+ * toolbar and a register/list and is recomputed from the FILTERED rows, so it
31
+ * always describes WHAT'S SHOWN (not the whole dataset). This is NOT the
32
+ * dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
33
+ * pages, left untouched). Here there's no card, no big numbers, no column rules
34
+ * — a small `Metric` value + a muted label, items wrapping, separated by space.
35
+ */
36
+ export function SummaryLine(props: SummaryLineProps) {
37
+ const { items } = props;
38
+ return (
39
+ <View style={styles.row}>
40
+ {items.map((item) => (
41
+ <View key={item.label} style={styles.item}>
42
+ <Metric
43
+ value={item.value}
44
+ format={item.format}
45
+ currency={item.currency}
46
+ locale={item.locale}
47
+ emptyLabel={item.emptyLabel}
48
+ compact={item.compact}
49
+ tone={item.tone}
50
+ size="sm"
51
+ />
52
+ <Text size="sm" color="muted">
53
+ {item.label}
54
+ </Text>
55
+ {item.trend != null ? <TrendChip value={item.trend} /> : null}
56
+ {item.info ? <InfoPopover text={item.info} accessibilityLabel={`About ${item.label}`} /> : null}
57
+ </View>
58
+ ))}
59
+ </View>
60
+ );
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ row: {
65
+ flexDirection: "row",
66
+ flexWrap: "wrap",
67
+ alignItems: "center",
68
+ columnGap: 22,
69
+ rowGap: 8,
70
+ },
71
+ item: {
72
+ flexDirection: "row",
73
+ alignItems: "center",
74
+ gap: 6,
75
+ },
76
+ });
package/src/table.tsx CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  } from "react";
10
10
  import { StyleSheet, View, Pressable, type ViewStyle } from "react-native";
11
11
  import { Text } from "./text";
12
- import { Divider } from "./divider";
12
+ import { colors } from "./colors";
13
13
  import { PressableRow } from "./pressable_row";
14
+ import { Divider } from "./divider";
14
15
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
15
16
  import { FOCUS_RING } from "./control_surface";
16
17
  import { useFocusRing } from "./use_focus_ring";
@@ -54,6 +55,9 @@ export interface TableProps {
54
55
  onSort?: (key: string) => void;
55
56
  /** Localized a11y strings for the sortable headers. Omit to keep English. */
56
57
  sortLabels?: SortHeaderLabels;
58
+ /** A node for the header's leading gutter — a select-all `CheckboxInput` aligned over
59
+ * the rows' `leading` checkboxes. Set `leading` too, to reserve the gutter width. */
60
+ selectAll?: ReactNode;
57
61
  /** Reserve a leading gutter (px) for rows that render a `leading` slot (a checkbox). */
58
62
  leading?: number;
59
63
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
@@ -71,13 +75,13 @@ export interface TableProps {
71
75
  * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
72
76
  */
73
77
  export function Table(props: TableProps) {
74
- const { columns, sort, onSort, sortLabels, leading = 0, trailing = 0, children } = props;
78
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
75
79
  const rows = Children.toArray(children).filter(isValidElement);
76
80
 
77
81
  return (
78
82
  <TableContext.Provider value={{ columns, leading, trailing }}>
79
83
  <View style={styles.headerBand}>
80
- {leading > 0 ? <View style={{ width: leading }} /> : null}
84
+ {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
81
85
  {columns.map((col) => (
82
86
  <View key={col.key} style={colStyle(col)}>
83
87
  {col.label ? (
@@ -93,12 +97,14 @@ export function Table(props: TableProps) {
93
97
  ))}
94
98
  {trailing > 0 ? <View style={{ width: trailing }} /> : null}
95
99
  </View>
96
- {rows.map((row, i) => (
97
- <View key={(row as ReactElement).key ?? i}>
98
- <Divider />
99
- {row}
100
- </View>
101
- ))}
100
+ <View style={styles.body}>
101
+ {rows.map((row, i) => (
102
+ <View key={i}>
103
+ {i > 0 ? <Divider /> : null}
104
+ {row}
105
+ </View>
106
+ ))}
107
+ </View>
102
108
  </TableContext.Provider>
103
109
  );
104
110
  }
@@ -107,6 +113,8 @@ export interface TableRowProps {
107
113
  /** Opens the record (the drawer). The door + the full-row surface both call it. */
108
114
  onPress?: () => void;
109
115
  selected?: boolean;
116
+ /** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
117
+ marked?: boolean;
110
118
  /** Accessible name for the door (the keyboard-focusable open target). */
111
119
  accessibilityLabel?: string;
112
120
  /** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
@@ -127,7 +135,7 @@ export interface TableRowProps {
127
135
  * win. Cells map to `Table`'s columns by position.
128
136
  */
129
137
  export function TableRow(props: TableRowProps) {
130
- const { onPress, selected, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
138
+ const { onPress, selected, marked, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
131
139
  const ctx = useContext(TableContext);
132
140
  if (!ctx) throw new Error("TableRow must be used within a Table");
133
141
 
@@ -137,7 +145,7 @@ export function TableRow(props: TableRowProps) {
137
145
  const { focusVisible, focusProps } = useFocusRing();
138
146
 
139
147
  return (
140
- <PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
148
+ <PressableRow onPress={onPress ?? noop} selected={selected} marked={marked} style={styles.row}>
141
149
  {ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
142
150
  <Pressable
143
151
  accessibilityRole={onPress ? "button" : undefined}
@@ -168,12 +176,20 @@ export function TableCell(props: TableCellProps) {
168
176
  const noop = () => {};
169
177
 
170
178
  const styles = StyleSheet.create({
179
+ // A hairline under the column header anchors the columns; the rows below it are
180
+ // Divider-separated.
171
181
  headerBand: {
172
182
  paddingHorizontal: 20,
173
183
  paddingVertical: 10,
174
184
  flexDirection: "row",
175
185
  alignItems: "center",
176
186
  gap: 14,
187
+ borderBottomWidth: 1,
188
+ borderColor: colors.border,
189
+ },
190
+ // Rows are Divider-separated — the hairline between rows is the separation.
191
+ body: {
192
+ gap: 0,
177
193
  },
178
194
  row: {
179
195
  gap: 14,
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { useSelection } from "./use_selection";
5
+
6
+ describe("useSelection", () => {
7
+ it("toggles an id on and off, and sets it explicitly with `on`", () => {
8
+ const { result } = renderHook(() => useSelection());
9
+ expect(result.current.count).toBe(0);
10
+ expect(result.current.has("a")).toBe(false);
11
+
12
+ act(() => result.current.toggle("a"));
13
+ expect(result.current.has("a")).toBe(true);
14
+ expect(result.current.count).toBe(1);
15
+
16
+ act(() => result.current.toggle("a"));
17
+ expect(result.current.has("a")).toBe(false);
18
+
19
+ act(() => result.current.toggle("a", true));
20
+ act(() => result.current.toggle("a", true)); // explicit on is idempotent
21
+ expect(result.current.count).toBe(1);
22
+ act(() => result.current.toggle("a", false));
23
+ expect(result.current.has("a")).toBe(false);
24
+ });
25
+
26
+ it("setAll selects/deselects a batch; `selected` exposes the picked ids", () => {
27
+ const { result } = renderHook(() => useSelection());
28
+ act(() => result.current.setAll(["a", "b", "c"], true));
29
+ expect(result.current.count).toBe(3);
30
+ expect([...result.current.selected].sort()).toEqual(["a", "b", "c"]);
31
+
32
+ act(() => result.current.setAll(["a", "b"], false));
33
+ expect([...result.current.selected]).toEqual(["c"]);
34
+ });
35
+
36
+ it("allSelected / indeterminate drive a select-all checkbox", () => {
37
+ const { result } = renderHook(() => useSelection());
38
+ const page = ["a", "b", "c"];
39
+ expect(result.current.allSelected(page)).toBe(false);
40
+ expect(result.current.indeterminate(page)).toBe(false);
41
+ expect(result.current.allSelected([])).toBe(false); // an empty set is never "all"
42
+
43
+ act(() => result.current.toggle("a", true));
44
+ expect(result.current.allSelected(page)).toBe(false);
45
+ expect(result.current.indeterminate(page)).toBe(true);
46
+
47
+ act(() => result.current.setAll(page, true));
48
+ expect(result.current.allSelected(page)).toBe(true);
49
+ expect(result.current.indeterminate(page)).toBe(false);
50
+ });
51
+
52
+ it("only the passed ids count — a selection outside the set isn't 'all'/'indeterminate' for it", () => {
53
+ const { result } = renderHook(() => useSelection());
54
+ act(() => result.current.setAll(["x", "y"], true));
55
+ expect(result.current.allSelected(["a", "b"])).toBe(false);
56
+ expect(result.current.indeterminate(["a", "b"])).toBe(false);
57
+ });
58
+
59
+ it("clear empties the whole selection", () => {
60
+ const { result } = renderHook(() => useSelection());
61
+ act(() => result.current.setAll(["a", "b"], true));
62
+ act(() => result.current.clear());
63
+ expect(result.current.count).toBe(0);
64
+ expect(result.current.has("a")).toBe(false);
65
+ });
66
+ });
@@ -0,0 +1,75 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ export interface Selection {
4
+ /** The selected ids — read `.has(id)` / `.size`, or use the helpers below.
5
+ * Spread it (`[...selected]`) to hand the picked ids to a bulk action. */
6
+ selected: ReadonlySet<string>;
7
+ /** Whether `id` is currently selected. */
8
+ has: (id: string) => boolean;
9
+ /** How many ids are selected — across pages, so it's the running total a
10
+ * `FloatingActionBar` shows. */
11
+ count: number;
12
+ /** Toggle `id`, or set it explicitly when `on` is passed (a row checkbox's `onChange`). */
13
+ toggle: (id: string, on?: boolean) => void;
14
+ /** Set a whole batch of ids on/off at once — the controlled select-all `onChange(on)`. */
15
+ setAll: (ids: readonly string[], on: boolean) => void;
16
+ /** Are ALL of `ids` selected? — a select-all checkbox's `checked` (false for an empty set). */
17
+ allSelected: (ids: readonly string[]) => boolean;
18
+ /** SOME but not all of `ids` selected? — a select-all checkbox's `indeterminate`. */
19
+ indeterminate: (ids: readonly string[]) => boolean;
20
+ /** Clear the whole selection. */
21
+ clear: () => void;
22
+ }
23
+
24
+ /**
25
+ * Always-on multi-select state for a register / list: the `selected` Set plus the
26
+ * toggle, select-all, and all-or-some helpers a `Table` + `FloatingActionBar` need.
27
+ * Items are agnostic string ids — selectability GATING (which rows can be ticked)
28
+ * stays with the caller: filter to the selectable ids and pass THOSE to `setAll` /
29
+ * `allSelected` / `indeterminate` (a non-selectable row just gets a disabled
30
+ * checkbox). Selection persists across pages, so `count` is the running total.
31
+ * (For a files-grid that ENTERS a selection mode via tap-and-hold, that's
32
+ * `useSelectionMode`; this is the checkbox-always-visible register variant.)
33
+ */
34
+ export function useSelection(): Selection {
35
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
36
+
37
+ const toggle = useCallback((id: string, on?: boolean) => {
38
+ setSelected((prev) => {
39
+ const next = new Set(prev);
40
+ if (on ?? !prev.has(id)) next.add(id);
41
+ else next.delete(id);
42
+ return next;
43
+ });
44
+ }, []);
45
+
46
+ const setAll = useCallback((ids: readonly string[], on: boolean) => {
47
+ setSelected((prev) => {
48
+ const next = new Set(prev);
49
+ for (const id of ids) {
50
+ if (on) next.add(id);
51
+ else next.delete(id);
52
+ }
53
+ return next;
54
+ });
55
+ }, []);
56
+
57
+ const clear = useCallback(() => setSelected(new Set()), []);
58
+
59
+ return useMemo<Selection>(
60
+ () => ({
61
+ selected,
62
+ has: (id) => selected.has(id),
63
+ count: selected.size,
64
+ toggle,
65
+ setAll,
66
+ allSelected: (ids) => ids.length > 0 && ids.every((id) => selected.has(id)),
67
+ indeterminate: (ids) => {
68
+ const all = ids.length > 0 && ids.every((id) => selected.has(id));
69
+ return !all && ids.some((id) => selected.has(id));
70
+ },
71
+ clear,
72
+ }),
73
+ [selected, toggle, setAll, clear],
74
+ );
75
+ }