@colixsystems/widget-sdk 0.201.0 → 0.204.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 +47 -1
- package/dist/accordion-tokens.js +54 -0
- package/dist/accordion-view.js +210 -0
- package/dist/accordion.js +7 -0
- package/dist/accordion.native.js +7 -0
- package/dist/contract.cjs +52 -4
- package/dist/contract.js +52 -4
- package/dist/hooks.js +21 -20
- package/dist/index.d.ts +114 -0
- package/dist/index.js +4 -0
- package/dist/index.native.js +4 -0
- package/dist/loading-tokens.js +122 -0
- package/dist/loading-view.js +330 -0
- package/dist/loading.js +7 -0
- package/dist/loading.native.js +10 -0
- package/package.json +1 -1
- package/src/contract.js +52 -4
package/dist/index.d.ts
CHANGED
|
@@ -3441,6 +3441,120 @@ export const Tabs: (props: {
|
|
|
3441
3441
|
testID?: string;
|
|
3442
3442
|
}) => any;
|
|
3443
3443
|
|
|
3444
|
+
/**
|
|
3445
|
+
* Themed collapsible sections. The surface comes from the theme's `card`
|
|
3446
|
+
* scope and the headers and dividers from its `list` scope, so every
|
|
3447
|
+
* collapsible section in the app matches. Each header announces whether its
|
|
3448
|
+
* section is open, and a section's body mounts only while it is open.
|
|
3449
|
+
*
|
|
3450
|
+
* @example
|
|
3451
|
+
* <Accordion defaultValue="shipping">
|
|
3452
|
+
* <AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem>
|
|
3453
|
+
* <AccordionItem value="returns" title="Returns">…</AccordionItem>
|
|
3454
|
+
* </Accordion>
|
|
3455
|
+
*/
|
|
3456
|
+
export const Accordion: (props: {
|
|
3457
|
+
/** Several sections may be open at once; by default opening one closes the other. */
|
|
3458
|
+
multiple?: boolean;
|
|
3459
|
+
/** The open section key (an array of keys when `multiple`); makes it controlled. */
|
|
3460
|
+
value?: string | string[] | null;
|
|
3461
|
+
/** The initially open section(s) when uncontrolled. */
|
|
3462
|
+
defaultValue?: string | string[] | null;
|
|
3463
|
+
/** The open section(s) after a header press, in the same shape as `value`. */
|
|
3464
|
+
onChange?: (next: string | string[] | null) => void;
|
|
3465
|
+
children?: ReactNode;
|
|
3466
|
+
/** Names the group of sections for assistive technology. */
|
|
3467
|
+
accessibilityLabel?: string;
|
|
3468
|
+
style?: any;
|
|
3469
|
+
testID?: string;
|
|
3470
|
+
}) => any;
|
|
3471
|
+
|
|
3472
|
+
/** One section of an `<Accordion>`; on its own it is an accordion of one. */
|
|
3473
|
+
export const AccordionItem: (props: {
|
|
3474
|
+
/** The key `value` / `defaultValue` / `onChange` name it by; give one whenever you name sections. */
|
|
3475
|
+
value?: string;
|
|
3476
|
+
title?: ReactNode;
|
|
3477
|
+
subtitle?: ReactNode;
|
|
3478
|
+
/** Beside the header and outside its toggle, so a button here does its own thing. */
|
|
3479
|
+
trailing?: ReactNode;
|
|
3480
|
+
disabled?: boolean;
|
|
3481
|
+
/** Rule under the section (default true); the surface edge hides the last one's. */
|
|
3482
|
+
divider?: boolean;
|
|
3483
|
+
children?: ReactNode;
|
|
3484
|
+
accessibilityLabel?: string;
|
|
3485
|
+
style?: any;
|
|
3486
|
+
/** Also tags the header (`<testID>-header`) and open body (`<testID>-panel`). */
|
|
3487
|
+
testID?: string;
|
|
3488
|
+
}) => any;
|
|
3489
|
+
|
|
3490
|
+
export type SpinnerSize = "sm" | "md" | "lg";
|
|
3491
|
+
|
|
3492
|
+
/**
|
|
3493
|
+
* Themed loading spinner. It wears the theme's `colors.loader`, stops turning
|
|
3494
|
+
* under reduced motion, and is a labelled element in the busy state.
|
|
3495
|
+
*
|
|
3496
|
+
* @example
|
|
3497
|
+
* <Spinner />
|
|
3498
|
+
* <Spinner size="lg" label="Loading orders" />
|
|
3499
|
+
*/
|
|
3500
|
+
export const Spinner: (props: {
|
|
3501
|
+
/** A named size, or a diameter in px. Defaults to "md". */
|
|
3502
|
+
size?: SpinnerSize | number;
|
|
3503
|
+
/** Overrides `colors.loader`, for contrast inside a control you fill yourself. */
|
|
3504
|
+
color?: string;
|
|
3505
|
+
/** Drawn under the ring. A string is also the accessibility label; a node stays exposed as content. */
|
|
3506
|
+
label?: ReactNode;
|
|
3507
|
+
/** The accessibility label, instead of `label` / the app's own "Loading…". */
|
|
3508
|
+
accessibilityLabel?: string;
|
|
3509
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
3510
|
+
theme?: any;
|
|
3511
|
+
style?: any;
|
|
3512
|
+
testID?: string;
|
|
3513
|
+
}) => any;
|
|
3514
|
+
|
|
3515
|
+
export type SkeletonShape = "text" | "block" | "circle" | "listRow";
|
|
3516
|
+
|
|
3517
|
+
/**
|
|
3518
|
+
* Themed loading placeholder. Its colour and corners come from the theme's
|
|
3519
|
+
* `loading` scope. The outermost Skeleton pulses as a whole (still under
|
|
3520
|
+
* reduced motion), is the one loading region (`role="status"` + `aria-busy`
|
|
3521
|
+
* on the web; a labelled accessible element in the busy state on native), and
|
|
3522
|
+
* hides every bone inside it from assistive technology — pass children to
|
|
3523
|
+
* group bones.
|
|
3524
|
+
*
|
|
3525
|
+
* @example
|
|
3526
|
+
* <Skeleton shape="listRow" count={4} />
|
|
3527
|
+
* <Skeleton style={{ flexDirection: "row", gap: 12 }}>
|
|
3528
|
+
* <Skeleton shape="circle" />
|
|
3529
|
+
* <Skeleton shape="text" lines={2} style={{ flex: 1 }} />
|
|
3530
|
+
* </Skeleton>
|
|
3531
|
+
*/
|
|
3532
|
+
export const Skeleton: (props: {
|
|
3533
|
+
/** Defaults to "text". Ignored when children are passed. */
|
|
3534
|
+
shape?: SkeletonShape;
|
|
3535
|
+
/** `text` / `block`. Defaults to "100%". */
|
|
3536
|
+
width?: number | string;
|
|
3537
|
+
/** `text` / `block`. */
|
|
3538
|
+
height?: number;
|
|
3539
|
+
/** `circle` diameter. */
|
|
3540
|
+
size?: number;
|
|
3541
|
+
/** `text`: how many lines; the last one stops short. */
|
|
3542
|
+
lines?: number;
|
|
3543
|
+
/** `listRow`: how many rows. Defaults to 3. */
|
|
3544
|
+
count?: number;
|
|
3545
|
+
/** `listRow`: draw the leading avatar. Defaults to true. */
|
|
3546
|
+
leading?: boolean;
|
|
3547
|
+
radius?: number;
|
|
3548
|
+
color?: string;
|
|
3549
|
+
/** The region's accessibility label, instead of the app's own "Loading…". */
|
|
3550
|
+
accessibilityLabel?: string;
|
|
3551
|
+
/** For hosts rendering outside a widget context; widgets omit it. */
|
|
3552
|
+
theme?: any;
|
|
3553
|
+
style?: any;
|
|
3554
|
+
testID?: string;
|
|
3555
|
+
children?: ReactNode;
|
|
3556
|
+
}) => any;
|
|
3557
|
+
|
|
3444
3558
|
export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
|
|
3445
3559
|
export const TEXT_ROLES: readonly TextRole[];
|
|
3446
3560
|
export const TEXT_TRANSFORMS: readonly string[];
|
package/dist/index.js
CHANGED
|
@@ -257,8 +257,12 @@ export {
|
|
|
257
257
|
} from "./button-style.js";
|
|
258
258
|
// The themed text-entry field; follows the theme's `muted` scope.
|
|
259
259
|
export { TextField } from "./text-field.js";
|
|
260
|
+
// Themed collapsible sections, drawn from the `card` and `list` scopes.
|
|
261
|
+
export { Accordion, AccordionItem } from "./accordion.js";
|
|
260
262
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
261
263
|
export { Tabs } from "./tabs.js";
|
|
264
|
+
// Themed loading states; follow the theme's `loading` scope.
|
|
265
|
+
export { Spinner, Skeleton } from "./loading.js";
|
|
262
266
|
// The semantic text tiers, shared with the built-in widgets.
|
|
263
267
|
export {
|
|
264
268
|
textRoleStyle,
|
package/dist/index.native.js
CHANGED
|
@@ -250,8 +250,12 @@ export {
|
|
|
250
250
|
} from "./button-style.js";
|
|
251
251
|
// The themed text-entry field; follows the theme's `muted` scope.
|
|
252
252
|
export { TextField } from "./text-field.native.js";
|
|
253
|
+
// Themed collapsible sections, drawn from the `card` and `list` scopes.
|
|
254
|
+
export { Accordion, AccordionItem } from "./accordion.native.js";
|
|
253
255
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
254
256
|
export { Tabs } from "./tabs.native.js";
|
|
257
|
+
// Themed loading states; follow the theme's `loading` scope.
|
|
258
|
+
export { Spinner, Skeleton } from "./loading.native.js";
|
|
255
259
|
// The semantic text tiers, shared with the built-in widgets.
|
|
256
260
|
export {
|
|
257
261
|
textRoleStyle,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The values `<Spinner>` and `<Skeleton>` paint with. Platform-free, like
|
|
2
|
+
// tabs-tokens.js, so both hosts draw one loading look and tests can pin it.
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
5
|
+
import { normaliseThemeComponents } from "./theme-components.js";
|
|
6
|
+
|
|
7
|
+
// `md` and `lg` are the platform spinner's small and large.
|
|
8
|
+
export const SPINNER_SIZES = Object.freeze({ sm: 16, md: 20, lg: 36 });
|
|
9
|
+
export const DEFAULT_SPINNER_SIZE = "md";
|
|
10
|
+
export const SPINNER_TURN_MS = 750;
|
|
11
|
+
|
|
12
|
+
export const SKELETON_SHAPES = Object.freeze(["text", "block", "circle", "listRow"]);
|
|
13
|
+
export const DEFAULT_SKELETON_SHAPE = "text";
|
|
14
|
+
// One way of the pulse; a full breath is twice this.
|
|
15
|
+
export const SKELETON_PULSE_MS = 900;
|
|
16
|
+
export const SKELETON_PULSE_MIN_OPACITY = 0.5;
|
|
17
|
+
export const SKELETON_TEXT_HEIGHT = 12;
|
|
18
|
+
export const SKELETON_TEXT_GAP = 8;
|
|
19
|
+
export const SKELETON_BLOCK_HEIGHT = 96;
|
|
20
|
+
export const SKELETON_CIRCLE_SIZE = 40;
|
|
21
|
+
export const SKELETON_LIST_ROWS = 3;
|
|
22
|
+
|
|
23
|
+
// A paragraph's last line stops short, as real text does.
|
|
24
|
+
const LAST_LINE_WIDTH = "60%";
|
|
25
|
+
// A list row's title and subtitle.
|
|
26
|
+
export const SKELETON_LIST_ROW_LINE_WIDTHS = Object.freeze(["45%", "30%"]);
|
|
27
|
+
|
|
28
|
+
// Per-instance overrides a widget may pass, named as the theme tokens are.
|
|
29
|
+
export const LOADING_TOKEN_NAMES = Object.freeze(["skeletonColor", "skeletonRadius"]);
|
|
30
|
+
|
|
31
|
+
function isSet(value) {
|
|
32
|
+
return value !== undefined && value !== null && value !== "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pick(overrides, scope, name) {
|
|
36
|
+
if (overrides && isSet(overrides[name])) return overrides[name];
|
|
37
|
+
if (scope && isSet(scope[name])) return scope[name];
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a skeleton's look: explicit override, then the theme's `loading`
|
|
43
|
+
* scope, then the theme tokens. The scope passes the contract's own bounds, so
|
|
44
|
+
* a hand-edited theme cannot reach a native style unvalidated.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} theme — the resolved workspace theme (`useTheme()` shape).
|
|
47
|
+
* @param {object} [overrides] — per-instance values keyed by LOADING_TOKEN_NAMES.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveLoadingTokens(theme, overrides) {
|
|
50
|
+
const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
|
|
51
|
+
const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
|
|
52
|
+
const radii = base.radii || DEFAULT_THEME_TOKENS.radii;
|
|
53
|
+
const scope = normaliseThemeComponents({
|
|
54
|
+
loading: base.components && base.components.loading,
|
|
55
|
+
}).loading;
|
|
56
|
+
return {
|
|
57
|
+
skeletonColor: pick(overrides, scope, "skeletonColor") ?? colors.surfaceMuted,
|
|
58
|
+
skeletonRadius: pick(overrides, scope, "skeletonRadius") ?? radii.sm,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A spinner's colour: the one a widget passed, else the theme's `loader`, the
|
|
64
|
+
* app's Loading Indicator colour, which each painted surface re-derives.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveSpinnerColor(theme, color) {
|
|
67
|
+
if (isSet(color)) return color;
|
|
68
|
+
const colors = (theme && theme.colors) || DEFAULT_THEME_TOKENS.colors;
|
|
69
|
+
return colors.loader || DEFAULT_THEME_TOKENS.colors.loader;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A spinner's diameter: a named size, or the number a widget passed. */
|
|
73
|
+
export function spinnerDiameter(size) {
|
|
74
|
+
if (typeof size === "number" && Number.isFinite(size) && size > 0) return size;
|
|
75
|
+
return SPINNER_SIZES[size] || SPINNER_SIZES[DEFAULT_SPINNER_SIZE];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function spinnerStrokeWidth(diameter) {
|
|
79
|
+
return Math.max(2, Math.round(diameter / 8));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normaliseSkeletonShape(shape) {
|
|
83
|
+
return SKELETON_SHAPES.includes(shape) ? shape : DEFAULT_SKELETON_SHAPE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The box one bone is drawn in.
|
|
88
|
+
*
|
|
89
|
+
* @param {"text"|"block"|"circle"} shape
|
|
90
|
+
* @param {{ width?: number|string, height?: number, size?: number }} dims
|
|
91
|
+
* @param {number} radius — the resolved `skeletonRadius`.
|
|
92
|
+
*/
|
|
93
|
+
export function skeletonBox(shape, dims, radius) {
|
|
94
|
+
const { width, height, size } = dims || {};
|
|
95
|
+
if (shape === "circle") {
|
|
96
|
+
const diameter = isSet(size) ? size : SKELETON_CIRCLE_SIZE;
|
|
97
|
+
return { width: diameter, height: diameter, borderRadius: diameter / 2 };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
width: isSet(width) ? width : "100%",
|
|
101
|
+
height: isSet(height)
|
|
102
|
+
? height
|
|
103
|
+
: shape === "block"
|
|
104
|
+
? SKELETON_BLOCK_HEIGHT
|
|
105
|
+
: SKELETON_TEXT_HEIGHT,
|
|
106
|
+
borderRadius: radius,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The width of each line of a `text` skeleton, top to bottom. */
|
|
111
|
+
export function skeletonLineWidths(lines, width) {
|
|
112
|
+
const count = Math.max(1, Math.floor(Number(lines) || 1));
|
|
113
|
+
const full = isSet(width) ? width : "100%";
|
|
114
|
+
return Array.from({ length: count }, (_, i) =>
|
|
115
|
+
count > 1 && i === count - 1 ? LAST_LINE_WIDTH : full,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function skeletonListRowCount(count) {
|
|
120
|
+
const n = Math.floor(Number(count));
|
|
121
|
+
return Number.isFinite(n) && n > 0 ? n : SKELETON_LIST_ROWS;
|
|
122
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// The ONE implementation behind `<Spinner>` and `<Skeleton>`. The bindings
|
|
2
|
+
// (`loading.js` / `loading.native.js`) differ only in their primitives.
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { useHostTheme, useHostSharedText } from "./hooks.js";
|
|
6
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
7
|
+
import { CONTRACT } from "./contract.js";
|
|
8
|
+
import { resolveListTokens, listRowFrame } from "./list-tokens.js";
|
|
9
|
+
import { textRoleStyle } from "./text-roles.js";
|
|
10
|
+
import { flattenStyle } from "./flatten-style.js";
|
|
11
|
+
import {
|
|
12
|
+
resolveLoadingTokens,
|
|
13
|
+
resolveSpinnerColor,
|
|
14
|
+
spinnerDiameter,
|
|
15
|
+
spinnerStrokeWidth,
|
|
16
|
+
normaliseSkeletonShape,
|
|
17
|
+
skeletonBox,
|
|
18
|
+
skeletonLineWidths,
|
|
19
|
+
skeletonListRowCount,
|
|
20
|
+
SPINNER_TURN_MS,
|
|
21
|
+
SKELETON_PULSE_MS,
|
|
22
|
+
SKELETON_PULSE_MIN_OPACITY,
|
|
23
|
+
SKELETON_TEXT_GAP,
|
|
24
|
+
SKELETON_LIST_ROW_LINE_WIDTHS,
|
|
25
|
+
} from "./loading-tokens.js";
|
|
26
|
+
|
|
27
|
+
const LOADING_FALLBACK = CONTRACT.sharedTranslationKeys.loading.en;
|
|
28
|
+
const FILL = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 };
|
|
29
|
+
const TRACK_OPACITY = 0.2;
|
|
30
|
+
const HIDDEN = { "aria-hidden": true, importantForAccessibility: "no-hide-descendants" };
|
|
31
|
+
|
|
32
|
+
// A labelled element in the busy state, on both hosts.
|
|
33
|
+
function busyRegion(label) {
|
|
34
|
+
return { accessible: true, accessibilityLabel: label, "aria-busy": true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The web needs a role to expose the label; native has none for `status`.
|
|
38
|
+
const LOADING_REGION = { role: "status" };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bind the shared loading components to one platform's primitives.
|
|
42
|
+
*
|
|
43
|
+
* @param {{View: any, Text: any, Animated: any, Easing: any, AccessibilityInfo: any}} rn
|
|
44
|
+
* @param {{ useNativeDriver: boolean }} options — false where no native driver exists.
|
|
45
|
+
*/
|
|
46
|
+
export function makeLoading(rn, { useNativeDriver }) {
|
|
47
|
+
const { View, Text, Animated, Easing, AccessibilityInfo } = rn;
|
|
48
|
+
|
|
49
|
+
// One listener for every loading state on screen. Reduced until the host
|
|
50
|
+
// answers, so nothing moves for a user who asked it not to.
|
|
51
|
+
const motion = (() => {
|
|
52
|
+
let reduced = true;
|
|
53
|
+
let subscription = null;
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
const set = (value) => {
|
|
56
|
+
if (reduced === !!value) return;
|
|
57
|
+
reduced = !!value;
|
|
58
|
+
listeners.forEach((notify) => notify());
|
|
59
|
+
};
|
|
60
|
+
const subscribe = (notify) => {
|
|
61
|
+
listeners.add(notify);
|
|
62
|
+
if (listeners.size === 1) {
|
|
63
|
+
AccessibilityInfo.isReduceMotionEnabled().then(set, () => {});
|
|
64
|
+
subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", set);
|
|
65
|
+
}
|
|
66
|
+
return () => {
|
|
67
|
+
listeners.delete(notify);
|
|
68
|
+
if (listeners.size > 0) return;
|
|
69
|
+
if (subscription && typeof subscription.remove === "function") subscription.remove();
|
|
70
|
+
subscription = null;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
return { subscribe, isReduced: () => reduced };
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
function useReducedMotion() {
|
|
77
|
+
return React.useSyncExternalStore(motion.subscribe, motion.isReduced, motion.isReduced);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One running animation however many components draw from it. Nothing is
|
|
81
|
+
// allocated until the first loading state asks for it.
|
|
82
|
+
function sharedLoop(build, project) {
|
|
83
|
+
let value = null;
|
|
84
|
+
let output = null;
|
|
85
|
+
let users = 0;
|
|
86
|
+
let loop = null;
|
|
87
|
+
const animated = () => {
|
|
88
|
+
if (!value) {
|
|
89
|
+
value = new Animated.Value(0);
|
|
90
|
+
output = project(value);
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
};
|
|
94
|
+
const retain = () => {
|
|
95
|
+
animated();
|
|
96
|
+
users += 1;
|
|
97
|
+
if (users === 1) {
|
|
98
|
+
loop = Animated.loop(build(value));
|
|
99
|
+
loop.start();
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
users -= 1;
|
|
103
|
+
if (users > 0) return;
|
|
104
|
+
loop.stop();
|
|
105
|
+
loop = null;
|
|
106
|
+
value.setValue(0);
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
return { animated, retain };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const turn = sharedLoop(
|
|
113
|
+
(value) =>
|
|
114
|
+
Animated.timing(value, {
|
|
115
|
+
toValue: 1,
|
|
116
|
+
duration: SPINNER_TURN_MS,
|
|
117
|
+
easing: Easing.linear,
|
|
118
|
+
useNativeDriver,
|
|
119
|
+
}),
|
|
120
|
+
(value) => value.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const breathe = (value, toValue) =>
|
|
124
|
+
Animated.timing(value, {
|
|
125
|
+
toValue,
|
|
126
|
+
duration: SKELETON_PULSE_MS,
|
|
127
|
+
easing: Easing.inOut(Easing.ease),
|
|
128
|
+
useNativeDriver,
|
|
129
|
+
});
|
|
130
|
+
const pulse = sharedLoop(
|
|
131
|
+
(value) => Animated.sequence([breathe(value, 1), breathe(value, 0)]),
|
|
132
|
+
(value) =>
|
|
133
|
+
value.interpolate({ inputRange: [0, 1], outputRange: [1, SKELETON_PULSE_MIN_OPACITY] }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
function useSharedLoop(loop, moving) {
|
|
137
|
+
React.useEffect(() => (moving ? loop.retain() : undefined), [loop, moving]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function useLoadingTheme(explicitTheme) {
|
|
141
|
+
const hostTheme = useHostTheme();
|
|
142
|
+
// A host drawing outside a widget context passes its own theme.
|
|
143
|
+
return explicitTheme || hostTheme || DEFAULT_THEME_TOKENS;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Spinner({
|
|
147
|
+
size,
|
|
148
|
+
color,
|
|
149
|
+
label,
|
|
150
|
+
accessibilityLabel,
|
|
151
|
+
theme: explicitTheme,
|
|
152
|
+
style,
|
|
153
|
+
testID,
|
|
154
|
+
}) {
|
|
155
|
+
const theme = useLoadingTheme(explicitTheme);
|
|
156
|
+
const loadingText = useHostSharedText("loading", LOADING_FALLBACK);
|
|
157
|
+
const moving = !useReducedMotion();
|
|
158
|
+
useSharedLoop(turn, moving);
|
|
159
|
+
|
|
160
|
+
const ink = resolveSpinnerColor(theme, color);
|
|
161
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
162
|
+
const diameter = spinnerDiameter(size);
|
|
163
|
+
const ring = {
|
|
164
|
+
...FILL,
|
|
165
|
+
borderRadius: diameter / 2,
|
|
166
|
+
borderWidth: spinnerStrokeWidth(diameter),
|
|
167
|
+
};
|
|
168
|
+
const hasLabel = label !== undefined && label !== null && label !== "";
|
|
169
|
+
// A string caption is already the spinner's name; a node speaks for itself.
|
|
170
|
+
const named = typeof label === "string" && label !== "";
|
|
171
|
+
|
|
172
|
+
return React.createElement(
|
|
173
|
+
View,
|
|
174
|
+
{
|
|
175
|
+
...busyRegion(accessibilityLabel || (named && label) || loadingText),
|
|
176
|
+
accessibilityRole: "progressbar",
|
|
177
|
+
style: [{ alignItems: "center", justifyContent: "center", gap: spacing.sm }, style],
|
|
178
|
+
testID,
|
|
179
|
+
},
|
|
180
|
+
React.createElement(
|
|
181
|
+
Animated.View,
|
|
182
|
+
{
|
|
183
|
+
...HIDDEN,
|
|
184
|
+
style: [
|
|
185
|
+
{ width: diameter, height: diameter },
|
|
186
|
+
moving ? { transform: [{ rotate: turn.animated() }] } : null,
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
React.createElement(View, {
|
|
190
|
+
style: [ring, { borderColor: ink, opacity: TRACK_OPACITY }],
|
|
191
|
+
}),
|
|
192
|
+
React.createElement(View, {
|
|
193
|
+
style: [ring, { borderColor: "transparent", borderTopColor: ink }],
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
hasLabel
|
|
197
|
+
? React.createElement(
|
|
198
|
+
Text,
|
|
199
|
+
{
|
|
200
|
+
...(named ? HIDDEN : null),
|
|
201
|
+
style: textRoleStyle(
|
|
202
|
+
"caption",
|
|
203
|
+
theme.typography || DEFAULT_THEME_TOKENS.typography,
|
|
204
|
+
theme.colors || DEFAULT_THEME_TOKENS.colors,
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
label,
|
|
208
|
+
)
|
|
209
|
+
: null,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
Spinner.displayName = "Spinner";
|
|
213
|
+
|
|
214
|
+
// True inside a `<Skeleton>` that already is the loading region.
|
|
215
|
+
const RegionContext = React.createContext(false);
|
|
216
|
+
|
|
217
|
+
// A bone the widget sizes by aspect ratio must not keep the default height.
|
|
218
|
+
function boneBox(box, style) {
|
|
219
|
+
if (flattenStyle(style).aspectRatio === undefined) return box;
|
|
220
|
+
const { height: _defaultHeight, ...rest } = box;
|
|
221
|
+
return rest;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function boneStyle(box, tokens, style) {
|
|
225
|
+
return [boneBox(box, style), { backgroundColor: tokens.skeletonColor }, style];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function bone(shape, dims, tokens, key) {
|
|
229
|
+
return React.createElement(View, {
|
|
230
|
+
...HIDDEN,
|
|
231
|
+
key,
|
|
232
|
+
style: boneStyle(skeletonBox(shape, dims, tokens.skeletonRadius), tokens),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function listRows(theme, tokens, { count, leading = true }) {
|
|
237
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
238
|
+
// The real rows' own frame, so nothing shifts when they arrive.
|
|
239
|
+
const list = resolveListTokens(theme);
|
|
240
|
+
const rows = skeletonListRowCount(count);
|
|
241
|
+
return Array.from({ length: rows }, (_, row) =>
|
|
242
|
+
React.createElement(
|
|
243
|
+
View,
|
|
244
|
+
{
|
|
245
|
+
key: row,
|
|
246
|
+
style: {
|
|
247
|
+
...listRowFrame(list, spacing),
|
|
248
|
+
paddingVertical: list.rowPadding,
|
|
249
|
+
minHeight: list.rowMinHeight,
|
|
250
|
+
...(!list.separated && row < rows - 1 && list.dividerWidth > 0
|
|
251
|
+
? { borderBottomWidth: list.dividerWidth, borderColor: list.dividerColor }
|
|
252
|
+
: null),
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
leading ? bone("circle", null, tokens, "leading") : null,
|
|
256
|
+
React.createElement(
|
|
257
|
+
View,
|
|
258
|
+
{ style: { flex: 1, gap: SKELETON_TEXT_GAP } },
|
|
259
|
+
...SKELETON_LIST_ROW_LINE_WIDTHS.map((width) => bone("text", { width }, tokens, width)),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// What a skeleton of several bones holds; null when it is a single bone.
|
|
266
|
+
function composite(kind, props, theme, tokens) {
|
|
267
|
+
if (props.children !== undefined && props.children !== null) {
|
|
268
|
+
return { content: props.children, layout: null };
|
|
269
|
+
}
|
|
270
|
+
if (kind === "listRow") {
|
|
271
|
+
return {
|
|
272
|
+
content: listRows(theme, tokens, props),
|
|
273
|
+
layout: { gap: resolveListTokens(theme).gap },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (kind !== "text" || !(props.lines > 1)) return null;
|
|
277
|
+
const { lines, width, height } = props;
|
|
278
|
+
return {
|
|
279
|
+
content: skeletonLineWidths(lines, width).map((lineWidth, i) =>
|
|
280
|
+
bone("text", { width: lineWidth, height }, tokens, i),
|
|
281
|
+
),
|
|
282
|
+
layout: { gap: SKELETON_TEXT_GAP },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Skeleton(props) {
|
|
287
|
+
const { shape, accessibilityLabel, theme: explicitTheme, style, testID } = props;
|
|
288
|
+
const theme = useLoadingTheme(explicitTheme);
|
|
289
|
+
const loadingText = useHostSharedText("loading", LOADING_FALLBACK);
|
|
290
|
+
const nested = React.useContext(RegionContext);
|
|
291
|
+
// The outermost skeleton pulses as one; the bones inside it never animate.
|
|
292
|
+
const moving = !useReducedMotion() && !nested;
|
|
293
|
+
useSharedLoop(pulse, moving);
|
|
294
|
+
|
|
295
|
+
const tokens = resolveLoadingTokens(theme, {
|
|
296
|
+
skeletonColor: props.color,
|
|
297
|
+
skeletonRadius: props.radius,
|
|
298
|
+
});
|
|
299
|
+
const kind = normaliseSkeletonShape(shape);
|
|
300
|
+
const group = composite(kind, props, theme, tokens);
|
|
301
|
+
const own = group
|
|
302
|
+
? [group.layout, style]
|
|
303
|
+
: boneStyle(skeletonBox(kind, props, tokens.skeletonRadius), tokens, style);
|
|
304
|
+
|
|
305
|
+
if (nested) {
|
|
306
|
+
return React.createElement(
|
|
307
|
+
View,
|
|
308
|
+
{ ...(group ? null : HIDDEN), style: own, testID },
|
|
309
|
+
group && group.content,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return React.createElement(
|
|
313
|
+
RegionContext.Provider,
|
|
314
|
+
{ value: true },
|
|
315
|
+
React.createElement(
|
|
316
|
+
Animated.View,
|
|
317
|
+
{
|
|
318
|
+
...busyRegion(accessibilityLabel || loadingText),
|
|
319
|
+
...LOADING_REGION,
|
|
320
|
+
style: [own, moving ? { opacity: pulse.animated() } : null],
|
|
321
|
+
testID,
|
|
322
|
+
},
|
|
323
|
+
group && group.content,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
Skeleton.displayName = "Skeleton";
|
|
328
|
+
|
|
329
|
+
return { Spinner, Skeleton };
|
|
330
|
+
}
|
package/dist/loading.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `<Spinner>` and `<Skeleton>` (web). Binds ./loading-view.js to
|
|
2
|
+
// react-native-web; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makeLoading } from "./loading-view.js";
|
|
6
|
+
|
|
7
|
+
export const { Spinner, Skeleton } = makeLoading(ReactNative, { useNativeDriver: false });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `<Spinner>` and `<Skeleton>` (native). Binds ./loading-view.js to React
|
|
2
|
+
// Native's own primitives; only the import differs from ./loading.js.
|
|
3
|
+
|
|
4
|
+
import { View, Text, Animated, Easing, AccessibilityInfo } from "react-native";
|
|
5
|
+
import { makeLoading } from "./loading-view.js";
|
|
6
|
+
|
|
7
|
+
export const { Spinner, Skeleton } = makeLoading(
|
|
8
|
+
{ View, Text, Animated, Easing, AccessibilityInfo },
|
|
9
|
+
{ useNativeDriver: true },
|
|
10
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.204.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",
|