@oxy.so/bloom 4.30.1 → 4.31.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.
Files changed (38) hide show
  1. package/docs/adoption-matrix.mdx +4 -3
  2. package/docs/input-otp.mdx +22 -5
  3. package/lib/commonjs/input-otp/InputOtp.js +71 -18
  4. package/lib/commonjs/input-otp/InputOtp.js.map +1 -1
  5. package/lib/commonjs/input-otp/index.js +6 -0
  6. package/lib/commonjs/input-otp/index.js.map +1 -1
  7. package/lib/module/input-otp/InputOtp.js +70 -18
  8. package/lib/module/input-otp/InputOtp.js.map +1 -1
  9. package/lib/module/input-otp/index.js +1 -1
  10. package/lib/module/input-otp/index.js.map +1 -1
  11. package/lib/typescript/commonjs/input-otp/InputOtp.d.ts +4 -2
  12. package/lib/typescript/commonjs/input-otp/InputOtp.d.ts.map +1 -1
  13. package/lib/typescript/commonjs/input-otp/index.d.ts +2 -2
  14. package/lib/typescript/commonjs/input-otp/index.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/input-otp/types.d.ts +14 -2
  16. package/lib/typescript/commonjs/input-otp/types.d.ts.map +1 -1
  17. package/lib/typescript/module/input-otp/InputOtp.d.ts +4 -2
  18. package/lib/typescript/module/input-otp/InputOtp.d.ts.map +1 -1
  19. package/lib/typescript/module/input-otp/index.d.ts +2 -2
  20. package/lib/typescript/module/input-otp/index.d.ts.map +1 -1
  21. package/lib/typescript/module/input-otp/types.d.ts +14 -2
  22. package/lib/typescript/module/input-otp/types.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/__tests__/support/adoption-matrix.ts +708 -0
  25. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  26. package/src/__tests__/support/commerce-harness.tsx +97 -0
  27. package/src/__tests__/support/composite.ts +68 -0
  28. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  29. package/src/__tests__/support/press-host.ts +30 -0
  30. package/src/__tests__/support/rendered-style.ts +99 -0
  31. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  32. package/src/__tests__/support/worklet-capture-fixture.tsx +28 -0
  33. package/src/input-otp/InputOtp.tsx +66 -19
  34. package/src/input-otp/index.ts +2 -2
  35. package/src/input-otp/types.ts +15 -2
  36. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  37. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  38. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A barrel that DELIBERATELY offers one name from two declarations, so that
