@colixsystems/widget-sdk 0.245.0 → 0.246.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 +37 -1
- 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/contract.cjs +86 -33
- package/dist/contract.js +86 -33
- 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/index.d.ts +247 -18
- package/dist/index.js +7 -1
- package/dist/index.native.js +7 -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/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-view.js +15 -31
- 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 +86 -33
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,145 @@
|
|
|
1
|
+
// The values `<Menu>`, `<Popover>` and `<Tooltip>` paint and place with, and
|
|
2
|
+
// the `popover` scope look `<Select>`'s option sheet shares. No renderer and
|
|
3
|
+
// no React here, so both hosts share it and the node suite tests it.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import { isNarrowWidth } from "./container-width.js";
|
|
7
|
+
import { overlayCornerStyle } from "./overlay-tokens.js";
|
|
8
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
9
|
+
|
|
10
|
+
export const POPOVER_PLACEMENTS = Object.freeze(["bottom", "top"]);
|
|
11
|
+
export const POPOVER_BORDER_WIDTH = 1;
|
|
12
|
+
|
|
13
|
+
export const POPOVER_DEFAULTS = Object.freeze({
|
|
14
|
+
// Between the trigger and the panel.
|
|
15
|
+
gap: 4,
|
|
16
|
+
// The closest a panel comes to a screen edge.
|
|
17
|
+
screenMargin: 8,
|
|
18
|
+
menuMinWidth: 180,
|
|
19
|
+
menuMaxWidth: 320,
|
|
20
|
+
popoverMaxWidth: 360,
|
|
21
|
+
tooltipMaxWidth: 240,
|
|
22
|
+
// A pointer passing over must not flash a tooltip; leaving waits a beat so
|
|
23
|
+
// the pointer can cross the gap onto the bubble.
|
|
24
|
+
tooltipShowDelayMs: 400,
|
|
25
|
+
tooltipHideDelayMs: 150,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A themed shadow replaces the overlay's, so a lower level must zero each key.
|
|
29
|
+
const NO_SHADOW = Object.freeze({ shadowOpacity: 0, shadowRadius: 0, elevation: 0 });
|
|
30
|
+
|
|
31
|
+
export function normalisePopoverPlacement(placement) {
|
|
32
|
+
return POPOVER_PLACEMENTS.includes(placement) ? placement : "bottom";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The theme's validated `popover` scope (Design > Components > Dropdown menus). */
|
|
36
|
+
export function popoverThemeScope(theme) {
|
|
37
|
+
const components = theme && typeof theme === "object" ? theme.components : null;
|
|
38
|
+
return normaliseThemeComponents({ popover: components && components.popover }).popover || {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the `popover` scope. `radius` and `shadow` stay null while unset,
|
|
43
|
+
* so the panel keeps the `<Overlay>` look every option sheet had before.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
46
|
+
*/
|
|
47
|
+
export function resolvePopoverTokens(theme) {
|
|
48
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
49
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
50
|
+
const levels = base.elevation || DEFAULT_THEME_TOKENS.elevation;
|
|
51
|
+
const scope = popoverThemeScope(base);
|
|
52
|
+
return {
|
|
53
|
+
background: scope.background || colors.surface,
|
|
54
|
+
textColor: scope.textColor || colors.onSurface,
|
|
55
|
+
borderColor: scope.borderColor || colors.border,
|
|
56
|
+
radius: scope.radius ?? null,
|
|
57
|
+
shadow: scope.shadow ? { ...NO_SHADOW, ...levels[scope.shadow] } : null,
|
|
58
|
+
dangerColor: colors.danger || DEFAULT_THEME_TOKENS.colors.danger,
|
|
59
|
+
mutedColor: colors.onSurfaceMuted || DEFAULT_THEME_TOKENS.colors.onSurfaceMuted,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The style a `popover`-scope panel lays over the `<Overlay>` panel.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} popover — `resolvePopoverTokens()`'s result.
|
|
67
|
+
* @param {"dialog"|"sheet"} presentation — a sheet rounds its top corners only.
|
|
68
|
+
*/
|
|
69
|
+
export function popoverPanelStyle(popover, presentation) {
|
|
70
|
+
return {
|
|
71
|
+
backgroundColor: popover.background,
|
|
72
|
+
borderWidth: POPOVER_BORDER_WIDTH,
|
|
73
|
+
borderColor: popover.borderColor,
|
|
74
|
+
...(popover.radius === null ? null : overlayCornerStyle(popover.radius, presentation)),
|
|
75
|
+
...popover.shadow,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether an anchored surface opens as a bottom sheet instead: always on a
|
|
81
|
+
* device, where a sheet is the platform's own menu, and in a narrow window.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} os — `Platform.OS`.
|
|
84
|
+
* @param {number} windowWidth
|
|
85
|
+
*/
|
|
86
|
+
export function presentsAsSheet(os, windowWidth) {
|
|
87
|
+
return os !== "web" || isNarrowWidth(windowWidth);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clamp(value, min, max) {
|
|
91
|
+
return Math.max(min, Math.min(value, max));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where a panel goes beside its trigger, in window coordinates. It opens on
|
|
96
|
+
* the preferred side, flips to the other when that side is short of room and
|
|
97
|
+
* the other has more, lines up with the trigger's start edge (its end edge
|
|
98
|
+
* when that would run off screen), and never leaves the screen margin.
|
|
99
|
+
*
|
|
100
|
+
* @param {{x: number, y: number, width: number, height: number}} anchor
|
|
101
|
+
* @param {{width: number, height: number}} panel
|
|
102
|
+
* @param {{width: number, height: number}} viewport
|
|
103
|
+
* @param {{placement?: string, gap?: number, margin?: number}} [options]
|
|
104
|
+
* @returns {{top: number, left: number, placement: "bottom"|"top", maxHeight: number}}
|
|
105
|
+
*/
|
|
106
|
+
export function placePopover(anchor, panel, viewport, options = {}) {
|
|
107
|
+
const gap = options.gap ?? POPOVER_DEFAULTS.gap;
|
|
108
|
+
const margin = options.margin ?? POPOVER_DEFAULTS.screenMargin;
|
|
109
|
+
const preferred = normalisePopoverPlacement(options.placement);
|
|
110
|
+
const below = viewport.height - margin - (anchor.y + anchor.height + gap);
|
|
111
|
+
const above = anchor.y - gap - margin;
|
|
112
|
+
const fitsPreferred = (preferred === "bottom" ? below : above) >= panel.height;
|
|
113
|
+
const flips = !fitsPreferred && (preferred === "bottom" ? above > below : below > above);
|
|
114
|
+
const placement = flips ? (preferred === "bottom" ? "top" : "bottom") : preferred;
|
|
115
|
+
const room = Math.max(0, placement === "bottom" ? below : above);
|
|
116
|
+
const height = Math.min(panel.height, room);
|
|
117
|
+
const top =
|
|
118
|
+
placement === "bottom" ? anchor.y + anchor.height + gap : anchor.y - gap - height;
|
|
119
|
+
|
|
120
|
+
const endAligned = anchor.x + anchor.width - panel.width;
|
|
121
|
+
const startFits = anchor.x + panel.width <= viewport.width - margin;
|
|
122
|
+
const left = clamp(
|
|
123
|
+
startFits ? anchor.x : endAligned,
|
|
124
|
+
margin,
|
|
125
|
+
Math.max(margin, viewport.width - margin - panel.width),
|
|
126
|
+
);
|
|
127
|
+
return { top: Math.max(margin, top), left, placement, maxHeight: room };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A `<Menu>`'s items as it draws them: falsy entries and entries with no
|
|
132
|
+
* label dropped, `disabled` a boolean, and a stable `key` on each.
|
|
133
|
+
*
|
|
134
|
+
* @param {Array<object|null|false|undefined>} items
|
|
135
|
+
*/
|
|
136
|
+
export function menuItemList(items) {
|
|
137
|
+
const hasLabel = (item) => typeof item.label === "string" ? item.label !== "" : typeof item.label === "number";
|
|
138
|
+
return (Array.isArray(items) ? items : [])
|
|
139
|
+
.filter((item) => item && typeof item === "object" && hasLabel(item))
|
|
140
|
+
.map((item, index) => ({
|
|
141
|
+
...item,
|
|
142
|
+
disabled: Boolean(item.disabled),
|
|
143
|
+
key: item.key !== undefined && item.key !== null ? String(item.key) : `${index}:${item.label}`,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The ONE implementation behind `<Popover>`, and the anchored surface that
|
|
2
|
+
// `<Menu>` and `<Tooltip>` open with. It is drawn in `<Overlay>`, so no card
|
|
3
|
+
// or scroll container clips it on either host. It sits beside its trigger,
|
|
4
|
+
// measured with `measureInWindow`, and flips at the screen edge. On a device
|
|
5
|
+
// and in a narrow window it is a bottom sheet instead.
|
|
6
|
+
//
|
|
7
|
+
// Bound per host by popover.js / popover.native.js, which differ only in
|
|
8
|
+
// where the primitives come from.
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { ThemeScope, useHostTheme } from "./hooks.js";
|
|
12
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
13
|
+
import {
|
|
14
|
+
POPOVER_BORDER_WIDTH,
|
|
15
|
+
POPOVER_DEFAULTS,
|
|
16
|
+
placePopover,
|
|
17
|
+
popoverPanelStyle,
|
|
18
|
+
popoverThemeScope,
|
|
19
|
+
presentsAsSheet,
|
|
20
|
+
resolvePopoverTokens,
|
|
21
|
+
} from "./popover-tokens.js";
|
|
22
|
+
import { compositeOver } from "./readable-ink.js";
|
|
23
|
+
import { surfaceAwareTheme } from "./surface-theme.js";
|
|
24
|
+
|
|
25
|
+
const h = React.createElement;
|
|
26
|
+
const UNMEASURABLE = "unmeasurable";
|
|
27
|
+
const HIDDEN = Object.freeze({ opacity: 0 });
|
|
28
|
+
const useNoInsets = () => null;
|
|
29
|
+
const sameSize = (a, b) => Math.abs(a.width - b.width) <= 1 && Math.abs(a.height - b.height) <= 1;
|
|
30
|
+
|
|
31
|
+
/** The ref an element was created with, on React 18 and on React 19. */
|
|
32
|
+
export function elementRef(element) {
|
|
33
|
+
const major = Number(String(React.version).split(".")[0]);
|
|
34
|
+
return major >= 19 ? element.props.ref : element.ref;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One callback ref that feeds every ref given, function or object. */
|
|
38
|
+
export function mergeRefs(...refs) {
|
|
39
|
+
return (node) => {
|
|
40
|
+
for (const ref of refs) {
|
|
41
|
+
if (typeof ref === "function") ref(node);
|
|
42
|
+
else if (ref && typeof ref === "object") ref.current = node;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The trigger element, cloned so a press opens the surface and the surface
|
|
49
|
+
* can measure it. Its own `onPress` still runs first.
|
|
50
|
+
*/
|
|
51
|
+
export function triggerWith(trigger, { anchorRef, onOpen, extraProps }) {
|
|
52
|
+
if (!React.isValidElement(trigger)) return null;
|
|
53
|
+
const own = trigger.props.onPress;
|
|
54
|
+
return React.cloneElement(trigger, {
|
|
55
|
+
ref: mergeRefs(elementRef(trigger), anchorRef),
|
|
56
|
+
onPress: (...args) => {
|
|
57
|
+
if (typeof own === "function") own(...args);
|
|
58
|
+
onOpen();
|
|
59
|
+
},
|
|
60
|
+
...extraProps,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The trigger's window rectangle, measured each time the surface opens.
|
|
65
|
+
function useAnchorRect(anchorRef, open, windowWidth, windowHeight) {
|
|
66
|
+
const [rect, setRect] = React.useState(null);
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (!open) {
|
|
69
|
+
setRect(null);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const node = anchorRef.current;
|
|
73
|
+
if (!node || typeof node.measureInWindow !== "function") {
|
|
74
|
+
setRect(UNMEASURABLE);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
let live = true;
|
|
78
|
+
node.measureInWindow((x, y, width, height) => {
|
|
79
|
+
if (live) setRect({ x, y, width, height });
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
live = false;
|
|
83
|
+
};
|
|
84
|
+
}, [anchorRef, open, windowWidth, windowHeight]);
|
|
85
|
+
return rect;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A popover scope background re-inks what is drawn on it, as `<Modal>` does.
|
|
89
|
+
function usePanelTheme(theme) {
|
|
90
|
+
return React.useMemo(() => {
|
|
91
|
+
const { background } = popoverThemeScope(theme);
|
|
92
|
+
if (!background) return null;
|
|
93
|
+
const surface = (theme.colors || DEFAULT_THEME_TOKENS.colors).surface;
|
|
94
|
+
return surfaceAwareTheme(theme, compositeOver(background, surface));
|
|
95
|
+
}, [theme]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bind the shared surface and `<Popover>` to one platform's primitives.
|
|
100
|
+
*
|
|
101
|
+
* @param {{View: any, Platform: any, useWindowDimensions: () => {width: number,
|
|
102
|
+
* height: number}, useSafeAreaInsets?: () => ({bottom: number}|null)}} rn
|
|
103
|
+
* @param {any} Overlay — the host's bound `<Overlay>`.
|
|
104
|
+
*/
|
|
105
|
+
export function makePopover(rn, Overlay) {
|
|
106
|
+
const { View, Platform, useWindowDimensions } = rn;
|
|
107
|
+
const useSafeAreaInsets = rn.useSafeAreaInsets || useNoInsets;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @internal The panel `<Popover>`, `<Menu>` and `<Tooltip>` open. `passive`
|
|
111
|
+
* (a hovered tooltip) never takes focus or blocks the pointer. `onDismiss`
|
|
112
|
+
* runs once a closed panel has left the screen (see `<Overlay>`).
|
|
113
|
+
*/
|
|
114
|
+
function AnchoredSurface({
|
|
115
|
+
visible,
|
|
116
|
+
onRequestClose,
|
|
117
|
+
onDismiss,
|
|
118
|
+
anchorRef,
|
|
119
|
+
placement,
|
|
120
|
+
passive = false,
|
|
121
|
+
padding = 0,
|
|
122
|
+
minWidth,
|
|
123
|
+
maxWidth,
|
|
124
|
+
accessibilityLabel,
|
|
125
|
+
closeAccessibilityLabel,
|
|
126
|
+
testID,
|
|
127
|
+
children,
|
|
128
|
+
}) {
|
|
129
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
130
|
+
const { width, height } = useWindowDimensions();
|
|
131
|
+
const insets = useSafeAreaInsets();
|
|
132
|
+
const popover = React.useMemo(() => resolvePopoverTokens(theme), [theme]);
|
|
133
|
+
const panelTheme = usePanelTheme(theme);
|
|
134
|
+
const rect = useAnchorRect(anchorRef, visible, width, height);
|
|
135
|
+
const [size, setSize] = React.useState(null);
|
|
136
|
+
React.useEffect(() => setSize(null), [visible]);
|
|
137
|
+
|
|
138
|
+
// Still rendered while closed, so the overlay can report its dismissal.
|
|
139
|
+
if (!visible) return h(Overlay, { visible: false, onDismiss });
|
|
140
|
+
const body = h(ThemeScope, { theme: panelTheme }, children);
|
|
141
|
+
const shared = { visible: true, onRequestClose, onDismiss, accessibilityLabel, testID };
|
|
142
|
+
if (closeAccessibilityLabel) shared.closeAccessibilityLabel = closeAccessibilityLabel;
|
|
143
|
+
|
|
144
|
+
if (passive && rect === UNMEASURABLE) return null;
|
|
145
|
+
if (!passive && (presentsAsSheet(Platform.OS, width) || rect === UNMEASURABLE)) {
|
|
146
|
+
const bottom = (insets && insets.bottom) || 0;
|
|
147
|
+
return h(
|
|
148
|
+
Overlay,
|
|
149
|
+
{
|
|
150
|
+
...shared,
|
|
151
|
+
presentation: "sheet",
|
|
152
|
+
style: { ...popoverPanelStyle(popover, "sheet"), padding, paddingBottom: padding + bottom },
|
|
153
|
+
},
|
|
154
|
+
body,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Placed once the panel's natural size is known, and again when it
|
|
159
|
+
// changes (content that grows must not cover the trigger); hidden until then.
|
|
160
|
+
const place =
|
|
161
|
+
rect && rect !== UNMEASURABLE && size
|
|
162
|
+
? placePopover(rect, size, { width, height }, { placement })
|
|
163
|
+
: null;
|
|
164
|
+
const onLayout = (event) => {
|
|
165
|
+
const { layout } = event.nativeEvent;
|
|
166
|
+
const border = 2 * POPOVER_BORDER_WIDTH;
|
|
167
|
+
const next = { width: layout.width + border, height: layout.height + border };
|
|
168
|
+
setSize((own) => (own && sameSize(own, next) ? own : next));
|
|
169
|
+
};
|
|
170
|
+
return h(
|
|
171
|
+
Overlay,
|
|
172
|
+
{
|
|
173
|
+
...shared,
|
|
174
|
+
passive,
|
|
175
|
+
dismissOnBackdropPress: !passive,
|
|
176
|
+
backdropAccessible: false,
|
|
177
|
+
anchoredAt: place ? { top: place.top, left: place.left } : { top: 0, left: 0 },
|
|
178
|
+
style: [
|
|
179
|
+
{ ...popoverPanelStyle(popover, "dialog"), padding: 0, minWidth, maxWidth },
|
|
180
|
+
place ? { maxHeight: place.maxHeight } : HIDDEN,
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
h(View, { onLayout, style: { padding, flexShrink: 1 } }, body),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Free content beside a trigger: a filter form, a colour swatch picker, a
|
|
189
|
+
* detail card. A press on `trigger` opens it; the backdrop, Escape (web)
|
|
190
|
+
* and the Android back button close it.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} props
|
|
193
|
+
* @param {any} props.trigger — the pressable element that opens it.
|
|
194
|
+
* @param {any|((api: {close: () => void}) => any)} props.children
|
|
195
|
+
* @param {boolean} [props.visible] — set it to control the popover.
|
|
196
|
+
* @param {(visible: boolean) => void} [props.onVisibleChange]
|
|
197
|
+
* @param {"bottom"|"top"} [props.placement] — default "bottom"; flips.
|
|
198
|
+
* @param {string} [props.accessibilityLabel] — names the panel.
|
|
199
|
+
* @param {string} [props.closeAccessibilityLabel] — the sheet backdrop's name.
|
|
200
|
+
* @param {string} [props.testID] — on the panel.
|
|
201
|
+
*/
|
|
202
|
+
function Popover({
|
|
203
|
+
trigger,
|
|
204
|
+
children,
|
|
205
|
+
visible,
|
|
206
|
+
onVisibleChange,
|
|
207
|
+
placement = "bottom",
|
|
208
|
+
accessibilityLabel,
|
|
209
|
+
closeAccessibilityLabel,
|
|
210
|
+
testID,
|
|
211
|
+
}) {
|
|
212
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
213
|
+
const anchorRef = React.useRef(null);
|
|
214
|
+
const [ownOpen, setOwnOpen] = React.useState(false);
|
|
215
|
+
const controlled = typeof visible === "boolean";
|
|
216
|
+
const open = controlled ? visible : ownOpen;
|
|
217
|
+
const setOpen = (next) => {
|
|
218
|
+
if (!controlled) setOwnOpen(next);
|
|
219
|
+
if (typeof onVisibleChange === "function") onVisibleChange(next);
|
|
220
|
+
};
|
|
221
|
+
const close = () => setOpen(false);
|
|
222
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
223
|
+
|
|
224
|
+
return h(
|
|
225
|
+
React.Fragment,
|
|
226
|
+
null,
|
|
227
|
+
triggerWith(trigger, {
|
|
228
|
+
anchorRef,
|
|
229
|
+
onOpen: () => setOpen(true),
|
|
230
|
+
extraProps: { "aria-haspopup": "dialog", "aria-expanded": open },
|
|
231
|
+
}),
|
|
232
|
+
h(
|
|
233
|
+
AnchoredSurface,
|
|
234
|
+
{
|
|
235
|
+
visible: open,
|
|
236
|
+
onRequestClose: close,
|
|
237
|
+
anchorRef,
|
|
238
|
+
placement,
|
|
239
|
+
padding: spacing.md,
|
|
240
|
+
maxWidth: POPOVER_DEFAULTS.popoverMaxWidth,
|
|
241
|
+
accessibilityLabel,
|
|
242
|
+
closeAccessibilityLabel,
|
|
243
|
+
testID,
|
|
244
|
+
},
|
|
245
|
+
typeof children === "function" ? children({ close }) : children,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
Popover.displayName = "Popover";
|
|
251
|
+
return { Popover, AnchoredSurface };
|
|
252
|
+
}
|
package/dist/popover.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// `<Popover>`, `<Menu>` and `<Tooltip>` (web). Bind ./popover-view.js,
|
|
2
|
+
// ./menu-view.js and ./tooltip-view.js to react-native-web and the web
|
|
3
|
+
// `<Overlay>`; see ./primitives.js for why not the bare specifier.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import { makePopover } from "./popover-view.js";
|
|
7
|
+
import { makeMenu } from "./menu-view.js";
|
|
8
|
+
import { makeTooltip } from "./tooltip-view.js";
|
|
9
|
+
import { Overlay } from "./overlay.js";
|
|
10
|
+
|
|
11
|
+
const primitives = {
|
|
12
|
+
View: ReactNative.View,
|
|
13
|
+
Text: ReactNative.Text,
|
|
14
|
+
Pressable: ReactNative.Pressable,
|
|
15
|
+
ScrollView: ReactNative.ScrollView,
|
|
16
|
+
Platform: ReactNative.Platform,
|
|
17
|
+
useWindowDimensions: ReactNative.useWindowDimensions,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
|
|
21
|
+
export { Popover };
|
|
22
|
+
export const Menu = makeMenu(primitives, AnchoredSurface);
|
|
23
|
+
export const Tooltip = makeTooltip(primitives, AnchoredSurface);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `<Popover>`, `<Menu>` and `<Tooltip>` (native). Bind the shared views to
|
|
2
|
+
// React Native's own primitives and the native `<Overlay>`; only the imports
|
|
3
|
+
// differ from ./popover.js, plus the safe-area inset a bottom sheet pads by.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { View, Text, Pressable, ScrollView, Platform, useWindowDimensions } from "react-native";
|
|
7
|
+
// Host-provided: the exported app pins it and mounts its provider at the root.
|
|
8
|
+
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
9
|
+
import { makePopover } from "./popover-view.js";
|
|
10
|
+
import { makeMenu } from "./menu-view.js";
|
|
11
|
+
import { makeTooltip } from "./tooltip-view.js";
|
|
12
|
+
import { Overlay } from "./overlay.native.js";
|
|
13
|
+
|
|
14
|
+
// Null with no provider above, which the view reads as "no insets".
|
|
15
|
+
const useSafeAreaInsets = () => React.useContext(SafeAreaInsetsContext);
|
|
16
|
+
|
|
17
|
+
const primitives = { View, Text, Pressable, ScrollView, Platform, useWindowDimensions, useSafeAreaInsets };
|
|
18
|
+
|
|
19
|
+
const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
|
|
20
|
+
export { Popover };
|
|
21
|
+
export const Menu = makeMenu(primitives, AnchoredSurface);
|
|
22
|
+
export const Tooltip = makeTooltip(primitives, AnchoredSurface);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The `<RadioGroup>` rules that need no renderer: which option is picked,
|
|
2
|
+
// which one holds the group's single tab stop, and where an arrow key moves
|
|
3
|
+
// the choice. Both hosts share them, and the node suite tests them.
|
|
4
|
+
|
|
5
|
+
export const RADIO_GROUP_VARIANTS = Object.freeze(["list", "card", "segmented"]);
|
|
6
|
+
export const DEFAULT_RADIO_GROUP_VARIANT = "list";
|
|
7
|
+
|
|
8
|
+
const ARROW_STEP = Object.freeze({ ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 });
|
|
9
|
+
|
|
10
|
+
/** A requested variant, or the list when it names none we draw. */
|
|
11
|
+
export function radioGroupVariant(variant) {
|
|
12
|
+
return RADIO_GROUP_VARIANTS.includes(variant) ? variant : DEFAULT_RADIO_GROUP_VARIANT;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function optionList(options) {
|
|
16
|
+
return Array.isArray(options) ? options.filter((option) => option && typeof option === "object") : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pickable(option, groupDisabled) {
|
|
20
|
+
return !groupDisabled && !option.disabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The index of the option whose `value` is `value` (strict), or -1. */
|
|
24
|
+
export function checkedIndex(options, value) {
|
|
25
|
+
return optionList(options).findIndex((option) => option.value === value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The option a Tab key lands on: the picked one when it can be picked, else
|
|
30
|
+
* the first that can, else -1 (nothing to focus).
|
|
31
|
+
*
|
|
32
|
+
* @param {Array<{value: any, disabled?: boolean}>} options
|
|
33
|
+
* @param {any} value
|
|
34
|
+
* @param {boolean} [groupDisabled]
|
|
35
|
+
*/
|
|
36
|
+
export function tabStopIndex(options, value, groupDisabled = false) {
|
|
37
|
+
const list = optionList(options);
|
|
38
|
+
const picked = checkedIndex(list, value);
|
|
39
|
+
if (picked !== -1 && pickable(list[picked], groupDisabled)) return picked;
|
|
40
|
+
return list.findIndex((option) => pickable(option, groupDisabled));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The option an arrow key moves the choice to: the next pickable one in that
|
|
45
|
+
* direction, wrapping at either end. Returns -1 for any other key, or when no
|
|
46
|
+
* other option can be picked.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<{disabled?: boolean}>} options
|
|
49
|
+
* @param {number} from — the focused option's index.
|
|
50
|
+
* @param {string} key — a KeyboardEvent `key`.
|
|
51
|
+
*/
|
|
52
|
+
export function arrowTargetIndex(options, from, key) {
|
|
53
|
+
const step = ARROW_STEP[key];
|
|
54
|
+
const list = optionList(options);
|
|
55
|
+
if (!step || list.length < 2) return -1;
|
|
56
|
+
for (let hop = 1; hop < list.length; hop += 1) {
|
|
57
|
+
const at = (((from + step * hop) % list.length) + list.length) % list.length;
|
|
58
|
+
if (pickable(list[at], false)) return at;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|