@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.
- package/README.md +60 -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/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +132 -35
- package/dist/contract.js +132 -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/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +481 -20
- package/dist/index.js +14 -1
- package/dist/index.native.js +14 -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/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +247 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- 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-search-logic.js +150 -0
- package/dist/select-view.js +255 -43
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- 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 +132 -35
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `<Popover>`, `<Menu>` and `<Tooltip>` (native). Bind the shared views to
|
|
2
|
+
// React Native's own primitives and the native `<Overlay>`; only the imports
|
|
3
|
+
// differ from ./popover.js, plus the safe-area inset a bottom sheet pads by.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { View, Text, Pressable, ScrollView, Platform, useWindowDimensions } from "react-native";
|
|
7
|
+
// Host-provided: the exported app pins it and mounts its provider at the root.
|
|
8
|
+
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
9
|
+
import { makePopover } from "./popover-view.js";
|
|
10
|
+
import { makeMenu } from "./menu-view.js";
|
|
11
|
+
import { makeTooltip } from "./tooltip-view.js";
|
|
12
|
+
import { Overlay } from "./overlay.native.js";
|
|
13
|
+
|
|
14
|
+
// Null with no provider above, which the view reads as "no insets".
|
|
15
|
+
const useSafeAreaInsets = () => React.useContext(SafeAreaInsetsContext);
|
|
16
|
+
|
|
17
|
+
const primitives = { View, Text, Pressable, ScrollView, Platform, useWindowDimensions, useSafeAreaInsets };
|
|
18
|
+
|
|
19
|
+
const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
|
|
20
|
+
export { Popover };
|
|
21
|
+
export const Menu = makeMenu(primitives, AnchoredSurface);
|
|
22
|
+
export const Tooltip = makeTooltip(primitives, AnchoredSurface);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The `<RadioGroup>` rules that need no renderer: which option is picked,
|
|
2
|
+
// which one holds the group's single tab stop, and where an arrow key moves
|
|
3
|
+
// the choice. Both hosts share them, and the node suite tests them.
|
|
4
|
+
|
|
5
|
+
export const RADIO_GROUP_VARIANTS = Object.freeze(["list", "card", "segmented"]);
|
|
6
|
+
export const DEFAULT_RADIO_GROUP_VARIANT = "list";
|
|
7
|
+
|
|
8
|
+
const ARROW_STEP = Object.freeze({ ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 });
|
|
9
|
+
|
|
10
|
+
/** A requested variant, or the list when it names none we draw. */
|
|
11
|
+
export function radioGroupVariant(variant) {
|
|
12
|
+
return RADIO_GROUP_VARIANTS.includes(variant) ? variant : DEFAULT_RADIO_GROUP_VARIANT;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function optionList(options) {
|
|
16
|
+
return Array.isArray(options) ? options.filter((option) => option && typeof option === "object") : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pickable(option, groupDisabled) {
|
|
20
|
+
return !groupDisabled && !option.disabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The index of the option whose `value` is `value` (strict), or -1. */
|
|
24
|
+
export function checkedIndex(options, value) {
|
|
25
|
+
return optionList(options).findIndex((option) => option.value === value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The option a Tab key lands on: the picked one when it can be picked, else
|
|
30
|
+
* the first that can, else -1 (nothing to focus).
|
|
31
|
+
*
|
|
32
|
+
* @param {Array<{value: any, disabled?: boolean}>} options
|
|
33
|
+
* @param {any} value
|
|
34
|
+
* @param {boolean} [groupDisabled]
|
|
35
|
+
*/
|
|
36
|
+
export function tabStopIndex(options, value, groupDisabled = false) {
|
|
37
|
+
const list = optionList(options);
|
|
38
|
+
const picked = checkedIndex(list, value);
|
|
39
|
+
if (picked !== -1 && pickable(list[picked], groupDisabled)) return picked;
|
|
40
|
+
return list.findIndex((option) => pickable(option, groupDisabled));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The option an arrow key moves the choice to: the next pickable one in that
|
|
45
|
+
* direction, wrapping at either end. Returns -1 for any other key, or when no
|
|
46
|
+
* other option can be picked.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<{disabled?: boolean}>} options
|
|
49
|
+
* @param {number} from — the focused option's index.
|
|
50
|
+
* @param {string} key — a KeyboardEvent `key`.
|
|
51
|
+
*/
|
|
52
|
+
export function arrowTargetIndex(options, from, key) {
|
|
53
|
+
const step = ARROW_STEP[key];
|
|
54
|
+
const list = optionList(options);
|
|
55
|
+
if (!step || list.length < 2) return -1;
|
|
56
|
+
for (let hop = 1; hop < list.length; hop += 1) {
|
|
57
|
+
const at = (((from + step * hop) % list.length) + list.length) % list.length;
|
|
58
|
+
if (pickable(list[at], false)) return at;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The values `<RadioGroup>` paints with. Its list and card variants are the
|
|
2
|
+
// form controls' own `muted` scope (`resolveFormControlTokens`); the segmented
|
|
3
|
+
// one is the `tabs` scope's segmented strip. Platform-free.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
6
|
+
import { CHECKBOX_SIZE, resolveFormControlTokens } from "./form-control-tokens.js";
|
|
7
|
+
import { resolveTabsTokens } from "./tabs-tokens.js";
|
|
8
|
+
|
|
9
|
+
export const RADIO_DOT_SIZE = 8;
|
|
10
|
+
// Narrow enough for two cards side by side in a phone-width panel.
|
|
11
|
+
export const RADIO_CARD_MIN_WIDTH = 140;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve one radio group from the workspace theme.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
17
|
+
*/
|
|
18
|
+
export function resolveRadioGroupTokens(theme) {
|
|
19
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
20
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
21
|
+
const spacing = base.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
22
|
+
const typography = base.typography || DEFAULT_THEME_TOKENS.typography;
|
|
23
|
+
const field = resolveFormControlTokens(base);
|
|
24
|
+
return {
|
|
25
|
+
...field,
|
|
26
|
+
// A picked row lifts out of the muted panel onto the surface.
|
|
27
|
+
pickedBackground: colors.surface,
|
|
28
|
+
descriptionColor: colors.onSurfaceMuted,
|
|
29
|
+
descriptionSize: typography.sizes.sm,
|
|
30
|
+
optionGap: spacing.sm,
|
|
31
|
+
cardPadding: spacing.md,
|
|
32
|
+
segmented: resolveTabsTokens(base, { style: "segmented" }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The round mark of one option: the input surface, filled with the accent
|
|
38
|
+
* and a dot when picked, so it reads as the `<Checkbox>` box's sibling.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} t — resolved radio group tokens.
|
|
41
|
+
* @param {{checked: boolean, invalid?: boolean}} state
|
|
42
|
+
*/
|
|
43
|
+
export function radioMarkStyles(t, { checked, invalid = false }) {
|
|
44
|
+
return {
|
|
45
|
+
ring: {
|
|
46
|
+
width: CHECKBOX_SIZE,
|
|
47
|
+
height: CHECKBOX_SIZE,
|
|
48
|
+
borderRadius: CHECKBOX_SIZE / 2,
|
|
49
|
+
borderWidth: 1,
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
|
|
53
|
+
backgroundColor: checked ? t.accent : t.background,
|
|
54
|
+
},
|
|
55
|
+
dot: {
|
|
56
|
+
width: RADIO_DOT_SIZE,
|
|
57
|
+
height: RADIO_DOT_SIZE,
|
|
58
|
+
borderRadius: RADIO_DOT_SIZE / 2,
|
|
59
|
+
backgroundColor: t.onAccent,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The ONE implementation behind the `<RadioGroup>` SDK primitive. The platform
|
|
2
|
+
// bindings (`radio-group.js` / `radio-group.native.js`) differ only in where
|
|
3
|
+
// the primitives come from, so the hosts cannot drift.
|
|
4
|
+
//
|
|
5
|
+
// Widgets each drew their own pick-one list, marking the choice with a glyph,
|
|
6
|
+
// an icon or a tab role. This one has the radio role and the app's look.
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useHostTheme } from "./hooks.js";
|
|
10
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
11
|
+
import { Icon } from "./icon.js";
|
|
12
|
+
import { CHECKBOX_SIZE, CONTROL_MIN_TARGET, focusHaloStyle } from "./form-control-tokens.js";
|
|
13
|
+
import {
|
|
14
|
+
errorMessage,
|
|
15
|
+
hasNote,
|
|
16
|
+
isShown,
|
|
17
|
+
isText,
|
|
18
|
+
needsFrame,
|
|
19
|
+
spaceActivates,
|
|
20
|
+
spokenLabel,
|
|
21
|
+
} from "./form-control-logic.js";
|
|
22
|
+
import { makeFieldFrame } from "./form-control-view.js";
|
|
23
|
+
import { RADIO_CARD_MIN_WIDTH, radioMarkStyles, resolveRadioGroupTokens } from "./radio-group-tokens.js";
|
|
24
|
+
import {
|
|
25
|
+
arrowTargetIndex,
|
|
26
|
+
radioGroupVariant,
|
|
27
|
+
tabStopIndex,
|
|
28
|
+
} from "./radio-group-logic.js";
|
|
29
|
+
import { TAB_ITEM_LAYOUT, segmentedStripStyles, tabLabelStyle } from "./tabs-tokens.js";
|
|
30
|
+
|
|
31
|
+
const h = React.createElement;
|
|
32
|
+
|
|
33
|
+
function groupStyle(kind, t, theme, invalid) {
|
|
34
|
+
if (kind === "segmented") {
|
|
35
|
+
const strip = segmentedStripStyles(t.segmented, theme);
|
|
36
|
+
return [
|
|
37
|
+
strip.bar,
|
|
38
|
+
strip.content,
|
|
39
|
+
// A form row's control spans the row; its segments share it evenly.
|
|
40
|
+
{ flexDirection: "row", alignSelf: "stretch", borderWidth: 1, borderColor: "transparent" },
|
|
41
|
+
invalid ? { borderColor: t.errorColor } : null,
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
if (kind === "card") return { flexDirection: "row", flexWrap: "wrap", gap: t.optionGap };
|
|
45
|
+
return {
|
|
46
|
+
borderWidth: 1,
|
|
47
|
+
borderColor: invalid ? t.errorColor : t.borderColor,
|
|
48
|
+
borderRadius: t.radius,
|
|
49
|
+
backgroundColor: t.background,
|
|
50
|
+
overflow: "hidden",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function optionStyle(kind, t, theme, { checked, invalid, focused, web }) {
|
|
55
|
+
if (kind === "segmented") {
|
|
56
|
+
return [
|
|
57
|
+
TAB_ITEM_LAYOUT,
|
|
58
|
+
segmentedStripStyles(t.segmented, theme).item(checked),
|
|
59
|
+
{ flexGrow: 1, flexShrink: 1, flexBasis: 0 },
|
|
60
|
+
// The segment's own border is its lift, so focus draws outside it.
|
|
61
|
+
focused && web
|
|
62
|
+
? { outlineWidth: 2, outlineStyle: "solid", outlineColor: t.focusColor, outlineOffset: 1 }
|
|
63
|
+
: null,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
const row = {
|
|
67
|
+
flexDirection: "row",
|
|
68
|
+
alignItems: kind === "card" ? "flex-start" : "center",
|
|
69
|
+
gap: t.optionGap,
|
|
70
|
+
minHeight: CONTROL_MIN_TARGET,
|
|
71
|
+
};
|
|
72
|
+
if (kind === "card") {
|
|
73
|
+
return [
|
|
74
|
+
row,
|
|
75
|
+
{
|
|
76
|
+
flexGrow: 1,
|
|
77
|
+
flexBasis: RADIO_CARD_MIN_WIDTH,
|
|
78
|
+
padding: t.cardPadding,
|
|
79
|
+
borderWidth: 1,
|
|
80
|
+
borderRadius: t.radius,
|
|
81
|
+
borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
|
|
82
|
+
backgroundColor: checked ? t.pickedBackground : t.background,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
return [
|
|
87
|
+
row,
|
|
88
|
+
{ paddingHorizontal: t.paddingHorizontal, paddingVertical: t.paddingVertical },
|
|
89
|
+
checked ? { backgroundColor: t.pickedBackground } : null,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Bind the shared radio group to one platform's primitives.
|
|
95
|
+
*
|
|
96
|
+
* @param {{View: any, Text: any, Pressable: any, Platform: any}} rn
|
|
97
|
+
*/
|
|
98
|
+
export function makeRadioGroup(rn) {
|
|
99
|
+
const { View, Text, Pressable, Platform } = rn;
|
|
100
|
+
const FieldFrame = makeFieldFrame(rn);
|
|
101
|
+
const web = Boolean(Platform && Platform.OS === "web");
|
|
102
|
+
|
|
103
|
+
function RadioMark({ t, checked, invalid, focused }) {
|
|
104
|
+
const mark = radioMarkStyles(t, { checked, invalid });
|
|
105
|
+
return h(
|
|
106
|
+
View,
|
|
107
|
+
{ style: focusHaloStyle(t, focused, CHECKBOX_SIZE / 2) },
|
|
108
|
+
h(View, { style: mark.ring }, checked ? h(View, { style: mark.dot }) : null),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function OptionText({ t, option, kind }) {
|
|
113
|
+
const label = isText(option.label)
|
|
114
|
+
? h(
|
|
115
|
+
Text,
|
|
116
|
+
{
|
|
117
|
+
style: {
|
|
118
|
+
color: t.textColor,
|
|
119
|
+
fontFamily: t.fontFamily,
|
|
120
|
+
fontSize: t.fontSize,
|
|
121
|
+
...(kind === "card" ? { fontWeight: "600" } : null),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
option.label,
|
|
125
|
+
)
|
|
126
|
+
: option.label;
|
|
127
|
+
const description = isText(option.description)
|
|
128
|
+
? h(
|
|
129
|
+
Text,
|
|
130
|
+
{ style: { color: t.descriptionColor, fontFamily: t.fontFamily, fontSize: t.descriptionSize } },
|
|
131
|
+
option.description,
|
|
132
|
+
)
|
|
133
|
+
: isShown(option.description)
|
|
134
|
+
? option.description
|
|
135
|
+
: null;
|
|
136
|
+
return h(View, { style: { flexGrow: 1, flexShrink: 1, gap: 2 } }, label, description);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function optionContent({ t, theme, kind, option, checked, invalid, focused }) {
|
|
140
|
+
if (kind === "segmented") {
|
|
141
|
+
const s = t.segmented;
|
|
142
|
+
return [
|
|
143
|
+
option.icon
|
|
144
|
+
? h(Icon, { key: "icon", name: option.icon, size: 16, color: checked ? s.activeTextColor : s.textColor })
|
|
145
|
+
: null,
|
|
146
|
+
h(Text, { key: "label", numberOfLines: 1, style: tabLabelStyle(s, theme, checked) }, option.label),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
return [
|
|
150
|
+
h(RadioMark, { key: "mark", t, checked, invalid, focused }),
|
|
151
|
+
option.icon
|
|
152
|
+
? h(Icon, { key: "icon", name: option.icon, size: 18, color: checked ? t.accent : t.descriptionColor })
|
|
153
|
+
: null,
|
|
154
|
+
h(OptionText, { key: "text", t, option, kind }),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
|
|
159
|
+
const {
|
|
160
|
+
options,
|
|
161
|
+
value,
|
|
162
|
+
onValueChange,
|
|
163
|
+
variant,
|
|
164
|
+
label,
|
|
165
|
+
helper,
|
|
166
|
+
error,
|
|
167
|
+
disabled = false,
|
|
168
|
+
accessibilityLabel,
|
|
169
|
+
style,
|
|
170
|
+
containerStyle,
|
|
171
|
+
...rest
|
|
172
|
+
} = props;
|
|
173
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
174
|
+
const t = resolveRadioGroupTokens(theme);
|
|
175
|
+
const kind = radioGroupVariant(variant);
|
|
176
|
+
const list = Array.isArray(options) ? options.filter((o) => o && typeof o === "object") : [];
|
|
177
|
+
const nodes = React.useRef([]);
|
|
178
|
+
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
|
179
|
+
const noteId = React.useId();
|
|
180
|
+
const invalid = isShown(error);
|
|
181
|
+
const noted = hasNote({ helper, error });
|
|
182
|
+
const framed = needsFrame({ label, helper, error });
|
|
183
|
+
const tabStop = tabStopIndex(list, value, disabled);
|
|
184
|
+
|
|
185
|
+
const pick = (option) => {
|
|
186
|
+
if (disabled || option.disabled || option.value === value) return;
|
|
187
|
+
if (typeof onValueChange === "function") onValueChange(option.value, option);
|
|
188
|
+
};
|
|
189
|
+
const keyHandler = (option, index) => (event) => {
|
|
190
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
191
|
+
const target = arrowTargetIndex(list, index, event.key);
|
|
192
|
+
if (target === -1) return spaceActivates(() => pick(option))(event);
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
pick(list[target]);
|
|
195
|
+
const node = nodes.current[target];
|
|
196
|
+
if (node && typeof node.focus === "function") node.focus();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const renderOption = (option, index) => {
|
|
200
|
+
const checked = option.value === value;
|
|
201
|
+
const optionDisabled = disabled || Boolean(option.disabled);
|
|
202
|
+
const focused = focusedIndex === index;
|
|
203
|
+
return h(
|
|
204
|
+
Pressable,
|
|
205
|
+
{
|
|
206
|
+
key: String(option.value),
|
|
207
|
+
ref: (node) => {
|
|
208
|
+
nodes.current[index] = node;
|
|
209
|
+
},
|
|
210
|
+
onPress: () => pick(option),
|
|
211
|
+
...(optionDisabled ? null : { onKeyDown: keyHandler(option, index) }),
|
|
212
|
+
onFocus: () => setFocusedIndex(index),
|
|
213
|
+
onBlur: () => setFocusedIndex((at) => (at === index ? -1 : at)),
|
|
214
|
+
disabled: optionDisabled,
|
|
215
|
+
// accessibilityRole + aria-*: the pair that announces on both hosts.
|
|
216
|
+
accessibilityRole: "radio",
|
|
217
|
+
"aria-checked": checked,
|
|
218
|
+
"aria-disabled": optionDisabled,
|
|
219
|
+
accessibilityLabel: option.accessibilityLabel,
|
|
220
|
+
testID: option.testID,
|
|
221
|
+
// One tab stop per group on the web; the arrows move between options.
|
|
222
|
+
...(web ? { tabIndex: index === tabStop ? 0 : -1 } : null),
|
|
223
|
+
style: ({ pressed }) => [
|
|
224
|
+
optionStyle(kind, t, theme, { checked, invalid, focused, web }),
|
|
225
|
+
pressed && !optionDisabled ? { opacity: 0.7 } : null,
|
|
226
|
+
option.disabled && !disabled ? { opacity: t.disabledOpacity } : null,
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
...optionContent({ t, theme, kind, option, checked, invalid, focused }),
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const group = h(
|
|
234
|
+
View,
|
|
235
|
+
{
|
|
236
|
+
...rest,
|
|
237
|
+
ref,
|
|
238
|
+
accessibilityRole: "radiogroup",
|
|
239
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label),
|
|
240
|
+
"aria-disabled": disabled,
|
|
241
|
+
...(invalid ? { "aria-invalid": true } : null),
|
|
242
|
+
...(noted ? { "aria-describedby": noteId } : null),
|
|
243
|
+
style: [
|
|
244
|
+
groupStyle(kind, t, theme, invalid),
|
|
245
|
+
// The frame dims a framed group as a whole.
|
|
246
|
+
disabled && !framed ? { opacity: t.disabledOpacity } : null,
|
|
247
|
+
style,
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
...list.map(renderOption),
|
|
251
|
+
);
|
|
252
|
+
if (!framed) return group;
|
|
253
|
+
return h(
|
|
254
|
+
FieldFrame,
|
|
255
|
+
{ t, label, helper, error: errorMessage(error), disabled, noteId, style: containerStyle },
|
|
256
|
+
group,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
RadioGroup.displayName = "RadioGroup";
|
|
261
|
+
return RadioGroup;
|
|
262
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// `<RadioGroup>` (web). Binds ./radio-group-view.js to react-native-web; see
|
|
2
|
+
// ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeRadioGroup } from "./radio-group-view.js";
|
|
6
|
+
|
|
7
|
+
export const RadioGroup = makeRadioGroup({
|
|
8
|
+
View: ReactNative.View,
|
|
9
|
+
Text: ReactNative.Text,
|
|
10
|
+
Pressable: ReactNative.Pressable,
|
|
11
|
+
Platform: ReactNative.Platform,
|
|
12
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<RadioGroup>` (native). Binds ./radio-group-view.js to React Native's own
|
|
2
|
+
// primitives; only the import differs from ./radio-group.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Pressable, Platform } from "react-native";
|
|
5
|
+
import { makeRadioGroup } from "./radio-group-view.js";
|
|
6
|
+
|
|
7
|
+
export const RadioGroup = makeRadioGroup({ View, Text, Pressable, Platform });
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The searchable `<Select>` rules that need no renderer: when a typed query
|
|
2
|
+
// is sent, which answer is still wanted, what the sheet shows meanwhile, and
|
|
3
|
+
// the labels it keeps for picked values the current page no longer holds.
|
|
4
|
+
// Both hosts share them, and the node suite tests them.
|
|
5
|
+
|
|
6
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
7
|
+
|
|
8
|
+
/** How long typing must pause before the query is sent, in ms. */
|
|
9
|
+
export const DEFAULT_SEARCH_DEBOUNCE_MS = 250;
|
|
10
|
+
|
|
11
|
+
/** A typed query as it is sent: trimmed, never null. */
|
|
12
|
+
export function normaliseQuery(text) {
|
|
13
|
+
return typeof text === "string" ? text.trim() : "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A trailing debounce: `push` restarts the wait, and only the last text
|
|
18
|
+
* pushed is delivered, once typing pauses. `cancel` drops a pending one.
|
|
19
|
+
*
|
|
20
|
+
* @param {number|(() => number)} delay — ms, or a getter read at each push
|
|
21
|
+
* so a changed delay applies to the next pause; below 0 counts as 0.
|
|
22
|
+
* @param {(query: string) => void} deliver
|
|
23
|
+
* @param {{setTimeout: Function, clearTimeout: Function}} [timers]
|
|
24
|
+
*/
|
|
25
|
+
export function createDebouncer(delay, deliver, timers = globalThis) {
|
|
26
|
+
const waitFor = () => {
|
|
27
|
+
const ms = typeof delay === "function" ? delay() : delay;
|
|
28
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
29
|
+
};
|
|
30
|
+
let pending = null;
|
|
31
|
+
const cancel = () => {
|
|
32
|
+
if (pending !== null) timers.clearTimeout(pending);
|
|
33
|
+
pending = null;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
push(text) {
|
|
37
|
+
cancel();
|
|
38
|
+
pending = timers.setTimeout(() => {
|
|
39
|
+
pending = null;
|
|
40
|
+
deliver(normaliseQuery(text));
|
|
41
|
+
}, waitFor());
|
|
42
|
+
},
|
|
43
|
+
cancel,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Numbers each request so only the latest one's answer is used; an answer to
|
|
49
|
+
* a query the person has already typed past is dropped.
|
|
50
|
+
*/
|
|
51
|
+
export function createRequestGate() {
|
|
52
|
+
let latest = 0;
|
|
53
|
+
return {
|
|
54
|
+
begin: () => ++latest,
|
|
55
|
+
isCurrent: (ticket) => ticket === latest,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The options whose label holds the query, case-insensitively; all of them
|
|
61
|
+
* for an empty query. For a list that is searched where it is held.
|
|
62
|
+
*
|
|
63
|
+
* @param {Array<{label: unknown}>} options
|
|
64
|
+
* @param {string} query
|
|
65
|
+
*/
|
|
66
|
+
export function filterOptions(options, query) {
|
|
67
|
+
const list = Array.isArray(options) ? options : [];
|
|
68
|
+
const needle = normaliseQuery(query).toLocaleLowerCase();
|
|
69
|
+
if (!needle) return list;
|
|
70
|
+
return list.filter((option) =>
|
|
71
|
+
String(option && option.label != null ? option.label : "")
|
|
72
|
+
.toLocaleLowerCase()
|
|
73
|
+
.includes(needle),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the sheet's body shows: a spinner while the answer is pending, the
|
|
79
|
+
* error, the "nothing matches" note for a query with no result, the empty
|
|
80
|
+
* note for no options at all, else the list.
|
|
81
|
+
*
|
|
82
|
+
* @param {{loading?: boolean, error?: boolean, query?: string, count: number}} state
|
|
83
|
+
* @returns {"loading"|"error"|"noResults"|"empty"|"list"}
|
|
84
|
+
*/
|
|
85
|
+
export function sheetBody({ loading, error, query, count }) {
|
|
86
|
+
if (loading) return "loading";
|
|
87
|
+
if (error) return "error";
|
|
88
|
+
if (count > 0) return "list";
|
|
89
|
+
return normaliseQuery(query) ? "noResults" : "empty";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 1 and "1" are two values; the key keeps them apart.
|
|
93
|
+
function valueKey(value) {
|
|
94
|
+
return `${typeof value}:${value}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A copy of `known` (value key → option) holding every option in `lists`,
|
|
99
|
+
* later lists winning. A searchable select remembers each option it has
|
|
100
|
+
* shown, so a picked one keeps its label after a search drops it.
|
|
101
|
+
*
|
|
102
|
+
* @param {Map<string, object>} known
|
|
103
|
+
* @param {...Array<{value: string|number}>} lists
|
|
104
|
+
*/
|
|
105
|
+
export function rememberOptions(known, ...lists) {
|
|
106
|
+
const next = new Map(known instanceof Map ? known : []);
|
|
107
|
+
for (const list of lists) {
|
|
108
|
+
for (const option of Array.isArray(list) ? list : []) {
|
|
109
|
+
if (option && (typeof option.value === "string" || typeof option.value === "number")) {
|
|
110
|
+
next.set(valueKey(option.value), option);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The remembered option for one value, or undefined. */
|
|
118
|
+
export function knownOption(known, value) {
|
|
119
|
+
return known instanceof Map ? known.get(valueKey(value)) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The remembered options for `values`, in their order; a value nothing has
|
|
124
|
+
* named is left out rather than shown by its raw value.
|
|
125
|
+
*
|
|
126
|
+
* @param {Map<string, object>} known
|
|
127
|
+
* @param {Array<string|number>} values
|
|
128
|
+
*/
|
|
129
|
+
export function knownOptions(known, values) {
|
|
130
|
+
return (Array.isArray(values) ? values : [])
|
|
131
|
+
.map((value) => knownOption(known, value))
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The row size a searchable sheet takes from the theme's `list` scope: only
|
|
137
|
+
* the values the theme sets, so an unthemed list keeps the option row's own.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} theme — the resolved workspace theme.
|
|
140
|
+
* @returns {{minHeight?: number, paddingHorizontal?: number}}
|
|
141
|
+
*/
|
|
142
|
+
export function searchRowOverrides(theme) {
|
|
143
|
+
const components = theme && typeof theme === "object" ? theme.components : undefined;
|
|
144
|
+
const list = normaliseThemeComponents({ list: components && components.list }).list || {};
|
|
145
|
+
const out = {};
|
|
146
|
+
// A row stays a 44px target however small the theme draws list rows.
|
|
147
|
+
if (Number.isFinite(list.rowMinHeight)) out.minHeight = Math.max(44, list.rowMinHeight);
|
|
148
|
+
if (Number.isFinite(list.rowPadding)) out.paddingHorizontal = list.rowPadding;
|
|
149
|
+
return out;
|
|
150
|
+
}
|