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.
- package/README.md +282 -180
- package/package.json +39 -13
- package/src/alert/alert.css +49 -0
- package/src/alert/index.android.tsx +68 -0
- package/src/alert/index.ios.tsx +47 -0
- package/src/alert/index.tsx +60 -0
- package/src/alert/shared.ts +10 -0
- package/src/alert/types.ts +58 -0
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +9 -2
- package/src/button/index.ios.tsx +8 -3
- package/src/button/index.tsx +19 -1
- package/src/button/types.ts +7 -0
- package/src/checkbox/checkbox.css +29 -0
- package/src/checkbox/index.android.tsx +54 -0
- package/src/checkbox/index.ios.tsx +50 -0
- package/src/checkbox/index.tsx +50 -0
- package/src/checkbox/types.ts +27 -0
- package/src/collapsible/collapsible.css +32 -0
- package/src/collapsible/index.android.tsx +34 -0
- package/src/collapsible/index.ios.tsx +31 -0
- package/src/collapsible/index.tsx +41 -0
- package/src/collapsible/shared.ts +21 -0
- package/src/collapsible/types.ts +24 -0
- package/src/color-picker/color-picker.css +66 -0
- package/src/color-picker/index.android.tsx +117 -0
- package/src/color-picker/index.ios.tsx +33 -0
- package/src/color-picker/index.tsx +62 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +34 -0
- package/src/context-menu/index.android.tsx +27 -0
- package/src/context-menu/index.ios.tsx +22 -0
- package/src/context-menu/index.tsx +56 -0
- package/src/date-time/index.android.tsx +3 -2
- package/src/divider/divider.css +16 -0
- package/src/divider/index.android.tsx +26 -0
- package/src/divider/index.ios.tsx +18 -0
- package/src/divider/index.tsx +21 -0
- package/src/divider/types.ts +20 -0
- package/src/field-group/field-group.css +23 -7
- package/src/field-group/index.android.tsx +22 -22
- package/src/field-group/index.web.tsx +2 -2
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/index.ts +24 -3
- package/src/menu/index.android.tsx +66 -0
- package/src/menu/index.ios.tsx +72 -0
- package/src/menu/index.tsx +30 -0
- package/src/menu/list.tsx +98 -0
- package/src/menu/menu.css +83 -0
- package/src/menu/types.ts +60 -0
- package/src/picker/index.android.tsx +3 -2
- package/src/progress/index.android.tsx +23 -9
- package/src/progress/index.ios.tsx +7 -6
- package/src/progress/index.tsx +43 -6
- package/src/progress/progress.css +33 -0
- package/src/progress/types.ts +23 -6
- package/src/screen/header.tsx +1 -0
- package/src/segmented/index.android.tsx +67 -0
- package/src/segmented/index.ios.tsx +47 -0
- package/src/segmented/index.tsx +58 -0
- package/src/segmented/segmented.css +55 -0
- package/src/segmented/types.ts +31 -0
- package/src/slider/index.android.tsx +87 -0
- package/src/slider/index.ios.tsx +64 -0
- package/src/slider/index.tsx +55 -0
- package/src/slider/slider.css +30 -0
- package/src/slider/types.ts +39 -0
- package/src/stepper/index.android.tsx +58 -0
- package/src/stepper/index.ios.tsx +55 -0
- package/src/stepper/index.tsx +47 -0
- package/src/stepper/shared.ts +17 -0
- package/src/stepper/stepper.css +61 -0
- package/src/stepper/types.ts +35 -0
- package/src/switch/index.tsx +2 -0
- package/src/text-field/shared.ts +2 -0
- package/src/tooltip/index.android.tsx +23 -0
- package/src/tooltip/index.ios.tsx +17 -0
- package/src/tooltip/index.tsx +41 -0
- package/src/tooltip/tooltip.css +40 -0
- package/src/tooltip/types.ts +25 -0
- package/src/link.ts +0 -36
- 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
|
+
}
|
package/src/switch/index.tsx
CHANGED
|
@@ -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}
|
package/src/text-field/shared.ts
CHANGED
|
@@ -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
|
+
}
|