expo-interface 0.1.0 → 0.2.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 (88) hide show
  1. package/README.md +282 -180
  2. package/package.json +39 -13
  3. package/src/alert/alert.css +49 -0
  4. package/src/alert/index.android.tsx +68 -0
  5. package/src/alert/index.ios.tsx +47 -0
  6. package/src/alert/index.tsx +60 -0
  7. package/src/alert/shared.ts +10 -0
  8. package/src/alert/types.ts +58 -0
  9. package/src/button/button.css +6 -0
  10. package/src/button/index.android.tsx +9 -2
  11. package/src/button/index.ios.tsx +8 -3
  12. package/src/button/index.tsx +19 -1
  13. package/src/button/types.ts +7 -0
  14. package/src/checkbox/checkbox.css +29 -0
  15. package/src/checkbox/index.android.tsx +54 -0
  16. package/src/checkbox/index.ios.tsx +50 -0
  17. package/src/checkbox/index.tsx +50 -0
  18. package/src/checkbox/types.ts +27 -0
  19. package/src/collapsible/collapsible.css +32 -0
  20. package/src/collapsible/index.android.tsx +34 -0
  21. package/src/collapsible/index.ios.tsx +31 -0
  22. package/src/collapsible/index.tsx +41 -0
  23. package/src/collapsible/shared.ts +21 -0
  24. package/src/collapsible/types.ts +24 -0
  25. package/src/color-picker/color-picker.css +66 -0
  26. package/src/color-picker/index.android.tsx +117 -0
  27. package/src/color-picker/index.ios.tsx +33 -0
  28. package/src/color-picker/index.tsx +62 -0
  29. package/src/color-picker/shared.ts +204 -0
  30. package/src/color-picker/sheet.tsx +432 -0
  31. package/src/color-picker/types.ts +34 -0
  32. package/src/context-menu/index.android.tsx +27 -0
  33. package/src/context-menu/index.ios.tsx +22 -0
  34. package/src/context-menu/index.tsx +56 -0
  35. package/src/date-time/index.android.tsx +3 -2
  36. package/src/divider/divider.css +16 -0
  37. package/src/divider/index.android.tsx +26 -0
  38. package/src/divider/index.ios.tsx +18 -0
  39. package/src/divider/index.tsx +21 -0
  40. package/src/divider/types.ts +20 -0
  41. package/src/field-group/field-group.css +23 -7
  42. package/src/field-group/index.android.tsx +22 -22
  43. package/src/field-group/index.web.tsx +2 -2
  44. package/src/gauge/gauge.css +200 -0
  45. package/src/gauge/index.android.tsx +212 -0
  46. package/src/gauge/index.ios.tsx +40 -0
  47. package/src/gauge/index.tsx +165 -0
  48. package/src/gauge/shared.ts +79 -0
  49. package/src/gauge/types.ts +64 -0
  50. package/src/index.ts +24 -3
  51. package/src/menu/index.android.tsx +66 -0
  52. package/src/menu/index.ios.tsx +72 -0
  53. package/src/menu/index.tsx +30 -0
  54. package/src/menu/list.tsx +98 -0
  55. package/src/menu/menu.css +83 -0
  56. package/src/menu/types.ts +60 -0
  57. package/src/picker/index.android.tsx +3 -2
  58. package/src/progress/index.android.tsx +23 -9
  59. package/src/progress/index.ios.tsx +7 -6
  60. package/src/progress/index.tsx +43 -6
  61. package/src/progress/progress.css +33 -0
  62. package/src/progress/types.ts +23 -6
  63. package/src/screen/header.tsx +1 -0
  64. package/src/segmented/index.android.tsx +67 -0
  65. package/src/segmented/index.ios.tsx +47 -0
  66. package/src/segmented/index.tsx +58 -0
  67. package/src/segmented/segmented.css +55 -0
  68. package/src/segmented/types.ts +31 -0
  69. package/src/slider/index.android.tsx +87 -0
  70. package/src/slider/index.ios.tsx +64 -0
  71. package/src/slider/index.tsx +55 -0
  72. package/src/slider/slider.css +30 -0
  73. package/src/slider/types.ts +39 -0
  74. package/src/stepper/index.android.tsx +58 -0
  75. package/src/stepper/index.ios.tsx +55 -0
  76. package/src/stepper/index.tsx +47 -0
  77. package/src/stepper/shared.ts +17 -0
  78. package/src/stepper/stepper.css +61 -0
  79. package/src/stepper/types.ts +35 -0
  80. package/src/switch/index.tsx +2 -0
  81. package/src/text-field/shared.ts +2 -0
  82. package/src/tooltip/index.android.tsx +23 -0
  83. package/src/tooltip/index.ios.tsx +17 -0
  84. package/src/tooltip/index.tsx +41 -0
  85. package/src/tooltip/tooltip.css +40 -0
  86. package/src/tooltip/types.ts +25 -0
  87. package/src/link.ts +0 -36
  88. package/src/qr/index.tsx +0 -26
