@delacour/react-native-ui 0.1.0-alpha.20260925055209 → 0.1.0-alpha.20260925055848

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925055209",
3
+ "version": "0.1.0-alpha.20260925055848",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,9 @@
43
43
  "./icon": "./src/components/icon/index.ts",
44
44
  "./input": "./src/components/input/index.ts",
45
45
  "./list-group": "./src/components/list-group/index.ts",
46
+ "./meter": "./src/components/meter/index.ts",
46
47
  "./pressable": "./src/components/pressable/index.ts",
48
+ "./progress": "./src/components/progress/index.ts",
47
49
  "./provider": "./src/components/provider/index.ts",
48
50
  "./radio": "./src/components/radio/index.ts",
49
51
  "./screen": "./src/components/screen/index.ts",
@@ -82,7 +84,7 @@
82
84
  },
83
85
  "peerDependencies": {
84
86
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
85
- "@delacour/react-native-charts": "0.1.0-alpha.20260925055209",
87
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925055848",
86
88
  "@gorhom/bottom-sheet": "^5.2.8",
87
89
  "@legendapp/list": ">=3.3",
88
90
  "expo-linear-gradient": ">=15",
@@ -0,0 +1,135 @@
1
+ # Meter
2
+
3
+ A measurement on a fixed scale, coloured by where it falls — storage used, a
4
+ battery's charge, a password's strength. Built on `Progress`: the same track, fill,
5
+ header and label, with a root that clamps the reading, judges it, and paints the
6
+ fill that judgement's colour. Adds `Meter.Segments` for a scale drawn as whole
7
+ blocks.
8
+
9
+ `import { Meter } from "@delacour/react-native-ui/meter";`
10
+
11
+ ## Files
12
+
13
+ | File | What it holds |
14
+ | --- | --- |
15
+ | `index.ts` | → `@delacour/react-native-ui/meter` |
16
+ | `meter.tsx` | Root + the `Object.assign` compound surface; publishes both contexts |
17
+ | `meter-header.tsx` | `Meter.Header` — `Progress.Header`, named for the meter |
18
+ | `meter-label.tsx` | `Meter.Label` — `Progress.Label`, named for the meter |
19
+ | `meter-output.tsx` | `Meter.Output`, the readout, whose function child is handed the region |
20
+ | `meter-track.tsx` | `Meter.Track` — `Progress.Track`, named for the meter |
21
+ | `meter-fill.tsx` | `Meter.Fill` — `Progress.Fill`, named for the meter |
22
+ | `meter-segments.tsx` | `Meter.Segments`, the blocks, and the internal `Meter.Segments.Segment` |
23
+ | `meter.context.tsx` | `MeterContext`, `useMeter()`, `useMeterContext()`, `useMeterPart()` |
24
+ | `meter.types.ts` | `MeterRenderProps`, shared by the context and the readout |
25
+ | `meter.variants.ts` | The blocks' `tv()` slots + the scale, region, threshold and segment resolvers |
26
+ | `meter.variants.test.ts` | |
27
+
28
+ ## Built on Progress
29
+
30
+ - **The root publishes `ProgressContextValue` as well as its own context.**
31
+ `Progress.Track` and `Progress.Fill` read only `ratio`, `isIndeterminate`,
32
+ `color`, `size` and `trackSize` — the progress bar was written to be reused
33
+ this way — so `Meter.Track` and `Meter.Fill` *are* those parts, and the fill's
34
+ UI-thread transform, its seeded first frame and its hide-until-measured come
35
+ along without a line restated. `isIndeterminate` is always `false`.
36
+ - **Each reused part is a thin wrapper, not the progress part assigned
37
+ directly.** The wrapper calls `useMeterPart` first, so a `Meter.Fill` outside
38
+ a meter throws naming `Meter.Fill` rather than `Progress.Fill`, and DevTools
39
+ shows `DelacourUI.Meter.Fill` (rule 12).
40
+ - **The parts come through `../progress`, the index; the resolvers through
41
+ `../progress/progress.variants`, the leaf** (rule 3). Progress imports nothing
42
+ from here, so there is no cycle, and the registry records `progress` as a
43
+ dependency of `meter`.
44
+ - **Colours and sizes are the progress bar's tuples, re-exported, not
45
+ restated.** A test pins them equal, and another pins a block's height and
46
+ paint to the progress track's and fill's, so a segmented meter and a
47
+ continuous one in one card read as one instrument.
48
+
49
+ ## Judging the reading
50
+
51
+ - **Two ways, never both, and the prop type says so.** `MeterProps` is a union:
52
+ `low` / `high` / `optimum` *or* `thresholds`. Passing both is a type error
53
+ rather than a precedence rule. `resolveMeterScale` turns the props into a
54
+ `MeterScale` — `plain`, `regions` or `thresholds` — and `resolveMeterColor`
55
+ switches on its `kind`.
56
+ - **Regions follow a gauge's three-part model.** `low` and `high` default to the
57
+ scale's ends and `optimum` to its middle; each is clamped into the scale and
58
+ `high` is never below `low`, so a boundary written the wrong way round is
59
+ pulled into order rather than inverting every region. `optimum` below `low`
60
+ makes the bottom good (a disk), above `high` the top (a battery), between them
61
+ the band (a temperature). The regions paint `success`, `warning`,
62
+ `destructive` — `METER_REGION_COLORS`, fixed, so a region reads the same in
63
+ every meter in an app — and `color` is ignored.
64
+ - **Thresholds: the highest `from` reached wins, in any listed order.** `from`
65
+ is in the reading's units, not a percentage — a threshold on a 0–256 GB meter
66
+ is written in gigabytes. A non-finite `from` is ignored. Below every threshold
67
+ `color` applies.
68
+ - **The clamped reading is what is judged.** A disk over its capacity is
69
+ `critical`, not off the scale.
70
+ - **Colour is never the only signal, and `valueLabel` takes a function for it.**
71
+ Nothing speaks the region on its own — the words are domain-specific ("Almost
72
+ full", "Weak") — so `valueLabel` may be a function handed the judged
73
+ `MeterReading`, and `resolveMeterValueLabel` calls it. A `Meter.Output`
74
+ function reading `region` is **not** enough: the output is hidden from
75
+ assistive technology, so its words are seen and never heard. The simulator
76
+ pass caught exactly that — the regions demo drew "Filling up · 82%" while
77
+ VoiceOver's value was "82%".
78
+
79
+ ## The scale
80
+
81
+ - **`meterRange` makes the scale finite and ordered.** A non-finite bound takes
82
+ 0 or 100; an inverted or empty scale collapses at its floor and reads empty.
83
+ `progressRatio` would already return 0 for it, but the accessibility value
84
+ needs `min ≤ now ≤ max` too.
85
+ - **`meterValue` clamps to the ends; `NaN` reads at the floor and an infinity at
86
+ the end it points to.** `progressRatio` alone returns 0 for `+Infinity`,
87
+ which would draw a full disk as empty.
88
+ - **The formatted readout uses the clamped reading**, so a non-percent format
89
+ never prints `300 GB` on a 256 GB scale.
90
+
91
+ ## Segments
92
+
93
+ - **Whole blocks only.** `litSegments` floors, so four blocks say "three out of
94
+ four" where a bar says "about seventy percent" — a password is not seventy
95
+ percent strong. A small epsilon absorbs floating-point error, so `0.7 × 10`
96
+ lights seven.
97
+ - **Any reading above the floor lights at least one block.** Rounding down would
98
+ leave the first quarter of a four-block meter dark, and "a little" looking like
99
+ "none" is the reading a meter can least afford to get wrong.
100
+ - **`meterSegmentCount` rounds a fractional count down, clamps above
101
+ `METER_MAX_SEGMENTS` (100), and falls back to the continuous bar** for a count
102
+ under one or not finite, rather than drawing nothing.
103
+ - **A block's lit colour is a layer that fades its opacity**, not the block's own
104
+ background: an opacity animates on the UI thread, where a background would need
105
+ two token values interpolated in JavaScript. Each layer is seeded at its
106
+ current state, so a list of meters does not replay the lighting as rows mount,
107
+ and the timing keeps Reanimated's default reduce-motion policy — with the system
108
+ setting on, a block lights without a fade. Unlike the progress bar's loop,
109
+ nothing here has to keep moving to be read.
110
+ - **`segments` lives on the root, not on `Meter.Segments`.** The lit count is
111
+ part of the render props a readout reads, so the root has to know it.
112
+ `Meter.Segments` without a count renders nothing; `Meter.Track` with one still
113
+ draws the continuous bar.
114
+
115
+ ## What it refuses
116
+
117
+ - **No `isDisabled`, `isInvalid`, `Field` or indeterminate state.** A meter
118
+ reports a reading and accepts no input; `resolveMeterAxes` takes one argument
119
+ and a test pins that. A reading that is not known yet is `Progress`'s
120
+ indeterminate loop, not a meter.
121
+ - **No `label` / `showValueLabel` props.** The anatomy is written out, as
122
+ `Progress`'s is — `Meter.Header` with a `Meter.Label` and a `Meter.Output` — and
123
+ a bare `<Meter value={68} />` still draws the scale, because a compound root
124
+ with no default children renders an empty `View` with no error.
125
+
126
+ ## Accessibility
127
+
128
+ - **The root is the one accessible element**, `accessibilityRole="progressbar"`
129
+ — React Native has no meter role, and this is the one that publishes a range
130
+ and a value on both platforms. `resolveProgressAccessibility` decides the
131
+ spoken text: the percentage over 0–100, `168 of 256` otherwise, or
132
+ `valueLabel`. Thresholds, regions and segments never change it.
133
+ - **`Meter.Label` is the name, `Meter.Output` is hidden** — the root already
134
+ speaks the value, so a visible readout would be read twice. A meter with no
135
+ label needs an `accessibilityLabel`.
@@ -0,0 +1,43 @@
1
+ export { Meter, type MeterProps } from "./meter";
2
+ export { type MeterContextValue, MeterProvider, useMeter, useMeterContext } from "./meter.context";
3
+ export type { MeterRenderChildren, MeterRenderProps } from "./meter.types";
4
+ export {
5
+ litSegments,
6
+ METER_COLORS,
7
+ METER_DEFAULT_COLOR,
8
+ METER_DEFAULT_SIZE,
9
+ METER_MAX_SEGMENTS,
10
+ METER_MAX_VALUE,
11
+ METER_MIN_VALUE,
12
+ METER_REGION_COLORS,
13
+ METER_REGIONS,
14
+ METER_SEGMENT_FADE_MS,
15
+ METER_SIZES,
16
+ type MeterAxes,
17
+ type MeterColor,
18
+ type MeterOwnAxes,
19
+ type MeterReading,
20
+ type MeterRegion,
21
+ type MeterScale,
22
+ type MeterScaleInput,
23
+ type MeterSize,
24
+ type MeterThreshold,
25
+ type MeterValueLabel,
26
+ type MeterVariantProps,
27
+ meterRange,
28
+ meterSegmentCount,
29
+ meterValue,
30
+ meterVariants,
31
+ resolveMeterAxes,
32
+ resolveMeterColor,
33
+ resolveMeterRegion,
34
+ resolveMeterScale,
35
+ resolveMeterValueLabel,
36
+ thresholdColor,
37
+ } from "./meter.variants";
38
+ export type { MeterFillProps } from "./meter-fill";
39
+ export type { MeterHeaderProps } from "./meter-header";
40
+ export type { MeterLabelProps } from "./meter-label";
41
+ export type { MeterOutputProps } from "./meter-output";
42
+ export type { MeterSegmentsProps } from "./meter-segments";
43
+ export type { MeterTrackProps } from "./meter-track";
@@ -0,0 +1,19 @@
1
+ import type { ReactElement } from "react";
2
+ import { Progress, type ProgressFillProps } from "../progress";
3
+ import { useMeterPart } from "./meter.context";
4
+
5
+ export type MeterFillProps = ProgressFillProps;
6
+
7
+ /**
8
+ * The painted bar, in the colour the reading was judged to be.
9
+ *
10
+ * The progress bar's own fill: a transform on the UI thread, one timing per new
11
+ * reading, landing on the value without travel under the system's reduce-motion
12
+ * setting. A change of colour is a class, so it lands on the render that judged
13
+ * it.
14
+ */
15
+ export function MeterFill(props: MeterFillProps): ReactElement {
16
+ useMeterPart("Meter.Fill");
17
+ return <Progress.Fill {...props} />;
18
+ }
19
+ MeterFill.displayName = "DelacourUI.Meter.Fill";
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { Progress, type ProgressHeaderProps } from "../progress";
3
+ import { useMeterPart } from "./meter.context";
4
+
5
+ export type MeterHeaderProps = ProgressHeaderProps;
6
+
7
+ /**
8
+ * The row above the track: a label at the start, a readout at the end.
9
+ *
10
+ * The progress bar's own header — the meter publishes that bar's context, so the
11
+ * row, its gap and its type step are shared rather than restated.
12
+ */
13
+ export function MeterHeader(props: MeterHeaderProps): ReactElement {
14
+ useMeterPart("Meter.Header");
15
+ return <Progress.Header {...props} />;
16
+ }
17
+ MeterHeader.displayName = "DelacourUI.Meter.Header";
@@ -0,0 +1,18 @@
1
+ import type { ReactElement } from "react";
2
+ import { Progress, type ProgressLabelProps } from "../progress";
3
+ import { useMeterPart } from "./meter.context";
4
+
5
+ export type MeterLabelProps = ProgressLabelProps;
6
+
7
+ /**
8
+ * What is being measured — "Storage", "Battery".
9
+ *
10
+ * Left visible to assistive technology: the root is one accessible element, and
11
+ * a label inside it is what VoiceOver and TalkBack read as that element's name.
12
+ * A meter without one announces a number with nothing attached to it.
13
+ */
14
+ export function MeterLabel(props: MeterLabelProps): ReactElement {
15
+ useMeterPart("Meter.Label");
16
+ return <Progress.Label {...props} />;
17
+ }
18
+ MeterLabel.displayName = "DelacourUI.Meter.Label";
@@ -0,0 +1,42 @@
1
+ import type { ReactElement } from "react";
2
+ import { PROGRESS_OUTPUT_TEXT_SIZE, progressVariants } from "../progress/progress.variants";
3
+ import { Text, type TextPresetProps } from "../text";
4
+ import { useMeterPart } from "./meter.context";
5
+ import type { MeterRenderChildren } from "./meter.types";
6
+
7
+ export type MeterOutputProps = Omit<TextPresetProps, "children"> & {
8
+ /** Custom content, or a function called with the meter's settled state. */
9
+ children?: MeterRenderChildren;
10
+ };
11
+
12
+ /**
13
+ * The reading, formatted — `68%` by default, or `valueLabel` when one is given.
14
+ *
15
+ * A function child is handed the meter's state, including the `region` it was
16
+ * judged to be in. Words it draws are seen but not heard — see below — so a
17
+ * judgement a screen reader must hear belongs in a `valueLabel` function.
18
+ *
19
+ * **Hidden from assistive technology on purpose.** The root already publishes
20
+ * the reading as `accessibilityValue`, so a readout left visible inside it would
21
+ * be read twice.
22
+ */
23
+ export function MeterOutput({ children, className, size, ...props }: MeterOutputProps): ReactElement | null {
24
+ const { size: meterSize, renderProps } = useMeterPart("Meter.Output");
25
+
26
+ const content = typeof children === "function" ? children(renderProps) : (children ?? renderProps.formatted);
27
+
28
+ if (content === null || content === undefined) return null;
29
+
30
+ return (
31
+ <Text.Label
32
+ accessibilityElementsHidden
33
+ className={progressVariants({ size: meterSize }).output({ className })}
34
+ importantForAccessibility="no-hide-descendants"
35
+ size={size ?? PROGRESS_OUTPUT_TEXT_SIZE[meterSize]}
36
+ {...props}
37
+ >
38
+ {content}
39
+ </Text.Label>
40
+ );
41
+ }
42
+ MeterOutput.displayName = "DelacourUI.Meter.Output";
@@ -0,0 +1,83 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import { useMeterPart } from "./meter.context";
5
+ import { METER_SEGMENT_FADE_MS, type MeterColor, type MeterSize, meterVariants } from "./meter.variants";
6
+
7
+ export type MeterSegmentsProps = Omit<ViewProps, "children"> & {
8
+ /** Extra classes for every block. */
9
+ segmentClassName?: string;
10
+ /** Extra classes for a lit block's painted layer. */
11
+ fillClassName?: string;
12
+ };
13
+
14
+ type MeterSegmentProps = {
15
+ isLit: boolean;
16
+ color: MeterColor;
17
+ size: MeterSize;
18
+ className?: string;
19
+ fillClassName?: string;
20
+ };
21
+
22
+ /**
23
+ * One block: an unlit groove with its lit layer fading over it.
24
+ *
25
+ * The layer is seeded at its current state, so a meter mounting at three of
26
+ * four does not replay the lighting on every row that scrolls in. The timing
27
+ * takes Reanimated's default reduce-motion policy, so with the system setting on
28
+ * a block lights without a fade — nothing here needs to stay moving to be read.
29
+ */
30
+ function MeterSegment({ isLit, color, size, className, fillClassName }: MeterSegmentProps): ReactElement {
31
+ const opacity = useSharedValue(isLit ? 1 : 0);
32
+
33
+ useEffect(() => {
34
+ opacity.value = withTiming(isLit ? 1 : 0, { duration: METER_SEGMENT_FADE_MS });
35
+ }, [isLit, opacity]);
36
+
37
+ const fillStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
38
+ const slots = meterVariants({ color, size });
39
+
40
+ return (
41
+ <View className={slots.segment({ className })}>
42
+ <Animated.View className={slots.segmentFill({ className: fillClassName })} style={fillStyle} />
43
+ </View>
44
+ );
45
+ }
46
+ MeterSegment.displayName = "DelacourUI.Meter.Segments.Segment";
47
+
48
+ /**
49
+ * The scale drawn as discrete blocks — the segmented meter's track and fill in one.
50
+ *
51
+ * Draws the count the root was given as `segments`, lighting whole blocks only:
52
+ * four blocks say "three out of four" where a bar says "about seventy percent".
53
+ * With no count on the root it draws nothing, because there is no scale to cut.
54
+ */
55
+ export function MeterSegments({
56
+ className,
57
+ segmentClassName,
58
+ fillClassName,
59
+ ...props
60
+ }: MeterSegmentsProps): ReactElement | null {
61
+ const { size, renderProps } = useMeterPart("Meter.Segments");
62
+ const { segments, litSegments, color } = renderProps;
63
+
64
+ if (segments === null) return null;
65
+
66
+ const blocks = Array.from({ length: segments }, (_, index) => index);
67
+
68
+ return (
69
+ <View className={meterVariants({ color, size }).segments({ className })} {...props}>
70
+ {blocks.map((index) => (
71
+ <MeterSegment
72
+ className={segmentClassName}
73
+ color={color}
74
+ fillClassName={fillClassName}
75
+ isLit={index < litSegments}
76
+ key={index}
77
+ size={size}
78
+ />
79
+ ))}
80
+ </View>
81
+ );
82
+ }
83
+ MeterSegments.displayName = "DelacourUI.Meter.Segments";
@@ -0,0 +1,16 @@
1
+ import type { ReactElement } from "react";
2
+ import { Progress, type ProgressTrackProps } from "../progress";
3
+ import { useMeterPart } from "./meter.context";
4
+
5
+ export type MeterTrackProps = ProgressTrackProps;
6
+
7
+ /**
8
+ * The continuous groove the fill slides along — the progress bar's own track.
9
+ *
10
+ * For a segmented meter write `Meter.Segments` instead.
11
+ */
12
+ export function MeterTrack(props: MeterTrackProps): ReactElement {
13
+ useMeterPart("Meter.Track");
14
+ return <Progress.Track {...props} />;
15
+ }
16
+ MeterTrack.displayName = "DelacourUI.Meter.Track";
@@ -0,0 +1,62 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { MeterRenderProps } from "./meter.types";
3
+ import type { MeterSize } from "./meter.variants";
4
+
5
+ /**
6
+ * What a meter's own parts read.
7
+ *
8
+ * The continuous track and fill do not read this — the root also publishes the
9
+ * progress bar's context, and `Meter.Track` and `Meter.Fill` are the progress
10
+ * bar's parts reading that. This holds only what a meter adds: the judged colour,
11
+ * the region and the blocks.
12
+ */
13
+ export type MeterContextValue = {
14
+ size: MeterSize;
15
+ /** The settled state `Meter.Output` formats and a render function is handed. */
16
+ renderProps: MeterRenderProps;
17
+ };
18
+
19
+ const MeterContext = createContext<MeterContextValue | null>(null);
20
+
21
+ /**
22
+ * Supplies one meter's state to its own parts.
23
+ *
24
+ * Its own module, importing nothing but React and types, so a part can read it
25
+ * without importing `./meter` and closing a cycle.
26
+ */
27
+ export function MeterProvider({ value, children }: { value: MeterContextValue; children: ReactNode }): ReactElement {
28
+ return <MeterContext value={value}>{children}</MeterContext>;
29
+ }
30
+ MeterProvider.displayName = "DelacourUI.Meter.Provider";
31
+
32
+ /** The enclosing meter's state, or null outside a `<Meter>`. */
33
+ export function useMeterContext(): MeterContextValue | null {
34
+ return use(MeterContext);
35
+ }
36
+
37
+ /**
38
+ * Reads the enclosing meter's state.
39
+ *
40
+ * For a custom readout or legend that has to match the meter it sits in. Throws
41
+ * outside one — use {@link useMeterContext} where the meter is optional.
42
+ */
43
+ export function useMeter(): MeterContextValue {
44
+ const context = useMeterContext();
45
+ if (!context) {
46
+ throw new Error("useMeter must be called inside a <Meter>.");
47
+ }
48
+ return context;
49
+ }
50
+
51
+ /**
52
+ * The enclosing meter's state, for a compound part that cannot work without one.
53
+ *
54
+ * Internal: deliberately not re-exported from `index.ts`.
55
+ */
56
+ export function useMeterPart(component: string): MeterContextValue {
57
+ const context = useMeterContext();
58
+ if (!context) {
59
+ throw new Error(`${component} must be rendered inside a <Meter>.`);
60
+ }
61
+ return context;
62
+ }