@colixsystems/widget-sdk 0.244.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 +42 -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/contract.cjs +90 -35
- package/dist/contract.js +90 -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/index.d.ts +258 -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 +90 -35
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The pure rules behind `<FileImage>` and `<Avatar fileId>`: which rung of a
|
|
2
|
+
// stored file's size ladder to load, how the box is shaped, and the load state
|
|
3
|
+
// machine that retries an expired signed URL once. Renderer-free, so the node
|
|
4
|
+
// suite can pin every decision.
|
|
5
|
+
|
|
6
|
+
export const FILE_IMAGE_VARIANTS = Object.freeze(["thumbnail", "card", "large", "hero", "original"]);
|
|
7
|
+
export const DEFAULT_FILE_IMAGE_VARIANT = "large";
|
|
8
|
+
export const FILE_IMAGE_FALLBACKS = Object.freeze(["icon", "blank"]);
|
|
9
|
+
export const FILE_IMAGE_RESIZE_MODES = Object.freeze(["cover", "contain", "stretch", "center"]);
|
|
10
|
+
export const DEFAULT_FILE_IMAGE_ASPECT_RATIO = 4 / 3;
|
|
11
|
+
export const FILE_IMAGE_RETRIES = 1;
|
|
12
|
+
export const FILE_IMAGE_ICON_MAX = 24;
|
|
13
|
+
|
|
14
|
+
const ASPECT_RATIO_BOUNDS = Object.freeze({ min: 0.1, max: 10 });
|
|
15
|
+
const ICON_MIN = 12;
|
|
16
|
+
const ICON_SHARE = 0.6;
|
|
17
|
+
// The thumbnail rung's edge; an avatar this small at 2x density fits in it.
|
|
18
|
+
const THUMBNAIL_EDGE_PX = 128;
|
|
19
|
+
|
|
20
|
+
function clamp(n, { min, max }) {
|
|
21
|
+
return Math.min(max, Math.max(min, n));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function usableUrl(value) {
|
|
25
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function positive(n) {
|
|
29
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normaliseFileImageVariant(variant) {
|
|
33
|
+
return FILE_IMAGE_VARIANTS.includes(variant) ? variant : DEFAULT_FILE_IMAGE_VARIANT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normaliseFileImageFallback(fallback) {
|
|
37
|
+
return FILE_IMAGE_FALLBACKS.includes(fallback) ? fallback : "icon";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normaliseFileImageResizeMode(mode) {
|
|
41
|
+
return FILE_IMAGE_RESIZE_MODES.includes(mode) ? mode : "cover";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The URL a variant loads: its rung of the size ladder, else the original
|
|
46
|
+
* (a file projected before the ladder shipped has no `urls`).
|
|
47
|
+
*
|
|
48
|
+
* @param {{ thumbnail?: string, card?: string, large?: string, hero?: string } | null} urls
|
|
49
|
+
* @param {string | null} url — the original's signed URL.
|
|
50
|
+
* @param {string} variant
|
|
51
|
+
*/
|
|
52
|
+
export function pickFileImageUrl(urls, url, variant) {
|
|
53
|
+
const kind = normaliseFileImageVariant(variant);
|
|
54
|
+
const rung = kind !== "original" && urls && typeof urls === "object" ? usableUrl(urls[kind]) : null;
|
|
55
|
+
return rung || usableUrl(url);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The rung an avatar of `px` loads: enough pixels for a 2x screen. */
|
|
59
|
+
export function avatarFileVariant(px) {
|
|
60
|
+
return positive(px) && px * 2 <= THUMBNAIL_EDGE_PX ? "thumbnail" : "card";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The box's width / height: the caller's, else the loaded picture's own, else
|
|
65
|
+
* a photo's 4:3 until it arrives. Clamped, so a bad value never reaches a
|
|
66
|
+
* native style.
|
|
67
|
+
*
|
|
68
|
+
* @param {number | undefined} aspectRatio
|
|
69
|
+
* @param {{ width: number, height: number } | null} intrinsic
|
|
70
|
+
*/
|
|
71
|
+
export function fileImageAspectRatio(aspectRatio, intrinsic) {
|
|
72
|
+
if (positive(aspectRatio)) return clamp(aspectRatio, ASPECT_RATIO_BOUNDS);
|
|
73
|
+
if (intrinsic && positive(intrinsic.width) && positive(intrinsic.height)) {
|
|
74
|
+
return clamp(intrinsic.width / intrinsic.height, ASPECT_RATIO_BOUNDS);
|
|
75
|
+
}
|
|
76
|
+
return DEFAULT_FILE_IMAGE_ASPECT_RATIO;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A caller who sets the height (or a flex share) sizes the box themselves. */
|
|
80
|
+
export function callerSizesHeight(style) {
|
|
81
|
+
return Boolean(style) && (style.height != null || style.flex != null || style.aspectRatio != null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The corner radius: px, a theme radius name, or the theme's `md`.
|
|
86
|
+
*
|
|
87
|
+
* @param {number | string | undefined} radius
|
|
88
|
+
* @param {Record<string, number>} radii — the theme's radii.
|
|
89
|
+
*/
|
|
90
|
+
export function fileImageRadius(radius, radii) {
|
|
91
|
+
if (typeof radius === "number" && Number.isFinite(radius)) return Math.max(0, radius);
|
|
92
|
+
if (radius === "none") return 0;
|
|
93
|
+
if (typeof radius === "string" && radii && Object.hasOwn(radii, radius)) return radii[radius];
|
|
94
|
+
return radii && Number.isFinite(radii.md) ? radii.md : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The loaded picture's pixel size, from either host's load event: React Native
|
|
99
|
+
* reports `nativeEvent.source`, the web the decoded element's natural size.
|
|
100
|
+
*/
|
|
101
|
+
export function intrinsicSizeOf(event) {
|
|
102
|
+
const native = event && event.nativeEvent;
|
|
103
|
+
if (!native) return null;
|
|
104
|
+
const source = native.source;
|
|
105
|
+
if (source && positive(source.width) && positive(source.height)) {
|
|
106
|
+
return { width: source.width, height: source.height };
|
|
107
|
+
}
|
|
108
|
+
const element = native.target;
|
|
109
|
+
if (element && positive(element.naturalWidth) && positive(element.naturalHeight)) {
|
|
110
|
+
return { width: element.naturalWidth, height: element.naturalHeight };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The fallback glyph: a share of the box, never more than 24 px. */
|
|
116
|
+
export function fileImageIconSize(layout) {
|
|
117
|
+
if (!layout || !positive(layout.width) || !positive(layout.height)) return FILE_IMAGE_ICON_MAX;
|
|
118
|
+
const share = Math.round(Math.min(layout.width, layout.height) * ICON_SHARE);
|
|
119
|
+
return clamp(share, { min: ICON_MIN, max: FILE_IMAGE_ICON_MAX });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A picture that has not loaded yet. */
|
|
123
|
+
export function initialImageLoad() {
|
|
124
|
+
return { phase: "loading", attempt: 0, size: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The load state machine. `error` retries through `refetch` once when the
|
|
129
|
+
* source can mint a fresh signed URL (`canRetry`), then gives up; `refetched`
|
|
130
|
+
* reloads with whatever URL the refetch produced.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ phase: "loading"|"loaded"|"retrying"|"failed", attempt: number, size: object|null }} state
|
|
133
|
+
* @param {{ type: "load", size?: object|null } | { type: "error", canRetry: boolean } | { type: "refetched" }} event
|
|
134
|
+
*/
|
|
135
|
+
export function nextImageLoad(state, event) {
|
|
136
|
+
switch (event && event.type) {
|
|
137
|
+
case "load":
|
|
138
|
+
return { ...state, phase: "loaded", size: event.size || state.size };
|
|
139
|
+
case "error":
|
|
140
|
+
if (state.phase === "failed" || state.phase === "retrying") return state;
|
|
141
|
+
return event.canRetry && state.attempt < FILE_IMAGE_RETRIES
|
|
142
|
+
? { ...state, phase: "retrying", attempt: state.attempt + 1 }
|
|
143
|
+
: { ...state, phase: "failed" };
|
|
144
|
+
case "refetched":
|
|
145
|
+
return state.phase === "retrying" ? { ...state, phase: "loading" } : state;
|
|
146
|
+
default:
|
|
147
|
+
return state;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What the box shows: `resolving` (a placeholder, no URL yet), `loading` (the
|
|
153
|
+
* picture loading under the placeholder), `loaded`, or `failed` (the fallback).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ uri: string|null, resolving: boolean, phase: string }} input
|
|
156
|
+
*/
|
|
157
|
+
export function fileImageStatus({ uri, resolving, phase }) {
|
|
158
|
+
if (phase === "failed") return "failed";
|
|
159
|
+
if (phase === "retrying") return "resolving";
|
|
160
|
+
if (!uri) return resolving ? "resolving" : "failed";
|
|
161
|
+
return phase === "loaded" ? "loaded" : "loading";
|
|
162
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The ONE loading path behind `<FileImage>` and `<Avatar fileId>`: resolve the
|
|
2
|
+
// picture's URL, track its load, and retry an expired signed URL once through
|
|
3
|
+
// `useFilestoreFile`'s own `refetch`, never a second fetch of our own.
|
|
4
|
+
//
|
|
5
|
+
// The caller keys the component on the file id (or URL), so a new source
|
|
6
|
+
// starts from a fresh load state.
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useFilestoreFile } from "./hooks.js";
|
|
10
|
+
import {
|
|
11
|
+
fileImageStatus,
|
|
12
|
+
initialImageLoad,
|
|
13
|
+
intrinsicSizeOf,
|
|
14
|
+
nextImageLoad,
|
|
15
|
+
pickFileImageUrl,
|
|
16
|
+
} from "./file-image-logic.js";
|
|
17
|
+
|
|
18
|
+
function useImageLoad({ uri, resolving, refetch }) {
|
|
19
|
+
const [load, setLoad] = React.useState(initialImageLoad);
|
|
20
|
+
const loadRef = React.useRef(load);
|
|
21
|
+
loadRef.current = load;
|
|
22
|
+
const refetchRef = React.useRef(refetch);
|
|
23
|
+
refetchRef.current = refetch;
|
|
24
|
+
const mountedRef = React.useRef(true);
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
mountedRef.current = true;
|
|
27
|
+
return () => {
|
|
28
|
+
mountedRef.current = false;
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// Stable handlers: the web Image restarts its request when they change.
|
|
33
|
+
const onLoad = React.useCallback((event) => {
|
|
34
|
+
setLoad((state) => nextImageLoad(state, { type: "load", size: intrinsicSizeOf(event) }));
|
|
35
|
+
}, []);
|
|
36
|
+
const onError = React.useCallback(() => {
|
|
37
|
+
const retry = refetchRef.current;
|
|
38
|
+
const next = nextImageLoad(loadRef.current, { type: "error", canRetry: typeof retry === "function" });
|
|
39
|
+
loadRef.current = next;
|
|
40
|
+
setLoad(next);
|
|
41
|
+
if (next.phase !== "retrying") return;
|
|
42
|
+
const settle = () => {
|
|
43
|
+
if (mountedRef.current) setLoad((state) => nextImageLoad(state, { type: "refetched" }));
|
|
44
|
+
};
|
|
45
|
+
Promise.resolve(retry()).then(settle, settle);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
uri,
|
|
50
|
+
status: fileImageStatus({ uri, resolving, phase: load.phase }),
|
|
51
|
+
attempt: load.attempt,
|
|
52
|
+
size: load.size,
|
|
53
|
+
onLoad,
|
|
54
|
+
onError,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A stored file's picture at `variant`'s rung of the size ladder.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{ uri: string|null, status: "resolving"|"loading"|"loaded"|"failed", attempt: number, size: object|null, onLoad: Function, onError: Function }}
|
|
62
|
+
*/
|
|
63
|
+
export function useStoredFileImage(fileId, variant) {
|
|
64
|
+
const { url, urls, loading, refetch } = useFilestoreFile(fileId || null);
|
|
65
|
+
return useImageLoad({ uri: pickFileImageUrl(urls, url, variant), resolving: loading, refetch });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A picture at a URL the caller already has; nothing to refetch. */
|
|
69
|
+
export function useLinkedImage(uri) {
|
|
70
|
+
return useImageLoad({ uri: pickFileImageUrl(null, uri, "original"), resolving: false, refetch: null });
|
|
71
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The ONE implementation behind `<FileImage>`: a stored file's (or a URL's)
|
|
2
|
+
// picture with a themed placeholder while it loads and a fallback, never a
|
|
3
|
+
// broken image, when it cannot. The bindings (`file-image.js` /
|
|
4
|
+
// `file-image.native.js`) differ only in their primitives.
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { useHostTheme } from "./hooks.js";
|
|
8
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
9
|
+
import { Icon } from "./icon.js";
|
|
10
|
+
import { flattenStyle } from "./flatten-style.js";
|
|
11
|
+
import { useLinkedImage, useStoredFileImage } from "./file-image-source.js";
|
|
12
|
+
import {
|
|
13
|
+
callerSizesHeight,
|
|
14
|
+
fileImageAspectRatio,
|
|
15
|
+
fileImageIconSize,
|
|
16
|
+
fileImageRadius,
|
|
17
|
+
normaliseFileImageFallback,
|
|
18
|
+
normaliseFileImageResizeMode,
|
|
19
|
+
} from "./file-image-logic.js";
|
|
20
|
+
|
|
21
|
+
const h = React.createElement;
|
|
22
|
+
const FILL = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 };
|
|
23
|
+
const CENTRED = { alignItems: "center", justifyContent: "center" };
|
|
24
|
+
const HIDDEN = { "aria-hidden": true, importantForAccessibility: "no-hide-descendants" };
|
|
25
|
+
|
|
26
|
+
function suffixed(testID, part) {
|
|
27
|
+
return testID ? `${testID}-${part}` : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bind `<FileImage>` to one platform's primitives and that host's Skeleton.
|
|
32
|
+
*
|
|
33
|
+
* @param {{View: any, Image: any}} rn
|
|
34
|
+
* @param {any} Skeleton
|
|
35
|
+
*/
|
|
36
|
+
export function makeFileImage(rn, Skeleton) {
|
|
37
|
+
const { View, Image } = rn;
|
|
38
|
+
|
|
39
|
+
function Fallback({ kind, colors, label, testID }) {
|
|
40
|
+
const [layout, setLayout] = React.useState(null);
|
|
41
|
+
const onLayout = React.useCallback((event) => setLayout(event.nativeEvent.layout), []);
|
|
42
|
+
const named = label ? { accessible: true, accessibilityRole: "image", accessibilityLabel: label } : HIDDEN;
|
|
43
|
+
return h(
|
|
44
|
+
View,
|
|
45
|
+
{ ...named, onLayout, style: [FILL, CENTRED, { backgroundColor: colors.surfaceMuted }], testID },
|
|
46
|
+
kind === "icon" ? h(Icon, { name: "image", size: fileImageIconSize(layout), color: colors.onSurfaceMuted }) : null,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Picture({ image, props }) {
|
|
51
|
+
const { accessibilityLabel, aspectRatio, fallback, radius, resizeMode, style, testID } = props;
|
|
52
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
53
|
+
const own = flattenStyle(style);
|
|
54
|
+
const corner = fileImageRadius(radius, theme.radii);
|
|
55
|
+
const box = {
|
|
56
|
+
width: "100%",
|
|
57
|
+
overflow: "hidden",
|
|
58
|
+
borderRadius: corner,
|
|
59
|
+
...(callerSizesHeight(own) ? null : { aspectRatio: fileImageAspectRatio(aspectRatio, image.size) }),
|
|
60
|
+
};
|
|
61
|
+
const label = typeof accessibilityLabel === "string" ? accessibilityLabel : "";
|
|
62
|
+
const { status } = image;
|
|
63
|
+
return h(
|
|
64
|
+
View,
|
|
65
|
+
{ style: [box, style], testID },
|
|
66
|
+
status === "loading" || status === "loaded"
|
|
67
|
+
? h(Image, {
|
|
68
|
+
key: image.attempt,
|
|
69
|
+
source: { uri: image.uri },
|
|
70
|
+
resizeMode: normaliseFileImageResizeMode(resizeMode),
|
|
71
|
+
// The placeholder carries the name until the picture has loaded.
|
|
72
|
+
accessibilityLabel: (status === "loaded" && label) || undefined,
|
|
73
|
+
onLoad: image.onLoad,
|
|
74
|
+
onError: image.onError,
|
|
75
|
+
style: FILL,
|
|
76
|
+
testID: suffixed(testID, "image"),
|
|
77
|
+
})
|
|
78
|
+
: null,
|
|
79
|
+
status === "resolving" || status === "loading"
|
|
80
|
+
? h(Skeleton, {
|
|
81
|
+
shape: "block",
|
|
82
|
+
width: "100%",
|
|
83
|
+
height: "100%",
|
|
84
|
+
radius: corner,
|
|
85
|
+
accessibilityLabel: label || undefined,
|
|
86
|
+
style: FILL,
|
|
87
|
+
testID: suffixed(testID, "loading"),
|
|
88
|
+
})
|
|
89
|
+
: null,
|
|
90
|
+
status === "failed"
|
|
91
|
+
? h(Fallback, {
|
|
92
|
+
kind: normaliseFileImageFallback(fallback),
|
|
93
|
+
colors: theme.colors,
|
|
94
|
+
label,
|
|
95
|
+
testID: suffixed(testID, "fallback"),
|
|
96
|
+
})
|
|
97
|
+
: null,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function StoredPicture({ props }) {
|
|
102
|
+
return h(Picture, { image: useStoredFileImage(props.fileId, props.variant), props });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function LinkedPicture({ props }) {
|
|
106
|
+
return h(Picture, { image: useLinkedImage(props.uri), props });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function FileImage(props) {
|
|
110
|
+
const { fileId, uri } = props;
|
|
111
|
+
if (typeof fileId === "string" && fileId !== "") {
|
|
112
|
+
return h(StoredPicture, { key: `file:${fileId}`, props });
|
|
113
|
+
}
|
|
114
|
+
return h(LinkedPicture, { key: `uri:${uri || ""}`, props });
|
|
115
|
+
}
|
|
116
|
+
FileImage.displayName = "FileImage";
|
|
117
|
+
|
|
118
|
+
return { FileImage };
|
|
119
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `<FileImage>` (web). Binds ./file-image-view.js to react-native-web and the
|
|
2
|
+
// web `<Skeleton>`; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { Skeleton } from "./loading.js";
|
|
6
|
+
import { makeFileImage } from "./file-image-view.js";
|
|
7
|
+
|
|
8
|
+
export const { FileImage } = makeFileImage({ View: ReactNative.View, Image: ReactNative.Image }, Skeleton);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `<FileImage>` (native). Binds ./file-image-view.js to React Native and the
|
|
2
|
+
// native `<Skeleton>`; only the imports differ from ./file-image.js.
|
|
3
|
+
|
|
4
|
+
import { View, Image } from "react-native";
|
|
5
|
+
import { Skeleton } from "./loading.native.js";
|
|
6
|
+
import { makeFileImage } from "./file-image-view.js";
|
|
7
|
+
|
|
8
|
+
export const { FileImage } = makeFileImage({ View, Image }, Skeleton);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// named, when the frame is needed, and the keys a non-button role answers.
|
|
3
3
|
// No renderer here, so the node suite can test them and both hosts share them.
|
|
4
4
|
|
|
5
|
+
import { nextOptionIndex } from "./form-control-tokens.js";
|
|
6
|
+
|
|
5
7
|
export function isShown(value) {
|
|
6
8
|
return value !== undefined && value !== null && value !== false && value !== "";
|
|
7
9
|
}
|
|
@@ -53,3 +55,24 @@ export function spaceActivates(onPress) {
|
|
|
53
55
|
if (!event.repeat) onPress();
|
|
54
56
|
};
|
|
55
57
|
}
|
|
58
|
+
|
|
59
|
+
const OPTION_NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The keys one row of an option list answers (a `<Select>` option, a `<Menu>`
|
|
63
|
+
* item): arrows, Home and End move focus past disabled rows, Space activates.
|
|
64
|
+
*
|
|
65
|
+
* @param {{options: Array<{disabled?: boolean}>, index: number,
|
|
66
|
+
* focusAt: (index: number) => void, activate: () => void}} row
|
|
67
|
+
*/
|
|
68
|
+
export function optionKeyDown({ options, index, focusAt, activate }) {
|
|
69
|
+
const space = spaceActivates(activate);
|
|
70
|
+
return (event) => {
|
|
71
|
+
if (OPTION_NAVIGATION_KEYS.includes(event.key)) {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
focusAt(nextOptionIndex(options, index, event.key));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
space(event);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
8
|
import { resolveTextFieldTokens } from "./text-field-tokens.js";
|
|
9
|
+
import { resolvePopoverTokens } from "./popover-tokens.js";
|
|
9
10
|
|
|
10
11
|
// The smallest target a finger can hit reliably, as the field's own floor.
|
|
11
12
|
export const CONTROL_MIN_TARGET = 44;
|
|
@@ -15,10 +16,6 @@ export const HALO_WIDTH = 2;
|
|
|
15
16
|
export const HALO_GAP = 2;
|
|
16
17
|
export const DISABLED_OPACITY = 0.5;
|
|
17
18
|
|
|
18
|
-
function isSet(value) {
|
|
19
|
-
return value !== undefined && value !== null && value !== "";
|
|
20
|
-
}
|
|
21
|
-
|
|
22
19
|
/**
|
|
23
20
|
* Resolve one form control from the workspace theme.
|
|
24
21
|
*
|
|
@@ -30,7 +27,6 @@ export function resolveFormControlTokens(theme, overrides) {
|
|
|
30
27
|
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
31
28
|
const spacing = base.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
32
29
|
const typography = base.typography || DEFAULT_THEME_TOKENS.typography;
|
|
33
|
-
const popover = (base.components && base.components.popover) || {};
|
|
34
30
|
const field = resolveTextFieldTokens(base, overrides);
|
|
35
31
|
return {
|
|
36
32
|
...field,
|
|
@@ -45,14 +41,33 @@ export function resolveFormControlTokens(theme, overrides) {
|
|
|
45
41
|
rowGap: spacing.sm,
|
|
46
42
|
disabledOpacity: DISABLED_OPACITY,
|
|
47
43
|
errorColor: colors.danger,
|
|
48
|
-
popover:
|
|
49
|
-
background: isSet(popover.background) ? popover.background : colors.surface,
|
|
50
|
-
textColor: isSet(popover.textColor) ? popover.textColor : colors.onSurface,
|
|
51
|
-
borderColor: isSet(popover.borderColor) ? popover.borderColor : colors.border,
|
|
52
|
-
},
|
|
44
|
+
popover: resolvePopoverTokens(base),
|
|
53
45
|
};
|
|
54
46
|
}
|
|
55
47
|
|
|
48
|
+
/**
|
|
49
|
+
* One row of an option list: a `<Select>` option, a `<Menu>` item.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} t — resolved form-control tokens.
|
|
52
|
+
* @param {{pressed?: boolean, disabled?: boolean, justifyContent?: string}} state
|
|
53
|
+
*/
|
|
54
|
+
export function optionRowStyle(t, { pressed, disabled, justifyContent = "flex-start" }) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
justifyContent,
|
|
60
|
+
gap: t.rowGap,
|
|
61
|
+
minHeight: CONTROL_MIN_TARGET,
|
|
62
|
+
paddingHorizontal: t.paddingHorizontal,
|
|
63
|
+
paddingVertical: t.paddingVertical,
|
|
64
|
+
borderRadius: t.radius,
|
|
65
|
+
},
|
|
66
|
+
pressed ? { opacity: 0.7 } : null,
|
|
67
|
+
disabled ? { opacity: t.disabledOpacity } : null,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* The ring drawn AROUND a filled control (a checkbox, a switch), whose own
|
|
58
73
|
* border cannot show focus once it is the accent. Its width is always
|