3
+ * `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
4
+ * the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
5
+ * explicit re-export binding that same name to a different component.
6
+ *
7
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
8
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
9
+ * test files there — but no entry point reaches it, so no bundler links it, and
10
+ * `package.json#exports` never names it.
11
+ *
12
+ * The `Card` lines below are the other half of the
13
+ * control: one name offered twice from ONE declaration, which the detector must
14
+ * stay quiet about — otherwise its cheapest fix would be deleting a legitimate
15
+ * redundant re-export.
16
+ */
17
+ export * from '../../item';
18
+ export { Card as Item } from '../../card';
19
+ export { Card } from '../../card';
20
+ export * from '../../card';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The mount harness the four commerce suites share.
3
+ *
4
+ * Every one of them asserts EMITTED DOM — an `aria-*` attribute, a computed
5
+ * colour, whether a node exists at all — rather than the props it just passed,
6
+ * so each renders through the real react-native-web. The mock has to be
7
+ * installed by the SUITE (a `jest.mock` call is hoisted to the top of the file
8
+ * it is written in and does not travel through an import), which is why this
9
+ * module exports the harness and not the mock.
10
+ */
11
+ import React from 'react';
12
+ import { act } from 'react';
13
+ import { createRoot, type Root } from 'react-dom/client';
14
+
15
+ import { BloomThemeProvider } from '../../theme/BloomThemeProvider';
16
+ import type { Theme } from '../../theme/types';
17
+ import { useTheme } from '../../theme/use-theme';
18
+
19
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ let container: HTMLDivElement;
22
+ let root: Root;
23
+ let lastTheme: Theme | null = null;
24
+
25
+ function ThemeProbe() {
26
+ lastTheme = useTheme();
27
+ return null;
28
+ }
29
+
30
+ export function setupHarness(): void {
31
+ beforeEach(() => {
32
+ container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ root = createRoot(container);
35
+ });
36
+ afterEach(() => {
37
+ act(() => root.unmount());
38
+ container.remove();
39
+ lastTheme = null;
40
+ });
41
+ }
42
+
43
+ export function mount(ui: React.ReactElement, mode: 'light' | 'dark' = 'light'): HTMLElement {
44
+ act(() => {
45
+ root.render(
46
+ <BloomThemeProvider mode={mode} colorPreset="teal">
47
+ <ThemeProbe />
48
+ {ui}
49
+ </BloomThemeProvider>,
50
+ );
51
+ });
52
+ return container;
53
+ }
54
+
55
+ export function theme(): Theme {
56
+ if (!lastTheme) throw new Error('theme not captured');
57
+ return lastTheme;
58
+ }
59
+
60
+ export function root$(): HTMLElement {
61
+ return container;
62
+ }
63
+
64
+ export function byTestId(id: string): HTMLElement {
65
+ const el = document.querySelector(`[data-testid="${id}"]`);
66
+ if (!(el instanceof HTMLElement)) throw new Error(`No element for testID "${id}"`);
67
+ return el;
68
+ }
69
+
70
+ export function queryTestId(id: string): HTMLElement | null {
71
+ return document.querySelector(`[data-testid="${id}"]`);
72
+ }
73
+
74
+ export function byLabel(label: string): HTMLElement {
75
+ const el = document.querySelector(`[aria-label="${label}"]`);
76
+ if (!(el instanceof HTMLElement)) throw new Error(`No element labelled "${label}"`);
77
+ return el;
78
+ }
79
+
80
+ export function allByRole(role: string): HTMLElement[] {
81
+ return Array.from(document.querySelectorAll(`[role="${role}"]`)).filter(
82
+ (el): el is HTMLElement => el instanceof HTMLElement,
83
+ );
84
+ }
85
+
86
+ export function click(el: HTMLElement): void {
87
+ act(() => {
88
+ el.click();
89
+ });
90
+ }
91
+
92
+ /** A colour as the DOM serialises it, for comparing against a computed style. */
93
+ export function css(color: string): string {
94
+ const probe = document.createElement('div');
95
+ probe.style.color = color;
96
+ return probe.style.color;
97
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Compositing and WCAG maths for suites that measure a TRANSLUCENT surface.
3
+ *
4
+ * A pane you can see through has no colour of its own to read off a style prop:
5
+ * what a label lands on is the fill flattened onto whatever is behind it. Every
6
+ * gate on a glass material therefore needs the same four functions, and they
7
+ * are here so a new one does not start by copying them — which is how two
8
+ * copies of a luminance formula end up disagreeing about the sRGB knee.
9
+ *
10
+ * `theme/__tests__/glass-colors.test.ts` keeps its OWN copies, deliberately and
11
+ * with its own reasons written down; this is for everything since.
12
+ */
13
+
14
+ export interface Rgba {
15
+ r: number;
16
+ g: number;
17
+ b: number;
18
+ a: number;
19
+ }
20
+
21
+ /** Parse `#rgb`, `#rrggbb`, `rgb(...)` or `rgba(...)`. Throws on anything else. */
22
+ export function parseColor(value: string): Rgba {
23
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
24
+ if (hex?.[1] !== undefined) {
25
+ const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];
26
+ return {
27
+ r: parseInt(h.slice(0, 2), 16),
28
+ g: parseInt(h.slice(2, 4), 16),
29
+ b: parseInt(h.slice(4, 6), 16),
30
+ a: 1,
31
+ };
32
+ }
33
+ const fn = /^rgba?\(([^)]*)\)$/.exec(value.trim());
34
+ if (!fn?.[1]) throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
35
+ const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(Number);
36
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) {
37
+ throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
38
+ }
39
+ return { r: parts[0] ?? 0, g: parts[1] ?? 0, b: parts[2] ?? 0, a: parts[3] ?? 1 };
40
+ }
41
+
42
+ /** Flatten a translucent colour onto an opaque one — source-over. */
43
+ export function over(top: Rgba, bottom: Rgba): Rgba {
44
+ return {
45
+ r: top.r * top.a + bottom.r * (1 - top.a),
46
+ g: top.g * top.a + bottom.g * (1 - top.a),
47
+ b: top.b * top.a + bottom.b * (1 - top.a),
48
+ a: 1,
49
+ };
50
+ }
51
+
52
+ /** WCAG relative luminance. */
53
+ export function luminance({ r, g, b }: Rgba): number {
54
+ const channel = (v: number): number => {
55
+ const c = v / 255;
56
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
57
+ };
58
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
59
+ }
60
+
61
+ /** WCAG contrast ratio, order-independent. */
62
+ export function contrastRatio(a: Rgba, b: Rgba): number {
63
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
64
+ return (hi + 0.05) / (lo + 0.05);
65
+ }
66
+
67
+ export const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
68
+ export const BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
3
+ *
4
+ * jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
5
+ * `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
6
+ * fallback. That makes the whole CSP-safe path — the reason
7
+ * `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
8
+ * installs the API itself. Without this, a mutation that deleted the adoption
9
+ * branch entirely would leave the suite green.
10
+ *
11
+ * Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
12
+ */
13
+
14
+ export interface FakeStyleSheet {
15
+ /** The CSS most recently handed to `replaceSync`. */
16
+ cssText: string;
17
+ /** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
18
+ replaceSyncCalls: number;
19
+ replaceSync(css: string): void;
20
+ }
21
+
22
+ export interface ConstructedStyleSheetsHarness {
23
+ /** The document's adopted sheets, read live (the code reassigns the array). */
24
+ adopted(): readonly FakeStyleSheet[];
25
+ /** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
26
+ uninstall(): void;
27
+ }
28
+
29
+ export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
30
+ class FakeCSSStyleSheet implements FakeStyleSheet {
31
+ cssText = '';
32
+ replaceSyncCalls = 0;
33
+
34
+ replaceSync(css: string): void {
35
+ this.cssText = css;
36
+ this.replaceSyncCalls += 1;
37
+ }
38
+ }
39
+
40
+ const previousConstructor = Object.getOwnPropertyDescriptor(
41
+ globalThis,
42
+ 'CSSStyleSheet',
43
+ );
44
+
45
+ Object.defineProperty(globalThis, 'CSSStyleSheet', {
46
+ value: FakeCSSStyleSheet,
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+ Object.defineProperty(document, 'adoptedStyleSheets', {
51
+ value: [],
52
+ writable: true,
53
+ configurable: true,
54
+ });
55
+
56
+ return {
57
+ adopted: () =>
58
+ document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
59
+ uninstall: () => {
60
+ Reflect.deleteProperty(document, 'adoptedStyleSheets');
61
+ if (previousConstructor) {
62
+ Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
63
+ } else {
64
+ Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pressing a component's OWN host node, and proving the component is what
3
+ * installed the handler being measured.
4
+ *
5
+ * `fireEvent.press` walks UP from the element it is handed to the nearest
6
+ * ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
7
+ * COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
8
+ * `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
9
+ * Measured on a (since removed) card component: with the handler deleted from the
10
+ * component the card rendered as an inert `View`, and the test still reported exactly one
11
+ * call. Green, and measuring nothing. Nine suites had the same shape.
12
+ *
13
+ * Asserting the host node's own `onPress` FIRST closes both halves:
14
+ *
15
+ * - a component that installs no handler at all fails here, and
16
+ * - a component that installs one which drops the caller's fails on the call
17
+ * count, because `fireEvent` now finds a handler AT the node and never walks
18
+ * past it to the test's own JSX.
19
+ *
20
+ * `host` must therefore be the node the component itself made pressable — the
21
+ * one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
22
+ * it, or the walk-up is back and so is the hole.
23
+ */
24
+ import { fireEvent } from '@testing-library/react-native';
25
+ import type { ReactTestInstance } from 'react-test-renderer';
26
+
27
+ export function pressHost(host: ReactTestInstance): void {
28
+ expect(typeof host.props.onPress).toBe('function');
29
+ fireEvent.press(host);
30
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading what actually landed on a rendered node — shared by every suite that
3
+ * asserts the "className lands on the node the parent lays out" rule.
4
+ *
5
+ * Three components had the same two-node defect (`Button`, then `Fab` and
6
+ * `FrostedIconButton`), and asserting it needs the same two things each time:
7
+ * the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
8
+ * visible there) and a deep flatten of the `style` prop, because react-native-css
9
+ * merges a caller's class in as a `{ $$css: true, className }` descriptor
10
+ * ALONGSIDE the style objects rather than in a prop of its own.
11
+ */
12
+ import type { ReactTestRendererJSON } from 'react-test-renderer';
13
+
14
+ export type StyleEntry = Record<string, unknown>;
15
+
16
+ export interface HostNode {
17
+ type: string;
18
+ props: Record<string, unknown>;
19
+ children: Array<HostNode | string> | null;
20
+ }
21
+
22
+ /**
23
+ * Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
24
+ * list of style objects that actually reached the node.
25
+ */
26
+ export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
27
+ if (!style || typeof style !== 'object') return out;
28
+ if (Array.isArray(style)) {
29
+ for (const entry of style) styleEntries(entry, out);
30
+ return out;
31
+ }
32
+ out.push(style as StyleEntry);
33
+ return out;
34
+ }
35
+
36
+ /** Every style key/value that landed on a node, later entries winning. */
37
+ export function resolvedStyle(style: unknown): StyleEntry {
38
+ return Object.assign({}, ...styleEntries(style)) as StyleEntry;
39
+ }
40
+
41
+ /** The class tokens react-native-css accepted for a node, in arrival order. */
42
+ export function classNamesOn(style: unknown): string[] {
43
+ return styleEntries(style)
44
+ .filter((entry) => entry.$$css === true && typeof entry.className === 'string')
45
+ .map((entry) => String(entry.className));
46
+ }
47
+
48
+ export function isHostNode(value: unknown): value is HostNode {
49
+ return (
50
+ typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
51
+ );
52
+ }
53
+
54
+ /** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
55
+ export function findHost(
56
+ node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
57
+ testID: string,
58
+ ): HostNode | null {
59
+ if (Array.isArray(node)) {
60
+ for (const child of node) {
61
+ const hit = findHost(child, testID);
62
+ if (hit) return hit;
63
+ }
64
+ return null;
65
+ }
66
+ if (!isHostNode(node)) return null;
67
+ if (node.props.testID === testID) return node;
68
+ return findHost(node.children, testID);
69
+ }
70
+
71
+ /**
72
+ * Every host node in a rendered tree, in document order.
73
+ *
74
+ * The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
75
+ * latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
76
+ * two matches for one rendered element and every count is silently doubled —
77
+ * which reads as "the component rendered the thing twice", not as an artefact
78
+ * of the query.
79
+ */
80
+ export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
81
+ if (Array.isArray(tree)) {
82
+ for (const child of tree) hostNodes(child, out);
83
+ return out;
84
+ }
85
+ if (!isHostNode(tree)) return out;
86
+ out.push(tree);
87
+ return hostNodes(tree.children, out);
88
+ }
89
+
90
+ /**
91
+ * The host nodes a parent laid out. Callers assert the LENGTH themselves —
92
+ * "exactly one" is the property, and a helper that returned only the first would
93
+ * hide the second.
94
+ */
95
+ export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
96
+ const host = findHost(tree, hostTestID);
97
+ if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
98
+ return (host.children ?? []).filter(isHostNode);
99
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A module that DELIBERATELY throws away one of the values a hook hands back, so
3
+ * `hook-subscriptions-are-read.test.ts` can prove its detector fires. It carries
4
+ * both halves of the control: `unreadFlag` is bound and never read, which the
5
+ * detector must report, and `readFlag` is bound and read, which it must stay
6
+ * quiet about — otherwise the cheapest way to green the gate would be deleting a
7
+ * binding that is doing its job.
8
+ *
9
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
10
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
11
+ * 137 test files there — but no entry point reaches it, so no bundler links it.
12
+ * It is a plain `.ts` with no React import because the
13
+ * detector reads source, never types: a call named `use…` bound to a name is all
14
+ * the shape there is.
15
+ *
16
+ * The destructuring RENAMES, which is not decoration — it reproduces the shipped
17
+ * shape (`const { state: pressed, onIn: onPressIn } = useInteractionState()`) and
18
+ * it is what keeps the control honest. A hook declared in the same file as its
19
+ * caller mentions its own property names in its return type and its return
20
+ * object, and the detector counts every occurrence of a spelling as a possible
21
+ * read, so an unrenamed fixture would be silently unreportable for a reason no
22
+ * real call site has.
23
+ */
24
+
25
+ /** Stands in for a real subscription — a hook by name, with two return values. */
26
+ function useFixtureInteractionState(): { first: boolean; second: boolean } {
27
+ return { first: false, second: false };
28
+ }
29
+
30
+ export function fixtureComponentBody(): boolean {
31
+ const { first: readFlag, second: unreadFlag } = useFixtureInteractionState();
32
+ return readFlag;
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Positive control for `worklet-captures.test.ts`. Never rendered: the gate
3
+ * builds a program over this file and reads its worklets.
4
+ *
5
+ * `history` carries a `Date` and `slot` a React element — the two shapes the
6
+ * native worklet runtime cannot copy — and each is captured whole by a mapper.
7
+ * `hasHistory` is the fix: the primitive derived OUTSIDE the worklet, which the
8
+ * gate must leave alone.
9
+ */
10
+ import type { ReactNode } from 'react';
11
+ import { Gesture } from 'react-native-gesture-handler';
12
+ import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
13
+
14
+ interface Conversation {
15
+ title: string;
16
+ createdAt: Date;
17
+ }
18
+
19
+ export function useWorkletCaptureFixture(history: Conversation[] | undefined, slot: ReactNode) {
20
+ const progress = useSharedValue(0);
21
+ const hasHistory = Boolean(history);
22
+ const whole = useAnimatedStyle(() => ({ opacity: history ? progress.value : 1 }), [progress, history]);
23
+ const derived = useAnimatedStyle(() => ({ opacity: hasHistory ? progress.value : 1 }), [progress, hasHistory]);
24
+ const pan = Gesture.Pan().onUpdate(() => {
25
+ progress.value = slot ? 1 : 0;
26
+ });
27
+ return { whole, derived, pan };
28
+ }
@@ -19,13 +19,14 @@ import {
19
19
  TEXT_FIELD_TRANSITION_MS,
20
20
  } from '../text-field/shared';
21
21
  import { TYPE_SCALE } from '../typography/scale';
22
- import type { InputOtpProps } from './types';
22
+ import type { InputOtpProps, InputOtpType } from './types';
23
23
  import { useFieldMembership } from '../field/membership';
24
24
 
25
25
  /**
26
26
  * `InputOtp`: one box per digit, side by side, behaving as ONE value.
27
27
  *
28
- * box 48 × 48, radius 10, 1px border, shadow-xs
28
+ * box 48 × 48, radius 10, 1px border, shadow-xs; narrower (never
29
+ * wider) when the row does not fit its container
29
30
  * digit mono, title-3-medium 18/26 500, centred, tabular numerals
30
31
  * gap 8 between boxes, +12 before each `groupEvery` group
31
32
  * focus accent-500 border + 2px accent-500 ring (outside the border)
@@ -46,12 +47,19 @@ import { useFieldMembership } from '../field/membership';
46
47
  * Digits are monospace so the boxes stay optically even — in a proportional
47
48
  * face a `1` is visibly narrower than an `8`.
48
49
  *
50
+ * `type="alphanumeric"` is the same field for codes that carry letters: every
51
+ * character is upper-cased, anything outside `A`–`Z`/`0`–`9` is dropped (so a
52
+ * code printed as `ABCDE-12345` pastes whole), and the keyboard is a letters
53
+ * one with auto-capitalisation instead of the number pad.
54
+ *
49
55
  * Focus paints on EVERY focus, not only keyboard focus: a text input matches
50
56
  * `:focus-visible` on a pointer focus too, so the ring shows on click, and
51
57
  * state (not CSS) carries it here so native paints the same ring.
52
58
  */
53
59
 
54
- const DIGITS_ONLY = /\D/g;
60
+ const NOT_DIGIT = /\D/g;
61
+ /** Filtered BEFORE upper-casing: `'ß'.toUpperCase()` is `SS`, `'ı'` becomes `I`. */
62
+ const NOT_ALPHANUMERIC = /[^A-Za-z0-9]/g;
55
63
  const BOX_SIZE = 48;
56
64
  const BOX_GAP = 8;
57
65
  const GROUP_GAP = 12;
@@ -141,7 +149,34 @@ export function resolveInputOtpBoxPaint(
141
149
  return { backgroundColor, borderColor, color, boxShadow };
142
150
  }
143
151
 
144
- const clean = (raw: string, length: number) => raw.replace(DIGITS_ONLY, '').slice(0, length);
152
+ /** The characters `type` accepts, in order; everything else is dropped. Pure. */
153
+ export function cleanInputOtpValue(raw: string, type: InputOtpType = 'numeric'): string {
154
+ return type === 'alphanumeric' ? raw.replace(NOT_ALPHANUMERIC, '').toUpperCase() : raw.replace(NOT_DIGIT, '');
155
+ }
156
+
157
+ const clean = (raw: string, length: number, type: InputOtpType) => cleanInputOtpValue(raw, type).slice(0, length);
158
+
159
+ /**
160
+ * Keyboard and input hints per `type`. The one-time-code autofill hint is
161
+ * shared (below); only what the keyboard offers differs. Android has no
162
+ * `ascii-capable`, and its `visible-password` is the letters keyboard without
163
+ * suggestions or autocorrect — both of which would rewrite a code.
164
+ *
165
+ * Alphanumeric sets `inputMode` on web ONLY: React Native gives `inputMode`
166
+ * precedence over `keyboardType`, so `inputMode: 'text'` on native would open
167
+ * the ordinary keyboard instead of `ascii-capable` / `visible-password`.
168
+ */
169
+ const KEYBOARD_PROPS: Record<InputOtpType, Record<string, unknown>> = {
170
+ numeric: { inputMode: 'numeric', keyboardType: 'number-pad' },
171
+ alphanumeric: {
172
+ ...(IS_WEB
173
+ ? { inputMode: 'text' }
174
+ : { keyboardType: Platform.OS === 'android' ? 'visible-password' : 'ascii-capable' }),
175
+ autoCapitalize: 'characters',
176
+ autoCorrect: false,
177
+ spellCheck: false,
178
+ },
179
+ };
145
180
 
146
181
  const WEB_BOX_STYLE: TextStyle | undefined = IS_WEB
147
182
  ? ({
@@ -155,6 +190,7 @@ const WEB_BOX_STYLE: TextStyle | undefined = IS_WEB
155
190
 
156
191
  export function InputOtp({
157
192
  length = 6,
193
+ type = 'numeric',
158
194
  value,
159
195
  defaultValue = '',
160
196
  onChange,
@@ -186,21 +222,24 @@ export function InputOtp({
186
222
  const groupName = membership.accessibilityLabel ?? 'One-time code';
187
223
 
188
224
  const inputsRef = useRef<Array<TextInput | null>>([]);
189
- const [internal, setInternal] = useState(() => clean(defaultValue, length));
225
+ const [internal, setInternal] = useState(() => clean(defaultValue, length, type));
190
226
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
191
227
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
192
228
 
193
229
  const controlled = value !== undefined;
194
- const code = clean(controlled ? value : internal, length);
230
+ const code = clean(controlled ? value : internal, length, type);
231
+ const unit = type === 'alphanumeric' ? 'Character' : 'Digit';
195
232
 
196
233
  const commit = useCallback(
197
234
  (next: string) => {
198
- const cleaned = clean(next, length);
235
+ // `next` is already cleaned per character (a cleared box is a space
236
+ // placeholder, which `clean` drops along with anything else).
237
+ const cleaned = clean(next, length, type);
199
238
  if (!controlled) setInternal(cleaned);
200
239
  onChange?.(cleaned);
201
240
  if (cleaned.length === length) onComplete?.(cleaned);
202
241
  },
203
- [controlled, length, onChange, onComplete],
242
+ [controlled, length, type, onChange, onComplete],
204
243
  );
205
244
 
206
245
  const focusBox = useCallback(
@@ -210,9 +249,9 @@ export function InputOtp({
210
249
  [length],
211
250
  );
212
251
 
213
- /** Writes `digits` starting at `index`, which covers typing, paste and autofill. */
214
- const writeFrom = (index: number, digits: string) => {
215
- const incoming = digits.replace(DIGITS_ONLY, '');
252
+ /** Writes `text` starting at `index`, which covers typing, paste and autofill. */
253
+ const writeFrom = (index: number, text: string) => {
254
+ const incoming = cleanInputOtpValue(text, type);
216
255
  if (incoming === '') return;
217
256
  const chars = code.padEnd(length, ' ').split('');
218
257
  for (let offset = 0; offset < incoming.length && index + offset < length; offset += 1) {
@@ -281,16 +320,18 @@ export function InputOtp({
281
320
  } as Record<string, unknown>)
282
321
  : {})}
283
322
  testID={testID ? `${testID}-${index}` : undefined}
284
- // `text` with a numeric keyboard rather than a number input: a number
285
- // input brings spinners, accepts `e` and `-`, and reports an empty
286
- // value for anything it considers malformed.
287
- inputMode="numeric"
288
- keyboardType="number-pad"
323
+ // Numeric is `text` with a numeric keyboard rather than a number
324
+ // input: a number input brings spinners, accepts `e` and `-`, and
325
+ // reports an empty value for anything it considers malformed.
326
+ {...KEYBOARD_PROPS[type]}
289
327
  autoComplete={Platform.OS === 'android' ? 'sms-otp' : 'one-time-code'}
290
328
  textContentType="oneTimeCode"
291
329
  autoFocus={autoFocus && index === 0}
292
- // Long enough to accept a full autofilled code in one box.
293
- maxLength={length}
330
+ // Long enough to accept a full autofilled or pasted code in one box,
331
+ // separators included: a browser truncates a paste to `maxLength`
332
+ // BEFORE the change handler sees it, so `length` alone lost the
333
+ // last character of `ABCDE-12345`.
334
+ maxLength={length * 2}
294
335
  // Typing into a filled box must REPLACE its digit, so the digit is
295
336
  // selected on focus. Web's `selectTextOnFocus` does that. Android's
296
337
  // does not: it selects on the input's next layout, and focusing a box
@@ -300,7 +341,7 @@ export function InputOtp({
300
341
  selectTextOnFocus={IS_WEB}
301
342
  caretHidden={false}
302
343
  editable={!disabled}
303
- accessibilityLabel={`Digit ${index + 1} of ${length}`}
344
+ accessibilityLabel={`${unit} ${index + 1} of ${length}`}
304
345
  aria-invalid={invalid || undefined}
305
346
  aria-disabled={disabled || undefined}
306
347
  value={digit === ' ' ? '' : digit}
@@ -315,6 +356,12 @@ export function InputOtp({
315
356
  style={[
316
357
  {
317
358
  width: BOX_SIZE,
359
+ // A box shrinks (never grows) when the row is narrower than
360
+ // its natural width, so ten boxes still fit a phone. `minWidth`
361
+ // 0 is what lets it: a flex item's automatic minimum is its
362
+ // specified width, and an `<input>` would otherwise overflow.
363
+ flexShrink: 1,
364
+ minWidth: 0,
318
365
  height: BOX_SIZE,
319
366
  marginLeft: gapBefore ? GROUP_GAP : 0,
320
367
  padding: 0,
@@ -1,2 +1,2 @@
1
- export { InputOtp } from './InputOtp';
2
- export type { InputOtpProps } from './types';
1
+ export { InputOtp, cleanInputOtpValue } from './InputOtp';
2
+ export type { InputOtpProps, InputOtpType } from './types';
@@ -1,9 +1,22 @@
1
1
  import type { ViewStyleProp } from '../styles';
2
2
 
3
+ /**
4
+ * What a box accepts. `numeric` (the default) keeps digits only;
5
+ * `alphanumeric` upper-cases and keeps `A`–`Z` and `0`–`9`.
6
+ */
7
+ export type InputOtpType = 'numeric' | 'alphanumeric';
8
+
3
9
  export interface InputOtpProps extends ViewStyleProp {
4
- /** Number of digit boxes, default `6`. */
10
+ /** Number of boxes, default `6`. */
5
11
  length?: number;
6
- /** Controlled value. Non-digits are dropped and longer strings truncated to `length`. */
12
+ /**
13
+ * `numeric` (default): digits only, number pad. `alphanumeric`: letters and
14
+ * digits, upper-cased, on a letters keyboard with auto-capitalisation.
15
+ * Anything else — a dash, a space — is dropped either way, so a pasted
16
+ * `ABCDE-12345` fills ten boxes.
17
+ */
18
+ type?: InputOtpType;
19
+ /** Controlled value. Characters the `type` does not accept are dropped and longer strings truncated to `length`. */
7
20
  value?: string;
8
21
  /** Initial value when uncontrolled. */
9
22
  defaultValue?: string;