@colixsystems/widget-sdk 0.245.0 → 0.247.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 (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -112,6 +112,8 @@ export {
112
112
  widthTier,
113
113
  WIDE_WIDTH_PX,
114
114
  } from "./container-width.js";
115
+ // Balanced lines of cells sized from the grid's own measured width.
116
+ export { Grid } from "./grid.native.js";
115
117
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
116
118
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
117
119
  export { useToast } from "./toast.native.js";
@@ -234,6 +236,8 @@ export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
234
236
  export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
235
237
  export { initialsFor } from "./initials.js";
236
238
  export { avatarColorFor } from "./avatar-color.js";
239
+ // A stored file's picture: a themed placeholder, then a fallback on failure.
240
+ export { FileImage } from "./file-image.native.js";
237
241
  // The one card surface: the theme's `card` scope, a gradient fill, margin.
238
242
  export { Card } from "./card.native.js";
239
243
  export {
@@ -251,7 +255,7 @@ export {
251
255
  cardGradientProps,
252
256
  } from "./card-style.js";
253
257
  // The themed button; follows the theme's button, destructive and ring scopes.
254
- export { Button, BUTTON_VARIANTS } from "./button.native.js";
258
+ export { Button, IconButton, BUTTON_VARIANTS } from "./button.native.js";
255
259
  export {
256
260
  BUTTON_PADDING_MAX,
257
261
  BUTTON_BORDER_WIDTH_MAX,
@@ -271,6 +275,10 @@ export { Checkbox } from "./checkbox.native.js";
271
275
  export { Switch } from "./switch.native.js";
272
276
  export { Select } from "./select.native.js";
273
277
  export { SearchField } from "./search-field.native.js";
278
+ // A searchable `<Select>` of users, groups or one table's records.
279
+ export { PeoplePicker } from "./people-picker.native.js";
280
+ // Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
281
+ export { RadioGroup } from "./radio-group.native.js";
274
282
  // Space for a control whose role is not a button; a no-op on native.
275
283
  export { spaceActivates } from "./form-control-logic.js";
276
284
  // A themed tab strip; follows the theme's `tabs` scope.
@@ -285,8 +293,13 @@ export { Modal } from "./modal.native.js";
285
293
  export { ConfirmDialog } from "./confirm-dialog.native.js";
286
294
  // Themed filter chips, tags and pills; follow the theme's `chip` scope.
287
295
  export { Chip, ChipGroup } from "./chip.native.js";
296
+ // Themed bar, line, area and donut charts; follow the theme's `chart` scope.
297
+ export { Chart } from "./chart.native.js";
298
+ export { chartColors } from "./chart-tokens.js";
288
299
  // A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
289
300
  export { FileUpload } from "./file-upload.native.js";
301
+ // Anchored actions, content and labels; follow the theme's `popover` scope.
302
+ export { Menu, Popover, Tooltip } from "./popover.native.js";
290
303
  // The semantic text tiers, shared with the built-in widgets.
291
304
  export {
292
305
  textRoleStyle,
@@ -0,0 +1,168 @@
1
+ // The ONE implementation behind `<Menu>`: a list of actions opened from a
2
+ // trigger, on the anchored surface `<Popover>` uses. Its rows are `<Select>`'s
3
+ // option rows, so a menu and a dropdown in one app look alike.
4
+
5
+ import React from "react";
6
+ import { useHostTheme } from "./hooks.js";
7
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
+ import { Icon } from "./icon.js";
9
+ import { optionRowStyle, resolveFormControlTokens } from "./form-control-tokens.js";
10
+ import { isText, optionKeyDown } from "./form-control-logic.js";
11
+ import { initialFocusIndex } from "./select-logic.js";
12
+ import { POPOVER_DEFAULTS, menuItemList } from "./popover-tokens.js";
13
+ import { triggerWith } from "./popover-view.js";
14
+
15
+ const h = React.createElement;
16
+ const ICON_SIZE = 18;
17
+ const NOTHING_PICKED = () => false;
18
+
19
+ /**
20
+ * Bind the shared menu to one platform's primitives and anchored surface.
21
+ *
22
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any}} rn
23
+ * @param {any} AnchoredSurface — from `makePopover()` for the same host.
24
+ */
25
+ export function makeMenu(rn, AnchoredSurface) {
26
+ const { Text, Pressable, ScrollView, Platform } = rn;
27
+
28
+ // Keyboard focus is a web concern: a device moves its reader into the
29
+ // sheet by itself, and a programmatic focus there would fight it.
30
+ function focusNode(node) {
31
+ if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
32
+ }
33
+
34
+ function itemGlyph(icon, color) {
35
+ if (typeof icon === "string" && icon) return h(Icon, { name: icon, size: ICON_SIZE, color });
36
+ return React.isValidElement(icon) ? icon : null;
37
+ }
38
+
39
+ function MenuItems({ t, items, onPick, accessibilityLabel, testID }) {
40
+ const nodes = React.useRef([]);
41
+ const initial = initialFocusIndex(items, NOTHING_PICKED);
42
+ const pendingFocus = React.useRef(true);
43
+ return h(
44
+ ScrollView,
45
+ {
46
+ style: { flexShrink: 1 },
47
+ role: "menu",
48
+ accessibilityLabel,
49
+ keyboardShouldPersistTaps: "handled",
50
+ testID: testID ? `${testID}-items` : undefined,
51
+ },
52
+ ...items.map((item, index) => {
53
+ // The web host still delivers keys to a disabled Pressable.
54
+ const activate = () => {
55
+ if (!item.disabled) onPick(item);
56
+ };
57
+ const color = item.danger ? t.popover.dangerColor : t.popover.textColor;
58
+ return h(
59
+ Pressable,
60
+ {
61
+ key: item.key,
62
+ ref: (node) => {
63
+ nodes.current[index] = node;
64
+ if (node && index === initial && pendingFocus.current) {
65
+ pendingFocus.current = false;
66
+ focusNode(node);
67
+ }
68
+ },
69
+ onPress: activate,
70
+ onKeyDown: optionKeyDown({
71
+ options: items,
72
+ index,
73
+ focusAt: (next) => focusNode(nodes.current[next]),
74
+ activate,
75
+ }),
76
+ disabled: item.disabled,
77
+ role: "menuitem",
78
+ "aria-disabled": item.disabled,
79
+ accessibilityLabel: item.accessibilityLabel,
80
+ testID: item.testID,
81
+ style: ({ pressed }) => optionRowStyle(t, { pressed, disabled: item.disabled }),
82
+ },
83
+ itemGlyph(item.icon, color),
84
+ h(
85
+ Text,
86
+ { style: { fontFamily: t.fontFamily, fontSize: t.fontSize, color, flexShrink: 1 } },
87
+ item.label,
88
+ ),
89
+ );
90
+ }),
91
+ );
92
+ }
93
+
94
+ /**
95
+ * A list of actions opened from a trigger: a row's "…" button, a toolbar's
96
+ * overflow. Beside the trigger on a wide screen, a bottom sheet on a phone.
97
+ * Each item closes the menu, then runs its `onPress` once the menu has left
98
+ * the screen, so an item can open a dialog of its own.
99
+ *
100
+ * @param {object} props
101
+ * @param {any} props.trigger — the pressable element that opens it.
102
+ * @param {Array<{label: string, onPress?: () => void, icon?: string|any,
103
+ * danger?: boolean, disabled?: boolean, testID?: string,
104
+ * accessibilityLabel?: string, key?: string}|null|false>} props.items —
105
+ * falsy entries are skipped, so `cond && {…}` works.
106
+ * @param {string} [props.accessibilityLabel] — names the menu.
107
+ * @param {"bottom"|"top"} [props.placement] — default "bottom"; flips.
108
+ * @param {string} [props.closeAccessibilityLabel] — the sheet backdrop's name.
109
+ * @param {string} [props.testID] — on the panel; the list takes `-items`.
110
+ */
111
+ function Menu({
112
+ trigger,
113
+ items,
114
+ accessibilityLabel,
115
+ placement = "bottom",
116
+ closeAccessibilityLabel,
117
+ testID,
118
+ }) {
119
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
120
+ const t = resolveFormControlTokens(theme);
121
+ const anchorRef = React.useRef(null);
122
+ const [open, setOpen] = React.useState(false);
123
+ const list = menuItemList(items);
124
+ const close = () => setOpen(false);
125
+ // A dialog opened before the menu is gone would lose focus on the web and
126
+ // fail to present on iOS.
127
+ const picked = React.useRef(null);
128
+ const pick = (item) => {
129
+ picked.current = item.onPress;
130
+ close();
131
+ };
132
+ const runPicked = () => {
133
+ const run = picked.current;
134
+ picked.current = null;
135
+ if (typeof run === "function") run();
136
+ };
137
+
138
+ return h(
139
+ React.Fragment,
140
+ null,
141
+ triggerWith(trigger, {
142
+ anchorRef,
143
+ onOpen: () => setOpen(true),
144
+ extraProps: { "aria-haspopup": "menu", "aria-expanded": open },
145
+ }),
146
+ h(
147
+ AnchoredSurface,
148
+ {
149
+ visible: open && list.length > 0,
150
+ onRequestClose: close,
151
+ onDismiss: runPicked,
152
+ anchorRef,
153
+ placement,
154
+ padding: t.rowGap,
155
+ minWidth: POPOVER_DEFAULTS.menuMinWidth,
156
+ maxWidth: POPOVER_DEFAULTS.menuMaxWidth,
157
+ accessibilityLabel: isText(accessibilityLabel) ? String(accessibilityLabel) : undefined,
158
+ closeAccessibilityLabel,
159
+ testID,
160
+ },
161
+ h(MenuItems, { t, items: list, onPick: pick, accessibilityLabel, testID }),
162
+ ),
163
+ );
164
+ }
165
+
166
+ Menu.displayName = "Menu";
167
+ return Menu;
168
+ }
@@ -85,6 +85,56 @@ export function overlayCornerStyle(radius, presentation) {
85
85
  : { borderRadius: radius };
86
86
  }
87
87
 
88
+ /**
89
+ * The screen box and the panel box of one overlay. An anchored overlay (a
90
+ * menu or popover placed at its trigger) has no scrim, and its panel sits at
91
+ * `anchoredAt`, a window position, instead of in the centre or at the bottom.
92
+ *
93
+ * @param {object} tokens — `resolveOverlayTokens()`'s result.
94
+ * @param {{top: number, left: number}|null} [anchoredAt]
95
+ */
96
+ export function overlayFrameStyles(tokens, anchoredAt) {
97
+ const look = {
98
+ backgroundColor: tokens.surface,
99
+ padding: tokens.padding,
100
+ ...tokens.elevation,
101
+ };
102
+ if (anchoredAt) {
103
+ return {
104
+ backdrop: { flex: 1, backgroundColor: "transparent" },
105
+ panel: {
106
+ position: "absolute",
107
+ top: anchoredAt.top,
108
+ left: anchoredAt.left,
109
+ ...look,
110
+ ...overlayCornerStyle(tokens.radius, "dialog"),
111
+ },
112
+ };
113
+ }
114
+ return {
115
+ backdrop: {
116
+ flex: 1,
117
+ alignItems: "center",
118
+ justifyContent: tokens.verticalAlign,
119
+ padding: tokens.screenPadding,
120
+ ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
121
+ backgroundColor: tokens.scrim,
122
+ },
123
+ panel: {
124
+ width: "100%",
125
+ // Capped to the padded screen box so a tall panel scrolls inside its own
126
+ // ScrollView instead of overflowing past the viewport unreachably.
127
+ maxHeight: "100%",
128
+ flexShrink: 1,
129
+ ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
130
+ backgroundColor: look.backgroundColor,
131
+ ...overlayCornerStyle(tokens.radius, tokens.presentation),
132
+ padding: look.padding,
133
+ ...tokens.elevation,
134
+ },
135
+ };
136
+ }
137
+
88
138
  /**
89
139
  * Resolve the values one overlay is painted with, from the workspace theme.
90
140
  *
@@ -16,7 +16,7 @@
16
16
 
17
17
  import React from "react";
18
18
  import { useHostTheme } from "./hooks.js";
19
- import { overlayCornerStyle, resolveOverlayTokens } from "./overlay-tokens.js";
19
+ import { overlayFrameStyles, resolveOverlayTokens } from "./overlay-tokens.js";
20
20
 
21
21
  // Kept clickable, but out of the tab order and the accessibility tree.
22
22
  const SILENT_BACKDROP = {
@@ -32,13 +32,58 @@ const SILENT_BACKDROP = {
32
32
  *
33
33
  * `namesPanel` is for a host whose `Modal` cannot carry the accessible name
34
34
  * (React Native's): the panel carries it there, the dialog node on the web.
35
+ * `awaitsDismiss` is for a host whose `Modal` leaves the screen after it is
36
+ * told to (iOS): it stays mounted, hidden, until its `onDismiss`.
35
37
  *
36
38
  * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any,
37
- * namesPanel?: boolean}} rn
39
+ * namesPanel?: boolean, awaitsDismiss?: boolean}} rn
38
40
  */
39
41
  export function makeOverlay(rn) {
40
42
  const { Modal, View, Pressable, StyleSheet } = rn;
41
43
  const namesPanel = Boolean(rn.namesPanel);
44
+ const awaitsDismiss = Boolean(rn.awaitsDismiss);
45
+ // Compiled, so the web host can express "box-none" as real CSS rules.
46
+ let passiveBackdrop = null;
47
+ const passiveBackdropStyle = () => {
48
+ passiveBackdrop =
49
+ passiveBackdrop || StyleSheet.create({ backdrop: { pointerEvents: "box-none" } }).backdrop;
50
+ return passiveBackdrop;
51
+ };
52
+
53
+ /**
54
+ * Runs `onDismiss` once a shown overlay has left the screen: after the
55
+ * commit that removed it (focus already returned on the web), or, where
56
+ * the host takes it away itself, from that host's `onDismiss`. Returns
57
+ * the handler to hand the host while it is still dismissing, else null.
58
+ */
59
+ function useDismissal(visible, onDismiss) {
60
+ const onDismissRef = React.useRef(onDismiss);
61
+ onDismissRef.current = onDismiss;
62
+ // Decided in render, so the host's Modal is never unmounted mid-dismissal.
63
+ const [prevVisible, setPrevVisible] = React.useState(visible);
64
+ const [hostDismissing, setHostDismissing] = React.useState(false);
65
+ if (visible !== prevVisible) {
66
+ setPrevVisible(visible);
67
+ setHostDismissing(!visible && awaitsDismiss && typeof onDismiss === "function");
68
+ }
69
+ const hostDismissed = React.useCallback(() => {
70
+ setHostDismissing(false);
71
+ if (typeof onDismissRef.current === "function") onDismissRef.current();
72
+ }, []);
73
+
74
+ const shown = React.useRef(false);
75
+ React.useEffect(() => {
76
+ if (visible) {
77
+ shown.current = true;
78
+ return;
79
+ }
80
+ if (!shown.current) return;
81
+ shown.current = false;
82
+ if (!awaitsDismiss && typeof onDismissRef.current === "function") onDismissRef.current();
83
+ }, [visible]);
84
+
85
+ return hostDismissing ? hostDismissed : null;
86
+ }
42
87
 
43
88
  function Overlay({
44
89
  visible = false,
@@ -53,6 +98,9 @@ export function makeOverlay(rn) {
53
98
  closeAccessibilityLabel = "Close",
54
99
  initialFocusRef,
55
100
  panelFrame: PanelFrame,
101
+ anchoredAt,
102
+ passive = false,
103
+ onDismiss,
56
104
  style,
57
105
  testID,
58
106
  children,
@@ -65,36 +113,28 @@ export function makeOverlay(rn) {
65
113
  const requestClose = React.useCallback(() => {
66
114
  if (typeof onRequestClose === "function") onRequestClose();
67
115
  }, [onRequestClose]);
116
+ const dismissing = useDismissal(visible, onDismiss);
68
117
 
118
+ if (dismissing) {
119
+ return React.createElement(Modal, {
120
+ visible: false,
121
+ transparent: true,
122
+ animationType: "none",
123
+ onDismiss: dismissing,
124
+ });
125
+ }
69
126
  // Unmounted while closed: `Modal` appends a host container the moment it
70
127
  // renders, so every widget on the page would otherwise leave one behind.
71
128
  if (!visible) return null;
72
129
 
73
- const backdrop = {
74
- flex: 1,
75
- alignItems: "center",
76
- justifyContent: tokens.verticalAlign,
77
- padding: tokens.screenPadding,
78
- ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
79
- backgroundColor: tokens.scrim,
80
- };
81
- const panel = {
82
- width: "100%",
83
- // Capped to the padded screen box so a tall panel scrolls inside its own
84
- // ScrollView instead of overflowing past the viewport unreachably.
85
- maxHeight: "100%",
86
- flexShrink: 1,
87
- ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
88
- backgroundColor: tokens.surface,
89
- ...overlayCornerStyle(tokens.radius, tokens.presentation),
90
- padding: tokens.padding,
91
- ...tokens.elevation,
92
- };
130
+ const frame = overlayFrameStyles(tokens, anchoredAt);
131
+ // A passive layer lets the pointer through everywhere but its panel.
132
+ const backdrop = passive ? [frame.backdrop, passiveBackdropStyle()] : frame.backdrop;
93
133
 
94
134
  const panelNode = React.createElement(
95
135
  View,
96
136
  {
97
- style: [panel, style],
137
+ style: [frame.panel, style],
98
138
  accessibilityLabel: namesPanel ? accessibilityLabel : undefined,
99
139
  testID,
100
140
  },
@@ -113,11 +153,13 @@ export function makeOverlay(rn) {
113
153
  "aria-label": accessibilityLabelledBy ? undefined : accessibilityLabel,
114
154
  "aria-labelledby": accessibilityLabelledBy,
115
155
  initialFocusRef,
156
+ ...(passive ? { passive } : null),
157
+ ...(anchoredAt ? { closesOnOutsideScroll: true } : null),
116
158
  },
117
159
  React.createElement(
118
160
  View,
119
161
  { style: backdrop },
120
- dismissOnBackdropPress
162
+ dismissOnBackdropPress && !passive
121
163
  ? React.createElement(Pressable, {
122
164
  style: StyleSheet.absoluteFill,
123
165
  onPress: requestClose,
@@ -130,6 +172,7 @@ export function makeOverlay(rn) {
130
172
  })
131
173
  : null,
132
174
  // @internal `panelFrame`: `<Modal>` wraps its panel, never the scrim.
175
+ // @internal `anchoredAt` / `passive` / `onDismiss`: `<Popover>`, `<Menu>`, `<Tooltip>`.
133
176
  PanelFrame
134
177
  ? React.createElement(PanelFrame, { verticalAlign: tokens.verticalAlign }, panelNode)
135
178
  : panelNode,
@@ -5,14 +5,16 @@
5
5
  // hardware back button to `onRequestClose`, matching what react-native-web's
6
6
  // portal does on the web (./overlay.js). Only the import differs.
7
7
 
8
- import { Modal, View, Pressable, StyleSheet } from "react-native";
8
+ import { Modal, View, Pressable, StyleSheet, Platform } from "react-native";
9
9
  import { makeOverlay } from "./overlay-view.js";
10
10
 
11
11
  // RN's `Modal` forwards no ARIA props, so the panel carries the name here.
12
+ // iOS cannot present a modal while another is still being dismissed.
12
13
  export const Overlay = makeOverlay({
13
14
  Modal,
14
15
  View,
15
16
  Pressable,
16
17
  StyleSheet,
17
18
  namesPanel: true,
19
+ awaitsDismiss: Platform.OS === "ios",
18
20
  });
@@ -0,0 +1,203 @@
1
+ // The `<PeoplePicker>` rules that need no renderer: which source it reads,
2
+ // the query each source is asked, how a row is named, and which saved values
3
+ // still need a name. Both hosts share them, and the node suite tests them.
4
+
5
+ export const PEOPLE_PICKER_SOURCES = Object.freeze(["users", "groups", "records"]);
6
+ export const DEFAULT_PEOPLE_PAGE_SIZE = 50;
7
+ // The directory resolves at most this many ids per request.
8
+ export const MAX_RESOLVE_IDS = 100;
9
+
10
+ /** A `source` prop as the picker reads it; anything unknown is "users". */
11
+ export function pickerSource(source) {
12
+ return PEOPLE_PICKER_SOURCES.includes(source) ? source : "users";
13
+ }
14
+
15
+ function isId(value) {
16
+ return (typeof value === "string" && value !== "") || typeof value === "number";
17
+ }
18
+
19
+ function text(value) {
20
+ if (typeof value === "number") return String(value);
21
+ if (typeof value !== "string") return "";
22
+ return value.trim();
23
+ }
24
+
25
+ /**
26
+ * The page query a source's hook is asked: the typed search sent to the
27
+ * server, never filtered from one page here.
28
+ *
29
+ * @param {"users"|"groups"|"records"} source
30
+ * @param {{query?: string, pageSize?: number, groupId?: string, filter?: object}} own
31
+ */
32
+ export function pageQuery(source, { query, pageSize, groupId, filter } = {}) {
33
+ const q = text(query);
34
+ const limit =
35
+ Number.isInteger(pageSize) && pageSize > 0 ? Math.min(pageSize, 200) : DEFAULT_PEOPLE_PAGE_SIZE;
36
+ const out = { ...(q ? { q } : null), limit };
37
+ if (source === "users" && isId(groupId)) out.group_id = groupId;
38
+ if (source === "records" && filter && typeof filter === "object") out.filter = filter;
39
+ return out;
40
+ }
41
+
42
+ // Schema columns arrive as wire `data_type` / `is_identification`; a
43
+ // manifest-derived column carries `dataType` / `isIdentification`, and a
44
+ // column being written names the flag `is_identification_column`.
45
+ function columnType(column) {
46
+ return column.data_type ?? column.dataType;
47
+ }
48
+
49
+ function isIdentificationColumn(column) {
50
+ return Boolean(column.is_identification ?? column.isIdentification ?? column.is_identification_column);
51
+ }
52
+
53
+ /**
54
+ * The column a record is named by: the one given, else the table's
55
+ * identification column, else its first text column, else none.
56
+ *
57
+ * @param {{columns?: Array<{name: string, data_type?: string, dataType?: string,
58
+ * is_identification?: boolean, isIdentification?: boolean}>}|null} schema
59
+ * @param {string} [own]
60
+ */
61
+ export function recordLabelColumn(schema, own) {
62
+ if (typeof own === "string" && own !== "") return own;
63
+ const columns = (schema && Array.isArray(schema.columns) ? schema.columns : []).filter(Boolean);
64
+ const named = columns.find(isIdentificationColumn);
65
+ if (named) return named.name;
66
+ const textual = columns.find((column) => ["STRING", "TEXT"].includes(columnType(column)));
67
+ return textual ? textual.name : null;
68
+ }
69
+
70
+ /**
71
+ * A row's name: a user's name or email, a group's name, a record's label
72
+ * column. A row with none is `unknownText`, never its id.
73
+ *
74
+ * @param {object} row
75
+ * @param {{source: string, labelColumn?: string|null, unknownText: string}} how
76
+ */
77
+ export function rowLabel(row, { source, labelColumn, unknownText }) {
78
+ if (!row || typeof row !== "object") return unknownText;
79
+ if (source === "records") {
80
+ const cell = labelColumn ? row[labelColumn] : undefined;
81
+ // A relation cell is hydrated as `{ id, label }`.
82
+ const shown = cell && typeof cell === "object" ? text(cell.label) : text(cell);
83
+ return shown || unknownText;
84
+ }
85
+ if (source === "users") return text(row.name) || text(row.email) || unknownText;
86
+ return text(row.name) || unknownText;
87
+ }
88
+
89
+ /** The rows a page offers: each with an id, none of `excludeIds`. */
90
+ export function offeredRows(rows, excludeIds) {
91
+ const excluded = new Set(Array.isArray(excludeIds) ? excludeIds.map(String) : []);
92
+ return (Array.isArray(rows) ? rows : []).filter(
93
+ (row) => row && isId(row.id) && !excluded.has(String(row.id)),
94
+ );
95
+ }
96
+
97
+ /** A `value` or `values` prop as the list of picked ids. */
98
+ export function pickedIds({ multiple, value, values }) {
99
+ if (multiple) return (Array.isArray(values) ? values : []).filter(isId);
100
+ return isId(value) ? [value] : [];
101
+ }
102
+
103
+ /**
104
+ * The picked ids no row has named and no lookup has tried yet: each is asked
105
+ * for once, so a deleted person is not looked up on every render.
106
+ *
107
+ * @param {Array<string|number>} ids
108
+ * @param {Map<string, object>} knownRows — id → row.
109
+ * @param {Set<string>} attempted — ids already looked up.
110
+ */
111
+ export function unresolvedIds(ids, knownRows, attempted) {
112
+ return (Array.isArray(ids) ? ids : []).filter(
113
+ (id) => !knownRows.has(String(id)) && !attempted.has(String(id)),
114
+ );
115
+ }
116
+
117
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
118
+
119
+ /**
120
+ * Split saved ids into those a users or groups lookup can ask for and those
121
+ * it cannot: the directory refuses a whole batch over one id that is not a
122
+ * UUID, so such an id is named unknown at once instead. Record ids are each
123
+ * read alone, so every one is asked for.
124
+ *
125
+ * @param {Array<string|number>} ids
126
+ * @param {"users"|"groups"|"records"} source
127
+ */
128
+ export function lookupableIds(ids, source) {
129
+ const list = Array.isArray(ids) ? ids : [];
130
+ if (source === "records") return { lookup: list, unknown: [] };
131
+ const lookup = [];
132
+ const unknown = [];
133
+ for (const id of list) (typeof id === "string" && UUID.test(id) ? lookup : unknown).push(id);
134
+ return { lookup, unknown };
135
+ }
136
+
137
+ /**
138
+ * The lookups in flight, with `ids` that none of them holds yet added as new
139
+ * batches. A batch keeps the key it was given, so an id resolving elsewhere
140
+ * never restarts a lookup still out. Returns `queue` itself when nothing is new.
141
+ *
142
+ * @param {{pending: Array<{key: number, ids: Array<string|number>}>, next: number}} queue
143
+ * @param {Array<string|number>} ids
144
+ * @param {number} [size]
145
+ */
146
+ export function queueLookups(queue, ids, size = MAX_RESOLVE_IDS) {
147
+ const out = new Set(queue.pending.flatMap((batch) => batch.ids.map(String)));
148
+ const fresh = (Array.isArray(ids) ? ids : []).filter((id) => !out.has(String(id)));
149
+ if (fresh.length === 0) return queue;
150
+ const added = batches(fresh, size).map((batch, i) => ({ key: queue.next + i, ids: batch }));
151
+ return { pending: [...queue.pending, ...added], next: queue.next + added.length };
152
+ }
153
+
154
+ /** `queue` without the lookup `key`. */
155
+ export function settleLookup(queue, key) {
156
+ return { ...queue, pending: queue.pending.filter((batch) => batch.key !== key) };
157
+ }
158
+
159
+ /**
160
+ * Whether a page hook's result answers the query it was last asked. After the
161
+ * query changes, the hook still holds the previous answer until it starts
162
+ * loading or returns new rows, so that answer is not reported as this one.
163
+ *
164
+ * @param {{queryKey: string|null, rows: unknown, error: unknown}|null} last — what was last reported.
165
+ * @param {{queryKey: string|null, rows: unknown, error: unknown, loading: boolean}} now
166
+ */
167
+ export function answersQuery(last, now) {
168
+ if (!last || last.queryKey === now.queryKey) return true;
169
+ return now.loading || now.rows !== last.rows || now.error !== last.error;
170
+ }
171
+
172
+ /** `ids` in batches of at most `size`. */
173
+ export function batches(ids, size = MAX_RESOLVE_IDS) {
174
+ const list = Array.isArray(ids) ? ids : [];
175
+ const out = [];
176
+ for (let start = 0; start < list.length; start += size) out.push(list.slice(start, start + size));
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * What the trigger shows for each picked id: its row's name, "loading" while
182
+ * the lookup is out, `unknownText` once it found nothing. Never the raw id.
183
+ *
184
+ * @param {Array<string|number>} ids
185
+ * @param {{knownRows: Map<string, object>, attempted: Set<string>,
186
+ * nameOf: (row: object) => string, loadingText: string, unknownText: string}} how
187
+ */
188
+ export function pickedLabels(ids, { knownRows, attempted, nameOf, loadingText, unknownText }) {
189
+ return (Array.isArray(ids) ? ids : []).map((id) => {
190
+ const row = knownRows.get(String(id));
191
+ if (row) return { value: id, label: nameOf(row), row };
192
+ return { value: id, label: attempted.has(String(id)) ? unknownText : loadingText };
193
+ });
194
+ }
195
+
196
+ /** `knownRows` with every row in `rows` added by id. */
197
+ export function rememberRows(knownRows, rows) {
198
+ const next = new Map(knownRows);
199
+ for (const row of Array.isArray(rows) ? rows : []) {
200
+ if (row && isId(row.id)) next.set(String(row.id), row);
201
+ }
202
+ return next;
203
+ }