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

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.20260925055848",
3
+ "version": "0.1.0-alpha.20260925060251",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "./field": "./src/components/field/index.ts",
43
43
  "./icon": "./src/components/icon/index.ts",
44
44
  "./input": "./src/components/input/index.ts",
45
+ "./label": "./src/components/label/index.ts",
45
46
  "./list-group": "./src/components/list-group/index.ts",
46
47
  "./meter": "./src/components/meter/index.ts",
47
48
  "./pressable": "./src/components/pressable/index.ts",
@@ -84,7 +85,7 @@
84
85
  },
85
86
  "peerDependencies": {
86
87
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
87
- "@delacour/react-native-charts": "0.1.0-alpha.20260925055848",
88
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925060251",
88
89
  "@gorhom/bottom-sheet": "^5.2.8",
89
90
  "@legendapp/list": ">=3.3",
90
91
  "expo-linear-gradient": ">=15",
@@ -103,6 +103,13 @@ A form field's layout, and the one place its state is written down. Root plus
103
103
  - **`Field.Group` inserts no dividers**, unlike [`ListGroup`](../list-group/AGENTS.md). A list of rows
104
104
  without lines is a wall of text; fields are already held apart by whitespace,
105
105
  and a rule between every one is noise.
106
+ - **`Field.Label` is not built on [`Label`](../label/AGENTS.md)**, the standalone
107
+ primitive. `Label` narrows `children` from `Text`'s and reports
108
+ `accessibilityState.disabled`, so wrapping it would change this part's public
109
+ surface. The two colour and fade identically instead, and
110
+ `label.variants.test.ts` holds them to it against `fieldVariants`' label slot
111
+ and `resolveFieldTextColor`. A required field uses `Label` directly, since
112
+ `Field.Label` has no `isRequired`.
106
113
  - **There is no `Field.Title`.** On the web it exists because a `<div>` is not a
107
114
  `<label>` — label-styled text with nothing to point `htmlFor` at. React Native
108
115
  has neither element nor association, so it and `Field.Label` would render the
@@ -0,0 +1,89 @@
1
+ # Label
2
+
3
+ The name of a form control, with required, invalid and disabled states. One
4
+ component, no parts.
5
+
6
+ `import { Label } from "@delacour/react-native-ui/label";`
7
+
8
+ ## Files
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `index.ts` | → `@delacour/react-native-ui/label` |
13
+ | `label.tsx` | `Label` |
14
+ | `label.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
15
+ | `label.variants.test.ts` | |
16
+
17
+ ## Design
18
+
19
+ - **States**: `isRequired`, `isInvalid`, `isDisabled`. All default `false`, all
20
+ independent — a required field that is also wrong shows both.
21
+ - **It *is* `Text.Label`, and passes a colour, never a scale.** The same move
22
+ [`Field`](../field/AGENTS.md)'s text parts make: a `text-sm font-medium` in a
23
+ slot here would be a second definition of [`Text.Label`](../text/AGENTS.md)
24
+ that could drift from it. `resolveLabelColor` returns `destructive` when
25
+ invalid and `undefined` otherwise, and `undefined` means "leave the preset's
26
+ own colour alone". A caller's `color` beats it; `size` and `weight` are
27
+ `Text`'s own axes and work unchanged. A test asserts neither slot carries a
28
+ size, weight or colour.
29
+ - **The required mark is a nested `Text`, not a sibling.** A row of two `Text`s
30
+ would put the asterisk beside the label's **box**: a label long enough to wrap
31
+ would carry it in a column of its own at the right edge of the first line. As a
32
+ nested run it flows with the text and lands after the last word — and a
33
+ no-break space in front of it (` *`) keeps it from wrapping onto a line by
34
+ itself. It inherits the label's size and weight through `Text`'s own nesting,
35
+ so there is nothing to keep in step.
36
+ - **The mark is destructive in every state.** Required is a warning about what
37
+ the form will refuse, so it wears the colour of that refusal whether or not the
38
+ value is wrong yet. `LABEL_REQUIRED_MARK_COLOR` is the constant;
39
+ `requiredMarkClassName` restyles it.
40
+ - **Only the root fades when disabled.** The mark is a run inside the root's own
41
+ text, so the root's `opacity-50` already reaches it. Fading it again would
42
+ square the fade. A disabled label also reports `accessibilityState.disabled`,
43
+ so a screen reader says "dimmed" rather than leaving the fade visual-only.
44
+ - **A required label is announced as "Email, required", not "Email, star".**
45
+ React Native reads a `Text` and its nested runs as one string, so without an
46
+ `accessibilityLabel` VoiceOver speaks the punctuation. `resolveLabelAccessibilityLabel`
47
+ builds one when the children are plain text — `labelText` reads strings,
48
+ numbers and arrays of them, and gives up on anything else rather than guessing
49
+ at an element's words. A caller's own `accessibilityLabel` always wins, and is
50
+ the answer when the children are not text.
51
+ - **`children` and `accessibilityLabel` are narrowed from `Text`'s.** `Text`
52
+ renders `Animated.Text`, whose props also accept Reanimated `SharedValue`s. A
53
+ label has to append its mark to its children and read them as text, and a
54
+ shared value supports neither, so the narrower type is the honest one — the
55
+ reason [`Field.Error`](../field/AGENTS.md) narrows its own children.
56
+ - **It reads no context.** A standalone primitive is whatever it is told, which
57
+ is what makes it usable outside a `Field` — above a `Slider`, beside a
58
+ `Switch`, in a settings row — without a layout it does not need.
59
+ `Field.Label` stays the field-aware counterpart: it reads the enclosing
60
+ field's state, and it is **not** built on `Label`, because doing so would
61
+ change its public surface — `Label` narrows `children`, and a disabled `Label`
62
+ reports `accessibilityState.disabled`, which `Field.Label` does not. The two
63
+ must still read as one component, so `label.variants.test.ts` pins
64
+ `resolveLabelColor` and the disabled fade to `fieldVariants`' label slot and
65
+ `resolveFieldTextColor("label", …)`. Change one and the test names the other.
66
+ - **Inside a `Field`, `Label` is the way to say "required".** `Field.Label` has
67
+ no `isRequired`. Pass the field's state yourself —
68
+ `<Label isInvalid={isInvalid} isRequired>Email</Label>` — or read it in a
69
+ custom part with `useField()`.
70
+ - **There is no `htmlFor`.** React Native has no `<label>` element and no
71
+ label-for-control association. On Android, give the label a `nativeID` and the
72
+ control `accessibilityLabelledBy` with the same string; iOS has no equivalent,
73
+ which is why a control's own `accessibilityLabel` matters more than the label
74
+ beside it.
75
+ - **There is no `Label.Text` part.** The label is already text; a `View` root
76
+ holding a text part would be the row of two boxes the nested mark exists to
77
+ avoid, and would take `ViewProps` where every caller wants `Text`'s `size`,
78
+ `weight` and `numberOfLines`.
79
+ - **No `onPress` wiring.** `Text` already takes `onPress`, so a label that should
80
+ focus its control passes one — the same thing `Field`'s whole-row press does
81
+ for a checkbox. Mounting a gesture detector under every label in a form would
82
+ announce static text as something to activate, which
83
+ [`Badge`](../badge/AGENTS.md) refuses for the same reason. A `Text` with `onPress` is exposed to VoiceOver as a **link**, and
84
+ `accessible={false}` does not change that on iOS — checked on a simulator,
85
+ where it still read as `AXLink`. A caller wiring one should pass
86
+ `accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"`,
87
+ and let the control, which carries its own `accessibilityLabel`, be the element
88
+ a screen reader lands on — the move `Field`'s row makes. The playground's
89
+ `/label` "Beside a switch" demo shows it.
@@ -0,0 +1,10 @@
1
+ export { Label, type LabelProps } from "./label";
2
+ export {
3
+ LABEL_REQUIRED_MARK,
4
+ LABEL_REQUIRED_MARK_COLOR,
5
+ type LabelVariantProps,
6
+ labelText,
7
+ labelVariants,
8
+ resolveLabelAccessibilityLabel,
9
+ resolveLabelColor,
10
+ } from "./label.variants";
@@ -0,0 +1,97 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { Text, type TextProps } from "../text";
3
+ import {
4
+ LABEL_REQUIRED_MARK,
5
+ LABEL_REQUIRED_MARK_COLOR,
6
+ labelVariants,
7
+ resolveLabelAccessibilityLabel,
8
+ resolveLabelColor,
9
+ } from "./label.variants";
10
+
11
+ /**
12
+ * `Text`'s props minus the role it already is, with two narrowed.
13
+ *
14
+ * `Text` renders `Animated.Text`, so its `children` and `accessibilityLabel` may
15
+ * also be Reanimated `SharedValue`s. A label has to append its required mark to
16
+ * its children and read them as text for a screen reader, and neither can be
17
+ * done to a shared value, so the narrower type is the honest one — the move
18
+ * `Field.Error` makes for the same reason.
19
+ */
20
+ export type LabelProps = Omit<TextProps, "variant" | "children" | "accessibilityLabel"> & {
21
+ children?: ReactNode;
22
+ /** What a screen reader announces. Defaults to "Email, required" for a required label of plain text. */
23
+ accessibilityLabel?: string;
24
+ /** Appends a destructive asterisk, and announces the label as required. */
25
+ isRequired?: boolean;
26
+ /** Turns the label destructive, to match a control whose value is wrong. */
27
+ isInvalid?: boolean;
28
+ /** Fades the label, to match a control that is unavailable. */
29
+ isDisabled?: boolean;
30
+ /** Merged into the required mark's class. */
31
+ requiredMarkClassName?: string;
32
+ };
33
+
34
+ /**
35
+ * The name of a form control.
36
+ *
37
+ * Renders `Text.Label` and passes it a colour, never a size or a weight, so the
38
+ * type scale stays the preset's. `isInvalid` turns it destructive and
39
+ * `isDisabled` fades it, to read as one state with the control beside it.
40
+ *
41
+ * `isRequired` appends an asterisk as a **nested** `Text` rather than a sibling,
42
+ * with a no-break space in front of it, so a label long enough to wrap carries
43
+ * the mark on its last line, glued to its last word, instead of in a column of
44
+ * its own. A nested run is read as part of the label's text, so a required label
45
+ * whose children are text is announced as "Email, required" rather than
46
+ * "Email, star". Pass `accessibilityLabel` when the children are not text.
47
+ *
48
+ * A standalone primitive: it reads no context, so its state is whatever it is
49
+ * told. `Field.Label` is the field-aware counterpart — it reads a `Field`'s
50
+ * state for itself, and colours and fades exactly as this does, which a test
51
+ * holds. Reach for this one outside a `Field`, or inside one when the label has
52
+ * to say "required", which `Field.Label` does not.
53
+ *
54
+ * There is no `htmlFor`: React Native has no label-for-control association. On
55
+ * Android, give the label a `nativeID` and the control
56
+ * `accessibilityLabelledBy` with the same string.
57
+ *
58
+ * @example
59
+ * <Label isRequired>Email</Label>
60
+ *
61
+ * @example
62
+ * <Label isInvalid={!isValid} isRequired>
63
+ * Email
64
+ * </Label>
65
+ */
66
+ export function Label({
67
+ accessibilityLabel,
68
+ accessibilityState,
69
+ children,
70
+ className,
71
+ color,
72
+ isDisabled = false,
73
+ isInvalid = false,
74
+ isRequired = false,
75
+ requiredMarkClassName,
76
+ ...props
77
+ }: LabelProps): ReactElement {
78
+ const slots = labelVariants({ isDisabled, isInvalid });
79
+
80
+ return (
81
+ <Text.Label
82
+ accessibilityLabel={resolveLabelAccessibilityLabel({ accessibilityLabel, children, isRequired })}
83
+ accessibilityState={isDisabled ? { ...accessibilityState, disabled: true } : accessibilityState}
84
+ className={slots.root({ className })}
85
+ color={color ?? resolveLabelColor(isInvalid)}
86
+ {...props}
87
+ >
88
+ {children}
89
+ {isRequired ? (
90
+ <Text className={slots.requiredMark({ className: requiredMarkClassName })} color={LABEL_REQUIRED_MARK_COLOR}>
91
+ {` ${LABEL_REQUIRED_MARK}`}
92
+ </Text>
93
+ ) : null}
94
+ </Text.Label>
95
+ );
96
+ }
97
+ Label.displayName = "DelacourUI.Label";
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fieldVariants, resolveFieldTextColor } from "../field/field.variants";
3
+ import { TEXT_COLORS } from "../text/text.variants";
4
+ import {
5
+ LABEL_REQUIRED_MARK,
6
+ LABEL_REQUIRED_MARK_COLOR,
7
+ labelText,
8
+ labelVariants,
9
+ resolveLabelAccessibilityLabel,
10
+ resolveLabelColor,
11
+ } from "./label.variants";
12
+
13
+ /**
14
+ * A slot's class string, with `tv`'s empty-slot `undefined` flattened.
15
+ *
16
+ * Both slots are handed to a `Text` and carry state only, so they are empty most
17
+ * of the time — and a bare `not.toMatch()` throws on `undefined` rather than
18
+ * passing.
19
+ */
20
+ function cls(value: string | undefined): string {
21
+ return value ?? "";
22
+ }
23
+
24
+ const SLOTS = ["root", "requiredMark"] as const;
25
+
26
+ describe("the slots", () => {
27
+ // The type scale belongs to `Text.Label`. A size or a weight here would be a
28
+ // second definition of it that could drift from the preset.
29
+ test("restate no type scale in any state", () => {
30
+ for (const isDisabled of [false, true]) {
31
+ for (const isInvalid of [false, true]) {
32
+ const slots = labelVariants({ isDisabled, isInvalid });
33
+ for (const slot of SLOTS) {
34
+ expect(cls(slots[slot]())).not.toMatch(/\btext-(xs|sm|base|lg|xl|2xl|3xl)\b/);
35
+ expect(cls(slots[slot]())).not.toMatch(/\bfont-(normal|medium|semibold|bold)\b/);
36
+ }
37
+ }
38
+ }
39
+ });
40
+
41
+ // Colour rides `Text`'s own axis, so a caller's `color` beats it through the
42
+ // same channel rather than through a tailwind-merge race.
43
+ test("carry no colour, since that rides the preset's own axis", () => {
44
+ for (const isInvalid of [false, true]) {
45
+ const slots = labelVariants({ isInvalid });
46
+ for (const slot of SLOTS) {
47
+ expect(cls(slots[slot]())).not.toMatch(/\btext-\w+-?\w*\b/);
48
+ }
49
+ }
50
+ });
51
+
52
+ test("merge an incoming className last", () => {
53
+ expect(labelVariants({ isDisabled: true }).root({ className: "opacity-80" })).toContain("opacity-80");
54
+ expect(labelVariants({ isDisabled: true }).root({ className: "opacity-80" })).not.toContain("opacity-50");
55
+ expect(labelVariants().requiredMark({ className: "ml-1" })).toContain("ml-1");
56
+ });
57
+ });
58
+
59
+ describe("a disabled label", () => {
60
+ test("fades", () => {
61
+ expect(cls(labelVariants({ isDisabled: true }).root())).toContain("opacity-50");
62
+ expect(cls(labelVariants({ isDisabled: false }).root())).not.toContain("opacity-50");
63
+ });
64
+
65
+ // The mark is a nested run inside the label's own text, so the root's opacity
66
+ // already reaches it. Fading it again would square the fade.
67
+ test("does not fade the required mark a second time", () => {
68
+ expect(cls(labelVariants({ isDisabled: true }).requiredMark())).not.toContain("opacity");
69
+ });
70
+
71
+ test("is enabled by default", () => {
72
+ expect(labelVariants().root()).toBe(labelVariants({ isDisabled: false }).root());
73
+ });
74
+ });
75
+
76
+ describe("resolveLabelColor", () => {
77
+ test("turns destructive only while invalid", () => {
78
+ expect(resolveLabelColor(true)).toBe("destructive");
79
+ expect(resolveLabelColor(false)).toBeUndefined();
80
+ });
81
+
82
+ // `undefined` means "leave the preset's own colour alone" — `Text`'s colour
83
+ // axis emits nothing when it is not named.
84
+ test("names a colour Text actually has", () => {
85
+ expect(TEXT_COLORS).toContain(resolveLabelColor(true) ?? "default");
86
+ });
87
+ });
88
+
89
+ describe("the required mark", () => {
90
+ test("is an asterisk", () => {
91
+ expect(LABEL_REQUIRED_MARK).toBe("*");
92
+ });
93
+
94
+ // Required is a warning about what the form will refuse, so it is drawn in
95
+ // the colour of the refusal whether or not the field is currently wrong.
96
+ test("is destructive, and a colour Text has", () => {
97
+ expect(LABEL_REQUIRED_MARK_COLOR).toBe("destructive");
98
+ expect(TEXT_COLORS).toContain(LABEL_REQUIRED_MARK_COLOR);
99
+ });
100
+ });
101
+
102
+ describe("labelText", () => {
103
+ test("reads a string or a number", () => {
104
+ expect(labelText("Email")).toBe("Email");
105
+ expect(labelText(42)).toBe("42");
106
+ });
107
+
108
+ test("joins an array of strings, the shape `{name} address` compiles to", () => {
109
+ expect(labelText(["Work", " ", "email"])).toBe("Work email");
110
+ expect(labelText(["Line ", 2])).toBe("Line 2");
111
+ });
112
+
113
+ // The holes a conditional child leaves behind are not text.
114
+ test("skips null, undefined and booleans inside an array", () => {
115
+ expect(labelText(["Email", null, undefined, false, true])).toBe("Email");
116
+ });
117
+
118
+ // An element could be anything — an icon, a link — and guessing at its text
119
+ // would put words in a screen reader's mouth the caller never wrote.
120
+ test("gives up on anything it cannot read as text", () => {
121
+ expect(labelText({ type: "Icon" })).toBeUndefined();
122
+ expect(labelText(["Email", { type: "Icon" }])).toBeUndefined();
123
+ expect(labelText(undefined)).toBeUndefined();
124
+ expect(labelText(null)).toBeUndefined();
125
+ });
126
+
127
+ test("treats empty text as nothing to read", () => {
128
+ expect(labelText("")).toBeUndefined();
129
+ expect(labelText(["", null])).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("resolveLabelAccessibilityLabel", () => {
134
+ // A nested asterisk reads as "star" — so a required label is announced by
135
+ // what it means instead of by its punctuation.
136
+ test("reads a required label as required, not as an asterisk", () => {
137
+ expect(resolveLabelAccessibilityLabel({ children: "Email", isRequired: true })).toBe("Email, required");
138
+ });
139
+
140
+ test("leaves an optional label to the text itself", () => {
141
+ expect(resolveLabelAccessibilityLabel({ children: "Email", isRequired: false })).toBeUndefined();
142
+ });
143
+
144
+ test("lets the caller's own label win", () => {
145
+ expect(
146
+ resolveLabelAccessibilityLabel({ accessibilityLabel: "Work email", children: "Email", isRequired: true })
147
+ ).toBe("Work email");
148
+ expect(
149
+ resolveLabelAccessibilityLabel({ accessibilityLabel: "Work email", children: "Email", isRequired: false })
150
+ ).toBe("Work email");
151
+ });
152
+
153
+ // With children it cannot read, the text stays whatever the platform makes
154
+ // of it rather than becoming a bare "required" with the name dropped.
155
+ test("says nothing when it cannot read the children", () => {
156
+ expect(resolveLabelAccessibilityLabel({ children: { type: "Icon" }, isRequired: true })).toBeUndefined();
157
+ });
158
+ });
159
+
160
+ // `Field.Label` is the field-aware counterpart of `Label`, styled from
161
+ // `fieldVariants`. A label in a field and a label outside one must not read as
162
+ // two different components, so the decision written twice is held together by a
163
+ // test rather than by a comment hoping it stays so.
164
+ describe("parity with Field", () => {
165
+ test("colours the label the way Field's resolver does", () => {
166
+ for (const isInvalid of [false, true]) {
167
+ expect(resolveLabelColor(isInvalid)).toBe(resolveFieldTextColor("label", isInvalid));
168
+ }
169
+ });
170
+
171
+ test("fades the label the way Field's slot does", () => {
172
+ for (const isDisabled of [false, true]) {
173
+ expect(cls(labelVariants({ isDisabled }).root())).toBe(cls(fieldVariants({ isDisabled }).label()));
174
+ }
175
+ });
176
+ });
@@ -0,0 +1,123 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { TextColor } from "../text/text.variants";
4
+
5
+ /** What a required label appends. */
6
+ export const LABEL_REQUIRED_MARK = "*";
7
+
8
+ /**
9
+ * The required mark's colour, in every state.
10
+ *
11
+ * Required is a warning about what the form will refuse, so it is drawn in the
12
+ * colour of that refusal whether or not the value is wrong right now.
13
+ */
14
+ export const LABEL_REQUIRED_MARK_COLOR: TextColor = "destructive";
15
+
16
+ /**
17
+ * State for a form label and the required mark inside it.
18
+ *
19
+ * Two slots because the mark is a second `Text` nested in the label's own, not
20
+ * because either carries a style of its own: **no slot here holds a type scale
21
+ * or a colour.** The label renders `Text.Label` and passes a colour through
22
+ * {@link resolveLabelColor}, so the scale lives in exactly one place — a
23
+ * `text-sm font-medium` written here would be a second definition of
24
+ * `Text.Label` that could drift from it. The mark inherits that scale by
25
+ * nesting.
26
+ *
27
+ * Only the root fades. The mark is a run inside the root's text, so the root's
28
+ * opacity already reaches it; fading it again would square the fade.
29
+ *
30
+ * Free of React Native imports so it stays unit-testable — `bun test` cannot
31
+ * parse React Native's Flow-typed source. See AGENTS.md.
32
+ */
33
+ export const labelVariants = tv({
34
+ slots: {
35
+ root: "",
36
+ requiredMark: "",
37
+ },
38
+ variants: {
39
+ // The empty `false` branches are load-bearing typing, not placeholders.
40
+ // `tv` derives the prop type from the declared keys, so a map with only
41
+ // `true` types the prop as `true` rather than `boolean`. See the note in
42
+ // button.variants.ts.
43
+ isInvalid: { true: {}, false: {} },
44
+ isDisabled: { true: { root: "opacity-50" }, false: {} },
45
+ },
46
+ defaultVariants: {
47
+ isInvalid: false,
48
+ isDisabled: false,
49
+ },
50
+ });
51
+
52
+ /**
53
+ * The `Text` colour a label takes.
54
+ *
55
+ * Returning `undefined` is meaningful rather than lazy: `Text`'s `color` axis
56
+ * emits nothing when it is not named, so the label falls through to
57
+ * `Text.Label`'s own `text-foreground` without this module knowing which token
58
+ * the preset chose.
59
+ *
60
+ * Pure, so the matrix is reachable from `bun test`. See AGENTS.md.
61
+ */
62
+ export function resolveLabelColor(isInvalid: boolean): TextColor | undefined {
63
+ return isInvalid ? "destructive" : undefined;
64
+ }
65
+
66
+ /**
67
+ * The plain text a label's children spell, or `undefined` when they are
68
+ * anything but text.
69
+ *
70
+ * Strings and numbers are read, and an array of them is joined — that is what
71
+ * `{name} address` compiles to. The `null`, `undefined` and booleans a
72
+ * conditional child leaves behind are skipped. An element is not guessed at: it
73
+ * could be an icon or a link, and inventing its words would put something in a
74
+ * screen reader's mouth the caller never wrote.
75
+ *
76
+ * Typed on `unknown` rather than `ReactNode` so this module takes not even a
77
+ * type import from React, which is what keeps it reachable from `bun test`.
78
+ */
79
+ export function labelText(children: unknown): string | undefined {
80
+ const parts = Array.isArray(children) ? children : [children];
81
+ let text = "";
82
+
83
+ for (const part of parts) {
84
+ if (part === null || part === undefined || typeof part === "boolean") continue;
85
+ if (typeof part === "string" || typeof part === "number") {
86
+ text += String(part);
87
+ continue;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ return text === "" ? undefined : text;
93
+ }
94
+
95
+ /**
96
+ * What a screen reader announces for a label.
97
+ *
98
+ * The caller's own `accessibilityLabel` always wins. Otherwise a required label
99
+ * whose children are text is announced as `"Email, required"`: the mark is a
100
+ * nested `Text`, and React Native reads a `Text` and its nested runs as one
101
+ * string, so without this VoiceOver says "Email, star". An optional label, or
102
+ * one whose children cannot be read as text, returns `undefined` and is left to
103
+ * the platform.
104
+ *
105
+ * Pure, so the matrix is reachable from `bun test`. See AGENTS.md.
106
+ */
107
+ export function resolveLabelAccessibilityLabel({
108
+ accessibilityLabel,
109
+ children,
110
+ isRequired,
111
+ }: {
112
+ accessibilityLabel?: string;
113
+ children: unknown;
114
+ isRequired: boolean;
115
+ }): string | undefined {
116
+ if (accessibilityLabel !== undefined) return accessibilityLabel;
117
+ if (!isRequired) return undefined;
118
+
119
+ const text = labelText(children);
120
+ return text === undefined ? undefined : `${text}, required`;
121
+ }
122
+
123
+ export type LabelVariantProps = VariantProps<typeof labelVariants>;