@colixsystems/widget-sdk 0.196.0 → 0.198.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.
@@ -6,7 +6,7 @@
6
6
  // (`overflow: hidden` cards, scroll containers) clip it and where React Native
7
7
  // has no `position: "fixed"` at all. `<Overlay>` fixes that for both hosts at
8
8
  // once by rendering into React Native's `Modal`, which leaves the parent's
9
- // layout on BOTH platforms — react-native-web portals it to `document.body`,
9
+ // layout on BOTH platforms — the web binding portals it to `document.body`,
10
10
  // native hands it to the OS — while keeping the children in the widget's own
11
11
  // React tree, so their state, hooks and WidgetContext all survive.
12
12
  //
@@ -74,10 +74,7 @@ export function makeOverlay(rn) {
74
74
  {
75
75
  visible: true,
76
76
  transparent: true,
77
- // Not animated on purpose: the web host arms the overlay's dialog role
78
- // and its Escape handler when the entrance animation ENDS, so a fade
79
- // leaves a window where the keyboard cannot dismiss it — and a dropped
80
- // animation event would leave it un-dismissable for good.
77
+ // Not animated: a sheet must answer the tap that opened it at once.
81
78
  animationType: "none",
82
79
  onRequestClose: requestClose,
83
80
  },
package/dist/overlay.js CHANGED
@@ -1,13 +1,20 @@
1
1
  // sc-6607 — `<Overlay>` (web). Binds the shared implementation in
2
- // ./overlay-view.js to react-native-web's primitives.
2
+ // ./overlay-view.js to react-native-web's primitives, with ./web-modal.js in
3
+ // place of react-native-web's `Modal`, which breaks under React StrictMode.
3
4
  //
4
5
  // The import is `react-native-web` rather than the bare `react-native`
5
6
  // specifier for the rolldown optional-peer-dep reason spelled out at the top of
6
- // ./primitives.js. react-native-web's `Modal` portals its subtree into a
7
- // `document.body` container at `position: fixed`, which is exactly what makes
8
- // the overlay escape the widget's clipping ancestors.
7
+ // ./primitives.js. The modal layer portals its subtree into a `document.body`
8
+ // container at `position: fixed`, which is exactly what makes the overlay
9
+ // escape the widget's clipping ancestors.
9
10
 
10
11
  import * as ReactNative from "react-native-web";
11
12
  import { makeOverlay } from "./overlay-view.js";
13
+ import { WebModal } from "./web-modal.js";
12
14
 
13
- export const Overlay = makeOverlay(ReactNative);
15
+ export const Overlay = makeOverlay({
16
+ Modal: WebModal,
17
+ View: ReactNative.View,
18
+ Pressable: ReactNative.Pressable,
19
+ StyleSheet: ReactNative.StyleSheet,
20
+ });
@@ -80,3 +80,5 @@ export { FilePicker } from "./filepicker.js";
80
80
  // `linear-gradient` on a View; the native build (./gradient.native.js) delegates
81
81
  // to expo-linear-gradient through the same angle projection.
82
82
  export { Gradient } from "./gradient.js";
83
+ // sc-10268 — `<Video>` (web): the browser's <video>, inline with controls.
84
+ export { Video } from "./video.js";
@@ -40,3 +40,5 @@ export { FilePicker } from "./filepicker.native.js";
40
40
  // expo-linear-gradient (pinned unconditionally in every export); the web build
41
41
  // (./gradient.js) paints the equivalent CSS `linear-gradient`.
42
42
  export { Gradient } from "./gradient.native.js";
43
+ // sc-10268 — `<Video>` (native): expo-video's VideoView.
44
+ export { Video } from "./video.native.js";
package/dist/video.js ADDED
@@ -0,0 +1,51 @@
1
+ // sc-10268 — `<Video>` (web).
2
+ //
3
+ // Plays a video file inline: the browser's own <video> element with its own
4
+ // controls. The native counterpart (./video.native.js) is expo-video's
5
+ // VideoView over the same props, so a widget plays video on both hosts with
6
+ // no platform split.
7
+ //
8
+ // `preload="metadata"` plus a `#t=` fragment paints the first frame as the
9
+ // thumbnail; Safari draws nothing before playback without the fragment.
10
+
11
+ import React from "react";
12
+ import * as ReactNative from "react-native-web";
13
+
14
+ const FIRST_FRAME = "#t=0.001";
15
+ const FITS = { contain: "contain", cover: "cover" };
16
+
17
+ export function Video({
18
+ source,
19
+ controls = true,
20
+ autoPlay = false,
21
+ contentFit = "contain",
22
+ style,
23
+ accessibilityLabel,
24
+ testID,
25
+ onError,
26
+ }) {
27
+ const uri = typeof source === "string" && source ? source : null;
28
+ return React.createElement(
29
+ ReactNative.View,
30
+ { style, testID },
31
+ uri
32
+ ? React.createElement("video", {
33
+ src: uri.includes("#") ? uri : `${uri}${FIRST_FRAME}`,
34
+ controls: Boolean(controls),
35
+ // Browsers only autoplay muted video.
36
+ autoPlay: Boolean(autoPlay),
37
+ muted: Boolean(autoPlay),
38
+ playsInline: true,
39
+ preload: "metadata",
40
+ "aria-label": accessibilityLabel,
41
+ onError: onError ? () => onError() : undefined,
42
+ style: {
43
+ width: "100%",
44
+ height: "100%",
45
+ objectFit: FITS[contentFit] || FITS.contain,
46
+ display: "block",
47
+ },
48
+ })
49
+ : null,
50
+ );
51
+ }
@@ -0,0 +1,61 @@
1
+ // sc-10268 — `<Video>` (native).
2
+ //
3
+ // expo-video's VideoView (AVPlayer on iOS, ExoPlayer on Android), pinned in
4
+ // every export (BASE_DEPENDENCIES in
5
+ // backend/src/core/services/export-dependencies.ts), so Metro always resolves
6
+ // it. The web counterpart (./video.js) is the browser's <video> over the same
7
+ // props; keep the two in lockstep.
8
+
9
+ import React, { useEffect } from "react";
10
+ import { View, StyleSheet } from "react-native";
11
+ import { VideoView, useVideoPlayer } from "expo-video";
12
+
13
+ const FITS = { contain: "contain", cover: "cover" };
14
+
15
+ export function Video({
16
+ source,
17
+ controls = true,
18
+ autoPlay = false,
19
+ contentFit = "contain",
20
+ style,
21
+ accessibilityLabel,
22
+ testID,
23
+ onError,
24
+ }) {
25
+ const uri = typeof source === "string" && source ? source : null;
26
+ // A bare string keeps the source stable, so the player is not recreated
27
+ // every render; null is an idle player.
28
+ const player = useVideoPlayer(uri);
29
+ // An effect rather than the setup callback, so turning autoPlay on later
30
+ // still starts it. Muted, as the web host must be to autoplay.
31
+ useEffect(() => {
32
+ if (!uri || !autoPlay) return;
33
+ try {
34
+ player.muted = true;
35
+ player.play();
36
+ } catch (_) {
37
+ // A released player has nothing to start.
38
+ }
39
+ }, [player, uri, autoPlay]);
40
+ useEffect(() => {
41
+ if (!onError) return undefined;
42
+ const subscription = player.addListener("statusChange", (event) => {
43
+ if (event && event.status === "error") onError();
44
+ });
45
+ return () => subscription.remove();
46
+ }, [player, onError]);
47
+ return React.createElement(
48
+ View,
49
+ { style, testID },
50
+ uri
51
+ ? React.createElement(VideoView, {
52
+ player,
53
+ nativeControls: Boolean(controls),
54
+ contentFit: FITS[contentFit] || FITS.contain,
55
+ allowsFullscreen: true,
56
+ accessibilityLabel,
57
+ style: StyleSheet.absoluteFill,
58
+ })
59
+ : null,
60
+ );
61
+ }
@@ -0,0 +1,139 @@
1
+ // The web `<Overlay>`'s modal layer: a body-level portal that owns its
2
+ // container in a layout effect.
3
+ //
4
+ // react-native-web's `Modal` creates its container during render and removes it
5
+ // in an effect cleanup. React StrictMode's mount → cleanup → mount detaches that
6
+ // container, so the overlay rendered into an off-page node until some unrelated
7
+ // re-render rebuilt it, and its Escape and focus handling never re-armed.
8
+ //
9
+ // Implements only the `Modal` props `<Overlay>` passes: `visible`,
10
+ // `onRequestClose` and `children`; it is never animated.
11
+
12
+ import React from "react";
13
+ import { createPortal } from "react-dom";
14
+
15
+ const FOCUSABLE =
16
+ 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
17
+
18
+ const layerStyle = {
19
+ position: "fixed",
20
+ top: 0,
21
+ right: 0,
22
+ bottom: 0,
23
+ left: 0,
24
+ zIndex: 9999,
25
+ display: "flex",
26
+ flexDirection: "column",
27
+ outline: "none",
28
+ };
29
+
30
+ // Open layers, innermost last: only the top one handles Escape and Tab.
31
+ const openLayers = [];
32
+
33
+ function tabStops(dialog) {
34
+ return Array.from(dialog.querySelectorAll(FOCUSABLE)).filter(
35
+ (node) => node.getAttribute("aria-hidden") !== "true",
36
+ );
37
+ }
38
+
39
+ /**
40
+ * @param {{visible?: boolean, onRequestClose?: () => void, children?: any}} props
41
+ */
42
+ export function WebModal({ visible = true, onRequestClose, children }) {
43
+ const [container, setContainer] = React.useState(null);
44
+ const dialogRef = React.useRef(null);
45
+ const onCloseRef = React.useRef(onRequestClose);
46
+ onCloseRef.current = onRequestClose;
47
+ // Taken before the dialog mounts, so a child that focuses itself on mount
48
+ // cannot become the place focus returns to.
49
+ const returnFocusRef = React.useRef(null);
50
+
51
+ React.useLayoutEffect(() => {
52
+ if (!visible) return undefined;
53
+ returnFocusRef.current = document.activeElement;
54
+ const node = document.createElement("div");
55
+ document.body.appendChild(node);
56
+ setContainer(node);
57
+ return () => {
58
+ node.remove();
59
+ setContainer(null);
60
+ };
61
+ }, [visible]);
62
+
63
+ React.useEffect(() => {
64
+ const dialog = dialogRef.current;
65
+ if (!container || !dialog) return undefined;
66
+ const layer = {};
67
+ openLayers.push(layer);
68
+ const isTop = () => openLayers[openLayers.length - 1] === layer;
69
+ const returnFocusTo = returnFocusRef.current;
70
+ if (!dialog.contains(document.activeElement)) {
71
+ dialog.focus({ preventScroll: true });
72
+ }
73
+
74
+ // keyup, as react-native-web's Modal did, so the keydown that opened a
75
+ // nested overlay cannot also close it.
76
+ const onKeyUp = (event) => {
77
+ if (event.key !== "Escape" || !isTop()) return;
78
+ event.stopPropagation();
79
+ if (typeof onCloseRef.current === "function") onCloseRef.current();
80
+ };
81
+ const onKeyDown = (event) => {
82
+ if (event.key !== "Tab" || !isTop()) return;
83
+ const stops = tabStops(dialog);
84
+ if (!stops.length) {
85
+ event.preventDefault();
86
+ return;
87
+ }
88
+ const first = stops[0];
89
+ const last = stops[stops.length - 1];
90
+ const current = document.activeElement;
91
+ const outside = !dialog.contains(current) || current === dialog;
92
+ if (event.shiftKey && (outside || current === first)) {
93
+ event.preventDefault();
94
+ last.focus();
95
+ } else if (!event.shiftKey && (outside || current === last)) {
96
+ event.preventDefault();
97
+ first.focus();
98
+ }
99
+ };
100
+ const onFocusIn = (event) => {
101
+ if (isTop() && !dialog.contains(event.target)) {
102
+ dialog.focus({ preventScroll: true });
103
+ }
104
+ };
105
+ document.addEventListener("keyup", onKeyUp);
106
+ document.addEventListener("keydown", onKeyDown);
107
+ document.addEventListener("focusin", onFocusIn);
108
+ return () => {
109
+ document.removeEventListener("keyup", onKeyUp);
110
+ document.removeEventListener("keydown", onKeyDown);
111
+ document.removeEventListener("focusin", onFocusIn);
112
+ const index = openLayers.indexOf(layer);
113
+ if (index !== -1) openLayers.splice(index, 1);
114
+ if (
115
+ returnFocusTo &&
116
+ typeof returnFocusTo.focus === "function" &&
117
+ returnFocusTo.isConnected
118
+ ) {
119
+ returnFocusTo.focus({ preventScroll: true });
120
+ }
121
+ };
122
+ }, [container]);
123
+
124
+ if (!visible || !container) return null;
125
+ return createPortal(
126
+ React.createElement(
127
+ "div",
128
+ {
129
+ ref: dialogRef,
130
+ role: "dialog",
131
+ "aria-modal": "true",
132
+ tabIndex: -1,
133
+ style: layerStyle,
134
+ },
135
+ children,
136
+ ),
137
+ container,
138
+ );
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.196.0",
3
+ "version": "0.198.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",
@@ -62,6 +62,7 @@
62
62
  "peerDependencies": {
63
63
  "react": ">=18.0.0",
64
64
  "react-native": "*",
65
+ "react-dom": ">=18.0.0",
65
66
  "react-native-web": ">=0.19.0"
66
67
  },
67
68
  "peerDependenciesMeta": {
@@ -73,6 +74,9 @@
73
74
  },
74
75
  "react-native-web": {
75
76
  "optional": true
77
+ },
78
+ "react-dom": {
79
+ "optional": true
76
80
  }
77
81
  },
78
82
  "optionalDependencies": {