@@ -0,0 +1,87 @@
1
+ import type {SliderProps} from './types';
2
+
3
+ import {useEffect, useRef} from 'react';
4
+ import {Row, Slider as ComposeSlider, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
5
+ import {fillMaxWidth, testID as testIDModifier, weight} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {useColor} from '../theme';
7
+
8
+ /**
9
+ * Android renders the Material 3 `Slider`, colored with the live accent seed
10
+ * (matching the iOS `tint` cascade and the web `accent-color`) instead of the
11
+ * host's tonal M3 primary. With a `label` the row fills the available width
12
+ * and the slider takes the remaining space, mirroring the iOS Form row.
13
+ */
14
+ export function Slider({
15
+ label,
16
+ value,
17
+ onValueChange,
18
+ onSlidingComplete,
19
+ min = 0,
20
+ max = 1,
21
+ step,
22
+ disabled,
23
+ accentColor,
24
+ testID,
25
+ }: SliderProps) {
26
+ const colors = useMaterialColors();
27
+ const tint = useColor('tint');
28
+ const track = useColor('backgroundSelected');
29
+ const accent = accentColor ?? tint;
30
+
31
+ // Compose `steps` is the number of discrete intervals between min and max
32
+ // (exclusive); the universal `step` is an increment size.
33
+ let steps: number | undefined;
34
+ if (step != null && step > 0) {
35
+ steps = Math.max(0, Math.round((max - min) / step) - 1);
36
+ }
37
+ const snap = (next: number) =>
38
+ step != null && step > 0 ? Math.round((next - min) / step) * step + min : next;
39
+
40
+ // The value the drag last reported: `onValueChangeFinished` fires on
41
+ // release, which can be before a controlling parent re-renders `value`.
42
+ const latest = useRef(value);
43
+ useEffect(() => {
44
+ latest.current = value;
45
+ }, [value]);
46
+
47
+ const slider = (
48
+ <ComposeSlider
49
+ value={value}
50
+ min={min}
51
+ max={max}
52
+ steps={steps}
53
+ enabled={!disabled}
54
+ onValueChange={
55
+ disabled ? undefined : next => {
56
+ const snapped = snap(next);
57
+ latest.current = snapped;
58
+ onValueChange(snapped);
59
+ }
60
+ }
61
+ onValueChangeFinished={onSlidingComplete ? () => onSlidingComplete(latest.current) : undefined}
62
+ colors={{
63
+ thumbColor: accent,
64
+ activeTrackColor: accent,
65
+ inactiveTrackColor: track,
66
+ activeTickColor: track,
67
+ inactiveTickColor: accent,
68
+ }}
69
+ modifiers={[
70
+ ...(label != null ? [weight(1)] : [fillMaxWidth()]),
71
+ ...(testID ? [testIDModifier(testID)] : []),
72
+ ]}
73
+ />
74
+ );
75
+
76
+ if (label == null) return slider;
77
+
78
+ return (
79
+ <Row
80
+ verticalAlignment="center"
81
+ horizontalArrangement={{spacedBy: 12}}
82
+ modifiers={[fillMaxWidth()]}>
83
+ <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
84
+ {slider}
85
+ </Row>
86
+ );
87
+ }
@@ -0,0 +1,64 @@
1
+ import type {SliderProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {useEffect, useRef} from 'react';
5
+ import {HStack, Slider as SwiftUISlider, Text} from '@expo/ui/swift-ui';
6
+ import {disabled as disabledMod, foregroundStyle, tint} from '@expo/ui/swift-ui/modifiers';
7
+ import {useColor} from '../theme';
8
+
9
+ /**
10
+ * iOS renders SwiftUI's `Slider`. The Host-level `tint` cascade colors it with
11
+ * the accent seed; `accentColor` overrides that per instance. With a `label`
12
+ * the row is an `HStack` of the label and the slider, the Form row look the
13
+ * other platforms emulate. Drop it straight into a `FieldGroup.Section`.
14
+ */
15
+ export function Slider({
16
+ label,
17
+ value,
18
+ onValueChange,
19
+ onSlidingComplete,
20
+ min = 0,
21
+ max = 1,
22
+ step,
23
+ disabled,
24
+ accentColor,
25
+ testID,
26
+ }: SliderProps) {
27
+ const labelColor = useColor(disabled ? 'secondaryLabel' : 'label');
28
+ // The value the drag last reported: `onSlidingComplete` fires on release,
29
+ // which can be before a controlling parent re-renders the `value` prop.
30
+ const latest = useRef(value);
31
+ useEffect(() => {
32
+ latest.current = value;
33
+ }, [value]);
34
+ const modifiers: ViewModifier[] = [];
35
+ if (accentColor) modifiers.push(tint(accentColor));
36
+ if (disabled) modifiers.push(disabledMod(true));
37
+
38
+ const slider = (
39
+ <SwiftUISlider
40
+ value={value}
41
+ min={min}
42
+ max={max}
43
+ step={step}
44
+ onValueChange={next => {
45
+ latest.current = next;
46
+ onValueChange(next);
47
+ }}
48
+ onEditingChanged={
49
+ onSlidingComplete ? editing => { if (!editing) onSlidingComplete(latest.current); } : undefined
50
+ }
51
+ modifiers={modifiers}
52
+ testID={testID}
53
+ />
54
+ );
55
+
56
+ if (label == null) return slider;
57
+
58
+ return (
59
+ <HStack spacing={12}>
60
+ <Text modifiers={[foregroundStyle(labelColor)]}>{label}</Text>
61
+ {slider}
62
+ </HStack>
63
+ );
64
+ }
@@ -0,0 +1,55 @@
1
+ import './slider.css';
2
+ import type {ChangeEvent, CSSProperties} from 'react';
3
+ import type {SliderProps} from './types';
4
+ import {StyleSheet, type TextStyle} from 'react-native';
5
+ import {Label} from '../typography';
6
+ import {flatten} from '../theme';
7
+
8
+ /**
9
+ * On web the slider is a native `<input type="range">` element themed through
10
+ * the `accent-color` CSS property, so it reads as a real web control. The row
11
+ * mirrors the native layout: a label on the leading edge with the track
12
+ * filling the remaining width.
13
+ */
14
+ export function Slider({
15
+ label,
16
+ value,
17
+ onValueChange,
18
+ onSlidingComplete,
19
+ min = 0,
20
+ max = 1,
21
+ step,
22
+ disabled,
23
+ accentColor,
24
+ testID,
25
+ style,
26
+ }: SliderProps) {
27
+ const vars = {
28
+ ...(accentColor ? {'--ui-slider-accent': accentColor} : null),
29
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
30
+ } as CSSProperties;
31
+ const read = (event: ChangeEvent<HTMLInputElement>) => Number(event.target.value);
32
+ return (
33
+ <div
34
+ className={['ui-slider', disabled && 'ui-slider--disabled'].filter(Boolean).join(' ')}
35
+ style={vars}
36
+ data-testid={testID}>
37
+ {label != null ? (
38
+ <Label color="label" style={{flexShrink: 1}}>{label}</Label>
39
+ ) : null}
40
+ <input
41
+ className="ui-slider__input"
42
+ type="range"
43
+ min={min}
44
+ max={max}
45
+ step={step ?? 'any'}
46
+ value={value}
47
+ disabled={disabled}
48
+ aria-label={label}
49
+ onChange={event => onValueChange(read(event))}
50
+ onPointerUp={event => onSlidingComplete?.(Number(event.currentTarget.value))}
51
+ onKeyUp={event => onSlidingComplete?.(Number(event.currentTarget.value))}
52
+ />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,30 @@
1
+ .ui-slider {
2
+ --ui-slider-accent: var(--color-tint);
3
+ display: flex;
4
+ flex-direction: row;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ gap: 12px;
8
+ width: 100%;
9
+ }
10
+
11
+ .ui-slider__label {
12
+ flex-shrink: 1;
13
+ }
14
+
15
+ .ui-slider__input {
16
+ flex: 1;
17
+ min-width: 96px;
18
+ height: 22px;
19
+ margin: 0;
20
+ cursor: pointer;
21
+ accent-color: var(--ui-slider-accent);
22
+ }
23
+
24
+ .ui-slider__input:disabled {
25
+ cursor: default;
26
+ }
27
+
28
+ .ui-slider--disabled {
29
+ opacity: 0.4;
30
+ }
@@ -0,0 +1,39 @@
1
+ import type {StyleProp, ViewStyle} from 'react-native';
2
+
3
+ /**
4
+ * Cross-platform slider for picking a value from a continuous or stepped range.
5
+ *
6
+ * Bridges the SwiftUI `Slider` on iOS, the Jetpack Compose Material 3 `Slider`
7
+ * on Android, and the HTML `<input type="range">` element on web. A controlled
8
+ * control: pair `value` with `onValueChange`.
9
+ */
10
+ export interface SliderProps {
11
+ /** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
12
+ label?: string;
13
+ /** Current value, between `min` and `max`. */
14
+ value: number;
15
+ /** Called continuously while the user drags the thumb. */
16
+ onValueChange: (value: number) => void;
17
+ /** Called once when the user releases the thumb. */
18
+ onSlidingComplete?: (value: number) => void;
19
+ /**
20
+ * Minimum value of the range.
21
+ * @default 0
22
+ */
23
+ min?: number;
24
+ /**
25
+ * Maximum value of the range.
26
+ * @default 1
27
+ */
28
+ max?: number;
29
+ /** Increment size. Omit for a continuous slider. */
30
+ step?: number;
31
+ /** Disables interaction. */
32
+ disabled?: boolean;
33
+ /** Tint applied to the thumb and filled track (overrides the theme accent). */
34
+ accentColor?: string;
35
+ /** Identifier used to locate the component in end-to-end tests. */
36
+ testID?: string;
37
+ /** Style applied to the row container (web only). */
38
+ style?: StyleProp<ViewStyle>;
39
+ }
@@ -0,0 +1,58 @@
1
+ import type {StepperProps} from './types';
2
+
3
+ import add from '@expo/material-symbols/add.xml';
4
+ import remove from '@expo/material-symbols/remove.xml';
5
+ import {Icon, OutlinedIconButton, Row, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
6
+ import {fillMaxWidth, size, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
7
+ import {useColor} from '../theme';
8
+ import {clampStep, stepBounds} from './shared';
9
+
10
+ const BUTTON = 36;
11
+ const ICON = 18;
12
+
13
+ /**
14
+ * Jetpack Compose has no stepper control, so Android composes one from two
15
+ * Material 3 `OutlinedIconButton`s tinted with the live accent, with the
16
+ * value shown beside them. The row fills the available width and pins the
17
+ * control to the trailing edge, mirroring the iOS Form row.
18
+ */
19
+ export function Stepper(props: StepperProps) {
20
+ const {label, value, onValueChange, step = 1, min, max, formatValue, disabled, testID} = props;
21
+ const colors = useMaterialColors();
22
+ const tint = useColor('tint');
23
+ const {canDecrement, canIncrement} = stepBounds(props);
24
+ const buttonColors = {
25
+ contentColor: tint,
26
+ disabledContentColor: colors.onSurfaceVariant,
27
+ };
28
+
29
+ return (
30
+ <Row
31
+ verticalAlignment="center"
32
+ horizontalArrangement="spaceBetween"
33
+ modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
34
+ {label != null ? (
35
+ <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
36
+ ) : null}
37
+ <Row verticalAlignment="center" horizontalArrangement={{spacedBy: 8}}>
38
+ <Text color={colors.onSurfaceVariant}>
39
+ {formatValue ? formatValue(value) : String(value)}
40
+ </Text>
41
+ <OutlinedIconButton
42
+ enabled={canDecrement}
43
+ colors={buttonColors}
44
+ onClick={canDecrement ? () => onValueChange(clampStep(value - step, min, max)) : undefined}
45
+ modifiers={[size(BUTTON, BUTTON)]}>
46
+ <Icon source={remove} size={ICON} contentDescription="Decrement"/>
47
+ </OutlinedIconButton>
48
+ <OutlinedIconButton
49
+ enabled={canIncrement}
50
+ colors={buttonColors}
51
+ onClick={canIncrement ? () => onValueChange(clampStep(value + step, min, max)) : undefined}
52
+ modifiers={[size(BUTTON, BUTTON)]}>
53
+ <Icon source={add} size={ICON} contentDescription="Increment"/>
54
+ </OutlinedIconButton>
55
+ </Row>
56
+ </Row>
57
+ );
58
+ }
@@ -0,0 +1,55 @@
1
+ import type {StepperProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {HStack, Spacer, Stepper as SwiftUIStepper, Text} from '@expo/ui/swift-ui';
5
+ import {disabled as disabledMod, foregroundStyle, labelsHidden} from '@expo/ui/swift-ui/modifiers';
6
+ import {fillWidth} from '../fill';
7
+ import {useColor} from '../theme';
8
+ import {clampStep} from './shared';
9
+
10
+ /**
11
+ * iOS renders SwiftUI's `Stepper`. Its own label is hidden so the row can be
12
+ * composed like the other platforms — label leading, value and the `− +`
13
+ * control trailing — exactly the iOS Settings look. Drop it straight into a
14
+ * `FieldGroup.Section`.
15
+ */
16
+ export function Stepper({
17
+ label,
18
+ value,
19
+ onValueChange,
20
+ step = 1,
21
+ min,
22
+ max,
23
+ formatValue,
24
+ disabled,
25
+ testID,
26
+ }: StepperProps) {
27
+ const labelColor = useColor(disabled ? 'secondaryLabel' : 'label');
28
+ const valueColor = useColor('secondaryLabel');
29
+ const modifiers: ViewModifier[] = [labelsHidden()];
30
+ if (disabled) modifiers.push(disabledMod(true));
31
+
32
+ const control = (
33
+ <SwiftUIStepper
34
+ label={label ?? ''}
35
+ value={value}
36
+ step={step}
37
+ min={min}
38
+ max={max}
39
+ onValueChange={next => onValueChange(clampStep(next, min, max))}
40
+ modifiers={modifiers}
41
+ testID={testID}
42
+ />
43
+ );
44
+
45
+ return (
46
+ <HStack spacing={12} modifiers={fillWidth}>
47
+ {label != null ? <Text modifiers={[foregroundStyle(labelColor)]}>{label}</Text> : null}
48
+ <Spacer/>
49
+ <Text modifiers={[foregroundStyle(valueColor)]}>
50
+ {formatValue ? formatValue(value) : String(value)}
51
+ </Text>
52
+ {control}
53
+ </HStack>
54
+ );
55
+ }
@@ -0,0 +1,47 @@
1
+ import './stepper.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {StepperProps} from './types';
4
+ import {StyleSheet, type TextStyle} from 'react-native';
5
+ import {Label} from '../typography';
6
+ import {flatten} from '../theme';
7
+ import {clampStep, stepBounds} from './shared';
8
+
9
+ /**
10
+ * On web the control is two native `<button>` elements in an iOS-style pill,
11
+ * with the value shown beside them. The row mirrors the native layout: label
12
+ * on the leading edge, control pinned to the trailing edge.
13
+ */
14
+ export function Stepper(props: StepperProps) {
15
+ const {label, value, onValueChange, step = 1, min, max, formatValue, disabled, testID, style} = props;
16
+ const {canDecrement, canIncrement} = stepBounds(props);
17
+ const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
18
+ return (
19
+ <div
20
+ className={['ui-stepper', disabled && 'ui-stepper--disabled'].filter(Boolean).join(' ')}
21
+ style={vars}
22
+ data-testid={testID}>
23
+ {label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
24
+ <div className="ui-stepper__control">
25
+ <Label color="secondaryLabel">{formatValue ? formatValue(value) : String(value)}</Label>
26
+ <div className="ui-stepper__buttons" role="group" aria-label={label}>
27
+ <button
28
+ type="button"
29
+ className="ui-stepper__button"
30
+ aria-label="Decrement"
31
+ disabled={!canDecrement}
32
+ onClick={() => onValueChange(clampStep(value - step, min, max))}>
33
+
34
+ </button>
35
+ <button
36
+ type="button"
37
+ className="ui-stepper__button"
38
+ aria-label="Increment"
39
+ disabled={!canIncrement}
40
+ onClick={() => onValueChange(clampStep(value + step, min, max))}>
41
+ +
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,17 @@
1
+ import type {StepperProps} from './types';
2
+
3
+ /** Clamps `next` into `[min, max]`, rounding away float drift from repeated steps. */
4
+ export function clampStep(next: number, min?: number, max?: number): number {
5
+ let value = Math.round(next * 1e6) / 1e6;
6
+ if (min != null) value = Math.max(min, value);
7
+ if (max != null) value = Math.min(max, value);
8
+ return value;
9
+ }
10
+
11
+ /** Whether each button is pressable given the bounds and disabled state. */
12
+ export function stepBounds({value, min, max, disabled}: StepperProps) {
13
+ return {
14
+ canDecrement: !disabled && (min == null || value > min),
15
+ canIncrement: !disabled && (max == null || value < max),
16
+ };
17
+ }
@@ -0,0 +1,61 @@
1
+ .ui-stepper {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: center;
5
+ justify-content: space-between;
6
+ gap: 12px;
7
+ width: 100%;
8
+ }
9
+
10
+ .ui-stepper__control {
11
+ display: inline-flex;
12
+ flex-shrink: 0;
13
+ flex-direction: row;
14
+ align-items: center;
15
+ gap: 12px;
16
+ }
17
+
18
+ .ui-stepper__buttons {
19
+ display: inline-flex;
20
+ flex-direction: row;
21
+ border-radius: 8px;
22
+ background: var(--color-pill-background);
23
+ overflow: hidden;
24
+ }
25
+
26
+ .ui-stepper__button {
27
+ -webkit-appearance: none;
28
+ appearance: none;
29
+ width: 42px;
30
+ height: 30px;
31
+ margin: 0;
32
+ padding: 0;
33
+ border: none;
34
+ background: transparent;
35
+ color: var(--color-label);
36
+ font-family: var(--font-display);
37
+ font-size: 18px;
38
+ line-height: 1;
39
+ cursor: pointer;
40
+ }
41
+
42
+ .ui-stepper__button + .ui-stepper__button {
43
+ border-left: 1px solid var(--color-separator);
44
+ }
45
+
46
+ .ui-stepper__button:hover:not(:disabled) {
47
+ background: color-mix(in srgb, var(--color-label) 8%, transparent);
48
+ }
49
+
50
+ .ui-stepper__button:active:not(:disabled) {
51
+ background: color-mix(in srgb, var(--color-label) 16%, transparent);
52
+ }
53
+
54
+ .ui-stepper__button:disabled {
55
+ opacity: 0.35;
56
+ cursor: default;
57
+ }
58
+
59
+ .ui-stepper--disabled {
60
+ opacity: 0.4;
61
+ }
@@ -0,0 +1,35 @@
1
+ import type {StyleProp, ViewStyle} from 'react-native';
2
+
3
+ /**
4
+ * Cross-platform stepper: a numeric value with increment/decrement buttons.
5
+ *
6
+ * Bridges the SwiftUI `Stepper` on iOS, a pair of Jetpack Compose Material 3
7
+ * icon buttons on Android (Compose has no stepper control), and native
8
+ * `<button>` elements on web. A controlled control: pair `value` with
9
+ * `onValueChange`.
10
+ */
11
+ export interface StepperProps {
12
+ /** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
13
+ label?: string;
14
+ /** Current value. */
15
+ value: number;
16
+ /** Called with the new value when a button is pressed. */
17
+ onValueChange: (value: number) => void;
18
+ /**
19
+ * Amount added or subtracted per press.
20
+ * @default 1
21
+ */
22
+ step?: number;
23
+ /** Lower bound; the decrement button disables at this value. */
24
+ min?: number;
25
+ /** Upper bound; the increment button disables at this value. */
26
+ max?: number;
27
+ /** Formats the displayed value (web/android; iOS shows the raw number). */
28
+ formatValue?: (value: number) => string;
29
+ /** Disables interaction. */
30
+ disabled?: boolean;
31
+ /** Identifier used to locate the component in end-to-end tests. */
32
+ testID?: string;
33
+ /** Style applied to the row container (web only). */
34
+ style?: StyleProp<ViewStyle>;
35
+ }
@@ -28,6 +28,8 @@ export function Switch({
28
28
  value={value}
29
29
  onValueChange={onValueChange}
30
30
  disabled={disabled}
31
+ // The label is a sibling, not a <label>, so name the input directly.
32
+ accessibilityLabel={label}
31
33
  thumbColor={THUMB}
32
34
  trackColor={{true: onColor, false: offColor}}
33
35
  testID={label == null ? testID : undefined}
@@ -53,6 +53,8 @@ export function useTextValue(
53
53
  export function useSyncedState(state: ObservableState<string>, value: string | undefined): void {
54
54
  useEffect(() => {
55
55
  if (value !== undefined && state.value !== value) {
56
+ // Writing `.value` is the `@expo/ui` observable's API, not a prop mutation.
57
+ // eslint-disable-next-line react-hooks/immutability
56
58
  state.value = value;
57
59
  }
58
60
  }, [state, value]);
@@ -0,0 +1,23 @@
1
+ import type {TooltipProps} from './types';
2
+
3
+ import {Text, TooltipBox} from '@expo/ui/jetpack-compose';
4
+ import {testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
5
+ import {useColor} from '../theme';
6
+
7
+ /**
8
+ * Android renders the Material 3 `TooltipBox` with a `PlainTooltip`: a
9
+ * long-press on `children` shows the hint, colored like the web hint
10
+ * (inverse surface) from the theme tokens.
11
+ */
12
+ export function Tooltip({text, children, testID}: TooltipProps) {
13
+ const container = useColor('label');
14
+ const content = useColor('background');
15
+ return (
16
+ <TooltipBox modifiers={testID ? [testIDModifier(testID)] : undefined}>
17
+ <TooltipBox.PlainTooltip containerColor={container} contentColor={content}>
18
+ <Text color={content}>{text}</Text>
19
+ </TooltipBox.PlainTooltip>
20
+ {children}
21
+ </TooltipBox>
22
+ );
23
+ }
@@ -0,0 +1,17 @@
1
+ import type {TooltipProps} from './types';
2
+
3
+ import {Group} from '@expo/ui/swift-ui';
4
+ import {accessibilityHint} from '@expo/ui/swift-ui/modifiers';
5
+
6
+ /**
7
+ * iOS has no tooltip idiom (SwiftUI's `help()` is macOS/visionOS only), so
8
+ * the content renders unchanged inside a `Group` that exposes `text` to
9
+ * VoiceOver as an accessibility hint.
10
+ */
11
+ export function Tooltip({text, children, testID}: TooltipProps) {
12
+ return (
13
+ <Group modifiers={[accessibilityHint(text)]} testID={testID}>
14
+ {children}
15
+ </Group>
16
+ );
17
+ }
@@ -0,0 +1,41 @@
1
+ import './tooltip.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {TooltipProps} from './types';
4
+ import {useId} from 'react';
5
+
6
+ /**
7
+ * Whether the browser implements the Interest Invoker API (`interestfor`).
8
+ * When it does, the hint is a real `popover="hint"` (top layer, shown on
9
+ * hover / focus / long-press, dismissed by the browser); otherwise the
10
+ * `title` attribute provides the platform tooltip.
11
+ */
12
+ const INTEREST_SUPPORTED =
13
+ typeof HTMLButtonElement !== 'undefined' && 'interestForElement' in HTMLButtonElement.prototype;
14
+
15
+ export function Tooltip({text, children, testID}: TooltipProps) {
16
+ const ident = `ui-tooltip-${useId().replace(/[^A-Za-z0-9_-]/g, '_')}`;
17
+ const anchor = `--${ident}`;
18
+ return (
19
+ <>
20
+ <button
21
+ type="button"
22
+ className="ui-tooltip"
23
+ title={INTEREST_SUPPORTED ? undefined : text}
24
+ style={{anchorName: anchor} as CSSProperties}
25
+ data-testid={testID}
26
+ {...(INTEREST_SUPPORTED ? {interestfor: ident} : null)}>
27
+ {children}
28
+ </button>
29
+ {INTEREST_SUPPORTED ? (
30
+ <div
31
+ id={ident}
32
+ role="tooltip"
33
+ popover="hint"
34
+ className="ui-tooltip__hint"
35
+ style={{positionAnchor: anchor} as CSSProperties}>
36
+ {text}
37
+ </div>
38
+ ) : null}
39
+ </>
40
+ );
41
+ }