@delacour/react-native-ui 0.1.0-alpha.20260925053522
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 +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +193 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +434 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +242 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +246 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +150 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +105 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +293 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +112 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +47 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +95 -0
- package/src/components/field/field.tsx +157 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +240 -0
- package/src/components/field/field.variants.ts +156 -0
- package/src/components/field/index.ts +22 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +74 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +261 -0
- package/src/components/pressable/pressable.variants.test.ts +128 -0
- package/src/components/pressable/pressable.variants.ts +80 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +245 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +580 -0
- package/src/components/radio/radio.variants.ts +271 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +50 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +193 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +856 -0
- package/src/components/slider/slider.variants.ts +661 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +287 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +670 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Spinner
|
|
2
|
+
|
|
3
|
+
An animated loading indicator. Compound root plus `Spinner.Content`, the part
|
|
4
|
+
that rotates.
|
|
5
|
+
|
|
6
|
+
`import { Spinner } from "@delacour/react-native-ui/spinner";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/spinner` |
|
|
13
|
+
| `spinner.tsx` | Root + the `Object.assign` compound surface |
|
|
14
|
+
| `spinner-content.tsx` | `Spinner.Content`, the rotating layer |
|
|
15
|
+
| `spinner-arc.tsx` | The default arc glyph |
|
|
16
|
+
| `spinner.context.tsx` | `SpinnerContext`, `useSpinner()`, `useSpinnerContext()` |
|
|
17
|
+
| `spinner.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
|
|
18
|
+
| `spinner.variants.test.ts` | |
|
|
19
|
+
|
|
20
|
+
## Design
|
|
21
|
+
|
|
22
|
+
- **Sizes**: the icon scale, shared outright — `SPINNER_SIZES` *is*
|
|
23
|
+
`ICON_SIZES`, `xs`…`2xl` (14/16/18/20/24/32pt) — or an explicit number. A
|
|
24
|
+
spinner stands in for an icon, so the two must agree on what `md` means. **The root is the only sized box** — `Spinner.Content` is
|
|
25
|
+
`size-full` and the arc carries no width or height at all, because
|
|
26
|
+
react-native-svg resolves both to `'100%'` when neither is set. That
|
|
27
|
+
`size-full` is load-bearing: content-size the middle layer and the percentage
|
|
28
|
+
resolves against an indefinite parent, collapsing the glyph to zero.
|
|
29
|
+
**Colours**: `default`, `success`, `warning`, `destructive`, plus any token the
|
|
30
|
+
theme emits (`primary-foreground`, `muted-foreground`) or a literal
|
|
31
|
+
(`#EC4899`). A Tailwind palette name like `emerald-500` only resolves if some
|
|
32
|
+
utility class already pulled that variable into the build — otherwise the
|
|
33
|
+
token is unresolved and nothing is drawn. Prefer the semantic tokens.
|
|
34
|
+
- **Size and colour are inherited, not passed** — through exactly one path, the
|
|
35
|
+
nearest `IconDefaultsProvider`, falling back to `md` on `foreground`. A
|
|
36
|
+
[`Button`](../button/AGENTS.md) already wraps the spinner it composes in with
|
|
37
|
+
its own icon class and variant foreground, so the spinner does **not** read
|
|
38
|
+
the button's context: a second path would recompute the same two values and
|
|
39
|
+
could drift from them. An explicit `size` or `color` always wins — the
|
|
40
|
+
precedence [`Icon`](../icon/AGENTS.md) follows.
|
|
41
|
+
- **Any child is the glyph**, wrapped in a `Spinner.Content` automatically so it
|
|
42
|
+
still rotates — a custom icon or asset needs nothing but to be passed in.
|
|
43
|
+
`Spinner.Content` is the rotating layer itself, so write it out by hand only
|
|
44
|
+
when that layer needs styling.
|
|
45
|
+
- **`speed` belongs to the root**, not to `Spinner.Content`. It rides the
|
|
46
|
+
spinner's context alongside the resolved size and colour, so every part of the
|
|
47
|
+
spinner turns at one rate and a caller never reaches past the root to set it:
|
|
48
|
+
`<Spinner speed={0.7}>` — 1 is one full turn per 900ms.
|
|
49
|
+
- **The default glyph is drawn from SVG primitives**, not a Central Icon — the
|
|
50
|
+
set has no loader glyph. Rule 5 governs *icons*; primitives are fine.
|
|
51
|
+
- **The arc's caps are `butt`, and its round head is a separate `Circle`.** The
|
|
52
|
+
two half-rings share the endpoint at the bottom of the ring, where both sit at
|
|
53
|
+
`SPINNER_ARC_JOINT_OPACITY`. Round caps there stack, and two semi-transparent
|
|
54
|
+
discs composite to roughly 0.8 alpha — a bright dot straddling the joint,
|
|
55
|
+
opposite the head, at every size. Butt caps abut instead, and the one end that
|
|
56
|
+
wants rounding gets a disc of its own, drawn last so it sits on top; the tail
|
|
57
|
+
terminates fully transparent at that same point, so its flat end is invisible.
|
|
58
|
+
Do not put `strokeLinecap="round"` back.
|
|
59
|
+
- **The gradient stops are angle-compensated, and run in user space.** A linear
|
|
60
|
+
gradient fades along its axis and that axis is `y`, but a point at angle θ
|
|
61
|
+
clockwise from the top sits at `y = 12 - 10·cos θ` — so two stops alone make
|
|
62
|
+
the fade stall near 3 and 9 o'clock and race through 12 and 6, and the ring
|
|
63
|
+
reads as a bright chunk beside a flat grey quadrant rather than as an even
|
|
64
|
+
comet. `spinnerArcStops` places each stop at the offset the arc actually
|
|
65
|
+
occupies at that angle while stepping the opacity evenly, which inverts the
|
|
66
|
+
skew; it is pure, so `bun test` pins the whole ladder.
|
|
67
|
+
`gradientUnits="userSpaceOnUse"` then keeps the endpoints exact, where an
|
|
68
|
+
object bounding box would leave open whether a given renderer includes the
|
|
69
|
+
stroke — which shifts both ends off 0 and 1 and clips the head and the tail.
|
|
70
|
+
- **The rotation sets `ReduceMotion.Never` deliberately.** Under the default
|
|
71
|
+
`System` policy `withTiming` completes instantly while the OS reduce-motion
|
|
72
|
+
setting is on, so `withRepeat(-1)` would spin a zero-length animation forever.
|
|
73
|
+
A status indicator is not decorative motion.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { Spinner, type SpinnerProps } from "./spinner";
|
|
2
|
+
export { useSpinner } from "./spinner.context";
|
|
3
|
+
export {
|
|
4
|
+
resolveSpinnerColor,
|
|
5
|
+
resolveSpinnerRootClass,
|
|
6
|
+
SPINNER_COLOR_TOKEN,
|
|
7
|
+
SPINNER_COLORS,
|
|
8
|
+
SPINNER_DURATION_MS,
|
|
9
|
+
SPINNER_FALLBACK_COLOR,
|
|
10
|
+
SPINNER_FALLBACK_SIZE_CLASS,
|
|
11
|
+
SPINNER_GLYPH_SIZE_CLASS,
|
|
12
|
+
SPINNER_SIZES,
|
|
13
|
+
type SpinnerColor,
|
|
14
|
+
type SpinnerSize,
|
|
15
|
+
spinnerVariants,
|
|
16
|
+
} from "./spinner.variants";
|
|
17
|
+
export type { SpinnerContentProps } from "./spinner-content";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type ReactElement, useId } from "react";
|
|
2
|
+
import Svg, { Circle, Defs, LinearGradient, Path, Stop } from "react-native-svg";
|
|
3
|
+
import { useSpinnerContext } from "./spinner.context";
|
|
4
|
+
import {
|
|
5
|
+
SPINNER_ARC_HEAD_OPACITY,
|
|
6
|
+
SPINNER_ARC_JOINT_OPACITY,
|
|
7
|
+
SPINNER_ARC_STROKE_WIDTH,
|
|
8
|
+
SPINNER_ARC_TAIL_OPACITY,
|
|
9
|
+
spinnerArcStops,
|
|
10
|
+
} from "./spinner.variants";
|
|
11
|
+
|
|
12
|
+
// The ring's geometry, in viewBox units. Everything below is derived from these
|
|
13
|
+
// three numbers — the paths, the gradient axis, the head cap and the viewBox —
|
|
14
|
+
// so the head cannot end up drawn somewhere the stroke no longer passes.
|
|
15
|
+
const CENTRE = 12;
|
|
16
|
+
const RADIUS = 10;
|
|
17
|
+
const TOP = CENTRE - RADIUS;
|
|
18
|
+
const BOTTOM = CENTRE + RADIUS;
|
|
19
|
+
|
|
20
|
+
const VIEW_BOX = `0 0 ${CENTRE * 2} ${CENTRE * 2}`;
|
|
21
|
+
const LEAD_PATH = `M ${CENTRE} ${TOP} A ${RADIUS} ${RADIUS} 0 0 1 ${CENTRE} ${BOTTOM}`;
|
|
22
|
+
const TAIL_PATH = `M ${CENTRE} ${BOTTOM} A ${RADIUS} ${RADIUS} 0 0 1 ${CENTRE} ${TOP}`;
|
|
23
|
+
|
|
24
|
+
const LEAD_STOPS = spinnerArcStops(SPINNER_ARC_HEAD_OPACITY, SPINNER_ARC_JOINT_OPACITY);
|
|
25
|
+
const TAIL_STOPS = spinnerArcStops(SPINNER_ARC_TAIL_OPACITY, SPINNER_ARC_JOINT_OPACITY);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The default glyph: a ring whose stroke fades from opaque to transparent
|
|
29
|
+
* around the circle.
|
|
30
|
+
*
|
|
31
|
+
* Drawn as two half-rings because a linear gradient only fades along one axis —
|
|
32
|
+
* one alone cannot carry the stroke all the way round. Central Icons has no
|
|
33
|
+
* loader glyph, so this is drawn from SVG primitives rather than imported.
|
|
34
|
+
*
|
|
35
|
+
* It carries no width or height on purpose. react-native-svg resolves both to
|
|
36
|
+
* `'100%'` when neither is set, so the arc fills whatever box the spinner root
|
|
37
|
+
* sized — one place decides the size instead of threading a number down. The
|
|
38
|
+
* viewBox stays fixed at 24, so the stroke stays proportional at every size
|
|
39
|
+
* with no geometry to recompute.
|
|
40
|
+
*
|
|
41
|
+
* **The caps are `butt`, and the round head is a separate `Circle`.** The two
|
|
42
|
+
* half-rings share the endpoint at the bottom of the ring, where both sit at
|
|
43
|
+
* `SPINNER_ARC_JOINT_OPACITY`. Round caps there stack, and two semi-transparent
|
|
44
|
+
* discs composite to roughly 0.8 alpha — a bright dot straddling the joint,
|
|
45
|
+
* opposite the head. Butt caps abut instead, and the one endpoint that actually
|
|
46
|
+
* wants a rounded end gets a disc of its own, drawn last so it sits on top. The
|
|
47
|
+
* tail terminates fully transparent at that same point, so its flat end is
|
|
48
|
+
* invisible and needs nothing. Do not put `strokeLinecap="round"` back.
|
|
49
|
+
*
|
|
50
|
+
* **The gradients run in user space.** The viewBox is fixed and the geometry is
|
|
51
|
+
* hard-coded, so `y = TOP … BOTTOM` is exact — where an object bounding box
|
|
52
|
+
* would leave open whether a given renderer includes the stroke, which shifts
|
|
53
|
+
* both endpoints off 0 and 1 and clips the head and the tail.
|
|
54
|
+
*
|
|
55
|
+
* Reads the spinner context optionally rather than through `useSpinner`: the
|
|
56
|
+
* arc is also the fallback glyph and must not throw outside a `<Spinner>`.
|
|
57
|
+
* Outside one there is no colour to resolve, and every paint here falls back to
|
|
58
|
+
* SVG's own black.
|
|
59
|
+
*/
|
|
60
|
+
export function SpinnerArc(): ReactElement {
|
|
61
|
+
const spinner = useSpinnerContext();
|
|
62
|
+
const resolvedColor = spinner?.color;
|
|
63
|
+
|
|
64
|
+
// An SVG gradient is referenced by id, and ids are global to the document.
|
|
65
|
+
// React 19 emits `«r1»`-style ids, whose delimiters are invalid in `url(#…)`.
|
|
66
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
67
|
+
const leadId = `spinner-lead-${id}`;
|
|
68
|
+
const tailId = `spinner-tail-${id}`;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Svg fill="none" viewBox={VIEW_BOX}>
|
|
72
|
+
<Defs>
|
|
73
|
+
<LinearGradient gradientUnits="userSpaceOnUse" id={leadId} x1={0} x2={0} y1={TOP} y2={BOTTOM}>
|
|
74
|
+
{LEAD_STOPS.map((stop) => (
|
|
75
|
+
<Stop key={stop.offset} offset={stop.offset} stopColor={resolvedColor} stopOpacity={stop.opacity} />
|
|
76
|
+
))}
|
|
77
|
+
</LinearGradient>
|
|
78
|
+
<LinearGradient gradientUnits="userSpaceOnUse" id={tailId} x1={0} x2={0} y1={TOP} y2={BOTTOM}>
|
|
79
|
+
{TAIL_STOPS.map((stop) => (
|
|
80
|
+
<Stop key={stop.offset} offset={stop.offset} stopColor={resolvedColor} stopOpacity={stop.opacity} />
|
|
81
|
+
))}
|
|
82
|
+
</LinearGradient>
|
|
83
|
+
</Defs>
|
|
84
|
+
<Path d={LEAD_PATH} stroke={`url(#${leadId})`} strokeLinecap="butt" strokeWidth={SPINNER_ARC_STROKE_WIDTH} />
|
|
85
|
+
<Path d={TAIL_PATH} stroke={`url(#${tailId})`} strokeLinecap="butt" strokeWidth={SPINNER_ARC_STROKE_WIDTH} />
|
|
86
|
+
<Circle cx={CENTRE} cy={TOP} fill={resolvedColor} r={SPINNER_ARC_STROKE_WIDTH / 2} />
|
|
87
|
+
</Svg>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
SpinnerArc.displayName = "DelacourUI.Spinner.Arc";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactElement, type ReactNode, useEffect } from "react";
|
|
2
|
+
import Animated, {
|
|
3
|
+
cancelAnimation,
|
|
4
|
+
Easing,
|
|
5
|
+
ReduceMotion,
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
useSharedValue,
|
|
8
|
+
withRepeat,
|
|
9
|
+
withTiming,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { useSpinner } from "./spinner.context";
|
|
12
|
+
import { SPINNER_DURATION_MS, spinnerVariants } from "./spinner.variants";
|
|
13
|
+
import { SpinnerArc } from "./spinner-arc";
|
|
14
|
+
|
|
15
|
+
export type SpinnerContentProps = Omit<ComponentProps<typeof Animated.View>, "children"> & {
|
|
16
|
+
className?: string;
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The rotating layer of a spinner.
|
|
22
|
+
*
|
|
23
|
+
* This is the part that turns, so a custom glyph or asset has to sit inside one
|
|
24
|
+
* to spin. The spinner wraps a bare child in it automatically, which leaves
|
|
25
|
+
* writing it out by hand for when the rotating layer itself needs styling.
|
|
26
|
+
*
|
|
27
|
+
* Colour and speed come from the spinner's context, and it fills the root
|
|
28
|
+
* rather than taking a size — there is nothing to pass down to it.
|
|
29
|
+
*/
|
|
30
|
+
export function SpinnerContent({ className, style, children, ...props }: SpinnerContentProps): ReactElement {
|
|
31
|
+
const { speed } = useSpinner();
|
|
32
|
+
const angle = useSharedValue(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
angle.value = withRepeat(
|
|
36
|
+
withTiming(360, {
|
|
37
|
+
duration: SPINNER_DURATION_MS / speed,
|
|
38
|
+
easing: Easing.linear,
|
|
39
|
+
// Deliberate. Under the default `System` policy `withTiming` completes
|
|
40
|
+
// instantly while the OS reduce-motion setting is on, so `withRepeat(-1)`
|
|
41
|
+
// would spin a zero-length animation forever. A status indicator is not
|
|
42
|
+
// decorative motion — it is the only thing saying work is in flight.
|
|
43
|
+
reduceMotion: ReduceMotion.Never,
|
|
44
|
+
}),
|
|
45
|
+
-1,
|
|
46
|
+
false
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Without this the repeat outlives the unmount when a button stops loading.
|
|
50
|
+
return () => cancelAnimation(angle);
|
|
51
|
+
}, [angle, speed]);
|
|
52
|
+
|
|
53
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
54
|
+
transform: [{ rotate: `${angle.value}deg` }],
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Animated.View className={spinnerVariants().content({ className })} style={[animatedStyle, style]} {...props}>
|
|
59
|
+
{children ?? <SpinnerArc />}
|
|
60
|
+
</Animated.View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
SpinnerContent.displayName = "DelacourUI.Spinner.Content";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
|
|
3
|
+
export type SpinnerContextValue = {
|
|
4
|
+
/** Resolved colour value, ready for an SVG stroke. */
|
|
5
|
+
color: string | undefined;
|
|
6
|
+
/** Rotation speed multiplier. 1 is one full turn per 900ms. */
|
|
7
|
+
speed: number;
|
|
8
|
+
/** Whether the spinner is showing. */
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const SpinnerContext = createContext<SpinnerContextValue | null>(null);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Supplies the enclosing spinner's resolved colour and speed to its subtree.
|
|
16
|
+
*
|
|
17
|
+
* Lives in its own module, importing nothing at all, so the parts the spinner
|
|
18
|
+
* renders — `Spinner.Content` and the default arc glyph — can read it without
|
|
19
|
+
* importing `./spinner`. That import would close a cycle through `spinner.tsx`,
|
|
20
|
+
* and Metro serves a partially initialised module for a cycle, leaving the
|
|
21
|
+
* context `undefined` at import time. See AGENTS.md rule 3.
|
|
22
|
+
*/
|
|
23
|
+
export function SpinnerProvider({
|
|
24
|
+
value,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
value: SpinnerContextValue;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}): ReactElement {
|
|
30
|
+
return <SpinnerContext value={value}>{children}</SpinnerContext>;
|
|
31
|
+
}
|
|
32
|
+
SpinnerProvider.displayName = "DelacourUI.Spinner.Provider";
|
|
33
|
+
|
|
34
|
+
/** The enclosing spinner's context, or null outside a `<Spinner>`. */
|
|
35
|
+
export function useSpinnerContext(): SpinnerContextValue | null {
|
|
36
|
+
return use(SpinnerContext);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads the enclosing spinner's resolved colour and speed.
|
|
41
|
+
*
|
|
42
|
+
* Lets a custom glyph match the spinner without either being passed down to
|
|
43
|
+
* it. Size is not here: the root sizes itself and everything under it fills. Throws outside a `<Spinner>` — use {@link useSpinnerContext}
|
|
44
|
+
* where the enclosing spinner is optional.
|
|
45
|
+
*/
|
|
46
|
+
export function useSpinner(): SpinnerContextValue {
|
|
47
|
+
const context = useSpinnerContext();
|
|
48
|
+
if (!context) {
|
|
49
|
+
throw new Error("useSpinner must be called inside a <Spinner>.");
|
|
50
|
+
}
|
|
51
|
+
return context;
|
|
52
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Children, type ComponentProps, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import Animated, { Easing, FadeIn, FadeOut } from "react-native-reanimated";
|
|
3
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
4
|
+
import { IconDefaultsProvider, useIconDefaults } from "../icon";
|
|
5
|
+
import { type SpinnerContextValue, SpinnerProvider } from "./spinner.context";
|
|
6
|
+
import {
|
|
7
|
+
resolveSpinnerColor,
|
|
8
|
+
resolveSpinnerRootClass,
|
|
9
|
+
SPINNER_GLYPH_SIZE_CLASS,
|
|
10
|
+
type SpinnerColor,
|
|
11
|
+
type SpinnerSize,
|
|
12
|
+
} from "./spinner.variants";
|
|
13
|
+
import { SpinnerContent } from "./spinner-content";
|
|
14
|
+
|
|
15
|
+
const ENTERING = FadeIn.duration(200).easing(Easing.out(Easing.ease));
|
|
16
|
+
const EXITING = FadeOut.duration(100);
|
|
17
|
+
|
|
18
|
+
export type SpinnerProps = Omit<ComponentProps<typeof Animated.View>, "children" | "style"> & {
|
|
19
|
+
/** A named size, or an edge length in points. Defaults to the enclosing component's icon size. */
|
|
20
|
+
size?: SpinnerSize | number;
|
|
21
|
+
/**
|
|
22
|
+
* A named colour, any theme token the active theme defines
|
|
23
|
+
* (`primary-foreground`, `muted-foreground`) or a literal (`#EC4899`).
|
|
24
|
+
* Defaults to the enclosing component's icon colour.
|
|
25
|
+
*/
|
|
26
|
+
color?: SpinnerColor | (string & {});
|
|
27
|
+
/** Whether the spinner is shown. Fades out when flipped off. */
|
|
28
|
+
isLoading?: boolean;
|
|
29
|
+
/** Rotation speed multiplier. 1 is one full turn per 900ms. */
|
|
30
|
+
speed?: number;
|
|
31
|
+
className?: string;
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function SpinnerRoot({
|
|
36
|
+
size,
|
|
37
|
+
color,
|
|
38
|
+
isLoading = true,
|
|
39
|
+
speed = 1,
|
|
40
|
+
className,
|
|
41
|
+
children,
|
|
42
|
+
...props
|
|
43
|
+
}: SpinnerProps): ReactElement | null {
|
|
44
|
+
// One inheritance path, not two. A Button wraps its whole subtree — the
|
|
45
|
+
// spinner it composes in included — in an `IconDefaultsProvider` carrying
|
|
46
|
+
// that button's icon class and its variant's foreground, so reading the
|
|
47
|
+
// button's own context here would recompute the same two values.
|
|
48
|
+
const iconDefaults = useIconDefaults();
|
|
49
|
+
|
|
50
|
+
// A number is not a class: Tailwind's scanner is static, so a runtime
|
|
51
|
+
// `size-[40px]` is never compiled. The numeric escape hatch rides `style`.
|
|
52
|
+
const numericSize = typeof size === "number" ? size : undefined;
|
|
53
|
+
|
|
54
|
+
const rootClassName = resolveSpinnerRootClass({ className, inherited: iconDefaults?.className, size });
|
|
55
|
+
const colorToken = resolveSpinnerColor(color, iconDefaults?.color);
|
|
56
|
+
const resolvedColor = useThemeColor(colorToken);
|
|
57
|
+
|
|
58
|
+
const context = useMemo<SpinnerContextValue>(
|
|
59
|
+
() => ({ color: resolvedColor, isLoading, speed }),
|
|
60
|
+
[resolvedColor, isLoading, speed]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// A glyph composed into the spinner adopts these unless told otherwise. It
|
|
64
|
+
// fills rather than taking a step, so it matches at a numeric size too.
|
|
65
|
+
const iconContext = useMemo(() => ({ className: SPINNER_GLYPH_SIZE_CLASS, color: colorToken }), [colorToken]);
|
|
66
|
+
|
|
67
|
+
const content = useMemo(() => wrapSpinnerChildren(children), [children]);
|
|
68
|
+
|
|
69
|
+
if (!isLoading) return null;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<SpinnerProvider value={context}>
|
|
73
|
+
<Animated.View
|
|
74
|
+
accessibilityRole="progressbar"
|
|
75
|
+
accessibilityState={{ busy: true }}
|
|
76
|
+
className={rootClassName}
|
|
77
|
+
entering={ENTERING}
|
|
78
|
+
exiting={EXITING}
|
|
79
|
+
style={numericSize === undefined ? undefined : { height: numericSize, width: numericSize }}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
<IconDefaultsProvider value={iconContext}>{content}</IconDefaultsProvider>
|
|
83
|
+
</Animated.View>
|
|
84
|
+
</SpinnerProvider>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wraps a bare glyph in a `Spinner.Content` so it rotates.
|
|
90
|
+
*
|
|
91
|
+
* Without this, `<Spinner><Icon … /></Spinner>` would render a motionless icon:
|
|
92
|
+
* the rotation lives on `Spinner.Content`, not on the root. A caller who passes
|
|
93
|
+
* `Spinner.Content` themselves is left alone, since they own the rotation.
|
|
94
|
+
*/
|
|
95
|
+
function wrapSpinnerChildren(children: ReactNode): ReactNode {
|
|
96
|
+
if (children === undefined || children === null) return <SpinnerContent />;
|
|
97
|
+
|
|
98
|
+
const items = Children.toArray(children);
|
|
99
|
+
const hasContent = items.some((child) => isValidElement(child) && child.type === SpinnerContent);
|
|
100
|
+
|
|
101
|
+
return hasContent ? children : <SpinnerContent>{children}</SpinnerContent>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An animated loading indicator, composed from parts rather than configured by
|
|
106
|
+
* flags.
|
|
107
|
+
*
|
|
108
|
+
* Size and colour are inherited, not passed. Inside a `Button` the spinner
|
|
109
|
+
* reads that button's context and comes out at its icon size in its variant's
|
|
110
|
+
* foreground; elsewhere it falls back to the nearest `IconDefaultsProvider`,
|
|
111
|
+
* then to `md` on `foreground`. An explicit `size` or `color` always wins.
|
|
112
|
+
*
|
|
113
|
+
* Any child becomes the glyph and is wrapped in a `Spinner.Content` so it still
|
|
114
|
+
* rotates, so a custom icon or asset needs nothing but to be passed in.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* <Spinner size="lg" color="success" />
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* <Spinner speed={0.7}>
|
|
121
|
+
* <Icon icon={IconArrowsRepeat} />
|
|
122
|
+
* </Spinner>
|
|
123
|
+
*/
|
|
124
|
+
export const Spinner = Object.assign(SpinnerRoot, {
|
|
125
|
+
/** The rotating layer. Wraps a custom glyph or asset so it spins; applied automatically. */
|
|
126
|
+
Content: SpinnerContent,
|
|
127
|
+
displayName: "DelacourUI.Spinner",
|
|
128
|
+
});
|