@colixsystems/widget-sdk 0.207.0 → 0.210.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 +44 -1
- package/dist/contract.cjs +37 -2
- package/dist/contract.js +37 -2
- package/dist/hooks.js +13 -0
- package/dist/host.d.ts +46 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/markdown-input-view.js +2 -3
- package/dist/markdown-input.js +4 -2
- package/dist/markdown-input.native.js +4 -2
- package/dist/markdown-link-dialog.js +94 -108
- package/dist/modal-tokens.js +107 -0
- package/dist/modal-view.js +255 -0
- package/dist/modal.js +8 -0
- package/dist/modal.native.js +45 -0
- package/dist/overlay-tokens.js +69 -5
- package/dist/overlay-view.js +52 -16
- package/dist/overlay.native.js +8 -1
- package/dist/surface-theme.js +108 -0
- package/dist/web-modal.js +24 -5
- package/package.json +1 -1
- package/src/contract.js +37 -2
package/dist/overlay-tokens.js
CHANGED
|
@@ -7,16 +7,28 @@
|
|
|
7
7
|
// thing tests can pin rather than a literal buried in JSX.
|
|
8
8
|
|
|
9
9
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
10
|
+
import { hexAlphaPercent, withHexAlpha } from "./contract.js";
|
|
11
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
10
12
|
|
|
11
13
|
export const OVERLAY_SIZES = Object.freeze(["sm", "md", "lg", "full"]);
|
|
12
14
|
|
|
15
|
+
// `dialog` is centred; `sheet` rises from the bottom edge of the screen.
|
|
16
|
+
export const OVERLAY_PRESENTATIONS = Object.freeze(["dialog", "sheet"]);
|
|
17
|
+
|
|
13
18
|
export const OVERLAY_DEFAULTS = Object.freeze({
|
|
14
19
|
size: "md",
|
|
20
|
+
presentation: "dialog",
|
|
21
|
+
elevation: "lg",
|
|
15
22
|
// Slate-black at 55%: dark enough to mute a busy page behind the panel,
|
|
16
23
|
// light enough that the page still reads as "still there, just behind".
|
|
17
24
|
scrim: "rgba(15, 23, 42, 0.55)",
|
|
18
25
|
// Breathing room between the panel and the screen edge on a phone.
|
|
19
26
|
screenPadding: 16,
|
|
27
|
+
// Scrim kept visible above a sheet, so it reads as a layer over the page.
|
|
28
|
+
sheetTopGap: 48,
|
|
29
|
+
// A themed scrim with no alpha takes the default's; none may hide the page.
|
|
30
|
+
scrimAlphaPercent: 55,
|
|
31
|
+
scrimAlphaMaxPercent: 85,
|
|
20
32
|
});
|
|
21
33
|
|
|
22
34
|
// `full` deliberately has no ceiling — a media/PDF preview wants the screen.
|
|
@@ -31,6 +43,48 @@ export function normaliseOverlaySize(size) {
|
|
|
31
43
|
return OVERLAY_SIZES.indexOf(size) === -1 ? OVERLAY_DEFAULTS.size : size;
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
export function normaliseOverlayPresentation(presentation) {
|
|
47
|
+
return OVERLAY_PRESENTATIONS.indexOf(presentation) === -1
|
|
48
|
+
? OVERLAY_DEFAULTS.presentation
|
|
49
|
+
: presentation;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The theme's validated `modal` component scope (Design > Components > Dialogs):
|
|
54
|
+
* `<Modal>` paints from all of it, `<Overlay>` from its scrim alone.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
57
|
+
* @returns {Record<string, string|number>} the set tokens; `{}` when none.
|
|
58
|
+
*/
|
|
59
|
+
export function modalThemeScope(theme) {
|
|
60
|
+
const components = theme && typeof theme === "object" ? theme.components : null;
|
|
61
|
+
return normaliseThemeComponents({ modal: components && components.modal }).modal || {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The scrim a themed colour really paints: always see-through, so a theme (or
|
|
66
|
+
* a planner) that names an opaque colour dims the page instead of hiding it.
|
|
67
|
+
*/
|
|
68
|
+
export function themedScrim(color) {
|
|
69
|
+
if (!color) return OVERLAY_DEFAULTS.scrim;
|
|
70
|
+
const alpha = hexAlphaPercent(color);
|
|
71
|
+
const { scrimAlphaPercent, scrimAlphaMaxPercent } = OVERLAY_DEFAULTS;
|
|
72
|
+
return withHexAlpha(
|
|
73
|
+
color,
|
|
74
|
+
alpha >= 100 ? scrimAlphaPercent : Math.min(alpha, scrimAlphaMaxPercent),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The panel's corners: all four for a dialog, the top two for a sheet, which
|
|
80
|
+
* meets the screen edge with square ones.
|
|
81
|
+
*/
|
|
82
|
+
export function overlayCornerStyle(radius, presentation) {
|
|
83
|
+
return presentation === "sheet"
|
|
84
|
+
? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
|
|
85
|
+
: { borderRadius: radius };
|
|
86
|
+
}
|
|
87
|
+
|
|
34
88
|
/**
|
|
35
89
|
* Resolve the values one overlay is painted with, from the workspace theme.
|
|
36
90
|
*
|
|
@@ -39,25 +93,35 @@ export function normaliseOverlaySize(size) {
|
|
|
39
93
|
*
|
|
40
94
|
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
41
95
|
* @param {string} size — `sm` | `md` | `lg` | `full`.
|
|
96
|
+
* @param {string} [presentation] — `dialog` | `sheet`; `full` is always a dialog.
|
|
42
97
|
*/
|
|
43
|
-
export function resolveOverlayTokens(theme, size) {
|
|
98
|
+
export function resolveOverlayTokens(theme, size, presentation) {
|
|
44
99
|
const safeSize = normaliseOverlaySize(size);
|
|
100
|
+
const isFull = safeSize === "full";
|
|
101
|
+
const safePresentation = isFull
|
|
102
|
+
? OVERLAY_DEFAULTS.presentation
|
|
103
|
+
: normaliseOverlayPresentation(presentation);
|
|
104
|
+
const isSheet = safePresentation === "sheet";
|
|
45
105
|
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
46
106
|
const fallback = DEFAULT_THEME_TOKENS;
|
|
47
107
|
const colors = base.colors || fallback.colors;
|
|
48
108
|
const radii = base.radii || fallback.radii;
|
|
49
109
|
const spacing = base.spacing || fallback.spacing;
|
|
50
|
-
const elevation =
|
|
110
|
+
const elevation =
|
|
111
|
+
(base.elevation || fallback.elevation)[OVERLAY_DEFAULTS.elevation] || {};
|
|
51
112
|
|
|
52
113
|
return {
|
|
53
114
|
size: safeSize,
|
|
54
|
-
|
|
115
|
+
presentation: safePresentation,
|
|
116
|
+
scrim: themedScrim(modalThemeScope(base).scrim),
|
|
55
117
|
surface: colors.surface || fallback.colors.surface,
|
|
56
|
-
radius:
|
|
118
|
+
radius: isFull ? 0 : radii.lg || fallback.radii.lg,
|
|
57
119
|
padding: spacing.lg || fallback.spacing.lg,
|
|
58
120
|
// A `full` overlay runs edge to edge; every other size keeps a margin so
|
|
59
121
|
// the scrim stays visible and the panel reads as a layer, not a screen.
|
|
60
|
-
screenPadding:
|
|
122
|
+
screenPadding: isFull || isSheet ? 0 : OVERLAY_DEFAULTS.screenPadding,
|
|
123
|
+
topGap: isSheet ? OVERLAY_DEFAULTS.sheetTopGap : 0,
|
|
124
|
+
verticalAlign: isSheet ? "flex-end" : "center",
|
|
61
125
|
maxWidth: SIZE_MAX_WIDTH[safeSize],
|
|
62
126
|
elevation,
|
|
63
127
|
};
|
package/dist/overlay-view.js
CHANGED
|
@@ -16,30 +16,51 @@
|
|
|
16
16
|
|
|
17
17
|
import React from "react";
|
|
18
18
|
import { useHostTheme } from "./hooks.js";
|
|
19
|
-
import { resolveOverlayTokens } from "./overlay-tokens.js";
|
|
19
|
+
import { overlayCornerStyle, resolveOverlayTokens } from "./overlay-tokens.js";
|
|
20
|
+
|
|
21
|
+
// Kept clickable, but out of the tab order and the accessibility tree.
|
|
22
|
+
const SILENT_BACKDROP = {
|
|
23
|
+
accessible: false,
|
|
24
|
+
focusable: false,
|
|
25
|
+
tabIndex: -1,
|
|
26
|
+
"aria-hidden": true,
|
|
27
|
+
importantForAccessibility: "no",
|
|
28
|
+
};
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* Bind the shared overlay component to one platform's primitives.
|
|
23
32
|
*
|
|
24
|
-
*
|
|
33
|
+
* `namesPanel` is for a host whose `Modal` cannot carry the accessible name
|
|
34
|
+
* (React Native's): the panel carries it there, the dialog node on the web.
|
|
35
|
+
*
|
|
36
|
+
* @param {{Modal: any, View: any, Pressable: any, StyleSheet: any,
|
|
37
|
+
* namesPanel?: boolean}} rn
|
|
25
38
|
*/
|
|
26
39
|
export function makeOverlay(rn) {
|
|
27
40
|
const { Modal, View, Pressable, StyleSheet } = rn;
|
|
41
|
+
const namesPanel = Boolean(rn.namesPanel);
|
|
28
42
|
|
|
29
43
|
function Overlay({
|
|
30
44
|
visible = false,
|
|
31
45
|
onRequestClose,
|
|
32
46
|
size = "md",
|
|
47
|
+
presentation = "dialog",
|
|
33
48
|
dismissOnBackdropPress = true,
|
|
49
|
+
backdropAccessible = true,
|
|
50
|
+
role = "dialog",
|
|
34
51
|
accessibilityLabel,
|
|
52
|
+
accessibilityLabelledBy,
|
|
35
53
|
closeAccessibilityLabel = "Close",
|
|
54
|
+
initialFocusRef,
|
|
55
|
+
panelFrame: PanelFrame,
|
|
36
56
|
style,
|
|
57
|
+
testID,
|
|
37
58
|
children,
|
|
38
59
|
}) {
|
|
39
60
|
const theme = useHostTheme();
|
|
40
61
|
const tokens = React.useMemo(
|
|
41
|
-
() => resolveOverlayTokens(theme, size),
|
|
42
|
-
[theme, size],
|
|
62
|
+
() => resolveOverlayTokens(theme, size, presentation),
|
|
63
|
+
[theme, size, presentation],
|
|
43
64
|
);
|
|
44
65
|
const requestClose = React.useCallback(() => {
|
|
45
66
|
if (typeof onRequestClose === "function") onRequestClose();
|
|
@@ -52,8 +73,9 @@ export function makeOverlay(rn) {
|
|
|
52
73
|
const backdrop = {
|
|
53
74
|
flex: 1,
|
|
54
75
|
alignItems: "center",
|
|
55
|
-
justifyContent:
|
|
76
|
+
justifyContent: tokens.verticalAlign,
|
|
56
77
|
padding: tokens.screenPadding,
|
|
78
|
+
...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
|
|
57
79
|
backgroundColor: tokens.scrim,
|
|
58
80
|
};
|
|
59
81
|
const panel = {
|
|
@@ -64,11 +86,21 @@ export function makeOverlay(rn) {
|
|
|
64
86
|
flexShrink: 1,
|
|
65
87
|
...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
|
|
66
88
|
backgroundColor: tokens.surface,
|
|
67
|
-
|
|
89
|
+
...overlayCornerStyle(tokens.radius, tokens.presentation),
|
|
68
90
|
padding: tokens.padding,
|
|
69
91
|
...tokens.elevation,
|
|
70
92
|
};
|
|
71
93
|
|
|
94
|
+
const panelNode = React.createElement(
|
|
95
|
+
View,
|
|
96
|
+
{
|
|
97
|
+
style: [panel, style],
|
|
98
|
+
accessibilityLabel: namesPanel ? accessibilityLabel : undefined,
|
|
99
|
+
testID,
|
|
100
|
+
},
|
|
101
|
+
children,
|
|
102
|
+
);
|
|
103
|
+
|
|
72
104
|
return React.createElement(
|
|
73
105
|
Modal,
|
|
74
106
|
{
|
|
@@ -77,6 +109,10 @@ export function makeOverlay(rn) {
|
|
|
77
109
|
// Not animated: a sheet must answer the tap that opened it at once.
|
|
78
110
|
animationType: "none",
|
|
79
111
|
onRequestClose: requestClose,
|
|
112
|
+
role: role === "alertdialog" ? "alertdialog" : "dialog",
|
|
113
|
+
"aria-label": accessibilityLabelledBy ? undefined : accessibilityLabel,
|
|
114
|
+
"aria-labelledby": accessibilityLabelledBy,
|
|
115
|
+
initialFocusRef,
|
|
80
116
|
},
|
|
81
117
|
React.createElement(
|
|
82
118
|
View,
|
|
@@ -85,18 +121,18 @@ export function makeOverlay(rn) {
|
|
|
85
121
|
? React.createElement(Pressable, {
|
|
86
122
|
style: StyleSheet.absoluteFill,
|
|
87
123
|
onPress: requestClose,
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
...(backdropAccessible
|
|
125
|
+
? {
|
|
126
|
+
accessibilityRole: "button",
|
|
127
|
+
accessibilityLabel: closeAccessibilityLabel,
|
|
128
|
+
}
|
|
129
|
+
: SILENT_BACKDROP),
|
|
90
130
|
})
|
|
91
131
|
: null,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
accessibilityLabel,
|
|
97
|
-
},
|
|
98
|
-
children,
|
|
99
|
-
),
|
|
132
|
+
// @internal `panelFrame`: `<Modal>` wraps its panel, never the scrim.
|
|
133
|
+
PanelFrame
|
|
134
|
+
? React.createElement(PanelFrame, { verticalAlign: tokens.verticalAlign }, panelNode)
|
|
135
|
+
: panelNode,
|
|
100
136
|
),
|
|
101
137
|
);
|
|
102
138
|
}
|
package/dist/overlay.native.js
CHANGED
|
@@ -8,4 +8,11 @@
|
|
|
8
8
|
import { Modal, View, Pressable, StyleSheet } 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
|
+
export const Overlay = makeOverlay({
|
|
13
|
+
Modal,
|
|
14
|
+
View,
|
|
15
|
+
Pressable,
|
|
16
|
+
StyleSheet,
|
|
17
|
+
namesPanel: true,
|
|
18
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// REQ-THEME-SURFACE: the theme a painted surface hands its subtree. Lives in
|
|
2
|
+
// the SDK so the web Player's containers and `<Modal>` on both hosts re-ink
|
|
3
|
+
// from one implementation.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import {
|
|
7
|
+
INK_CONTRAST,
|
|
8
|
+
deriveSurfaceTokens,
|
|
9
|
+
inkForBackdrop,
|
|
10
|
+
isHexColor,
|
|
11
|
+
} from "./contract.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* REQ-THEME-SURFACE: the token overrides a container's CHILDREN inherit.
|
|
15
|
+
*
|
|
16
|
+
* A container that paints its own `backgroundColor` becomes the surface its
|
|
17
|
+
* subtree sits on, so an unset text colour there must read against THAT fill
|
|
18
|
+
* rather than the page's. Without this, the only way to keep a heading legible
|
|
19
|
+
* inside a dark card was to write a literal hex onto the node — which is what
|
|
20
|
+
* froze a built app's appearance against its own theme.
|
|
21
|
+
*
|
|
22
|
+
* Returns the SAME reference when nothing changes, so an untouched page
|
|
23
|
+
* allocates nothing and every child sees the identical context value.
|
|
24
|
+
*
|
|
25
|
+
* A photo-backed container is deliberately NOT handled here: no colour can be
|
|
26
|
+
* derived from an image, so its subtree keeps what it inherited and the scrim
|
|
27
|
+
* plus an explicit light text colour carries the hero.
|
|
28
|
+
*
|
|
29
|
+
* The web Player calls this; the export's generated layout renderer mirrors
|
|
30
|
+
* it — the two hosts must not differ here (CLAUDE.md §8).
|
|
31
|
+
*/
|
|
32
|
+
export function surfaceAwareTheme(tenantTheme, backgroundColor) {
|
|
33
|
+
const surfaceTokens = deriveSurfaceTokens(backgroundColor);
|
|
34
|
+
if (!surfaceTokens) return tenantTheme;
|
|
35
|
+
const t = tenantTheme && typeof tenantTheme === "object" ? tenantTheme : {};
|
|
36
|
+
const typography = t.typography || null;
|
|
37
|
+
// The look's UNTOUCHED ink, carried forward so nesting stays idempotent. A
|
|
38
|
+
// band re-inks to the platform's light ink; a white card inside it must be
|
|
39
|
+
// judged against the LOOK's ink, or it can only ever land back on the
|
|
40
|
+
// platform default and the look's voice is lost one level in.
|
|
41
|
+
const base =
|
|
42
|
+
(typography && (typography.rolesBase || typography.roles)) || null;
|
|
43
|
+
// `base` when nothing needs replacing, which is what RESTORES the look's ink
|
|
44
|
+
// on a card nested inside an already-re-inked band. Falling through to the
|
|
45
|
+
// inherited `roles` there would leave the band's platform ink in place and
|
|
46
|
+
// the look could never come back.
|
|
47
|
+
const nextRoles = rolesOnSurface(base, surfaceTokens, backgroundColor) || base;
|
|
48
|
+
// sc-9346: buttons judge this fill against the surface the look was
|
|
49
|
+
// designed for, taken once at the outermost section and kept on nesting.
|
|
50
|
+
const colors = t.colors || {};
|
|
51
|
+
const backdropBase =
|
|
52
|
+
colors.backdropBase || colors.surface || DEFAULT_THEME_TOKENS.colors.surface;
|
|
53
|
+
const next = {
|
|
54
|
+
...t,
|
|
55
|
+
colors: { ...colors, ...surfaceTokens, backdrop: backgroundColor, backdropBase },
|
|
56
|
+
};
|
|
57
|
+
if (typography && nextRoles && nextRoles !== typography.roles) {
|
|
58
|
+
next.typography = { ...typography, roles: nextRoles, rolesBase: base };
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* sc-8370: re-ink the type tiers for a surface their own ink cannot be read on.
|
|
65
|
+
*
|
|
66
|
+
* `roleColor` returns a tier's explicit hex before it ever consults the surface
|
|
67
|
+
* token, so patching `colors` alone cannot move text that carries one — and
|
|
68
|
+
* since sc-8131 every look states an ink for all six tiers, chosen against that
|
|
69
|
+
* colourway's PAGE background. A section painting its own fill is a different
|
|
70
|
+
* surface, and nothing re-derived the ink for it: a headline shipped dark on a
|
|
71
|
+
* dark opener band.
|
|
72
|
+
*
|
|
73
|
+
* The gate is MEASURED CONTRAST, not a light/dark polarity test. Polarity is
|
|
74
|
+
* the tempting rule and it is wrong in both directions. The platform's own
|
|
75
|
+
* predicate flips at luminance 0.55, while the real break-even between the two
|
|
76
|
+
* inks sits near 0.20 — so every mid-tone fill in that band reads as "wants
|
|
77
|
+
* light text" when dark text is in fact the stronger choice. On `#a8a29e`, a
|
|
78
|
+
* shipped preset secondary, a polarity rule swaps a look's ink at 7.0:1 for
|
|
79
|
+
* the platform's at 2.4:1: it would have turned a passing section into an AA
|
|
80
|
+
* failure, which is the very defect this function exists to remove.
|
|
81
|
+
*
|
|
82
|
+
* So a tier is left alone whenever it is already READABLE, which is what keeps
|
|
83
|
+
* a look's voice everywhere it works, and is replaced only where it is not AND
|
|
84
|
+
* the derived token is genuinely better. A wash with no good answer — a
|
|
85
|
+
* white-to-black band, whose midpoint favours neither ink — therefore keeps
|
|
86
|
+
* what it inherited instead of committing to a colour that is wrong for half
|
|
87
|
+
* the band.
|
|
88
|
+
*
|
|
89
|
+
* Returns null when nothing moves, so the caller leaves `typography` untouched
|
|
90
|
+
* and an unaffected page keeps its identical context value.
|
|
91
|
+
*/
|
|
92
|
+
function rolesOnSurface(roles, surfaceTokens, backgroundColor) {
|
|
93
|
+
let out = null;
|
|
94
|
+
for (const [role, seed] of Object.entries(
|
|
95
|
+
DEFAULT_THEME_TOKENS.typography.roles,
|
|
96
|
+
)) {
|
|
97
|
+
const current = roles && roles[role] && roles[role].color;
|
|
98
|
+
// A tier with no frozen ink already resolves through `colors` per render.
|
|
99
|
+
if (!isHexColor(current)) continue;
|
|
100
|
+
const derived = surfaceTokens[seed.colorToken];
|
|
101
|
+
// The quiet tiers are body-sized too, so they get no softer bar.
|
|
102
|
+
const ink = inkForBackdrop(current, derived, backgroundColor, INK_CONTRAST.text);
|
|
103
|
+
if (ink === current) continue;
|
|
104
|
+
if (!out) out = { ...roles };
|
|
105
|
+
out[role] = { ...roles[role], color: derived };
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
package/dist/web-modal.js
CHANGED
|
@@ -7,7 +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
|
|
10
|
+
// `onRequestClose`, the dialog's role and name, `initialFocusRef` and
|
|
11
|
+
// `children`; it is never animated.
|
|
11
12
|
|
|
12
13
|
import React from "react";
|
|
13
14
|
import { createPortal } from "react-dom";
|
|
@@ -37,13 +38,25 @@ function tabStops(dialog) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* @param {{visible?: boolean, onRequestClose?: () => void,
|
|
41
|
+
* @param {{visible?: boolean, onRequestClose?: () => void, role?: string,
|
|
42
|
+
* "aria-label"?: string, "aria-labelledby"?: string,
|
|
43
|
+
* initialFocusRef?: {current: any}, children?: any}} props
|
|
41
44
|
*/
|
|
42
|
-
export function WebModal({
|
|
45
|
+
export function WebModal({
|
|
46
|
+
visible = true,
|
|
47
|
+
onRequestClose,
|
|
48
|
+
role = "dialog",
|
|
49
|
+
"aria-label": ariaLabel,
|
|
50
|
+
"aria-labelledby": ariaLabelledBy,
|
|
51
|
+
initialFocusRef,
|
|
52
|
+
children,
|
|
53
|
+
}) {
|
|
43
54
|
const [container, setContainer] = React.useState(null);
|
|
44
55
|
const dialogRef = React.useRef(null);
|
|
45
56
|
const onCloseRef = React.useRef(onRequestClose);
|
|
46
57
|
onCloseRef.current = onRequestClose;
|
|
58
|
+
const initialFocusRefRef = React.useRef(initialFocusRef);
|
|
59
|
+
initialFocusRefRef.current = initialFocusRef;
|
|
47
60
|
// Taken before the dialog mounts, so a child that focuses itself on mount
|
|
48
61
|
// cannot become the place focus returns to.
|
|
49
62
|
const returnFocusRef = React.useRef(null);
|
|
@@ -67,7 +80,11 @@ export function WebModal({ visible = true, onRequestClose, children }) {
|
|
|
67
80
|
openLayers.push(layer);
|
|
68
81
|
const isTop = () => openLayers[openLayers.length - 1] === layer;
|
|
69
82
|
const returnFocusTo = returnFocusRef.current;
|
|
70
|
-
|
|
83
|
+
const wanted = initialFocusRefRef.current;
|
|
84
|
+
const first = wanted && wanted.current;
|
|
85
|
+
if (first && typeof first.focus === "function") {
|
|
86
|
+
first.focus({ preventScroll: true });
|
|
87
|
+
} else if (!dialog.contains(document.activeElement)) {
|
|
71
88
|
dialog.focus({ preventScroll: true });
|
|
72
89
|
}
|
|
73
90
|
|
|
@@ -127,8 +144,10 @@ export function WebModal({ visible = true, onRequestClose, children }) {
|
|
|
127
144
|
"div",
|
|
128
145
|
{
|
|
129
146
|
ref: dialogRef,
|
|
130
|
-
role
|
|
147
|
+
role,
|
|
131
148
|
"aria-modal": "true",
|
|
149
|
+
"aria-label": ariaLabel,
|
|
150
|
+
"aria-labelledby": ariaLabelledBy,
|
|
132
151
|
tabIndex: -1,
|
|
133
152
|
style: layerStyle,
|
|
134
153
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.210.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",
|
package/src/contract.js
CHANGED
|
@@ -723,6 +723,25 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
723
723
|
}),
|
|
724
724
|
targets: Object.freeze({}),
|
|
725
725
|
}),
|
|
726
|
+
// Dialogs: `<Modal>` reads this scope directly, so it names the SDK component
|
|
727
|
+
// it styles and binds no widget field. `<Overlay>` reads the scrim alone.
|
|
728
|
+
modal: Object.freeze({
|
|
729
|
+
label: "Dialogs",
|
|
730
|
+
sdkComponents: Object.freeze(["Modal"]),
|
|
731
|
+
tokens: Object.freeze({
|
|
732
|
+
background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
|
|
733
|
+
titleColor: Object.freeze({ type: "color" }),
|
|
734
|
+
scrim: Object.freeze({
|
|
735
|
+
type: "color",
|
|
736
|
+
translucent: true,
|
|
737
|
+
note: "also the backdrop behind every <Overlay>",
|
|
738
|
+
}),
|
|
739
|
+
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.lg" }),
|
|
740
|
+
padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.lg" }),
|
|
741
|
+
shadow: Object.freeze({ type: "shadow" }),
|
|
742
|
+
}),
|
|
743
|
+
targets: Object.freeze({}),
|
|
744
|
+
}),
|
|
726
745
|
});
|
|
727
746
|
|
|
728
747
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -2886,7 +2905,15 @@ const PRIMITIVES = [
|
|
|
2886
2905
|
{
|
|
2887
2906
|
name: "Overlay",
|
|
2888
2907
|
description:
|
|
2889
|
-
'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a
|
|
2908
|
+
'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget and is not a dialog — a preview (PDF, image, video), a lightbox, a full-screen viewer. A dialog (a title, a close button, actions) is `<Modal>`, which is built on this. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (web only), `accessibilityLabel` (names the dialog), `testID`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme (the scrim from Design > Components > Dialogs) — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
|
|
2909
|
+
rnComponent: null,
|
|
2910
|
+
docsUrl: null,
|
|
2911
|
+
},
|
|
2912
|
+
// The standard dialog, built on `<Overlay>`, so widgets stop hand-building one.
|
|
2913
|
+
{
|
|
2914
|
+
name: "Modal",
|
|
2915
|
+
description:
|
|
2916
|
+
'Standard dialog. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<><Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} /><Button title="Save" onPress={save} /></>}>…body…</Modal>`. THE way to open a dialog, a form in a pop-up, a detail panel or a bottom sheet: it draws the title, the close button, a body that scrolls when it is long, and a footer action row laid out below it, and its background, corner radius, padding, scrim, shadow and title colour come from the theme (Design > Components > Dialogs), so every dialog in the app matches. It is built on `<Overlay>`, so it renders OUTSIDE the widget layout box on both hosts and nothing can clip it. Never hand-build a header, a close button or an action row inside a bare `<Overlay>`; keep `<Overlay>` for surfaces that are not a dialog (a media or document preview, a lightbox). Props: `visible` (boolean), `onRequestClose` (fired by the close button, the backdrop press, the Escape key on web and the Android back button — always wire it), `dismissible` (default true; false hides the close button and stops the backdrop, Escape and Android back from closing it, for a dialog that must stay open while it saves — only while that work runs, and always restore it, or nobody can close the dialog), `title` (string), `footer` (the action buttons, laid out as one right-aligned row that wraps on a phone; omit for no footer), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge; keep a form with text inputs in "dialog" — in the exported app the keyboard handling of a sheet is not yet verified on a device), `size` ("sm" | "md" | "lg" | "full", default "md"), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (a ref to the control focused on open; web only), `closeAccessibilityLabel` (default "Close" — pass your translated label), `accessibilityLabel` (names a dialog with no title) and `testID`. With no `onRequestClose` it draws no close button. Components rendered inside it read a theme re-inked for the background the dialog paints, so build the body as its own component. Do not wrap the body in your own `ScrollView` and do not re-style the panel.',
|
|
2890
2917
|
rnComponent: null,
|
|
2891
2918
|
docsUrl: null,
|
|
2892
2919
|
},
|
|
@@ -5550,7 +5577,15 @@ const CONTRACT = deepFreeze({
|
|
|
5550
5577
|
// 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
|
|
5551
5578
|
// error, warning, success or notice) and the `alert` component scope it
|
|
5552
5579
|
// reads.
|
|
5553
|
-
|
|
5580
|
+
// 1.169.0: additive (sc-10924) — the `Modal` primitive: the standard dialog
|
|
5581
|
+
// (title, close button, scrolling body, footer action row; `presentation`
|
|
5582
|
+
// "dialog" | "sheet") built on `Overlay`. New `themeComponents.modal`
|
|
5583
|
+
// scope (background, titleColor, scrim, radius, padding, shadow), read by
|
|
5584
|
+
// `Modal`; `Overlay` reads its `scrim` and gains `presentation`, `role`,
|
|
5585
|
+
// `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
|
|
5586
|
+
// name `sdkComponents` and bind no widget field; a colour token may be
|
|
5587
|
+
// `translucent` (always painted see-through) and carry a `note`.
|
|
5588
|
+
version: "1.169.0",
|
|
5554
5589
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5555
5590
|
hooks: HOOKS,
|
|
5556
5591
|
primitives: PRIMITIVES,
|