@colixsystems/widget-sdk 0.245.0 → 0.247.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.
Files changed (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -13,6 +13,8 @@ import { lucideGlyph, Icon } from "./icon.js";
13
13
  import { initialsFor } from "./initials.js";
14
14
  import { avatarColorFor } from "./avatar-color.js";
15
15
  import { readableTextColor } from "./contract.js";
16
+ import { useStoredFileImage } from "./file-image-source.js";
17
+ import { avatarFileVariant } from "./file-image-logic.js";
16
18
  import {
17
19
  avatarPixelSize,
18
20
  badgeTint,
@@ -26,6 +28,7 @@ import {
26
28
  } from "./detail-parts-tokens.js";
27
29
 
28
30
  const RING_SIZE_PX = 72;
31
+ const AVATAR_PHOTO = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 };
29
32
  const RING_STROKE_PX = 8;
30
33
  const h = React.createElement;
31
34
 
@@ -87,7 +90,28 @@ export function makeDetailParts(rn) {
87
90
  }
88
91
  Badge.displayName = "Badge";
89
92
 
90
- function Avatar({ name, url, seed, size = "md", shape = "circle", accessibilityLabel, style, testID }) {
93
+ // The stored photo loads over the initials, which stay if it cannot.
94
+ function FileAvatar({ fileId, px, frame, monogram }) {
95
+ const photo = useStoredFileImage(fileId, avatarFileVariant(px));
96
+ const showsPhoto = photo.status === "loading" || photo.status === "loaded";
97
+ return h(
98
+ View,
99
+ frame,
100
+ photo.status === "loaded" ? null : monogram,
101
+ showsPhoto
102
+ ? h(Image, {
103
+ key: photo.attempt,
104
+ source: { uri: photo.uri },
105
+ resizeMode: "cover",
106
+ onLoad: photo.onLoad,
107
+ onError: photo.onError,
108
+ style: AVATAR_PHOTO,
109
+ })
110
+ : null,
111
+ );
112
+ }
113
+
114
+ function Avatar({ name, url, fileId, seed, size = "md", shape = "circle", accessibilityLabel, style, testID }) {
91
115
  const { colors, radii, typography } = useDetailTheme();
92
116
  const px = avatarPixelSize(size);
93
117
  // sc-10268: a seed (a person's id) gives the monogram its own colour.
@@ -100,9 +124,10 @@ export function makeDetailParts(rn) {
100
124
  borderRadius: shape === "rounded" ? radii.md : px / 2,
101
125
  backgroundColor: tint || colors.surfaceMuted,
102
126
  };
127
+ const stored = typeof fileId === "string" && fileId !== "";
103
128
  const source = typeof url === "string" ? url.trim() : "";
104
129
  const label = accessibilityLabel || captionText(name) || "Avatar";
105
- if (source) {
130
+ if (source && !stored) {
106
131
  return h(Image, {
107
132
  source: { uri: source },
108
133
  accessibilityLabel: label,
@@ -127,6 +152,10 @@ export function makeDetailParts(rn) {
127
152
  initials,
128
153
  )
129
154
  : h(Icon, { name: "user", size: Math.round(px * 0.55), color: ink });
155
+ if (stored) {
156
+ const frame = { style: [box, fallback, { overflow: "hidden" }, style], accessibilityLabel: label, testID };
157
+ return h(FileAvatar, { key: fileId, fileId, px, frame, monogram: content });
158
+ }
130
159
  return h(View, { style: [box, fallback, style], accessibilityLabel: label, testID }, content);
131
160
  }
132
161
  Avatar.displayName = "Avatar";
@@ -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