@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.
- package/README.md +60 -2
- package/dist/button-tokens.js +154 -0
- package/dist/button-view.js +251 -93
- package/dist/button.js +5 -3
- package/dist/button.native.js +6 -4
- package/dist/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +132 -35
- package/dist/contract.js +132 -35
- package/dist/detail-parts-view.js +31 -2
- package/dist/file-image-logic.js +162 -0
- package/dist/file-image-source.js +71 -0
- package/dist/file-image-view.js +119 -0
- package/dist/file-image.js +8 -0
- package/dist/file-image.native.js +8 -0
- package/dist/form-control-logic.js +23 -0
- package/dist/form-control-tokens.js +25 -10
- package/dist/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +481 -20
- package/dist/index.js +14 -1
- package/dist/index.native.js +14 -1
- package/dist/menu-view.js +168 -0
- package/dist/overlay-tokens.js +50 -0
- package/dist/overlay-view.js +67 -24
- package/dist/overlay.native.js +3 -1
- package/dist/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +247 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- package/dist/popover-tokens.js +145 -0
- package/dist/popover-view.js +252 -0
- package/dist/popover.js +23 -0
- package/dist/popover.native.js +22 -0
- package/dist/radio-group-logic.js +61 -0
- package/dist/radio-group-tokens.js +62 -0
- package/dist/radio-group-view.js +262 -0
- package/dist/radio-group.js +12 -0
- package/dist/radio-group.native.js +7 -0
- package/dist/select-search-logic.js +150 -0
- package/dist/select-view.js +255 -43
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- package/dist/tabs-tokens.js +59 -0
- package/dist/tabs-view.js +9 -43
- package/dist/tooltip-view.js +137 -0
- package/dist/web-modal.js +95 -23
- package/package.json +1 -1
- package/src/contract.js +132 -35
package/dist/index.native.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/overlay-tokens.js
CHANGED
|
@@ -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
|
*
|
package/dist/overlay-view.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import React from "react";
|
|
18
18
|
import { useHostTheme } from "./hooks.js";
|
|
19
|
-
import {
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
package/dist/overlay.native.js
CHANGED
|
@@ -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
|
+
}
|