@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.
@@ -0,0 +1,137 @@
1
+ // The ONE implementation behind `<Tooltip>`: a short label for the control it
2
+ // wraps, usually an icon-only button. On the web it shows on hover and on
3
+ // keyboard focus; on a touch screen, which has no hover, a long press shows
4
+ // it instead (a sheet on a device, as `<Menu>` is).
5
+
6
+ import React from "react";
7
+ import { useHostTheme } from "./hooks.js";
8
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
9
+ import { isText } from "./form-control-logic.js";
10
+ import { POPOVER_DEFAULTS, resolvePopoverTokens } from "./popover-tokens.js";
11
+ import { elementRef, mergeRefs } from "./popover-view.js";
12
+
13
+ const h = React.createElement;
14
+
15
+ /**
16
+ * Bind the shared tooltip to one platform's primitives and anchored surface.
17
+ *
18
+ * @param {{View: any, Text: any}} rn
19
+ * @param {any} AnchoredSurface — from `makePopover()` for the same host.
20
+ */
21
+ export function makeTooltip(rn, AnchoredSurface) {
22
+ const { View, Text } = rn;
23
+
24
+ // "hover" (pointer or keyboard focus) or "press" (a long press), else null.
25
+ function useTooltipMode() {
26
+ const [mode, setMode] = React.useState(null);
27
+ const timer = React.useRef(null);
28
+ const cancel = () => clearTimeout(timer.current);
29
+ React.useEffect(() => cancel, []);
30
+ const after = (ms, next) => {
31
+ cancel();
32
+ timer.current = setTimeout(() => setMode(next), ms);
33
+ };
34
+ const ifHover = (next) => (own) => (own === "press" ? own : next);
35
+ return {
36
+ mode,
37
+ hoverIn: () => after(POPOVER_DEFAULTS.tooltipShowDelayMs, ifHover("hover")),
38
+ hoverOut: () => after(POPOVER_DEFAULTS.tooltipHideDelayMs, ifHover(null)),
39
+ stay: cancel,
40
+ focus: () => {
41
+ cancel();
42
+ setMode(ifHover("hover"));
43
+ },
44
+ blur: () => {
45
+ cancel();
46
+ setMode(ifHover(null));
47
+ },
48
+ press: () => {
49
+ cancel();
50
+ setMode("press");
51
+ },
52
+ close: () => {
53
+ cancel();
54
+ setMode(null);
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * A short label for the control it wraps. Wrap one pressable element (a
61
+ * `Pressable`, an icon button) that already carries its own
62
+ * `accessibilityLabel`: the tooltip shows that name to sighted users.
63
+ * A child with its own `onLongPress` keeps it, and gets no long-press
64
+ * tooltip.
65
+ *
66
+ * @param {object} props
67
+ * @param {string} props.label — one short phrase.
68
+ * @param {"top"|"bottom"} [props.placement] — default "top"; flips.
69
+ * @param {string} [props.testID] — on the label.
70
+ * @param {any} props.children — the one element it labels.
71
+ */
72
+ const Tooltip = React.forwardRef(function Tooltip(
73
+ { label, placement = "top", testID, children, ...passThrough },
74
+ forwardedRef,
75
+ ) {
76
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
77
+ const anchorRef = React.useRef(null);
78
+ const labelId = React.useId();
79
+ const tip = useTooltipMode();
80
+ if (!React.isValidElement(children)) return children ?? null;
81
+ if (!isText(label) || label === "") return children;
82
+
83
+ const popover = resolvePopoverTokens(theme);
84
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
85
+ const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
86
+ const ownLongPress = typeof children.props.onLongPress === "function";
87
+ const child = React.cloneElement(children, {
88
+ ...passThrough,
89
+ ref: mergeRefs(elementRef(children), anchorRef, forwardedRef),
90
+ ...(ownLongPress ? null : { onLongPress: tip.press }),
91
+ ...(tip.mode ? { "aria-describedby": labelId } : null),
92
+ });
93
+
94
+ return h(
95
+ View,
96
+ { onMouseEnter: tip.hoverIn, onMouseLeave: tip.hoverOut, onFocus: tip.focus, onBlur: tip.blur },
97
+ child,
98
+ h(
99
+ AnchoredSurface,
100
+ {
101
+ visible: tip.mode !== null,
102
+ onRequestClose: tip.close,
103
+ anchorRef,
104
+ placement,
105
+ passive: tip.mode === "hover",
106
+ maxWidth: POPOVER_DEFAULTS.tooltipMaxWidth,
107
+ accessibilityLabel: String(label),
108
+ },
109
+ h(
110
+ View,
111
+ {
112
+ nativeID: labelId,
113
+ role: "tooltip",
114
+ onMouseEnter: tip.stay,
115
+ onMouseLeave: tip.hoverOut,
116
+ testID,
117
+ style: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs },
118
+ },
119
+ h(
120
+ Text,
121
+ {
122
+ style: {
123
+ fontFamily: typography.fontFamily,
124
+ fontSize: typography.sizes.sm,
125
+ color: popover.textColor,
126
+ },
127
+ },
128
+ String(label),
129
+ ),
130
+ ),
131
+ ),
132
+ );
133
+ });
134
+
135
+ Tooltip.displayName = "Tooltip";
136
+ return Tooltip;
137
+ }
package/dist/web-modal.js CHANGED
@@ -7,8 +7,8 @@
7
7
  // re-render rebuilt it, and its Escape and focus handling never re-armed.
8
8
  //
9
9
  // Implements only the `Modal` props `<Overlay>` passes: `visible`,
10
- // `onRequestClose`, the dialog's role and name, `initialFocusRef` and
11
- // `children`; it is never animated.
10
+ // `onRequestClose`, the dialog's role and name, `initialFocusRef`, `passive`,
11
+ // `closesOnOutsideScroll` and `children`; it is never animated.
12
12
 
13
13
  import React from "react";
14
14
  import { createPortal } from "react-dom";
@@ -28,30 +28,111 @@ const layerStyle = {
28
28
  outline: "none",
29
29
  };
30
30
 
31
- // Open layers, innermost last: only the top one handles Escape and Tab.
31
+ // Open layers, innermost last. The top one handles Escape; the top modal
32
+ // one traps Tab and focus, as a tooltip above it takes neither.
32
33
  const openLayers = [];
33
34
 
35
+ function openLayer(passive) {
36
+ const layer = { passive };
37
+ openLayers.push(layer);
38
+ return {
39
+ isTop: () => openLayers[openLayers.length - 1] === layer,
40
+ isTopModal: () => openLayers.filter((open) => !open.passive).pop() === layer,
41
+ close: () => {
42
+ const index = openLayers.indexOf(layer);
43
+ if (index !== -1) openLayers.splice(index, 1);
44
+ },
45
+ };
46
+ }
47
+
48
+ // An anchored panel would drift from its trigger, so a scroll beneath closes it.
49
+ function useCloseOnOutsideScroll(container, enabled, onCloseRef) {
50
+ React.useEffect(() => {
51
+ if (!container || !enabled) return undefined;
52
+ const onScroll = (event) => {
53
+ const target = event.target;
54
+ if (target instanceof Node && container.contains(target)) return;
55
+ if (typeof onCloseRef.current === "function") onCloseRef.current();
56
+ };
57
+ document.addEventListener("scroll", onScroll, { capture: true, passive: true });
58
+ return () => document.removeEventListener("scroll", onScroll, { capture: true });
59
+ }, [container, enabled, onCloseRef]);
60
+ }
61
+
34
62
  function tabStops(dialog) {
35
63
  return Array.from(dialog.querySelectorAll(FOCUSABLE)).filter(
36
64
  (node) => node.getAttribute("aria-hidden") !== "true",
37
65
  );
38
66
  }
39
67
 
68
+ const passiveLayerStyle = { ...layerStyle, pointerEvents: "none" };
69
+
70
+ // Owns a body-level container while mounted, as the modal layer does.
71
+ function useBodyContainer(visible) {
72
+ const [container, setContainer] = React.useState(null);
73
+ React.useLayoutEffect(() => {
74
+ if (!visible) return undefined;
75
+ const node = document.createElement("div");
76
+ document.body.appendChild(node);
77
+ setContainer(node);
78
+ return () => {
79
+ node.remove();
80
+ setContainer(null);
81
+ };
82
+ }, [visible]);
83
+ return container;
84
+ }
85
+
86
+ // A tooltip's layer: no focus trap, no modal semantics, the pointer passes
87
+ // through. Escape dismisses it while it is the top layer, ahead of any
88
+ // dialog beneath; a dialog opened above it takes Escape first.
89
+ function PassiveLayer({ visible = true, onRequestClose, closesOnOutsideScroll, children }) {
90
+ const container = useBodyContainer(visible);
91
+ const onCloseRef = React.useRef(onRequestClose);
92
+ onCloseRef.current = onRequestClose;
93
+ useCloseOnOutsideScroll(container, closesOnOutsideScroll, onCloseRef);
94
+
95
+ React.useEffect(() => {
96
+ if (!container) return undefined;
97
+ const layer = openLayer(true);
98
+ const onKeyUp = (event) => {
99
+ if (event.key !== "Escape" || !layer.isTop()) return;
100
+ event.stopPropagation();
101
+ if (typeof onCloseRef.current === "function") onCloseRef.current();
102
+ };
103
+ document.addEventListener("keyup", onKeyUp);
104
+ return () => {
105
+ document.removeEventListener("keyup", onKeyUp);
106
+ layer.close();
107
+ };
108
+ }, [container]);
109
+
110
+ if (!visible || !container) return null;
111
+ return createPortal(
112
+ React.createElement("div", { style: passiveLayerStyle }, children),
113
+ container,
114
+ );
115
+ }
116
+
40
117
  /**
41
118
  * @param {{visible?: boolean, onRequestClose?: () => void, role?: string,
42
119
  * "aria-label"?: string, "aria-labelledby"?: string,
43
- * initialFocusRef?: {current: any}, children?: any}} props
120
+ * initialFocusRef?: {current: any}, passive?: boolean, children?: any}} props
44
121
  */
45
- export function WebModal({
122
+ export function WebModal(props) {
123
+ return React.createElement(props.passive ? PassiveLayer : ModalLayer, props);
124
+ }
125
+
126
+ function ModalLayer({
46
127
  visible = true,
47
128
  onRequestClose,
48
129
  role = "dialog",
49
130
  "aria-label": ariaLabel,
50
131
  "aria-labelledby": ariaLabelledBy,
51
132
  initialFocusRef,
133
+ closesOnOutsideScroll,
52
134
  children,
53
135
  }) {
54
- const [container, setContainer] = React.useState(null);
55
136
  const dialogRef = React.useRef(null);
56
137
  const onCloseRef = React.useRef(onRequestClose);
57
138
  onCloseRef.current = onRequestClose;
@@ -62,23 +143,15 @@ export function WebModal({
62
143
  const returnFocusRef = React.useRef(null);
63
144
 
64
145
  React.useLayoutEffect(() => {
65
- if (!visible) return undefined;
66
- returnFocusRef.current = document.activeElement;
67
- const node = document.createElement("div");
68
- document.body.appendChild(node);
69
- setContainer(node);
70
- return () => {
71
- node.remove();
72
- setContainer(null);
73
- };
146
+ if (visible) returnFocusRef.current = document.activeElement;
74
147
  }, [visible]);
148
+ const container = useBodyContainer(visible);
149
+ useCloseOnOutsideScroll(container, closesOnOutsideScroll, onCloseRef);
75
150
 
76
151
  React.useEffect(() => {
77
152
  const dialog = dialogRef.current;
78
153
  if (!container || !dialog) return undefined;
79
- const layer = {};
80
- openLayers.push(layer);
81
- const isTop = () => openLayers[openLayers.length - 1] === layer;
154
+ const layer = openLayer(false);
82
155
  const returnFocusTo = returnFocusRef.current;
83
156
  const wanted = initialFocusRefRef.current;
84
157
  const first = wanted && wanted.current;
@@ -91,12 +164,12 @@ export function WebModal({
91
164
  // keyup, as react-native-web's Modal did, so the keydown that opened a
92
165
  // nested overlay cannot also close it.
93
166
  const onKeyUp = (event) => {
94
- if (event.key !== "Escape" || !isTop()) return;
167
+ if (event.key !== "Escape" || !layer.isTop()) return;
95
168
  event.stopPropagation();
96
169
  if (typeof onCloseRef.current === "function") onCloseRef.current();
97
170
  };
98
171
  const onKeyDown = (event) => {
99
- if (event.key !== "Tab" || !isTop()) return;
172
+ if (event.key !== "Tab" || !layer.isTopModal()) return;
100
173
  const stops = tabStops(dialog);
101
174
  if (!stops.length) {
102
175
  event.preventDefault();
@@ -115,7 +188,7 @@ export function WebModal({
115
188
  }
116
189
  };
117
190
  const onFocusIn = (event) => {
118
- if (isTop() && !dialog.contains(event.target)) {
191
+ if (layer.isTopModal() && !dialog.contains(event.target)) {
119
192
  dialog.focus({ preventScroll: true });
120
193
  }
121
194
  };
@@ -126,8 +199,7 @@ export function WebModal({
126
199
  document.removeEventListener("keyup", onKeyUp);
127
200
  document.removeEventListener("keydown", onKeyDown);
128
201
  document.removeEventListener("focusin", onFocusIn);
129
- const index = openLayers.indexOf(layer);
130
- if (index !== -1) openLayers.splice(index, 1);
202
+ layer.close();
131
203
  if (
132
204
  returnFocusTo &&
133
205
  typeof returnFocusTo.focus === "function" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.245.0",
3
+ "version": "0.246.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",