expo-interface 0.1.0 → 0.1.1
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 +260 -180
- package/package.json +41 -10
- 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 +61 -0
- package/src/alert/shared.ts +10 -0
- package/src/alert/types.ts +58 -0
- package/src/button/index.tsx +17 -1
- 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/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/index.ts +20 -2
- 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 +24 -0
- package/src/progress/types.ts +23 -6
- package/src/qr/index.tsx +2 -2
- 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 +56 -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/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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
import type {IconToken} from '../icons';
|
|
3
|
+
import type {ButtonProps} from '../button/types';
|
|
4
|
+
|
|
5
|
+
/** One entry of a `Menu` / `ContextMenu`. */
|
|
6
|
+
export interface MenuItem {
|
|
7
|
+
/** Item text. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Leading icon. */
|
|
10
|
+
icon?: IconToken;
|
|
11
|
+
/**
|
|
12
|
+
* `destructive` renders the item in the danger color.
|
|
13
|
+
* @default 'default'
|
|
14
|
+
*/
|
|
15
|
+
role?: 'default' | 'destructive';
|
|
16
|
+
/** Greys the item out and ignores presses. */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Draw a separator above this item. */
|
|
19
|
+
separator?: boolean;
|
|
20
|
+
/** Called when the item is selected; the menu then closes. */
|
|
21
|
+
onPress?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cross-platform dropdown menu opened from a button.
|
|
26
|
+
*
|
|
27
|
+
* Bridges the SwiftUI `Menu` on iOS, the Jetpack Compose Material 3
|
|
28
|
+
* `DropdownMenu` on Android, and a `role="menu"` popup on web. The trigger
|
|
29
|
+
* looks like the kit's `Button` and takes the same styling props.
|
|
30
|
+
*/
|
|
31
|
+
export interface MenuProps extends Pick<ButtonProps, 'variant' | 'size' | 'shape' | 'color' | 'hideLabel' | 'disabled'> {
|
|
32
|
+
/** Trigger text (kept for accessibility when `hideLabel` is set). */
|
|
33
|
+
label: string;
|
|
34
|
+
/** Trigger icon. */
|
|
35
|
+
icon?: IconToken;
|
|
36
|
+
/** Entries shown when the menu opens. */
|
|
37
|
+
items: MenuItem[];
|
|
38
|
+
/** Identifier used to locate the trigger in end-to-end tests. */
|
|
39
|
+
testID?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cross-platform context menu attached to arbitrary content.
|
|
44
|
+
*
|
|
45
|
+
* Opens on long-press (iOS `contextMenu`, Android `DropdownMenu` anchored by
|
|
46
|
+
* `combinedClickable`) or right-click / long-press on web. `children` must
|
|
47
|
+
* be native (`@expo/ui`) content on iOS/Android.
|
|
48
|
+
*/
|
|
49
|
+
export interface ContextMenuProps {
|
|
50
|
+
/** Entries shown when the menu opens. */
|
|
51
|
+
items: MenuItem[];
|
|
52
|
+
/** Content that triggers the menu. */
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
/** Called on a plain tap of the content (Android/web; iOS taps pass through). */
|
|
55
|
+
onPress?: () => void;
|
|
56
|
+
/** Disables the menu. */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Identifier used to locate the trigger in end-to-end tests. */
|
|
59
|
+
testID?: string;
|
|
60
|
+
}
|
|
@@ -3,7 +3,7 @@ import type {PickerProps, PickerValue} from './types';
|
|
|
3
3
|
import {useState} from 'react';
|
|
4
4
|
import unfoldMore from '@expo/material-symbols/unfold_more.xml';
|
|
5
5
|
import {useMaterialColors, DropdownMenu, DropdownMenuItem, Column, Row, Text, Icon} from '@expo/ui/jetpack-compose';
|
|
6
|
-
import {background, clickable, clip, fillMaxWidth, padding, Shapes} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {background, clickable, clip, fillMaxWidth, padding, Shapes, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
7
7
|
import {extractItems, labelFor, PickerItem, useSelectedValue} from './shared';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -20,6 +20,7 @@ function PickerComponent<T extends PickerValue>({
|
|
|
20
20
|
accentColor,
|
|
21
21
|
selectedValue,
|
|
22
22
|
onValueChange,
|
|
23
|
+
testID,
|
|
23
24
|
}: PickerProps<T>) {
|
|
24
25
|
const colors = useMaterialColors();
|
|
25
26
|
const items = extractItems<T>(children);
|
|
@@ -30,7 +31,7 @@ function PickerComponent<T extends PickerValue>({
|
|
|
30
31
|
// a Form, and the web pill uses `secondaryLabel` to match.
|
|
31
32
|
const colorValue = accentColor && !disabled ? accentColor : colors.onSurfaceVariant;
|
|
32
33
|
return (
|
|
33
|
-
<Column modifiers={[fillMaxWidth()]}>
|
|
34
|
+
<Column modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
|
|
34
35
|
<Row
|
|
35
36
|
verticalAlignment="center"
|
|
36
37
|
horizontalArrangement="spaceBetween"
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import type {ProgressProps} from './types';
|
|
2
|
-
import {LinearProgressIndicator} from '@expo/ui/jetpack-compose';
|
|
3
|
-
import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
2
|
+
import {CircularProgressIndicator, LinearProgressIndicator} from '@expo/ui/jetpack-compose';
|
|
3
|
+
import {fillMaxWidth, size as sizeMod, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
4
4
|
import {useColor} from '../theme';
|
|
5
5
|
|
|
6
|
+
const STROKE = 3;
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
|
-
* Android renders the Material 3 `LinearProgressIndicator
|
|
8
|
-
* width
|
|
9
|
+
* Android renders the Material 3 `LinearProgressIndicator` (filling the row
|
|
10
|
+
* width to match the iOS and web bars) or `CircularProgressIndicator` sized
|
|
11
|
+
* to `size`.
|
|
9
12
|
*/
|
|
10
|
-
export function Progress({value, color, trackColor, testID}: ProgressProps) {
|
|
13
|
+
export function Progress({value, variant = 'linear', size = 24, color, trackColor, testID}: ProgressProps) {
|
|
11
14
|
const accent = useColor('tint');
|
|
12
15
|
const track = useColor('backgroundSelected');
|
|
16
|
+
const testMods = testID ? [testIDModifier(testID)] : [];
|
|
17
|
+
|
|
18
|
+
if (variant === 'circular') {
|
|
19
|
+
return (
|
|
20
|
+
<CircularProgressIndicator
|
|
21
|
+
progress={value ?? null}
|
|
22
|
+
color={color ?? accent}
|
|
23
|
+
trackColor={trackColor ?? track}
|
|
24
|
+
strokeWidth={STROKE}
|
|
25
|
+
modifiers={[sizeMod(size, size), ...testMods]}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
return (
|
|
14
31
|
<LinearProgressIndicator
|
|
15
32
|
progress={value ?? null}
|
|
16
33
|
color={color ?? accent}
|
|
17
34
|
trackColor={trackColor ?? track}
|
|
18
|
-
modifiers={[
|
|
19
|
-
fillMaxWidth(),
|
|
20
|
-
...(testID ? [testIDModifier(testID)] : []),
|
|
21
|
-
]}
|
|
35
|
+
modifiers={[fillMaxWidth(), ...testMods]}
|
|
22
36
|
/>
|
|
23
37
|
);
|
|
24
38
|
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import type {ProgressProps} from './types';
|
|
2
2
|
import {ProgressView} from '@expo/ui/swift-ui';
|
|
3
|
-
import {tint} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
import {progressViewStyle, tint} from '@expo/ui/swift-ui/modifiers';
|
|
4
4
|
import {useColor} from '../theme';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* iOS renders SwiftUI's `ProgressView` in its linear style (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* iOS renders SwiftUI's `ProgressView` in its linear style (a flexible-width
|
|
8
|
+
* bar that fills its parent) or circular style (the system ring / activity
|
|
9
|
+
* spinner, at its native size). `trackColor` has no SwiftUI equivalent and is
|
|
10
|
+
* ignored.
|
|
10
11
|
*/
|
|
11
|
-
export function Progress({value, color, testID}: ProgressProps) {
|
|
12
|
+
export function Progress({value, variant = 'linear', color, testID}: ProgressProps) {
|
|
12
13
|
const accent = useColor('tint');
|
|
13
14
|
return (
|
|
14
15
|
<ProgressView
|
|
15
16
|
value={value ?? null}
|
|
16
|
-
modifiers={[tint(color ?? accent)]}
|
|
17
|
+
modifiers={[progressViewStyle(variant), tint(color ?? accent)]}
|
|
17
18
|
testID={testID}
|
|
18
19
|
/>
|
|
19
20
|
);
|
package/src/progress/index.tsx
CHANGED
|
@@ -3,20 +3,57 @@ import type {CSSProperties} from 'react';
|
|
|
3
3
|
import type {ProgressProps} from './types';
|
|
4
4
|
import {useColor} from '../theme';
|
|
5
5
|
|
|
6
|
+
const STROKE = 3;
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
|
-
* On web the bar is a native `<meter>` element
|
|
8
|
-
* by the `--ui-progress-*` custom properties
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* On web the linear bar is a native `<meter>` element whose bar/track colors
|
|
10
|
+
* are driven by the `--ui-progress-*` custom properties (meter pseudo-elements
|
|
11
|
+
* can't be styled inline). Indeterminate meters don't exist on the web
|
|
12
|
+
* platform, so an undefined `value` renders an empty bar. The circular
|
|
13
|
+
* variant is an SVG ring; without a `value` it spins as an activity indicator.
|
|
11
14
|
*/
|
|
12
|
-
export function Progress({value, color, trackColor, testID}: ProgressProps) {
|
|
15
|
+
export function Progress({value, variant = 'linear', size = 24, color, trackColor, testID}: ProgressProps) {
|
|
13
16
|
const tint = useColor('tint');
|
|
14
17
|
const track = useColor('backgroundSelected');
|
|
15
|
-
const clamped = Math.max(0, Math.min(1, value ?? 0));
|
|
16
18
|
const vars = {
|
|
17
19
|
'--ui-progress-bar': color ?? tint,
|
|
18
20
|
'--ui-progress-track': trackColor ?? track,
|
|
19
21
|
} as CSSProperties;
|
|
22
|
+
|
|
23
|
+
if (variant === 'circular') {
|
|
24
|
+
const indeterminate = value == null;
|
|
25
|
+
const clamped = indeterminate ? 0.25 : Math.max(0, Math.min(1, value));
|
|
26
|
+
const radius = (size - STROKE) / 2;
|
|
27
|
+
const circumference = 2 * Math.PI * radius;
|
|
28
|
+
return (
|
|
29
|
+
<svg
|
|
30
|
+
className={['ui-progress-ring', indeterminate && 'ui-progress-ring--indeterminate'].filter(Boolean).join(' ')}
|
|
31
|
+
width={size}
|
|
32
|
+
height={size}
|
|
33
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
34
|
+
role="progressbar"
|
|
35
|
+
aria-valuemin={0}
|
|
36
|
+
aria-valuemax={1}
|
|
37
|
+
aria-valuenow={indeterminate ? undefined : clamped}
|
|
38
|
+
data-testid={testID}
|
|
39
|
+
style={vars}>
|
|
40
|
+
<circle className="ui-progress-ring__track" cx={size / 2} cy={size / 2} r={radius} fill="none" strokeWidth={STROKE}/>
|
|
41
|
+
<circle
|
|
42
|
+
className="ui-progress-ring__bar"
|
|
43
|
+
cx={size / 2}
|
|
44
|
+
cy={size / 2}
|
|
45
|
+
r={radius}
|
|
46
|
+
fill="none"
|
|
47
|
+
strokeWidth={STROKE}
|
|
48
|
+
strokeLinecap="round"
|
|
49
|
+
strokeDasharray={circumference}
|
|
50
|
+
strokeDashoffset={circumference * (1 - clamped)}
|
|
51
|
+
/>
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clamped = Math.max(0, Math.min(1, value ?? 0));
|
|
20
57
|
return (
|
|
21
58
|
<meter
|
|
22
59
|
className="ui-progress"
|
|
@@ -25,3 +25,27 @@
|
|
|
25
25
|
border-radius: 999px;
|
|
26
26
|
background: var(--ui-progress-bar);
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
.ui-progress-ring {
|
|
30
|
+
flex-shrink: 0;
|
|
31
|
+
display: inline-block;
|
|
32
|
+
transform: rotate(-90deg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ui-progress-ring__track {
|
|
36
|
+
stroke: var(--ui-progress-track);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ui-progress-ring__bar {
|
|
40
|
+
stroke: var(--ui-progress-bar);
|
|
41
|
+
transition: stroke-dashoffset 0.2s ease;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-progress-ring--indeterminate {
|
|
45
|
+
animation: ui-progress-spin 0.9s linear infinite;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@keyframes ui-progress-spin {
|
|
49
|
+
from { transform: rotate(-90deg); }
|
|
50
|
+
to { transform: rotate(270deg); }
|
|
51
|
+
}
|
package/src/progress/types.ts
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
|
+
/** Shape of the indicator. */
|
|
2
|
+
export type ProgressVariant = 'linear' | 'circular';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* Cross-platform
|
|
5
|
+
* Cross-platform progress indicator.
|
|
3
6
|
*
|
|
4
|
-
* Bridges the SwiftUI `ProgressView` on iOS, the
|
|
5
|
-
* `LinearProgressIndicator`
|
|
7
|
+
* Bridges the SwiftUI `ProgressView` (linear or circular style) on iOS, the
|
|
8
|
+
* Jetpack Compose `LinearProgressIndicator` / `CircularProgressIndicator` on
|
|
9
|
+
* Android, and the HTML `<meter>` element (linear) or an SVG ring (circular)
|
|
10
|
+
* on web.
|
|
6
11
|
*/
|
|
7
12
|
export interface ProgressProps {
|
|
8
13
|
/**
|
|
9
14
|
* Progress value between `0` and `1`.
|
|
10
|
-
* Omit for an indeterminate indicator (
|
|
15
|
+
* Omit for an indeterminate indicator (a spinner when `circular`; the linear
|
|
16
|
+
* web bar has no indeterminate state and renders empty).
|
|
11
17
|
*/
|
|
12
18
|
value?: number;
|
|
13
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Bar or ring.
|
|
21
|
+
* @default 'linear'
|
|
22
|
+
*/
|
|
23
|
+
variant?: ProgressVariant;
|
|
24
|
+
/**
|
|
25
|
+
* Diameter of the circular indicator in points/dp. Ignored for `linear`,
|
|
26
|
+
* and on iOS where the system spinner keeps its native size.
|
|
27
|
+
* @default 24
|
|
28
|
+
*/
|
|
29
|
+
size?: number;
|
|
30
|
+
/** Color of the filled portion. Defaults to the theme tint. */
|
|
14
31
|
color?: string;
|
|
15
|
-
/** Color of the unfilled track behind the bar. */
|
|
32
|
+
/** Color of the unfilled track behind the bar/ring. */
|
|
16
33
|
trackColor?: string;
|
|
17
34
|
/** Identifier used to locate the component in end-to-end tests. */
|
|
18
35
|
testID?: string;
|
package/src/qr/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {useMemo} from 'react';
|
|
2
2
|
import {Image} from 'expo-image';
|
|
3
|
-
import
|
|
3
|
+
import createQR from 'qrcode-generator';
|
|
4
4
|
|
|
5
5
|
export interface QRCodeProps {
|
|
6
6
|
/** Value encoded in the QR code. */
|
|
@@ -11,7 +11,7 @@ export interface QRCodeProps {
|
|
|
11
11
|
|
|
12
12
|
export function QRCode({value, size = 200}: QRCodeProps) {
|
|
13
13
|
const uri = useMemo(() => {
|
|
14
|
-
const qr =
|
|
14
|
+
const qr = createQR(0, 'M');
|
|
15
15
|
qr.addData(value);
|
|
16
16
|
qr.make();
|
|
17
17
|
return qr.createDataURL(8, 2);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {PickerValue} from '../picker/types';
|
|
2
|
+
import type {SegmentedControlProps} from './types';
|
|
3
|
+
|
|
4
|
+
import {Row, SegmentedButton, SingleChoiceSegmentedButtonRow, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {onAccent} from '../accent';
|
|
7
|
+
import {useColor} from '../theme';
|
|
8
|
+
import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Android renders the Material 3 `SingleChoiceSegmentedButtonRow`. The
|
|
12
|
+
* selected segment is filled with the live accent seed (matching the iOS
|
|
13
|
+
* tint cascade and the web accent) and the row pins the control to the
|
|
14
|
+
* trailing edge, mirroring the iOS Form row.
|
|
15
|
+
*/
|
|
16
|
+
function SegmentedControlComponent<T extends PickerValue>({
|
|
17
|
+
label,
|
|
18
|
+
children,
|
|
19
|
+
selectedValue,
|
|
20
|
+
onValueChange,
|
|
21
|
+
disabled,
|
|
22
|
+
accentColor,
|
|
23
|
+
testID,
|
|
24
|
+
}: SegmentedControlProps<T>) {
|
|
25
|
+
const colors = useMaterialColors();
|
|
26
|
+
const tint = useColor('tint');
|
|
27
|
+
const accent = accentColor ?? tint;
|
|
28
|
+
const items = extractItems<T>(children);
|
|
29
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
30
|
+
const segmentColors = {
|
|
31
|
+
activeContainerColor: accent,
|
|
32
|
+
activeContentColor: onAccent(accent),
|
|
33
|
+
activeBorderColor: colors.outline,
|
|
34
|
+
inactiveContainerColor: '#00000000',
|
|
35
|
+
inactiveContentColor: colors.onSurface,
|
|
36
|
+
inactiveBorderColor: colors.outline,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Row
|
|
41
|
+
verticalAlignment="center"
|
|
42
|
+
horizontalArrangement="spaceBetween"
|
|
43
|
+
modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
|
|
44
|
+
{label != null ? (
|
|
45
|
+
<Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
|
|
46
|
+
) : null}
|
|
47
|
+
<SingleChoiceSegmentedButtonRow>
|
|
48
|
+
{items.map(item => (
|
|
49
|
+
<SegmentedButton
|
|
50
|
+
key={String(item.value)}
|
|
51
|
+
selected={item.value === current}
|
|
52
|
+
enabled={!disabled}
|
|
53
|
+
colors={segmentColors}
|
|
54
|
+
onClick={disabled ? undefined : () => setValue(item.value)}>
|
|
55
|
+
<SegmentedButton.Label>
|
|
56
|
+
<Text>{item.label}</Text>
|
|
57
|
+
</SegmentedButton.Label>
|
|
58
|
+
</SegmentedButton>
|
|
59
|
+
))}
|
|
60
|
+
</SingleChoiceSegmentedButtonRow>
|
|
61
|
+
</Row>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
SegmentedControlComponent.Item = PickerItem;
|
|
66
|
+
|
|
67
|
+
export {SegmentedControlComponent as SegmentedControl};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {PickerValue} from '../picker/types';
|
|
2
|
+
import type {SegmentedControlProps} from './types';
|
|
3
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
|
|
5
|
+
import {Picker as SwiftUIPicker, Text} from '@expo/ui/swift-ui';
|
|
6
|
+
import {pickerStyle, tag, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
|
|
7
|
+
import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* iOS renders SwiftUI's `Picker` in the `segmented` style — the system
|
|
11
|
+
* `UISegmentedControl` — with the label leading inside a Form row. Drop it
|
|
12
|
+
* straight into a `FieldGroup.Section`.
|
|
13
|
+
*/
|
|
14
|
+
function SegmentedControlComponent<T extends PickerValue>({
|
|
15
|
+
label,
|
|
16
|
+
children,
|
|
17
|
+
selectedValue,
|
|
18
|
+
onValueChange,
|
|
19
|
+
disabled,
|
|
20
|
+
accentColor,
|
|
21
|
+
testID,
|
|
22
|
+
}: SegmentedControlProps<T>) {
|
|
23
|
+
const items = extractItems<T>(children);
|
|
24
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
25
|
+
const modifiers: ViewModifier[] = [pickerStyle('segmented')];
|
|
26
|
+
if (accentColor) modifiers.push(tint(accentColor));
|
|
27
|
+
if (disabled) modifiers.push(disabledMod(true));
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<SwiftUIPicker
|
|
31
|
+
label={label}
|
|
32
|
+
selection={current}
|
|
33
|
+
onSelectionChange={value => setValue(value as T)}
|
|
34
|
+
modifiers={modifiers}
|
|
35
|
+
testID={testID}>
|
|
36
|
+
{items.map(item => (
|
|
37
|
+
<Text key={String(item.value)} modifiers={[tag(item.value)]}>
|
|
38
|
+
{item.label}
|
|
39
|
+
</Text>
|
|
40
|
+
))}
|
|
41
|
+
</SwiftUIPicker>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SegmentedControlComponent.Item = PickerItem;
|
|
46
|
+
|
|
47
|
+
export {SegmentedControlComponent as SegmentedControl};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import './segmented.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {PickerValue} from '../picker/types';
|
|
4
|
+
import type {SegmentedControlProps} from './types';
|
|
5
|
+
import {StyleSheet, type TextStyle} from 'react-native';
|
|
6
|
+
import {Label} from '../typography';
|
|
7
|
+
import {flatten} from '../theme';
|
|
8
|
+
import {extractItems, PickerItem, useSelectedValue} from '../picker/shared';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* On web the control is a `radiogroup` of native `<button>`s styled after the
|
|
12
|
+
* iOS segmented control (grey pill, raised selected segment). The row mirrors
|
|
13
|
+
* the native layout: label on the leading edge, control on the trailing edge.
|
|
14
|
+
*/
|
|
15
|
+
function SegmentedControlComponent<T extends PickerValue>({
|
|
16
|
+
label,
|
|
17
|
+
children,
|
|
18
|
+
selectedValue,
|
|
19
|
+
onValueChange,
|
|
20
|
+
disabled,
|
|
21
|
+
accentColor,
|
|
22
|
+
testID,
|
|
23
|
+
style,
|
|
24
|
+
}: SegmentedControlProps<T>) {
|
|
25
|
+
const items = extractItems<T>(children);
|
|
26
|
+
const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
|
|
27
|
+
const vars = flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined) as CSSProperties;
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={['ui-segmented', disabled && 'ui-segmented--disabled'].filter(Boolean).join(' ')}
|
|
31
|
+
style={vars}
|
|
32
|
+
data-testid={testID}>
|
|
33
|
+
{label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
|
|
34
|
+
<div className="ui-segmented__group" role="radiogroup" aria-label={label}>
|
|
35
|
+
{items.map(item => {
|
|
36
|
+
const selected = item.value === current;
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
key={String(item.value)}
|
|
40
|
+
type="button"
|
|
41
|
+
role="radio"
|
|
42
|
+
className="ui-segmented__item"
|
|
43
|
+
aria-checked={selected}
|
|
44
|
+
disabled={disabled}
|
|
45
|
+
style={selected && accentColor ? {color: accentColor} : undefined}
|
|
46
|
+
onClick={() => setValue(item.value)}>
|
|
47
|
+
{item.label}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
SegmentedControlComponent.Item = PickerItem;
|
|
57
|
+
|
|
58
|
+
export {SegmentedControlComponent as SegmentedControl};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
.ui-segmented {
|
|
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-segmented__group {
|
|
11
|
+
display: inline-flex;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
flex-direction: row;
|
|
14
|
+
padding: 2px;
|
|
15
|
+
border-radius: 9px;
|
|
16
|
+
background: var(--color-pill-background);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.ui-segmented__item {
|
|
20
|
+
-webkit-appearance: none;
|
|
21
|
+
appearance: none;
|
|
22
|
+
min-width: 48px;
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 4px 12px;
|
|
25
|
+
border: none;
|
|
26
|
+
border-radius: 7px;
|
|
27
|
+
background: transparent;
|
|
28
|
+
color: var(--color-label);
|
|
29
|
+
font-family: var(--font-display);
|
|
30
|
+
font-size: 13px;
|
|
31
|
+
font-weight: 500;
|
|
32
|
+
line-height: 18px;
|
|
33
|
+
white-space: nowrap;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ui-segmented__item[aria-checked='true'] {
|
|
39
|
+
background: #ffffff;
|
|
40
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (prefers-color-scheme: dark) {
|
|
45
|
+
.ui-segmented__item[aria-checked='true'] {
|
|
46
|
+
background: #636366;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ui-segmented__item:disabled {
|
|
51
|
+
cursor: default;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.ui-segmented--disabled {
|
|
55
|
+
opacity: 0.4;
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {ReactNode} from 'react';
|
|
2
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
3
|
+
import type {PickerValue} from '../picker/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-platform segmented control: a single-choice row of segments.
|
|
7
|
+
*
|
|
8
|
+
* Bridges the SwiftUI `Picker` in its `segmented` style on iOS, the Jetpack
|
|
9
|
+
* Compose Material 3 `SingleChoiceSegmentedButtonRow` on Android, and a
|
|
10
|
+
* `radiogroup` of native `<button>`s on web. Options are declared with
|
|
11
|
+
* `SegmentedControl.Item` children, exactly like `Picker`. May be used
|
|
12
|
+
* controlled (`selectedValue` + `onValueChange`) or uncontrolled.
|
|
13
|
+
*/
|
|
14
|
+
export interface SegmentedControlProps<T extends PickerValue = PickerValue> {
|
|
15
|
+
/** Label rendered at the leading edge of the row, mirroring an iOS Form row. */
|
|
16
|
+
label?: string;
|
|
17
|
+
/** `SegmentedControl.Item` children that define the segments. */
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
/** Current value (controlled). When omitted the component keeps its own state. */
|
|
20
|
+
selectedValue?: T;
|
|
21
|
+
/** Called whenever the user selects a segment. */
|
|
22
|
+
onValueChange?: (value: T) => void;
|
|
23
|
+
/** Disables interaction. */
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
/** Tint of the selected segment (Android/web) or the picker (iOS). */
|
|
26
|
+
accentColor?: string;
|
|
27
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
28
|
+
testID?: string;
|
|
29
|
+
/** Style applied to the row container (web only). */
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
@@ -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
|
+
}
|