@vvedantb/native-ui 0.2.1 → 0.3.13
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 +171 -9
- package/package.json +84 -2
- package/src/AlertDialog.tsx +73 -0
- package/src/AmbientBackground.tsx +26 -11
- package/src/Avatar.tsx +141 -0
- package/src/Badge.tsx +108 -0
- package/src/BottomSheetContainer.tsx +7 -1
- package/src/Button.tsx +153 -52
- package/src/Card.tsx +111 -36
- package/src/Checkbox.tsx +78 -0
- package/src/Chip.tsx +5 -3
- package/src/ConfirmDialog.tsx +105 -0
- package/src/DateTimeField.tsx +6 -2
- package/src/Dialog.tsx +253 -0
- package/src/EmptyState.tsx +122 -7
- package/src/FloatingFAB.tsx +2 -4
- package/src/FloatingSurface.tsx +31 -9
- package/src/FloatingTabBar.tsx +39 -11
- package/src/Input.tsx +15 -4
- package/src/Label.tsx +31 -0
- package/src/LabeledSwitchRow.tsx +96 -0
- package/src/LiquidGlass.tsx +2 -10
- package/src/ListRow.tsx +7 -2
- package/src/PageTitle.tsx +81 -0
- package/src/PillTabs.tsx +28 -14
- package/src/Progress.tsx +67 -0
- package/src/SearchInput.tsx +163 -0
- package/src/Select.tsx +32 -6
- package/src/Separator.tsx +38 -0
- package/src/Sheet.tsx +107 -0
- package/src/Skeleton.tsx +25 -5
- package/src/Spinner.tsx +92 -0
- package/src/StatusDot.tsx +66 -0
- package/src/Switch.tsx +24 -7
- package/src/Tabs.tsx +190 -0
- package/src/Textarea.tsx +47 -0
- package/src/Toast.tsx +170 -0
- package/src/blur.ts +48 -0
- package/src/brand/AppIcons.tsx +106 -0
- package/src/brand/AppMark.tsx +83 -0
- package/src/brand/AppWordmark.tsx +97 -0
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +34 -0
- package/src/brand/svg.ts +263 -0
- package/src/brand/vProAppBenefits.ts +22 -0
- package/src/controlStyles.ts +395 -0
- package/src/dialogLayout.ts +20 -0
- package/src/entries/ambient.ts +3 -0
- package/src/entries/bottom-sheet.ts +2 -0
- package/src/entries/brand.ts +2 -0
- package/src/entries/button.ts +3 -0
- package/src/entries/date-time.ts +3 -0
- package/src/entries/floating.ts +24 -0
- package/src/entries/layout.ts +10 -0
- package/src/entries/marketing.ts +10 -0
- package/src/entries/page-title.ts +4 -0
- package/src/entries/primitives.ts +99 -0
- package/src/entries/search-input.ts +4 -0
- package/src/entries/tab-bar.ts +10 -0
- package/src/entries/theme.ts +15 -0
- package/src/entries/toast.ts +6 -0
- package/src/examples/ParityGallery.tsx +513 -0
- package/src/floating.ts +5 -1
- package/src/glassBlur.tsx +95 -0
- package/src/gradient.ts +21 -0
- package/src/haptics.ts +22 -2
- package/src/index.ts +101 -2
- package/src/initials.ts +10 -0
- package/src/marketing/PricingModal.tsx +410 -0
- package/src/marketing/UpgradeToProCard.tsx +103 -0
- package/src/marketing/pricing.ts +76 -0
- package/src/palette.ts +43 -0
- package/src/primitives/button.tsx +3 -1
- package/src/primitives/card.tsx +5 -2
- package/src/primitives/input.tsx +9 -2
- package/src/primitives/native-only-animated-view.tsx +10 -3
- package/src/primitives/overlay.tsx +179 -0
- package/src/primitives/select.tsx +25 -12
- package/src/primitives/switch.tsx +12 -13
- package/src/primitives/text.tsx +25 -8
- package/src/primitives/use-controllable-state.ts +24 -0
- package/src/settings/SettingsField.tsx +11 -2
- package/src/settings/SettingsSection.tsx +20 -6
- package/src/settings/SettingsToggleRow.tsx +7 -2
- package/src/tabBarRoutes.ts +52 -0
- package/src/toastChrome.ts +103 -0
- package/src/toastStore.ts +86 -0
- package/src/tokens.ts +19 -14
- package/src/typography.ts +24 -0
- package/src/useReduceMotion.ts +24 -0
- package/src/useScheme.ts +6 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
3
|
+
import { IconSearch, IconX } from "@tabler/icons-react-native";
|
|
4
|
+
import { Input as PrimitiveInput } from "./primitives/input";
|
|
5
|
+
import { cn } from "./cn";
|
|
6
|
+
import {
|
|
7
|
+
fieldStyle,
|
|
8
|
+
fieldTextStyle,
|
|
9
|
+
scaledFieldHeight,
|
|
10
|
+
searchInputSize,
|
|
11
|
+
sizeStyle,
|
|
12
|
+
touchSlop,
|
|
13
|
+
typeStyle,
|
|
14
|
+
type SearchInputSize,
|
|
15
|
+
} from "./controlStyles";
|
|
16
|
+
import { palette } from "./palette";
|
|
17
|
+
import { tokens } from "./tokens";
|
|
18
|
+
import { useLargerText } from "./theme";
|
|
19
|
+
import { useScheme } from "./useScheme";
|
|
20
|
+
|
|
21
|
+
type SearchInputProps = Omit<
|
|
22
|
+
React.ComponentPropsWithoutRef<typeof PrimitiveInput>,
|
|
23
|
+
"value" | "onChange" | "onChangeText"
|
|
24
|
+
> & {
|
|
25
|
+
value: string;
|
|
26
|
+
/** Called with the new text, as the web `onChange(value)`. */
|
|
27
|
+
onChange: (value: string) => void;
|
|
28
|
+
/** Accessibility label, and the placeholder when `placeholder` is not set. Default `"Search"`. */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** `sm` is 32 high (web `h-8`), `md` is 40 (web `h-10`), border included. Default `"sm"`. */
|
|
31
|
+
size?: SearchInputSize;
|
|
32
|
+
/** Called after `onChange` whenever the value becomes empty (typed away or cleared). */
|
|
33
|
+
onClear?: () => void;
|
|
34
|
+
/** Accessibility label of the clear button. Default `"Clear search"`. */
|
|
35
|
+
clearLabel?: string;
|
|
36
|
+
/** Classes for the field. */
|
|
37
|
+
className?: string;
|
|
38
|
+
/** Classes for the `View` around the field, icon and clear button. */
|
|
39
|
+
wrapperClassName?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The web change rule: `onChange`, then `onClear` whenever the value becomes empty. */
|
|
43
|
+
export function searchChange(onChange: (value: string) => void, onClear?: () => void) {
|
|
44
|
+
return (next: string) => {
|
|
45
|
+
onChange(next);
|
|
46
|
+
if (onClear && next.length === 0) onClear();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Search field, the web `SearchInput`: a 16 search icon at the start and,
|
|
52
|
+
* while there is text, a clear button (a 14 cross labelled "Clear search") at
|
|
53
|
+
* the end. Controlled with `value` and `onChange(text)`, as on the web. The
|
|
54
|
+
* field has the `Input` look (`fieldStyle`) at the web sizes from
|
|
55
|
+
* `searchInputSize`. The clear button is painted at the web 24 and reaches 44
|
|
56
|
+
* with its hit slop (`touchSlop`). It clears at once and leaves focus where it
|
|
57
|
+
* was, as the default web clear does after its dissolve. It is hidden when the
|
|
58
|
+
* field is not editable. Inside a `ScrollView`, set
|
|
59
|
+
* `keyboardShouldPersistTaps="handled"` so the first tap clears while the
|
|
60
|
+
* keyboard is up. The web `shortcut` hint and `toolbar` variant are not ported.
|
|
61
|
+
*/
|
|
62
|
+
const SearchInput = React.forwardRef<
|
|
63
|
+
React.ElementRef<typeof PrimitiveInput>,
|
|
64
|
+
SearchInputProps
|
|
65
|
+
>(
|
|
66
|
+
(
|
|
67
|
+
{
|
|
68
|
+
value,
|
|
69
|
+
onChange,
|
|
70
|
+
label = "Search",
|
|
71
|
+
placeholder,
|
|
72
|
+
size = "sm",
|
|
73
|
+
onClear,
|
|
74
|
+
clearLabel = "Clear search",
|
|
75
|
+
className,
|
|
76
|
+
wrapperClassName,
|
|
77
|
+
placeholderTextColor,
|
|
78
|
+
style,
|
|
79
|
+
...props
|
|
80
|
+
},
|
|
81
|
+
ref,
|
|
82
|
+
) => {
|
|
83
|
+
const largerText = useLargerText();
|
|
84
|
+
const scheme = useScheme();
|
|
85
|
+
const { fontScale } = useWindowDimensions();
|
|
86
|
+
const metrics = searchInputSize(size, largerText);
|
|
87
|
+
const change = searchChange(onChange, onClear);
|
|
88
|
+
const filled = value.length > 0;
|
|
89
|
+
const muted = tokens.color.muted[scheme];
|
|
90
|
+
return (
|
|
91
|
+
<View className={wrapperClassName}>
|
|
92
|
+
<PrimitiveInput
|
|
93
|
+
ref={ref}
|
|
94
|
+
value={value}
|
|
95
|
+
onChangeText={change}
|
|
96
|
+
accessibilityLabel={label}
|
|
97
|
+
placeholder={placeholder ?? label}
|
|
98
|
+
placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
|
|
99
|
+
// The web `enterKeyHint="search"`.
|
|
100
|
+
returnKeyType="search"
|
|
101
|
+
className={cn(
|
|
102
|
+
"border font-instrument-sans",
|
|
103
|
+
metrics.text,
|
|
104
|
+
largerText && "font-instrument-sans-medium",
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
style={[
|
|
108
|
+
fieldStyle(scheme),
|
|
109
|
+
fieldTextStyle(scheme),
|
|
110
|
+
{
|
|
111
|
+
minHeight: metrics.minHeight,
|
|
112
|
+
// The web border-box height, so the class line height cannot
|
|
113
|
+
// grow the field; it still grows with the system font size.
|
|
114
|
+
height: scaledFieldHeight(metrics.height, fontScale),
|
|
115
|
+
paddingTop: metrics.paddingY,
|
|
116
|
+
paddingBottom: metrics.paddingY,
|
|
117
|
+
paddingLeft: metrics.paddingLeft,
|
|
118
|
+
paddingRight: filled ? metrics.filledPaddingRight : metrics.paddingRight,
|
|
119
|
+
// The size without its line height: on a single-line field a
|
|
120
|
+
// line height can push the text off centre on iOS.
|
|
121
|
+
fontSize: sizeStyle(metrics.text).fontSize,
|
|
122
|
+
},
|
|
123
|
+
largerText ? typeStyle("medium") : null,
|
|
124
|
+
style,
|
|
125
|
+
]}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
<View
|
|
129
|
+
pointerEvents="none"
|
|
130
|
+
className="absolute bottom-0 top-0 justify-center"
|
|
131
|
+
style={{ left: metrics.iconLeft }}
|
|
132
|
+
>
|
|
133
|
+
<IconSearch size={metrics.icon} color={muted} />
|
|
134
|
+
</View>
|
|
135
|
+
{filled && props.editable !== false ? (
|
|
136
|
+
<View
|
|
137
|
+
pointerEvents="box-none"
|
|
138
|
+
className="absolute bottom-0 top-0 justify-center"
|
|
139
|
+
style={{ right: metrics.clearRight }}
|
|
140
|
+
>
|
|
141
|
+
<Pressable
|
|
142
|
+
testID="search-input-clear"
|
|
143
|
+
accessibilityRole="button"
|
|
144
|
+
accessibilityLabel={clearLabel}
|
|
145
|
+
hitSlop={touchSlop(metrics.clear)}
|
|
146
|
+
onPress={() => change("")}
|
|
147
|
+
className="items-center justify-center rounded-md active:opacity-70"
|
|
148
|
+
style={{ width: metrics.clear, height: metrics.clear }}
|
|
149
|
+
>
|
|
150
|
+
{/* `strokeWidth`, not the web `stroke`: here `stroke` is the colour. */}
|
|
151
|
+
<IconX size={metrics.clearIcon} color={muted} strokeWidth={1.75} />
|
|
152
|
+
</Pressable>
|
|
153
|
+
</View>
|
|
154
|
+
) : null}
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
SearchInput.displayName = "SearchInput";
|
|
161
|
+
|
|
162
|
+
export { SearchInput };
|
|
163
|
+
export type { SearchInputProps };
|
package/src/Select.tsx
CHANGED
|
@@ -9,7 +9,16 @@ import {
|
|
|
9
9
|
SelectValue,
|
|
10
10
|
} from "./primitives/select";
|
|
11
11
|
import { cn } from "./cn";
|
|
12
|
+
import {
|
|
13
|
+
MIN_TOUCH_TARGET,
|
|
14
|
+
fieldStyle,
|
|
15
|
+
fieldTextStyle,
|
|
16
|
+
touchSlop,
|
|
17
|
+
typeStyle,
|
|
18
|
+
} from "./controlStyles";
|
|
19
|
+
import { palette } from "./palette";
|
|
12
20
|
import { useLargerText } from "./theme";
|
|
21
|
+
import { useScheme } from "./useScheme";
|
|
13
22
|
|
|
14
23
|
interface SelectOption<TValue = string | number | null> {
|
|
15
24
|
label: string;
|
|
@@ -27,6 +36,8 @@ interface SelectProps<TValue = string | number | null> {
|
|
|
27
36
|
triggerClassName?: string;
|
|
28
37
|
contentClassName?: string;
|
|
29
38
|
testID?: string;
|
|
39
|
+
/** Names the field for VoiceOver and TalkBack, which otherwise read only the value or placeholder. */
|
|
40
|
+
accessibilityLabel?: string;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
const NULL_SENTINEL = "__null__";
|
|
@@ -62,8 +73,10 @@ function Select<TValue = string | number | null>({
|
|
|
62
73
|
triggerClassName,
|
|
63
74
|
contentClassName,
|
|
64
75
|
testID,
|
|
76
|
+
accessibilityLabel,
|
|
65
77
|
}: SelectProps<TValue>) {
|
|
66
78
|
const largerText = useLargerText();
|
|
79
|
+
const scheme = useScheme();
|
|
67
80
|
const primitiveValue = toPrimitiveValue(value, options);
|
|
68
81
|
|
|
69
82
|
return (
|
|
@@ -89,19 +102,32 @@ function Select<TValue = string | number | null>({
|
|
|
89
102
|
>
|
|
90
103
|
<SelectTrigger
|
|
91
104
|
testID={testID}
|
|
105
|
+
accessibilityRole="combobox"
|
|
106
|
+
accessibilityLabel={accessibilityLabel}
|
|
107
|
+
accessibilityState={{ disabled: Boolean(disabled) }}
|
|
108
|
+
// 40 high on screen, as on the web; a 44 target to touch.
|
|
109
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 : 40)}
|
|
92
110
|
className={cn(
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
// The web trigger: h-10 px-3.5, 16 px type (its phone size). A
|
|
112
|
+
// minimum height, so a large Dynamic Type size does not clip the value.
|
|
113
|
+
"h-auto min-h-10 w-full border px-3.5 py-0 shadow-none",
|
|
114
|
+
largerText && "min-h-16 px-4",
|
|
95
115
|
triggerClassName,
|
|
96
116
|
className,
|
|
97
117
|
)}
|
|
118
|
+
style={fieldStyle(scheme)}
|
|
98
119
|
>
|
|
99
120
|
<SelectValue
|
|
100
121
|
placeholder={placeholder ?? "Select option"}
|
|
101
|
-
className={
|
|
102
|
-
"font-instrument-sans-medium text-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
className={
|
|
123
|
+
largerText ? "font-instrument-sans-medium text-xl" : "font-instrument-sans text-base"
|
|
124
|
+
}
|
|
125
|
+
style={[
|
|
126
|
+
primitiveValue
|
|
127
|
+
? fieldTextStyle(scheme)
|
|
128
|
+
: { color: palette.fieldPlaceholder[scheme] },
|
|
129
|
+
largerText ? typeStyle("medium") : null,
|
|
130
|
+
]}
|
|
105
131
|
/>
|
|
106
132
|
</SelectTrigger>
|
|
107
133
|
<SelectContent className={cn("w-full", contentClassName)}>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { StyleSheet, View, type ViewProps } from "react-native";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
import { tokens } from "./tokens";
|
|
4
|
+
import { useScheme } from "./useScheme";
|
|
5
|
+
|
|
6
|
+
export type SeparatorProps = ViewProps & {
|
|
7
|
+
/** Default `"horizontal"`. */
|
|
8
|
+
orientation?: "horizontal" | "vertical";
|
|
9
|
+
/** Hidden from assistive tech. Default `true`, as on the web. */
|
|
10
|
+
decorative?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Hairline in `tokens.color.separator`. Vertical separators stretch to the row height. */
|
|
15
|
+
export function Separator({
|
|
16
|
+
orientation = "horizontal",
|
|
17
|
+
decorative = true,
|
|
18
|
+
className,
|
|
19
|
+
style,
|
|
20
|
+
...props
|
|
21
|
+
}: SeparatorProps) {
|
|
22
|
+
const scheme = useScheme();
|
|
23
|
+
return (
|
|
24
|
+
<View
|
|
25
|
+
accessibilityElementsHidden={decorative}
|
|
26
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
|
|
27
|
+
className={cn("shrink-0", className)}
|
|
28
|
+
style={[
|
|
29
|
+
orientation === "horizontal"
|
|
30
|
+
? { height: StyleSheet.hairlineWidth, width: "100%" }
|
|
31
|
+
: { width: StyleSheet.hairlineWidth, alignSelf: "stretch" },
|
|
32
|
+
{ backgroundColor: tokens.color.separator[scheme] },
|
|
33
|
+
style,
|
|
34
|
+
]}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/Sheet.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
|
+
import { SlideInDown } from "react-native-reanimated";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
5
|
+
import { cn } from "./cn";
|
|
6
|
+
import { withAlpha } from "./palette";
|
|
7
|
+
import { tokens } from "./tokens";
|
|
8
|
+
import { useScheme } from "./useScheme";
|
|
9
|
+
import { DialogBody, DialogDescription, DialogTitle } from "./Dialog";
|
|
10
|
+
import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
|
|
11
|
+
import {
|
|
12
|
+
OverlayClose,
|
|
13
|
+
OverlayModal,
|
|
14
|
+
OverlayRoot,
|
|
15
|
+
OverlayTrigger,
|
|
16
|
+
type OverlayPressableProps,
|
|
17
|
+
type OverlayRootProps,
|
|
18
|
+
} from "./primitives/overlay";
|
|
19
|
+
|
|
20
|
+
const SHEET_RADIUS = tokens.radius.card;
|
|
21
|
+
|
|
22
|
+
export type SheetProps = OverlayRootProps;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bottom sheet in an RN `Modal`, with no `@gorhom/bottom-sheet`. Same open
|
|
26
|
+
* state API as `Dialog`. Use `BottomSheetContainer` for drag-to-dismiss.
|
|
27
|
+
*/
|
|
28
|
+
export function Sheet(props: SheetProps) {
|
|
29
|
+
return <OverlayRoot {...props} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SheetTrigger(props: OverlayPressableProps) {
|
|
33
|
+
return <OverlayTrigger part="SheetTrigger" {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SheetClose(props: OverlayPressableProps) {
|
|
37
|
+
return <OverlayClose part="SheetClose" {...props} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SheetContentProps = ViewProps & {
|
|
41
|
+
className?: string;
|
|
42
|
+
/** Close on a backdrop tap. Default `true`. */
|
|
43
|
+
dismissible?: boolean;
|
|
44
|
+
/** Grab handle above the content. Default `true`. */
|
|
45
|
+
showHandle?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Panel pinned to the bottom edge: opaque surface, top corners at the card
|
|
50
|
+
* radius, safe-area bottom padding, slides in. Its bottom corners sit below the
|
|
51
|
+
* screen edge, so only the top corners show.
|
|
52
|
+
*/
|
|
53
|
+
export function SheetContent({
|
|
54
|
+
children,
|
|
55
|
+
className,
|
|
56
|
+
dismissible = true,
|
|
57
|
+
showHandle = true,
|
|
58
|
+
style,
|
|
59
|
+
...props
|
|
60
|
+
}: SheetContentProps) {
|
|
61
|
+
const insets = useSafeAreaInsets();
|
|
62
|
+
return (
|
|
63
|
+
<OverlayModal part="SheetContent" placement="bottom" dismissible={dismissible}>
|
|
64
|
+
<NativeOnlyAnimatedView
|
|
65
|
+
entering={SlideInDown.duration(240)}
|
|
66
|
+
style={{ marginBottom: -SHEET_RADIUS }}
|
|
67
|
+
>
|
|
68
|
+
<FloatingSurface blur={false} radius={SHEET_RADIUS}>
|
|
69
|
+
<View
|
|
70
|
+
accessibilityViewIsModal
|
|
71
|
+
className={cn("gap-4 px-5 pt-3", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
style={[{ paddingBottom: insets.bottom + 20 + SHEET_RADIUS }, style]}
|
|
74
|
+
>
|
|
75
|
+
{showHandle ? <SheetHandle /> : null}
|
|
76
|
+
{children}
|
|
77
|
+
</View>
|
|
78
|
+
</FloatingSurface>
|
|
79
|
+
</NativeOnlyAnimatedView>
|
|
80
|
+
</OverlayModal>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function SheetHandle({ className, style, ...props }: ViewProps & { className?: string }) {
|
|
85
|
+
const scheme = useScheme();
|
|
86
|
+
return (
|
|
87
|
+
<View
|
|
88
|
+
accessibilityElementsHidden
|
|
89
|
+
importantForAccessibility="no-hide-descendants"
|
|
90
|
+
className={cn("h-1.5 w-10 self-center rounded-full", className)}
|
|
91
|
+
style={[{ backgroundColor: withAlpha(tokens.color.muted[scheme], 0.4) }, style]}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function SheetHeader({ className, ...props }: ViewProps & { className?: string }) {
|
|
98
|
+
return <View className={cn("gap-1.5", className)} {...props} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const SheetTitle = DialogTitle;
|
|
102
|
+
export const SheetDescription = DialogDescription;
|
|
103
|
+
export const SheetBody = DialogBody;
|
|
104
|
+
/** Buttons stack full width, the last child on top: the web footer on a phone. */
|
|
105
|
+
export function SheetFooter({ className, ...props }: ViewProps & { className?: string }) {
|
|
106
|
+
return <View className={cn("flex-col-reverse gap-2", className)} {...props} />;
|
|
107
|
+
}
|
package/src/Skeleton.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { View } from "react-native";
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
2
|
import Animated, {
|
|
3
|
+
cancelAnimation,
|
|
3
4
|
useAnimatedStyle,
|
|
4
5
|
useSharedValue,
|
|
5
6
|
withRepeat,
|
|
@@ -7,13 +8,27 @@ import Animated, {
|
|
|
7
8
|
} from "react-native-reanimated";
|
|
8
9
|
import { useEffect } from "react";
|
|
9
10
|
import { cn } from "./cn";
|
|
11
|
+
import { cardStyle } from "./controlStyles";
|
|
12
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
13
|
+
import { useScheme } from "./useScheme";
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
/** Pulsing placeholder block. Holds still with Reduce Motion. Size it with `className` (for example `h-4 w-32`). */
|
|
16
|
+
export function Skeleton({
|
|
17
|
+
className,
|
|
18
|
+
style,
|
|
19
|
+
...props
|
|
20
|
+
}: ViewProps & { className?: string }) {
|
|
21
|
+
const reduceMotion = useReduceMotion();
|
|
12
22
|
const opacity = useSharedValue(0.4);
|
|
13
23
|
|
|
14
24
|
useEffect(() => {
|
|
25
|
+
if (reduceMotion) {
|
|
26
|
+
opacity.value = 0.7;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
15
29
|
opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true);
|
|
16
|
-
|
|
30
|
+
return () => cancelAnimation(opacity);
|
|
31
|
+
}, [opacity, reduceMotion]);
|
|
17
32
|
|
|
18
33
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
19
34
|
opacity: opacity.value,
|
|
@@ -21,15 +36,20 @@ export function Skeleton({ className }: { className?: string }) {
|
|
|
21
36
|
|
|
22
37
|
return (
|
|
23
38
|
<Animated.View
|
|
24
|
-
|
|
39
|
+
accessibilityElementsHidden
|
|
40
|
+
importantForAccessibility="no-hide-descendants"
|
|
41
|
+
{...props}
|
|
42
|
+
style={[animatedStyle, style]}
|
|
25
43
|
className={cn("bg-neutral-200 dark:bg-neutral-700 rounded-lg", className)}
|
|
26
44
|
/>
|
|
27
45
|
);
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
/** A `Card`-shaped placeholder: the flat card fill and radius. */
|
|
30
49
|
export function CardSkeleton() {
|
|
50
|
+
const scheme = useScheme();
|
|
31
51
|
return (
|
|
32
|
-
<View className="
|
|
52
|
+
<View className="gap-3 p-5" style={cardStyle(scheme)}>
|
|
33
53
|
<Skeleton className="h-4 w-32" />
|
|
34
54
|
<Skeleton className="h-16 w-full" />
|
|
35
55
|
</View>
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActivityIndicator,
|
|
3
|
+
Platform,
|
|
4
|
+
View,
|
|
5
|
+
type ActivityIndicatorProps,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
} from "react-native";
|
|
8
|
+
import { cn } from "./cn";
|
|
9
|
+
import { tokens } from "./tokens";
|
|
10
|
+
import { useScheme } from "./useScheme";
|
|
11
|
+
|
|
12
|
+
export type SpinnerProps = Omit<ActivityIndicatorProps, "size" | "color"> & {
|
|
13
|
+
/** `sm` is 16, the web `sm` and the `Button` loading ring. `md` is the small system spinner (20), `lg` the large one. A number sets the size on Android; iOS picks small below 30. Default `"md"`. */
|
|
14
|
+
size?: "sm" | "md" | "lg" | number;
|
|
15
|
+
/** `"muted"` (default) uses the muted text colour, `"inherit"` the foreground. Native has no `currentColor`. */
|
|
16
|
+
tone?: "muted" | "inherit";
|
|
17
|
+
/** Overrides `tone`. */
|
|
18
|
+
color?: string;
|
|
19
|
+
/** Hide from assistive tech, for example inside a labelled button. Default `false`. */
|
|
20
|
+
decorative?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The system `"small"` spinner box on iOS, Android and React Native Web. */
|
|
24
|
+
const SYSTEM_SMALL = 20;
|
|
25
|
+
/** Web `size-4`. No platform has a 16 spinner, so `sm` is the small one scaled down in a 16 box. */
|
|
26
|
+
const SM = 16;
|
|
27
|
+
|
|
28
|
+
function systemSize(size: NonNullable<SpinnerProps["size"]>) {
|
|
29
|
+
if (typeof size === "number") {
|
|
30
|
+
return Platform.OS === "android" ? size : size >= 30 ? "large" : "small";
|
|
31
|
+
}
|
|
32
|
+
return size === "lg" ? "large" : "small";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The platform `ActivityIndicator` with the web `Spinner` size and tone names.
|
|
37
|
+
* The web `mark`, `circle` and `ring` variants (animated SVG strokes) are not ported.
|
|
38
|
+
*/
|
|
39
|
+
export function Spinner({
|
|
40
|
+
size = "md",
|
|
41
|
+
tone = "muted",
|
|
42
|
+
color,
|
|
43
|
+
decorative = false,
|
|
44
|
+
accessibilityLabel,
|
|
45
|
+
style,
|
|
46
|
+
...props
|
|
47
|
+
}: SpinnerProps) {
|
|
48
|
+
const scheme = useScheme();
|
|
49
|
+
const small = size === "sm";
|
|
50
|
+
const indicator = (
|
|
51
|
+
<ActivityIndicator
|
|
52
|
+
size={systemSize(size)}
|
|
53
|
+
color={
|
|
54
|
+
color ??
|
|
55
|
+
(tone === "muted"
|
|
56
|
+
? tokens.color.muted[scheme]
|
|
57
|
+
: tokens.color.foreground[scheme])
|
|
58
|
+
}
|
|
59
|
+
accessibilityElementsHidden={decorative}
|
|
60
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
|
|
61
|
+
accessibilityRole={decorative ? undefined : "progressbar"}
|
|
62
|
+
accessibilityLabel={decorative ? undefined : (accessibilityLabel ?? "Loading")}
|
|
63
|
+
style={small ? { transform: [{ scale: SM / SYSTEM_SMALL }] } : style}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
if (!small) return indicator;
|
|
68
|
+
return (
|
|
69
|
+
<View
|
|
70
|
+
style={[{ width: SM, height: SM, alignItems: "center", justifyContent: "center" }, style]}
|
|
71
|
+
>
|
|
72
|
+
{indicator}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A `Spinner` centred in whatever box it is given. `label` names it for assistive tech. */
|
|
78
|
+
export function CenteredSpinner({
|
|
79
|
+
label,
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: ViewProps & { label: string; className?: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<View
|
|
85
|
+
accessibilityState={{ busy: true }}
|
|
86
|
+
className={cn("min-h-0 w-full flex-1 items-center justify-center p-6", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
>
|
|
89
|
+
<Spinner accessibilityLabel={label} />
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
import { palette, withAlpha } from "./palette";
|
|
4
|
+
import { tokens } from "./tokens";
|
|
5
|
+
import { useScheme } from "./useScheme";
|
|
6
|
+
|
|
7
|
+
/** The web `StatusTone`s. */
|
|
8
|
+
export type StatusTone =
|
|
9
|
+
| "progress"
|
|
10
|
+
| "business-review"
|
|
11
|
+
| "code-review"
|
|
12
|
+
| "done"
|
|
13
|
+
| "cancelled"
|
|
14
|
+
| "active"
|
|
15
|
+
| "critical"
|
|
16
|
+
| "neutral";
|
|
17
|
+
|
|
18
|
+
export type StatusDotProps = ViewProps & {
|
|
19
|
+
tone: StatusTone;
|
|
20
|
+
/** `"sm"` (6) or `"md"` (8). Default `"sm"`. */
|
|
21
|
+
size?: "sm" | "md";
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function toneColor(tone: StatusTone, scheme: "light" | "dark") {
|
|
26
|
+
switch (tone) {
|
|
27
|
+
case "progress":
|
|
28
|
+
return palette.warning[scheme];
|
|
29
|
+
case "business-review":
|
|
30
|
+
return palette.primary[scheme];
|
|
31
|
+
case "code-review":
|
|
32
|
+
return withAlpha(palette.primary[scheme], 0.7);
|
|
33
|
+
case "done":
|
|
34
|
+
case "active":
|
|
35
|
+
return palette.success[scheme];
|
|
36
|
+
case "cancelled":
|
|
37
|
+
return withAlpha(tokens.color.muted[scheme], 0.4);
|
|
38
|
+
case "critical":
|
|
39
|
+
return palette.danger[scheme];
|
|
40
|
+
default:
|
|
41
|
+
return withAlpha(tokens.color.muted[scheme], 0.6);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Small coloured dot that carries a status, so the label beside it stays neutral text. Hidden from assistive tech. */
|
|
46
|
+
export function StatusDot({ tone, size = "sm", className, style, ...props }: StatusDotProps) {
|
|
47
|
+
const scheme = useScheme();
|
|
48
|
+
const diameter = size === "md" ? 8 : 6;
|
|
49
|
+
return (
|
|
50
|
+
<View
|
|
51
|
+
accessibilityElementsHidden
|
|
52
|
+
importantForAccessibility="no-hide-descendants"
|
|
53
|
+
className={cn("shrink-0", className)}
|
|
54
|
+
style={[
|
|
55
|
+
{
|
|
56
|
+
width: diameter,
|
|
57
|
+
height: diameter,
|
|
58
|
+
borderRadius: diameter / 2,
|
|
59
|
+
backgroundColor: toneColor(tone, scheme),
|
|
60
|
+
},
|
|
61
|
+
style,
|
|
62
|
+
]}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/Switch.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { Switch as PrimitiveSwitch } from "./primitives/switch";
|
|
3
3
|
import { cn } from "./cn";
|
|
4
|
+
import { switchColors, switchSize, touchSlop } from "./controlStyles";
|
|
5
|
+
import { useLargerText } from "./theme";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
4
7
|
|
|
5
8
|
type SwitchProps = Omit<
|
|
6
9
|
React.ComponentPropsWithoutRef<typeof PrimitiveSwitch>,
|
|
@@ -10,13 +13,19 @@ type SwitchProps = Omit<
|
|
|
10
13
|
checked?: boolean;
|
|
11
14
|
onValueChange?: (value: boolean) => void;
|
|
12
15
|
onCheckedChange?: (value: boolean) => void;
|
|
16
|
+
/** Track fill when on or off, in place of the `palette` colours. */
|
|
17
|
+
onColor?: string;
|
|
18
|
+
offColor?: string;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
const Switch = React.forwardRef<
|
|
16
22
|
React.ElementRef<typeof PrimitiveSwitch>,
|
|
17
23
|
SwitchProps
|
|
18
|
-
>(({ value, checked, onValueChange, onCheckedChange, ...props }, ref) => {
|
|
24
|
+
>(({ value, checked, onValueChange, onCheckedChange, onColor, offColor, ...props }, ref) => {
|
|
19
25
|
const resolvedValue = checked ?? value ?? false;
|
|
26
|
+
const colors = switchColors(resolvedValue, useScheme());
|
|
27
|
+
const trackColor = resolvedValue ? onColor : offColor;
|
|
28
|
+
const size = switchSize(useLargerText());
|
|
20
29
|
|
|
21
30
|
const handleCheckedChange = (nextValue: boolean) => {
|
|
22
31
|
onCheckedChange?.(nextValue);
|
|
@@ -28,13 +37,21 @@ const Switch = React.forwardRef<
|
|
|
28
37
|
ref={ref}
|
|
29
38
|
checked={resolvedValue}
|
|
30
39
|
onCheckedChange={handleCheckedChange}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
// The web size on screen, a 44 target to touch.
|
|
41
|
+
hitSlop={touchSlop(size.width, size.height)}
|
|
42
|
+
accessibilityRole="switch"
|
|
43
|
+
className={cn(size.track, "justify-center")}
|
|
44
|
+
trackStyle={
|
|
45
|
+
trackColor ? { backgroundColor: trackColor, borderColor: trackColor } : colors.track
|
|
46
|
+
}
|
|
47
|
+
thumbStyle={colors.thumb}
|
|
48
|
+
thumbClassName={cn(size.thumb, resolvedValue ? size.on : "translate-x-0")}
|
|
37
49
|
{...props}
|
|
50
|
+
accessibilityState={{
|
|
51
|
+
...props.accessibilityState,
|
|
52
|
+
checked: resolvedValue,
|
|
53
|
+
disabled: Boolean(props.disabled),
|
|
54
|
+
}}
|
|
38
55
|
/>
|
|
39
56
|
);
|
|
40
57
|
});
|