@colixsystems/widget-sdk 0.202.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 +29 -1
- package/dist/contract.cjs +34 -3
- package/dist/contract.js +34 -3
- package/dist/hooks.js +21 -20
- package/dist/index.d.ts +68 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -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 +34 -3
package/README.md
CHANGED
|
@@ -83,7 +83,34 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.204.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.204.0 (contract 1.163.0)
|
|
89
|
+
|
|
90
|
+
**One loading look: `Spinner`, `Skeleton`, and the `loading` theme scope.** Widgets each drew their own loading state — a platform spinner in its default colour, hand-built placeholder bars, or a line of text. Draw these instead:
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
// You know the shape of what is coming: draw its outline.
|
|
94
|
+
if (loading) return <Skeleton shape="listRow" count={4} />;
|
|
95
|
+
|
|
96
|
+
// You do not: a pending action.
|
|
97
|
+
{saving ? <Spinner size="sm" color={theme.colors.onPrimary} /> : <Text>Save</Text>}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`<Spinner size label color>` takes `size` `"sm"` | `"md"` (default) | `"lg"` or a number, and an optional `label` drawn under the ring. `<Skeleton shape>` takes `"text"` (`width`, `lines`), `"block"` (`width`, `height`), `"circle"` (`size`) or the `"listRow"` preset (`count`, `leading`), whose rows share the frame of the theme's list rows. To mirror any other layout, wrap bones in one outer `<Skeleton>` and lay them out yourself:
|
|
101
|
+
|
|
102
|
+
```jsx
|
|
103
|
+
<Skeleton style={{ flexDirection: "row", gap: 12 }}>
|
|
104
|
+
<Skeleton shape="circle" />
|
|
105
|
+
<Skeleton shape="text" lines={2} style={{ flex: 1 }} />
|
|
106
|
+
</Skeleton>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A spinner wears `theme.colors.loader`, the app's one spinner colour: set under **Design → Loading Indicator**, and re-derived for whatever surface the widget sits on, so it stays visible in a dark section. Pass `color` only inside a control you fill yourself. A skeleton's colour and corners come from **Design → Components → Loading** (`themeConfig.components.loading`: `skeletonColor`, `skeletonRadius`), defaulting to the theme's muted surface; `color` and `radius` override one skeleton.
|
|
110
|
+
|
|
111
|
+
Both stop moving when the device asks for reduced motion; the outermost skeleton pulses as a whole, so a long list costs one animation. A spinner is a labelled `progressbar` in the busy state; a string `label` is its name, and a label that is a node stays exposed as content. The outermost skeleton is the one loading region: on the web a `status` region with a label and `aria-busy`, on native one labelled accessible element in the busy state (`accessibilityLabel` overrides the app's own "Loading…"). The bones inside it are hidden from assistive technology.
|
|
112
|
+
|
|
113
|
+
The `loading` scope styles an SDK component rather than a widget's style fields, so its `targets` is empty and it names `Skeleton` in the new optional scope key **`sdkComponents`**.
|
|
87
114
|
|
|
88
115
|
### What's new in 0.202.0 (contract 1.161.0)
|
|
89
116
|
|
|
@@ -2411,6 +2438,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2411
2438
|
- `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
|
|
2412
2439
|
- `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Theme Settings (Cards, Buttons, Muted); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
|
|
2413
2440
|
- `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
|
|
2441
|
+
- `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
|
|
2414
2442
|
- `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
|
|
2415
2443
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
|
2416
2444
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
package/dist/contract.cjs
CHANGED
|
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
692
692
|
"appstudio.user-management": TABS_FIELDS,
|
|
693
693
|
}),
|
|
694
694
|
}),
|
|
695
|
+
// Loading placeholders: `<Skeleton>` reads this scope directly, so it names
|
|
696
|
+
// the SDK component it styles and binds no widget field. `<Spinner>` is not
|
|
697
|
+
// here: it wears `colors.loader`, the app's one spinner colour.
|
|
698
|
+
loading: Object.freeze({
|
|
699
|
+
label: "Loading",
|
|
700
|
+
sdkComponents: Object.freeze(["Skeleton"]),
|
|
701
|
+
tokens: Object.freeze({
|
|
702
|
+
skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
|
|
703
|
+
skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
|
|
704
|
+
}),
|
|
705
|
+
targets: Object.freeze({}),
|
|
706
|
+
}),
|
|
695
707
|
});
|
|
696
708
|
|
|
697
709
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -717,7 +729,8 @@ const HOOKS = [
|
|
|
717
729
|
"page: a container painting its own background re-derives them for its " +
|
|
718
730
|
"subtree. Read them and your text is readable wherever the widget lands; " +
|
|
719
731
|
"there is nothing to opt into. `loader` is that surface's spinner colour " +
|
|
720
|
-
"(sc-6095) —
|
|
732
|
+
"(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
|
|
733
|
+
"state with it rather than a literal grey.",
|
|
721
734
|
spacing: "{ xs, sm, md, lg, xl }",
|
|
722
735
|
spacingScale:
|
|
723
736
|
"number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
|
|
@@ -2608,7 +2621,7 @@ const PRIMITIVES = [
|
|
|
2608
2621
|
{
|
|
2609
2622
|
name: "ActivityIndicator",
|
|
2610
2623
|
description:
|
|
2611
|
-
"
|
|
2624
|
+
"The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
|
|
2612
2625
|
rnComponent: "ActivityIndicator",
|
|
2613
2626
|
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
2614
2627
|
},
|
|
@@ -2846,6 +2859,20 @@ const PRIMITIVES = [
|
|
|
2846
2859
|
rnComponent: null,
|
|
2847
2860
|
docsUrl: null,
|
|
2848
2861
|
},
|
|
2862
|
+
{
|
|
2863
|
+
name: "Spinner",
|
|
2864
|
+
description:
|
|
2865
|
+
'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
|
|
2866
|
+
rnComponent: null,
|
|
2867
|
+
docsUrl: null,
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
name: "Skeleton",
|
|
2871
|
+
description:
|
|
2872
|
+
'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
|
|
2873
|
+
rnComponent: null,
|
|
2874
|
+
docsUrl: null,
|
|
2875
|
+
},
|
|
2849
2876
|
];
|
|
2850
2877
|
|
|
2851
2878
|
const CATEGORIES = [
|
|
@@ -5339,7 +5366,11 @@ const CONTRACT = deepFreeze({
|
|
|
5339
5366
|
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5340
5367
|
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5341
5368
|
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5342
|
-
|
|
5369
|
+
// 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
|
|
5370
|
+
// `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
|
|
5371
|
+
// component scope (colour, radius). A scope may now name `sdkComponents`
|
|
5372
|
+
// and bind no widget field. Both stop under reduced motion.
|
|
5373
|
+
version: "1.163.0",
|
|
5343
5374
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5344
5375
|
hooks: HOOKS,
|
|
5345
5376
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
692
692
|
"appstudio.user-management": TABS_FIELDS,
|
|
693
693
|
}),
|
|
694
694
|
}),
|
|
695
|
+
// Loading placeholders: `<Skeleton>` reads this scope directly, so it names
|
|
696
|
+
// the SDK component it styles and binds no widget field. `<Spinner>` is not
|
|
697
|
+
// here: it wears `colors.loader`, the app's one spinner colour.
|
|
698
|
+
loading: Object.freeze({
|
|
699
|
+
label: "Loading",
|
|
700
|
+
sdkComponents: Object.freeze(["Skeleton"]),
|
|
701
|
+
tokens: Object.freeze({
|
|
702
|
+
skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
|
|
703
|
+
skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
|
|
704
|
+
}),
|
|
705
|
+
targets: Object.freeze({}),
|
|
706
|
+
}),
|
|
695
707
|
});
|
|
696
708
|
|
|
697
709
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -717,7 +729,8 @@ const HOOKS = [
|
|
|
717
729
|
"page: a container painting its own background re-derives them for its " +
|
|
718
730
|
"subtree. Read them and your text is readable wherever the widget lands; " +
|
|
719
731
|
"there is nothing to opt into. `loader` is that surface's spinner colour " +
|
|
720
|
-
"(sc-6095) —
|
|
732
|
+
"(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
|
|
733
|
+
"state with it rather than a literal grey.",
|
|
721
734
|
spacing: "{ xs, sm, md, lg, xl }",
|
|
722
735
|
spacingScale:
|
|
723
736
|
"number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
|
|
@@ -2608,7 +2621,7 @@ const PRIMITIVES = [
|
|
|
2608
2621
|
{
|
|
2609
2622
|
name: "ActivityIndicator",
|
|
2610
2623
|
description:
|
|
2611
|
-
"
|
|
2624
|
+
"The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
|
|
2612
2625
|
rnComponent: "ActivityIndicator",
|
|
2613
2626
|
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
2614
2627
|
},
|
|
@@ -2846,6 +2859,20 @@ const PRIMITIVES = [
|
|
|
2846
2859
|
rnComponent: null,
|
|
2847
2860
|
docsUrl: null,
|
|
2848
2861
|
},
|
|
2862
|
+
{
|
|
2863
|
+
name: "Spinner",
|
|
2864
|
+
description:
|
|
2865
|
+
'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
|
|
2866
|
+
rnComponent: null,
|
|
2867
|
+
docsUrl: null,
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
name: "Skeleton",
|
|
2871
|
+
description:
|
|
2872
|
+
'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
|
|
2873
|
+
rnComponent: null,
|
|
2874
|
+
docsUrl: null,
|
|
2875
|
+
},
|
|
2849
2876
|
];
|
|
2850
2877
|
|
|
2851
2878
|
const CATEGORIES = [
|
|
@@ -5335,7 +5362,11 @@ const CONTRACT = deepFreeze({
|
|
|
5335
5362
|
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5336
5363
|
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5337
5364
|
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5338
|
-
|
|
5365
|
+
// 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
|
|
5366
|
+
// `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
|
|
5367
|
+
// component scope (colour, radius). A scope may now name `sdkComponents`
|
|
5368
|
+
// and bind no widget field. Both stop under reduced motion.
|
|
5369
|
+
version: "1.163.0",
|
|
5339
5370
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5340
5371
|
hooks: HOOKS,
|
|
5341
5372
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -221,6 +221,23 @@ export function useHostNavigation() {
|
|
|
221
221
|
return ctx ? ctx.navigation : undefined;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// A host translation, or null on a miss: both hosts answer a miss with the
|
|
225
|
+
// key itself or a `{{t:…}}` placeholder.
|
|
226
|
+
function hostTranslation(hostT, key) {
|
|
227
|
+
const text = hostT(key);
|
|
228
|
+
const hit =
|
|
229
|
+
typeof text === "string" && text.length > 0 && text !== key && !text.startsWith("{{t:");
|
|
230
|
+
return hit ? text : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// @internal — a predefined shared string in the host's language, or
|
|
234
|
+
// `fallback` outside a widget context. Not re-exported from index.js.
|
|
235
|
+
export function useHostSharedText(key, fallback) {
|
|
236
|
+
const ctx = useContext(HostWidgetContext);
|
|
237
|
+
const hostT = ctx && ctx.i18n && typeof ctx.i18n.t === "function" ? ctx.i18n.t : null;
|
|
238
|
+
return (hostT && hostTranslation(hostT, sharedTranslationKey(key))) || fallback;
|
|
239
|
+
}
|
|
240
|
+
|
|
224
241
|
/**
|
|
225
242
|
* REQ-THEME-13 — returns the author-set per-widget style values: the object the
|
|
226
243
|
* host delivers under `props.style`, keyed by the style-field names the widget
|
|
@@ -863,16 +880,8 @@ export function useI18n() {
|
|
|
863
880
|
// miss form both hosts emit). No fallback is passed here so a miss
|
|
864
881
|
// is unambiguous and we can fall through to the raw key.
|
|
865
882
|
if (widgetId) {
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
if (
|
|
869
|
-
typeof scoped === "string" &&
|
|
870
|
-
scoped.length > 0 &&
|
|
871
|
-
scoped !== namespaced &&
|
|
872
|
-
!scoped.startsWith("{{t:")
|
|
873
|
-
) {
|
|
874
|
-
return scoped;
|
|
875
|
-
}
|
|
883
|
+
const scoped = hostTranslation(hostT, widgetTranslationKey(widgetId, key));
|
|
884
|
+
if (scoped !== null) return scoped;
|
|
876
885
|
}
|
|
877
886
|
// 2) REQ-L10N-SHARED: for a predefined SHARED key, try the tenant-wide
|
|
878
887
|
// `shared.<key>` namespace next. This is what lets identical
|
|
@@ -883,16 +892,8 @@ export function useI18n() {
|
|
|
883
892
|
// instance. Only the predefined keys are tried here — an
|
|
884
893
|
// author-invented bare key is never silently shared.
|
|
885
894
|
if (isSharedTranslationKey(key)) {
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
if (
|
|
889
|
-
typeof scoped === "string" &&
|
|
890
|
-
scoped.length > 0 &&
|
|
891
|
-
scoped !== shared &&
|
|
892
|
-
!scoped.startsWith("{{t:")
|
|
893
|
-
) {
|
|
894
|
-
return scoped;
|
|
895
|
-
}
|
|
895
|
+
const scoped = hostTranslation(hostT, sharedTranslationKey(key));
|
|
896
|
+
if (scoped !== null) return scoped;
|
|
896
897
|
}
|
|
897
898
|
// 3) Fall back to the raw key (shared app keys + widgets with no
|
|
898
899
|
// manifest translations — unchanged from pre-1.10 behaviour).
|
package/dist/index.d.ts
CHANGED
|
@@ -3487,6 +3487,74 @@ export const AccordionItem: (props: {
|
|
|
3487
3487
|
testID?: string;
|
|
3488
3488
|
}) => any;
|
|
3489
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
|
+
|
|
3490
3558
|
export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
|
|
3491
3559
|
export const TEXT_ROLES: readonly TextRole[];
|
|
3492
3560
|
export const TEXT_TRANSFORMS: readonly string[];
|
package/dist/index.js
CHANGED
|
@@ -261,6 +261,8 @@ export { TextField } from "./text-field.js";
|
|
|
261
261
|
export { Accordion, AccordionItem } from "./accordion.js";
|
|
262
262
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
263
263
|
export { Tabs } from "./tabs.js";
|
|
264
|
+
// Themed loading states; follow the theme's `loading` scope.
|
|
265
|
+
export { Spinner, Skeleton } from "./loading.js";
|
|
264
266
|
// The semantic text tiers, shared with the built-in widgets.
|
|
265
267
|
export {
|
|
266
268
|
textRoleStyle,
|
package/dist/index.native.js
CHANGED
|
@@ -254,6 +254,8 @@ export { TextField } from "./text-field.native.js";
|
|
|
254
254
|
export { Accordion, AccordionItem } from "./accordion.native.js";
|
|
255
255
|
// A themed tab strip; follows the theme's `tabs` scope.
|
|
256
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";
|
|
257
259
|
// The semantic text tiers, shared with the built-in widgets.
|
|
258
260
|
export {
|
|
259
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",
|
package/src/contract.js
CHANGED
|
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
692
692
|
"appstudio.user-management": TABS_FIELDS,
|
|
693
693
|
}),
|
|
694
694
|
}),
|
|
695
|
+
// Loading placeholders: `<Skeleton>` reads this scope directly, so it names
|
|
696
|
+
// the SDK component it styles and binds no widget field. `<Spinner>` is not
|
|
697
|
+
// here: it wears `colors.loader`, the app's one spinner colour.
|
|
698
|
+
loading: Object.freeze({
|
|
699
|
+
label: "Loading",
|
|
700
|
+
sdkComponents: Object.freeze(["Skeleton"]),
|
|
701
|
+
tokens: Object.freeze({
|
|
702
|
+
skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
|
|
703
|
+
skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
|
|
704
|
+
}),
|
|
705
|
+
targets: Object.freeze({}),
|
|
706
|
+
}),
|
|
695
707
|
});
|
|
696
708
|
|
|
697
709
|
// sc-4505 — the value types a widget event payload field may declare. Narrower
|
|
@@ -717,7 +729,8 @@ const HOOKS = [
|
|
|
717
729
|
"page: a container painting its own background re-derives them for its " +
|
|
718
730
|
"subtree. Read them and your text is readable wherever the widget lands; " +
|
|
719
731
|
"there is nothing to opt into. `loader` is that surface's spinner colour " +
|
|
720
|
-
"(sc-6095) —
|
|
732
|
+
"(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
|
|
733
|
+
"state with it rather than a literal grey.",
|
|
721
734
|
spacing: "{ xs, sm, md, lg, xl }",
|
|
722
735
|
spacingScale:
|
|
723
736
|
"number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
|
|
@@ -2608,7 +2621,7 @@ const PRIMITIVES = [
|
|
|
2608
2621
|
{
|
|
2609
2622
|
name: "ActivityIndicator",
|
|
2610
2623
|
description:
|
|
2611
|
-
"
|
|
2624
|
+
"The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
|
|
2612
2625
|
rnComponent: "ActivityIndicator",
|
|
2613
2626
|
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
2614
2627
|
},
|
|
@@ -2846,6 +2859,20 @@ const PRIMITIVES = [
|
|
|
2846
2859
|
rnComponent: null,
|
|
2847
2860
|
docsUrl: null,
|
|
2848
2861
|
},
|
|
2862
|
+
{
|
|
2863
|
+
name: "Spinner",
|
|
2864
|
+
description:
|
|
2865
|
+
'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
|
|
2866
|
+
rnComponent: null,
|
|
2867
|
+
docsUrl: null,
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
name: "Skeleton",
|
|
2871
|
+
description:
|
|
2872
|
+
'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
|
|
2873
|
+
rnComponent: null,
|
|
2874
|
+
docsUrl: null,
|
|
2875
|
+
},
|
|
2849
2876
|
];
|
|
2850
2877
|
|
|
2851
2878
|
const CATEGORIES = [
|
|
@@ -5335,7 +5362,11 @@ const CONTRACT = deepFreeze({
|
|
|
5335
5362
|
// 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
|
|
5336
5363
|
// collapsible sections drawn from the `card` and `list` scopes: single
|
|
5337
5364
|
// or multiple open, controlled or uncontrolled, `aria-expanded` headers.
|
|
5338
|
-
|
|
5365
|
+
// 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
|
|
5366
|
+
// `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
|
|
5367
|
+
// component scope (colour, radius). A scope may now name `sdkComponents`
|
|
5368
|
+
// and bind no widget field. Both stop under reduced motion.
|
|
5369
|
+
version: "1.163.0",
|
|
5339
5370
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5340
5371
|
hooks: HOOKS,
|
|
5341
5372
|
primitives: PRIMITIVES,
|