@vvedantb/native-ui 0.3.17 → 0.5.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 +146 -13
- package/package.json +10 -2
- package/src/CaptureButton.tsx +62 -0
- package/src/Card.tsx +37 -13
- package/src/ColorSwatches.tsx +133 -0
- package/src/DateTimeField.tsx +75 -19
- package/src/HeaderIconButton.tsx +18 -1
- package/src/IconButton.tsx +87 -0
- package/src/ListRow.tsx +104 -30
- package/src/PageIndicator.tsx +87 -0
- package/src/SectionLabel.tsx +48 -0
- package/src/SheetRow.tsx +89 -0
- package/src/StackPageHeader.tsx +100 -0
- package/src/StatGrid.tsx +80 -0
- package/src/StatusDot.tsx +14 -1
- package/src/ThemeToggle.tsx +17 -5
- package/src/ai/activity-step.tsx +57 -0
- package/src/ai/attachments.tsx +153 -0
- package/src/ai/chain-of-thought.tsx +143 -0
- package/src/ai/chat-text.ts +35 -0
- package/src/ai/code-block.tsx +104 -0
- package/src/ai/collapsible.tsx +111 -0
- package/src/ai/conversation.tsx +245 -0
- package/src/ai/file-chip.tsx +66 -0
- package/src/ai/icon-button.tsx +51 -0
- package/src/ai/index.ts +123 -0
- package/src/ai/message.tsx +146 -0
- package/src/ai/prompt-input.tsx +186 -0
- package/src/ai/reasoning.tsx +200 -0
- package/src/ai/response-text.tsx +56 -0
- package/src/ai/shimmer.tsx +86 -0
- package/src/ai/sources.tsx +100 -0
- package/src/ai/suggestion.tsx +93 -0
- package/src/ai/trace.tsx +234 -0
- package/src/ai/use-streaming-disclosure.ts +50 -0
- package/src/entries/ai.ts +4 -0
- package/src/entries/date-time.ts +1 -0
- package/src/entries/layout.ts +6 -2
- package/src/entries/page-title.ts +7 -2
- package/src/entries/primitives.ts +19 -1
- package/src/examples/AiChatGallery.tsx +233 -0
- package/src/examples/ParityGallery.tsx +205 -3
- package/src/index.ts +19 -0
package/src/DateTimeField.tsx
CHANGED
|
@@ -4,26 +4,65 @@ import DateTimePicker, {
|
|
|
4
4
|
type DateTimePickerEvent,
|
|
5
5
|
} from "@react-native-community/datetimepicker";
|
|
6
6
|
import dayjs from "dayjs";
|
|
7
|
-
import {
|
|
7
|
+
import { IconX } from "@tabler/icons-react-native";
|
|
8
|
+
import { MIN_TOUCH_TARGET, textStyle, typeStyle } from "./controlStyles";
|
|
9
|
+
import { palette } from "./palette";
|
|
8
10
|
import { useLargerText } from "./theme";
|
|
11
|
+
import { tokens } from "./tokens";
|
|
9
12
|
import { useScheme } from "./useScheme";
|
|
10
13
|
|
|
11
14
|
type PickerMode = "date" | "time";
|
|
12
15
|
type PickerButton = { mode: PickerMode; label: string };
|
|
13
16
|
|
|
17
|
+
export type DateTimeFieldProps = {
|
|
18
|
+
/** `null` shows `placeholder` until a date is picked (from 0.5.0). */
|
|
19
|
+
value: Date | null;
|
|
20
|
+
onChange: (value: Date) => void;
|
|
21
|
+
/** Date and time buttons (default), the time button only, or the date button only. */
|
|
22
|
+
mode?: "datetime" | "time" | "date";
|
|
23
|
+
maximumDate?: Date;
|
|
24
|
+
/** Shown on each button while `value` is `null`. Default "Select date" or "Select time". */
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
/** Shows a clear button while `value` is set. It calls this; `onChange` is not called. */
|
|
27
|
+
onClear?: () => void;
|
|
28
|
+
/** Names the clear button. Default "Clear time" in `time` mode, "Clear date" otherwise. */
|
|
29
|
+
clearLabel?: string;
|
|
30
|
+
testID?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PLACEHOLDER: Record<PickerMode, string> = {
|
|
34
|
+
date: "Select date",
|
|
35
|
+
time: "Select time",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The date the picker opens on: `value`, or with no value `now`, held back to
|
|
40
|
+
* `maximumDate` when that is earlier.
|
|
41
|
+
*/
|
|
42
|
+
export function pickerSeed(value: Date | null, maximumDate?: Date, now: Date = new Date()): Date {
|
|
43
|
+
if (value) return value;
|
|
44
|
+
return maximumDate && maximumDate.getTime() < now.getTime() ? maximumDate : now;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Date and time buttons that open the system picker below them. `mode="date"`
|
|
49
|
+
* shows the date button only. A `null` value shows `placeholder` in the field
|
|
50
|
+
* placeholder colour, and the picker opens on today (or `maximumDate`, if
|
|
51
|
+
* earlier); picking calls `onChange`. With `onClear`, a 44 clear button
|
|
52
|
+
* follows the buttons while there is a value. On iOS the spinner sets the
|
|
53
|
+
* value when it is turned. The buttons have the web `bg-secondary` fill
|
|
54
|
+
* (`palette.default`) as a style, so it follows the scheme.
|
|
55
|
+
*/
|
|
14
56
|
export function DateTimeField({
|
|
15
57
|
value,
|
|
16
58
|
onChange,
|
|
17
59
|
mode = "datetime",
|
|
18
60
|
maximumDate,
|
|
61
|
+
placeholder,
|
|
62
|
+
onClear,
|
|
63
|
+
clearLabel,
|
|
19
64
|
testID,
|
|
20
|
-
}: {
|
|
21
|
-
value: Date;
|
|
22
|
-
onChange: (value: Date) => void;
|
|
23
|
-
mode?: "datetime" | "time";
|
|
24
|
-
maximumDate?: Date;
|
|
25
|
-
testID?: string;
|
|
26
|
-
}) {
|
|
65
|
+
}: DateTimeFieldProps) {
|
|
27
66
|
const largerText = useLargerText();
|
|
28
67
|
const scheme = useScheme();
|
|
29
68
|
const [picker, setPicker] = useState<PickerMode | null>(null);
|
|
@@ -33,18 +72,19 @@ export function DateTimeField({
|
|
|
33
72
|
if (event.type === "set" && next) onChange(next);
|
|
34
73
|
};
|
|
35
74
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
};
|
|
75
|
+
const label = (button: PickerMode, format: string) =>
|
|
76
|
+
value ? dayjs(value).format(format) : (placeholder ?? DEFAULT_PLACEHOLDER[button]);
|
|
77
|
+
const dateButton: PickerButton = { mode: "date", label: label("date", "D MMM YYYY") };
|
|
78
|
+
const timeButton: PickerButton = { mode: "time", label: label("time", "HH:mm") };
|
|
40
79
|
const buttons: PickerButton[] =
|
|
41
|
-
mode === "datetime"
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
mode === "datetime" ? [dateButton, timeButton] : mode === "date" ? [dateButton] : [timeButton];
|
|
81
|
+
const textColor = value
|
|
82
|
+
? textStyle("foreground", scheme)
|
|
83
|
+
: { color: palette.fieldPlaceholder[scheme] };
|
|
44
84
|
|
|
45
85
|
return (
|
|
46
86
|
<View className="gap-2">
|
|
47
|
-
<View className="flex-row gap-3">
|
|
87
|
+
<View className="flex-row items-center gap-3">
|
|
48
88
|
{buttons.map((button) => (
|
|
49
89
|
<Pressable
|
|
50
90
|
key={button.mode}
|
|
@@ -56,20 +96,36 @@ export function DateTimeField({
|
|
|
56
96
|
current === button.mode ? null : button.mode,
|
|
57
97
|
)
|
|
58
98
|
}
|
|
59
|
-
className={`${largerText ? "min-h-16" : "min-h-14"} flex-1 justify-center rounded-lg
|
|
99
|
+
className={`${largerText ? "min-h-16" : "min-h-14"} flex-1 justify-center rounded-lg px-4 py-2 active:opacity-70`}
|
|
100
|
+
style={{ backgroundColor: palette.default[scheme] }}
|
|
60
101
|
>
|
|
61
102
|
<Text
|
|
62
103
|
className={`${largerText ? "text-xl" : "text-lg"} font-instrument-sans-semibold`}
|
|
63
|
-
style={[
|
|
104
|
+
style={[textColor, typeStyle("semibold")]}
|
|
64
105
|
>
|
|
65
106
|
{button.label}
|
|
66
107
|
</Text>
|
|
67
108
|
</Pressable>
|
|
68
109
|
))}
|
|
110
|
+
{onClear && value ? (
|
|
111
|
+
<Pressable
|
|
112
|
+
testID={testID ? `${testID}-clear` : undefined}
|
|
113
|
+
accessibilityRole="button"
|
|
114
|
+
accessibilityLabel={clearLabel ?? (mode === "time" ? "Clear time" : "Clear date")}
|
|
115
|
+
onPress={() => {
|
|
116
|
+
setPicker(null);
|
|
117
|
+
onClear();
|
|
118
|
+
}}
|
|
119
|
+
className="items-center justify-center rounded-full active:opacity-70"
|
|
120
|
+
style={{ width: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
|
|
121
|
+
>
|
|
122
|
+
<IconX size={largerText ? 24 : 20} color={tokens.color.muted[scheme]} />
|
|
123
|
+
</Pressable>
|
|
124
|
+
) : null}
|
|
69
125
|
</View>
|
|
70
126
|
{picker ? (
|
|
71
127
|
<DateTimePicker
|
|
72
|
-
value={value}
|
|
128
|
+
value={pickerSeed(value, maximumDate)}
|
|
73
129
|
mode={picker}
|
|
74
130
|
is24Hour
|
|
75
131
|
maximumDate={picker === "date" ? maximumDate : undefined}
|
package/src/HeaderIconButton.tsx
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { TouchableOpacity } from "react-native";
|
|
2
|
+
import { withAlpha } from "./palette";
|
|
2
3
|
import { useLargerText } from "./theme";
|
|
4
|
+
import { tokens } from "./tokens";
|
|
5
|
+
import { useScheme } from "./useScheme";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fill and border of `HeaderIconButton`: the surface with a white hairline
|
|
9
|
+
* (40% in light, 10% in dark). Styles, not `bg-surface`, which an app theme
|
|
10
|
+
* that nests `dark` under `surface` keeps white in dark mode.
|
|
11
|
+
*/
|
|
12
|
+
export function headerIconButtonColors(scheme: "light" | "dark") {
|
|
13
|
+
return {
|
|
14
|
+
backgroundColor: tokens.color.surface[scheme],
|
|
15
|
+
borderColor: withAlpha(tokens.color.white, scheme === "dark" ? 0.1 : 0.4),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
3
18
|
|
|
4
19
|
export function HeaderIconButton({
|
|
5
20
|
onPress,
|
|
@@ -13,10 +28,12 @@ export function HeaderIconButton({
|
|
|
13
28
|
children: React.ReactNode;
|
|
14
29
|
}) {
|
|
15
30
|
const largerText = useLargerText();
|
|
31
|
+
const scheme = useScheme();
|
|
16
32
|
return (
|
|
17
33
|
<TouchableOpacity
|
|
18
34
|
testID={testID}
|
|
19
|
-
className={`${largerText ? "h-14 w-14" : "h-12 w-12"} items-center justify-center rounded-full border
|
|
35
|
+
className={`${largerText ? "h-14 w-14" : "h-12 w-12"} items-center justify-center rounded-full border`}
|
|
36
|
+
style={headerIconButtonColors(scheme)}
|
|
20
37
|
onPress={onPress}
|
|
21
38
|
accessibilityRole="button"
|
|
22
39
|
accessibilityLabel={label}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { Pressable } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import { touchSlop } from "./controlStyles";
|
|
6
|
+
import { withAlpha } from "./palette";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { tokens } from "./tokens";
|
|
9
|
+
import { useScheme } from "./useScheme";
|
|
10
|
+
|
|
11
|
+
export type IconButtonVariant = "default" | "overlay";
|
|
12
|
+
export type IconButtonSize = "sm" | "md" | "lg";
|
|
13
|
+
|
|
14
|
+
export type IconButtonProps = {
|
|
15
|
+
icon: ComponentType<IconProps>;
|
|
16
|
+
/** Names the button for VoiceOver and TalkBack; it has no text. */
|
|
17
|
+
label: string;
|
|
18
|
+
onPress?: () => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** `overlay` is for camera chrome over a live preview. Default `default`. */
|
|
21
|
+
variant?: IconButtonVariant;
|
|
22
|
+
/** `sm` 32, `md` 40, `lg` 48 across. Default `md`. */
|
|
23
|
+
size?: IconButtonSize;
|
|
24
|
+
testID?: string;
|
|
25
|
+
/** Default: the foreground in `default`, white in `overlay`. */
|
|
26
|
+
iconColor?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Diameter and icon size per size, a step up with larger text. */
|
|
30
|
+
const METRICS = {
|
|
31
|
+
regular: { sm: [32, 16], md: [40, 20], lg: [48, 24] },
|
|
32
|
+
larger: { sm: [44, 20], md: [48, 24], lg: [56, 28] },
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Fill, border and icon colour per variant. */
|
|
36
|
+
export function iconButtonColors(variant: IconButtonVariant, scheme: "light" | "dark") {
|
|
37
|
+
return variant === "overlay"
|
|
38
|
+
? {
|
|
39
|
+
backgroundColor: withAlpha(tokens.color.black, 0.35),
|
|
40
|
+
borderColor: withAlpha(tokens.color.white, 0.2),
|
|
41
|
+
color: tokens.color.white,
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
backgroundColor: tokens.color.surface[scheme],
|
|
45
|
+
borderColor: tokens.color.border[scheme],
|
|
46
|
+
color: tokens.color.foreground[scheme],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A round icon-only button. `default` is a bordered surface, like
|
|
52
|
+
* `HeaderIconButton` in `tokens` colours. `overlay` is a translucent black
|
|
53
|
+
* circle with a white icon, the same in light and dark, for camera chrome over
|
|
54
|
+
* a live preview (close, flash, flip). Sizes are the web icon buttons (`sm`
|
|
55
|
+
* 32, `md` 40) and 48, each reaching 44 with its hit slop. Not the AI chat
|
|
56
|
+
* `ChatIconButton`.
|
|
57
|
+
*/
|
|
58
|
+
export function IconButton({
|
|
59
|
+
icon: Icon,
|
|
60
|
+
label,
|
|
61
|
+
onPress,
|
|
62
|
+
disabled = false,
|
|
63
|
+
variant = "default",
|
|
64
|
+
size = "md",
|
|
65
|
+
testID,
|
|
66
|
+
iconColor,
|
|
67
|
+
}: IconButtonProps) {
|
|
68
|
+
const scheme = useScheme();
|
|
69
|
+
const largerText = useLargerText();
|
|
70
|
+
const [diameter, iconSize] = METRICS[largerText ? "larger" : "regular"][size];
|
|
71
|
+
const { color, ...frame } = iconButtonColors(variant, scheme);
|
|
72
|
+
return (
|
|
73
|
+
<Pressable
|
|
74
|
+
testID={testID}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel={label}
|
|
77
|
+
accessibilityState={{ disabled }}
|
|
78
|
+
hitSlop={touchSlop(diameter)}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
onPress={onPress}
|
|
81
|
+
className={cn("items-center justify-center active:opacity-70", disabled && "opacity-50")}
|
|
82
|
+
style={{ width: diameter, height: diameter, borderRadius: diameter / 2, borderWidth: 1, ...frame }}
|
|
83
|
+
>
|
|
84
|
+
<Icon size={iconSize} color={iconColor ?? color} />
|
|
85
|
+
</Pressable>
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/ListRow.tsx
CHANGED
|
@@ -6,45 +6,88 @@ import { cn } from "./cn";
|
|
|
6
6
|
import { textStyle, typeStyle } from "./controlStyles";
|
|
7
7
|
import { FLOATING_ROW_RADIUS } from "./floating";
|
|
8
8
|
import { useLargerText } from "./theme";
|
|
9
|
+
import { tokens } from "./tokens";
|
|
9
10
|
import { useScheme } from "./useScheme";
|
|
10
11
|
|
|
12
|
+
export type ListRowProps = {
|
|
13
|
+
title: string;
|
|
14
|
+
meta?: string;
|
|
15
|
+
detail?: ReactNode;
|
|
16
|
+
icon?: ComponentType<IconProps>;
|
|
17
|
+
iconColor?: string;
|
|
18
|
+
/** Any node at the start, such as an `Avatar` with an unread dot. Shown in place of `icon`. */
|
|
19
|
+
leading?: ReactNode;
|
|
20
|
+
/** At the end of the title line, such as a `Badge` or a time. The title is cut off first. */
|
|
21
|
+
titleEnd?: ReactNode;
|
|
22
|
+
/** At the end of the row, inside the pressable area. */
|
|
23
|
+
right?: ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* At the end of the row, outside the pressable area, such as a `Switch`:
|
|
26
|
+
* pressing it does not call `onPress`.
|
|
27
|
+
*/
|
|
28
|
+
control?: ReactNode;
|
|
29
|
+
onPress?: () => void;
|
|
30
|
+
onLongPress?: () => void;
|
|
31
|
+
/** Names the pressable row. Default `title`. */
|
|
32
|
+
accessibilityLabel?: string;
|
|
33
|
+
onCanvas?: boolean;
|
|
34
|
+
/** On the pressable area when there is a `control`, so a test can press it. */
|
|
35
|
+
testID?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A list row: an icon or `leading` node, a title with optional `titleEnd`,
|
|
40
|
+
* `meta` and `detail` lines, and `right` content. With `onPress` or
|
|
41
|
+
* `onLongPress` the row is a button named by `title`. A `control` sits beside
|
|
42
|
+
* the pressable area, not in it.
|
|
43
|
+
*/
|
|
11
44
|
export function ListRow({
|
|
12
45
|
title,
|
|
13
46
|
meta,
|
|
14
47
|
detail,
|
|
15
48
|
icon: Icon,
|
|
16
49
|
iconColor = NEUTRAL_500,
|
|
50
|
+
leading,
|
|
51
|
+
titleEnd,
|
|
17
52
|
right,
|
|
53
|
+
control,
|
|
18
54
|
onPress,
|
|
55
|
+
onLongPress,
|
|
56
|
+
accessibilityLabel,
|
|
19
57
|
onCanvas = false,
|
|
20
58
|
testID,
|
|
21
|
-
}: {
|
|
22
|
-
title: string;
|
|
23
|
-
meta?: string;
|
|
24
|
-
detail?: ReactNode;
|
|
25
|
-
icon?: ComponentType<IconProps>;
|
|
26
|
-
iconColor?: string;
|
|
27
|
-
right?: ReactNode;
|
|
28
|
-
onPress?: () => void;
|
|
29
|
-
onCanvas?: boolean;
|
|
30
|
-
testID?: string;
|
|
31
|
-
}) {
|
|
59
|
+
}: ListRowProps) {
|
|
32
60
|
const largerText = useLargerText();
|
|
33
61
|
const scheme = useScheme();
|
|
62
|
+
const titleText = (
|
|
63
|
+
<Text
|
|
64
|
+
numberOfLines={2}
|
|
65
|
+
className={cn(
|
|
66
|
+
"font-instrument-sans-semibold",
|
|
67
|
+
largerText ? "text-xl" : "text-lg",
|
|
68
|
+
titleEnd != null && "min-w-0 flex-1",
|
|
69
|
+
)}
|
|
70
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</Text>
|
|
74
|
+
);
|
|
34
75
|
const content = (
|
|
35
76
|
<>
|
|
36
|
-
{
|
|
77
|
+
{leading != null ? (
|
|
78
|
+
<View className="shrink-0">{leading}</View>
|
|
79
|
+
) : Icon ? (
|
|
80
|
+
<Icon size={largerText ? 30 : 26} color={iconColor} />
|
|
81
|
+
) : null}
|
|
37
82
|
<View className="min-w-0 flex-1">
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{title}
|
|
47
|
-
</Text>
|
|
83
|
+
{titleEnd != null ? (
|
|
84
|
+
<View className="flex-row items-center gap-2">
|
|
85
|
+
{titleText}
|
|
86
|
+
<View className="shrink-0">{titleEnd}</View>
|
|
87
|
+
</View>
|
|
88
|
+
) : (
|
|
89
|
+
titleText
|
|
90
|
+
)}
|
|
48
91
|
{meta ? (
|
|
49
92
|
<Text
|
|
50
93
|
className={cn(
|
|
@@ -61,14 +104,44 @@ export function ListRow({
|
|
|
61
104
|
{right}
|
|
62
105
|
</>
|
|
63
106
|
);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const rowStyle = {
|
|
107
|
+
const pressable = Boolean(onPress || onLongPress);
|
|
108
|
+
const height = largerText ? "min-h-[72px]" : "min-h-16";
|
|
109
|
+
const paddingY = largerText ? "py-4" : "py-3";
|
|
110
|
+
// The fill is a style: an app theme that nests `dark` under `surface` keeps
|
|
111
|
+
// `bg-surface` and `bg-surface-secondary` light in dark mode.
|
|
112
|
+
const rowStyle = {
|
|
113
|
+
backgroundColor: tokens.color[onCanvas ? "surface" : "surfaceSecondary"][scheme],
|
|
114
|
+
borderRadius: FLOATING_ROW_RADIUS,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (control != null) {
|
|
118
|
+
// The control is a sibling of the pressable area, so its presses stay its own.
|
|
119
|
+
const bodyClassName = cn("min-w-0 flex-1 flex-row items-center gap-3 self-stretch pl-4 pr-3", paddingY);
|
|
120
|
+
return (
|
|
121
|
+
<View className={cn("flex-row items-center", height)} style={rowStyle}>
|
|
122
|
+
{pressable ? (
|
|
123
|
+
<Pressable
|
|
124
|
+
testID={testID}
|
|
125
|
+
accessibilityRole="button"
|
|
126
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
127
|
+
onPress={onPress}
|
|
128
|
+
onLongPress={onLongPress}
|
|
129
|
+
className={cn(bodyClassName, "active:opacity-70")}
|
|
130
|
+
>
|
|
131
|
+
{content}
|
|
132
|
+
</Pressable>
|
|
133
|
+
) : (
|
|
134
|
+
<View testID={testID} className={bodyClassName}>
|
|
135
|
+
{content}
|
|
136
|
+
</View>
|
|
137
|
+
)}
|
|
138
|
+
<View className="shrink-0 pr-4">{control}</View>
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
70
142
|
|
|
71
|
-
|
|
143
|
+
const className = cn("flex-row items-center gap-3 px-4", height, paddingY);
|
|
144
|
+
if (!pressable) {
|
|
72
145
|
return (
|
|
73
146
|
<View testID={testID} className={className} style={rowStyle}>
|
|
74
147
|
{content}
|
|
@@ -79,8 +152,9 @@ export function ListRow({
|
|
|
79
152
|
<Pressable
|
|
80
153
|
testID={testID}
|
|
81
154
|
accessibilityRole="button"
|
|
82
|
-
accessibilityLabel={title}
|
|
155
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
83
156
|
onPress={onPress}
|
|
157
|
+
onLongPress={onLongPress}
|
|
84
158
|
className={cn(className, "active:opacity-70")}
|
|
85
159
|
style={rowStyle}
|
|
86
160
|
>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Pressable, View } from "react-native";
|
|
2
|
+
import { MIN_TOUCH_TARGET } from "./controlStyles";
|
|
3
|
+
import { palette, withAlpha } from "./palette";
|
|
4
|
+
import { useLargerText } from "./theme";
|
|
5
|
+
import { tokens } from "./tokens";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
7
|
+
|
|
8
|
+
export type PageIndicatorProps = {
|
|
9
|
+
count: number;
|
|
10
|
+
/** The current page, from 0. */
|
|
11
|
+
index: number;
|
|
12
|
+
/** Makes each dot a button that calls this with its page. */
|
|
13
|
+
onChange?: (index: number) => void;
|
|
14
|
+
testID?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Dot sizes: the web `CarouselDots` (`h-1.5`, active `w-4`, `gap-1.5`), a step up with larger text. */
|
|
18
|
+
export function pageDotSize(largerText: boolean) {
|
|
19
|
+
return largerText
|
|
20
|
+
? { height: 8, active: 20, gap: 8 }
|
|
21
|
+
: { height: 6, active: 16, gap: 6 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Page dots, as the web `CarouselDots`: the current page is a wider dot in
|
|
26
|
+
* `palette.primary`, the others muted at 30%. Nothing for a single page.
|
|
27
|
+
* Without `onChange` it is one element that reads "Page 2 of 5". With it each
|
|
28
|
+
* dot is a 44 button named "Page 2 of 5", `selected` on the current page; the
|
|
29
|
+
* dots then sit 44 apart, so each has a whole target.
|
|
30
|
+
*/
|
|
31
|
+
export function PageIndicator({ count, index, onChange, testID }: PageIndicatorProps) {
|
|
32
|
+
const scheme = useScheme();
|
|
33
|
+
const largerText = useLargerText();
|
|
34
|
+
if (count <= 1) return null;
|
|
35
|
+
const current = Math.min(Math.max(index, 0), count - 1);
|
|
36
|
+
const size = pageDotSize(largerText);
|
|
37
|
+
const pageLabel = (page: number) => `Page ${page + 1} of ${count}`;
|
|
38
|
+
const dot = (page: number) => {
|
|
39
|
+
const selected = page === current;
|
|
40
|
+
return (
|
|
41
|
+
<View
|
|
42
|
+
key={page}
|
|
43
|
+
style={{
|
|
44
|
+
height: size.height,
|
|
45
|
+
width: selected ? size.active : size.height,
|
|
46
|
+
borderRadius: size.height / 2,
|
|
47
|
+
backgroundColor: selected
|
|
48
|
+
? palette.primary[scheme]
|
|
49
|
+
: withAlpha(tokens.color.muted[scheme], 0.3),
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
const pages = Array.from({ length: count }, (_, page) => page);
|
|
55
|
+
|
|
56
|
+
if (!onChange) {
|
|
57
|
+
return (
|
|
58
|
+
<View
|
|
59
|
+
testID={testID}
|
|
60
|
+
accessible
|
|
61
|
+
accessibilityLabel={pageLabel(current)}
|
|
62
|
+
className="flex-row items-center justify-center"
|
|
63
|
+
style={{ gap: size.gap }}
|
|
64
|
+
>
|
|
65
|
+
{pages.map((page) => dot(page))}
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<View testID={testID} className="flex-row items-center justify-center">
|
|
71
|
+
{pages.map((page) => (
|
|
72
|
+
<Pressable
|
|
73
|
+
key={page}
|
|
74
|
+
testID={testID ? `${testID}-${page}` : undefined}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel={pageLabel(page)}
|
|
77
|
+
accessibilityState={{ selected: page === current }}
|
|
78
|
+
onPress={() => onChange(page)}
|
|
79
|
+
className="items-center justify-center active:opacity-70"
|
|
80
|
+
style={{ minWidth: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
|
|
81
|
+
>
|
|
82
|
+
{dot(page)}
|
|
83
|
+
</Pressable>
|
|
84
|
+
))}
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Text, type TextProps } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
|
|
5
|
+
import { useLargerText } from "./theme";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
7
|
+
|
|
8
|
+
export type SectionLabelProps = TextProps & {
|
|
9
|
+
className?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** Capitals with the web menu label tracking (`uppercase tracking-widest`). Default `false`. */
|
|
12
|
+
uppercase?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Letter spacing in em: slightly open, or the web `tracking-widest` (0.1em) in capitals. */
|
|
16
|
+
const TRACKING = { regular: 0.02, uppercase: 0.1 } as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The quiet label above a group of rows ("Today", "Library"), in the spirit of
|
|
20
|
+
* the web `sidebarSectionLabelClass`: `text-xs` medium (12 / 500), muted, with
|
|
21
|
+
* slightly open letter spacing. The web label is muted at 55% (2.1:1 on the
|
|
22
|
+
* light page); this one is the full muted colour (4.4:1). A header for
|
|
23
|
+
* VoiceOver and TalkBack. No side padding: pass `className="px-4"` to line it
|
|
24
|
+
* up with `ListRow` text. `SettingsSection` titles are the larger heading.
|
|
25
|
+
*/
|
|
26
|
+
export function SectionLabel({ className, uppercase = false, style, ...props }: SectionLabelProps) {
|
|
27
|
+
const scheme = useScheme();
|
|
28
|
+
const largerText = useLargerText();
|
|
29
|
+
const size = largerText ? "text-base" : "text-xs";
|
|
30
|
+
const { fontSize } = sizeStyle(size);
|
|
31
|
+
return (
|
|
32
|
+
<Text
|
|
33
|
+
accessibilityRole="header"
|
|
34
|
+
className={cn("font-instrument-sans-medium", size, className)}
|
|
35
|
+
style={[
|
|
36
|
+
textStyle("muted", scheme),
|
|
37
|
+
sizeStyle(size),
|
|
38
|
+
typeStyle("medium"),
|
|
39
|
+
{
|
|
40
|
+
letterSpacing: fontSize * TRACKING[uppercase ? "uppercase" : "regular"],
|
|
41
|
+
textTransform: uppercase ? "uppercase" : "none",
|
|
42
|
+
},
|
|
43
|
+
style,
|
|
44
|
+
]}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
package/src/SheetRow.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
|
|
6
|
+
import { palette } from "./palette";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { tokens } from "./tokens";
|
|
9
|
+
import { useScheme } from "./useScheme";
|
|
10
|
+
|
|
11
|
+
export type SheetRowProps = {
|
|
12
|
+
/** A Tabler icon at the start. */
|
|
13
|
+
icon?: ComponentType<IconProps>;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Muted line under the title, also the accessibility hint. */
|
|
16
|
+
detail?: string;
|
|
17
|
+
/** The current choice: a check at the end, and `selected` for assistive tech. */
|
|
18
|
+
active?: boolean;
|
|
19
|
+
/** A destructive action: title and icon in `palette.danger`. */
|
|
20
|
+
danger?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onPress?: () => void;
|
|
23
|
+
testID?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One action in a `Sheet` or bottom sheet, a touch size of the web menu item:
|
|
28
|
+
* icon, title and optional detail, at least 56 high (64 with larger text) with
|
|
29
|
+
* the `ListRow` padding (`px-4 py-3`). While pressed it takes the web menu
|
|
30
|
+
* item highlight (`bg-default`). `danger` draws the title and icon in the
|
|
31
|
+
* danger colour; `active` adds a check; `disabled` dims it to 50%, as the web
|
|
32
|
+
* item, and ignores presses.
|
|
33
|
+
*/
|
|
34
|
+
export function SheetRow({
|
|
35
|
+
icon: Icon,
|
|
36
|
+
title,
|
|
37
|
+
detail,
|
|
38
|
+
active = false,
|
|
39
|
+
danger = false,
|
|
40
|
+
disabled = false,
|
|
41
|
+
onPress,
|
|
42
|
+
testID,
|
|
43
|
+
}: SheetRowProps) {
|
|
44
|
+
const scheme = useScheme();
|
|
45
|
+
const largerText = useLargerText();
|
|
46
|
+
const color = danger ? palette.danger[scheme] : tokens.color.foreground[scheme];
|
|
47
|
+
const titleSize = largerText ? "text-xl" : "text-base";
|
|
48
|
+
const detailSize = largerText ? "text-lg" : "text-sm";
|
|
49
|
+
const iconSize = largerText ? 26 : 22;
|
|
50
|
+
return (
|
|
51
|
+
<Pressable
|
|
52
|
+
testID={testID}
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={title}
|
|
55
|
+
accessibilityHint={detail}
|
|
56
|
+
accessibilityState={{ disabled, selected: active }}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
onPress={onPress}
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex-row items-center gap-3 px-4 py-3",
|
|
61
|
+
largerText ? "min-h-16" : "min-h-14",
|
|
62
|
+
disabled && "opacity-50",
|
|
63
|
+
)}
|
|
64
|
+
style={({ pressed }) => ({
|
|
65
|
+
borderRadius: tokens.radius.sm,
|
|
66
|
+
backgroundColor: pressed && !disabled ? palette.default[scheme] : "transparent",
|
|
67
|
+
})}
|
|
68
|
+
>
|
|
69
|
+
{Icon ? <Icon size={iconSize} color={color} /> : null}
|
|
70
|
+
<View className="min-w-0 flex-1">
|
|
71
|
+
<Text
|
|
72
|
+
className={cn("font-instrument-sans-medium", titleSize)}
|
|
73
|
+
style={[{ color }, sizeStyle(titleSize), typeStyle("medium")]}
|
|
74
|
+
>
|
|
75
|
+
{title}
|
|
76
|
+
</Text>
|
|
77
|
+
{detail ? (
|
|
78
|
+
<Text
|
|
79
|
+
className={cn("font-instrument-sans", detailSize)}
|
|
80
|
+
style={[textStyle("muted", scheme), sizeStyle(detailSize)]}
|
|
81
|
+
>
|
|
82
|
+
{detail}
|
|
83
|
+
</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
</View>
|
|
86
|
+
{active ? <IconCheck size={iconSize} color={color} strokeWidth={2} /> : null}
|
|
87
|
+
</Pressable>
|
|
88
|
+
);
|
|
89
|
+
}
|