@vvedantb/native-ui 0.3.12 → 0.3.14
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 +16 -3
- package/package.json +5 -1
- package/src/PageTitle.tsx +81 -0
- package/src/Select.tsx +5 -2
- package/src/brand/AppIcons.tsx +5 -8
- package/src/brand/AppMark.tsx +5 -8
- package/src/controlStyles.ts +38 -3
- package/src/entries/layout.ts +4 -1
- package/src/entries/page-title.ts +4 -0
- package/src/examples/ParityGallery.tsx +17 -0
- package/src/index.ts +2 -0
- package/src/primitives/button.tsx +3 -1
- package/src/primitives/card.tsx +5 -2
- package/src/primitives/icon.tsx +5 -3
- package/src/primitives/select.tsx +12 -10
- package/src/primitives/text.tsx +25 -8
- package/src/selectA11y.ts +26 -0
- package/src/svgA11y.ts +43 -0
package/README.md
CHANGED
|
@@ -61,10 +61,11 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
61
61
|
| `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonSize`, `ButtonVariant` | none |
|
|
62
62
|
| `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster` | `expo-blur`, `expo-haptics` (loaders) |
|
|
63
63
|
| `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
|
|
64
|
+
| `@vvedantb/native-ui/page-title` | `PageTitle`, `PageHeader`, `PageTitleProps`, `PageHeaderProps` (from 0.3.13) | none |
|
|
64
65
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
65
66
|
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
66
67
|
| `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
|
|
67
|
-
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
|
|
68
|
+
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader` and their props types, `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
|
|
68
69
|
| `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
|
|
69
70
|
| `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
|
|
70
71
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
@@ -78,6 +79,7 @@ import { FloatingTabBar } from "@vvedantb/native-ui/tab-bar";
|
|
|
78
79
|
import { AppMark } from "@vvedantb/native-ui/brand";
|
|
79
80
|
import { NativeUiProvider } from "@vvedantb/native-ui/theme";
|
|
80
81
|
import { SearchInput } from "@vvedantb/native-ui/search-input";
|
|
82
|
+
import { PageHeader } from "@vvedantb/native-ui/page-title";
|
|
81
83
|
import { toast, Toaster } from "@vvedantb/native-ui/toast";
|
|
82
84
|
```
|
|
83
85
|
|
|
@@ -109,7 +111,7 @@ config.resolver.nodeModulesPaths = [
|
|
|
109
111
|
|
|
110
112
|
The app must define NativeWind tokens (`bg-surface`, `bg-secondary`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet. `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`, `Checkbox`, `Badge`, `Tabs` and `PillTabs` take their colours from `tokens` and `palette`, so they do not need `primary`, `input` or other theme keys. From 0.3.3 so does component text (titles, descriptions, labels, rows, toasts): it no longer uses `text-foreground` or `text-muted-foreground`, which stay light-mode colours in dark mode when an app defines `foreground` with a nested `dark` key. Override those colours with `style`, not `className`. App text needs the same care: `tokens.color.foreground[scheme]` reads in both schemes.
|
|
111
113
|
|
|
112
|
-
From 0.3.5 text that uses `font-instrument-sans-medium` or `-semibold` also sets `fontWeight` as a style (`typeStyle` in `src/controlStyles.ts`). Where an app maps every `font-instrument-sans-*` class to one family with no weight, as Expo web does, the class alone draws Regular.
|
|
114
|
+
From 0.3.5 text that uses `font-instrument-sans-medium` or `-semibold` also sets `fontWeight` as a style (`typeStyle` in `src/controlStyles.ts`). Where an app maps every `font-instrument-sans-*` class to one family with no weight, as Expo web does, the class alone draws Regular. A weight without the face class does not pick the Instrument Sans Medium or Semibold file, so set both. From 0.3.13 every title in the package pairs the two, the internal primitives included, and `tests/fontWeight.test.ts` fails on a face class without its weight, a weight without its face class, or a bare Tailwind `font-medium`, `font-semibold` or `font-bold`.
|
|
113
115
|
|
|
114
116
|
## Theme
|
|
115
117
|
|
|
@@ -137,7 +139,7 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
|
|
|
137
139
|
| Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
|
|
138
140
|
| Feedback | `toast()` and `<Toaster />` |
|
|
139
141
|
| Brand and upgrade | `APP_ICONS`, `IconVibot` and the other `Icon*`, `AppMark`, `AppWordmark`, `FAMILY_APPS`, `V_PRO_APP_BENEFITS`, `UpgradeToProCard`, `PricingModal` |
|
|
140
|
-
| Settings and layout | `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `ScreenContainer`, `SafeAreaContainer`, `FloatingSurface`, `AmbientBackground` |
|
|
142
|
+
| Settings and layout | `PageTitle`, `PageHeader` (0.3.13), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `ScreenContainer`, `SafeAreaContainer`, `FloatingSurface`, `AmbientBackground` |
|
|
141
143
|
|
|
142
144
|
Overlays hold their own open state, or take `open` and `onOpenChange`:
|
|
143
145
|
|
|
@@ -172,6 +174,15 @@ const [query, setQuery] = useState("");
|
|
|
172
174
|
<SearchInput value={query} onChange={setQuery} label="Search apps" />
|
|
173
175
|
```
|
|
174
176
|
|
|
177
|
+
From 0.3.13 `PageTitle` is the web page title, the type on the web `Breadcrumb` (`text-xl font-semibold leading-tight tracking-tight`): Instrument Sans 600 at 20 / 25 with -0.5 letter spacing (-0.025em), in the foreground colour. It sets the semibold face class and `fontWeight: "600"` together, so it is not drawn Regular on Android or Expo web. It is a header for VoiceOver and TalkBack, one line (pass `numberOfLines={0}` to wrap), and scales with the system font size. `PageHeader` is a tab-root title row: `PageTitle` at the start and an optional `action` at the end, which keeps its width while a long title is cut off. It has the `ScreenContainer` side padding (`px-4`) and a row at least 56 high, so tabs with and without an action put their titles at the same height. It pads the top by the safe-area inset; pass `safeTop={false}` under a native header or inside a view that already pads it. Give a `Button` in `action` `fullWidth={false}`.
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<PageHeader
|
|
181
|
+
title="Apps"
|
|
182
|
+
action={<Button variant="ghost" size="sm" fullWidth={false} onPress={updateAll}>Update all</Button>}
|
|
183
|
+
/>
|
|
184
|
+
```
|
|
185
|
+
|
|
175
186
|
Toasts need one `<Toaster />` inside `SafeAreaProvider`, after the navigator. Then call `toast("Saved")`, `toast.error("Failed")` or `toast.loading("Syncing")` from anywhere. Pass the returned id to replace a toast (`toast.success("Synced", { id })`). On iOS toasts show over modals; on Android a `Modal` covers them.
|
|
176
187
|
|
|
177
188
|
From 0.3.12 a toast looks like the web `SonnerToaster` at its default density with `toastIcons="filled24"`: the filled 24 success, info, warning and error icons (the web paths, in `palette` colours), a `text-sm` medium title, `px-4 py-3`, radius 16, and the flat overlay fill with a 1 px ring and a light shadow (`smooth-shadow-ring-md`). Toasts no longer blur.
|
|
@@ -186,6 +197,8 @@ The components keep the web sizes on screen and reach a 44 pt touch target with
|
|
|
186
197
|
|
|
187
198
|
Text scales with the system font size. Labelled controls have minimum heights, so a large Dynamic Type size grows them instead of clipping the label (the `SearchInput` lock grows with the font scale); only the `FloatingTabBar` labels are capped (1.5×), to fit the fixed bar. With Reduce Motion on, dialogs, sheets, toasts, the `Select` popover, `PricingModal`, `Progress` and `Skeleton` do not animate. Dialogs, sheets, toasts, `FloatingTabBar` and `FloatingFAB` stay inside the safe-area insets, and dialog and sheet fields stay above the keyboard on iOS and Android.
|
|
188
199
|
|
|
200
|
+
The brand marks (`APP_ICONS`, the `Icon*` exports and `AppMark`) are images named by `title`, and hidden from assistive tech without one. From 0.3.14 they set the props each platform reads: `accessibilityRole="image"` and `accessibilityLabel` on iOS and Android, `role="img"` and `aria-label` on web. Without a title they set `accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"` on native and `aria-hidden` on web. `testID` is `data-testid` on web. On web `react-native-svg` passes its props straight to the DOM `<svg>`, and React Native Web does not map `accessible`, the `accessibility*` props or `testID` there. So do not put them on an `Svg` or on a Tabler icon: put them on the parent `View` or `Pressable`. The package draws no Lucide icon, as a Lucide icon always sets `testID` on its `Svg`. On web the `Select` options are Radix DOM nodes, so from 0.3.14 they take their name and test id as `aria-label` and `data-testid` there, and as `accessibilityLabel` and `testID` on iOS and Android.
|
|
201
|
+
|
|
189
202
|
## Floating UI / Android shadows
|
|
190
203
|
|
|
191
204
|
Do not put `elevation` or NativeWind `shadow-*` on nested or translucent chrome. `floatingShadow` is iOS `shadow*` and Android/web `boxShadow`. `FloatingSurface` clips on an inner view.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar and the family app marks.",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/entries/search-input.ts",
|
|
38
38
|
"default": "./src/entries/search-input.ts"
|
|
39
39
|
},
|
|
40
|
+
"./page-title": {
|
|
41
|
+
"types": "./src/entries/page-title.ts",
|
|
42
|
+
"default": "./src/entries/page-title.ts"
|
|
43
|
+
},
|
|
40
44
|
"./toast": {
|
|
41
45
|
"types": "./src/entries/toast.ts",
|
|
42
46
|
"default": "./src/entries/toast.ts"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Text, View, type TextProps, type ViewProps } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import {
|
|
6
|
+
PAGE_HEADER_HEIGHT,
|
|
7
|
+
PAGE_TITLE_CLASS,
|
|
8
|
+
pageTitleStyle,
|
|
9
|
+
textStyle,
|
|
10
|
+
} from "./controlStyles";
|
|
11
|
+
import { useScheme } from "./useScheme";
|
|
12
|
+
|
|
13
|
+
export type PageTitleProps = TextProps & { className?: string };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The web page title (`Breadcrumb`: `text-xl font-semibold leading-tight
|
|
17
|
+
* tracking-tight`): Instrument Sans 600 at 20 / 25 with -0.5 letter spacing,
|
|
18
|
+
* in the scheme's foreground. A header for VoiceOver and TalkBack. One line,
|
|
19
|
+
* cut off at the end as the web `truncate`; pass `numberOfLines={0}` to wrap.
|
|
20
|
+
* It scales with the system font size.
|
|
21
|
+
*/
|
|
22
|
+
export function PageTitle({ className, style, ...props }: PageTitleProps) {
|
|
23
|
+
const scheme = useScheme();
|
|
24
|
+
return (
|
|
25
|
+
<Text
|
|
26
|
+
accessibilityRole="header"
|
|
27
|
+
numberOfLines={1}
|
|
28
|
+
className={cn(PAGE_TITLE_CLASS, className)}
|
|
29
|
+
style={[textStyle("foreground", scheme), pageTitleStyle(), style]}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PageHeaderProps = Omit<ViewProps, "children"> & {
|
|
36
|
+
/** The page title, drawn with `PageTitle`. */
|
|
37
|
+
title: string;
|
|
38
|
+
/**
|
|
39
|
+
* Content at the end of the row, such as an "Update all" `Button` (pass it
|
|
40
|
+
* `fullWidth={false}`). It keeps its width; a long title is cut off instead.
|
|
41
|
+
*/
|
|
42
|
+
action?: ReactNode;
|
|
43
|
+
/**
|
|
44
|
+
* Pad the top by the safe-area inset. Default `true`, for a header at the top
|
|
45
|
+
* of a screen with no navigation header. Pass `false` under a native header
|
|
46
|
+
* or inside a view that already pads the inset.
|
|
47
|
+
*/
|
|
48
|
+
safeTop?: boolean;
|
|
49
|
+
className?: string;
|
|
50
|
+
/** Classes on the title. */
|
|
51
|
+
titleClassName?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A screen's title row: `PageTitle` at the start and an optional `action` at
|
|
56
|
+
* the end, with the `ScreenContainer` side padding (`px-4`). The row is at
|
|
57
|
+
* least `PAGE_HEADER_HEIGHT` (56) high below the top inset, so tab roots with
|
|
58
|
+
* and without an action put their titles at the same height.
|
|
59
|
+
*/
|
|
60
|
+
export function PageHeader({
|
|
61
|
+
title,
|
|
62
|
+
action,
|
|
63
|
+
safeTop = true,
|
|
64
|
+
className,
|
|
65
|
+
titleClassName,
|
|
66
|
+
style,
|
|
67
|
+
...props
|
|
68
|
+
}: PageHeaderProps) {
|
|
69
|
+
const insets = useSafeAreaInsets();
|
|
70
|
+
const top = safeTop ? insets.top : 0;
|
|
71
|
+
return (
|
|
72
|
+
<View
|
|
73
|
+
className={cn("flex-row items-center justify-between gap-3 px-4", className)}
|
|
74
|
+
style={[{ paddingTop: top, minHeight: top + PAGE_HEADER_HEIGHT }, style]}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
<PageTitle className={cn("min-w-0 flex-1", titleClassName)}>{title}</PageTitle>
|
|
78
|
+
{action ? <View className="shrink-0">{action}</View> : null}
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/Select.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
2
3
|
import {
|
|
3
4
|
NativeSelectScrollView,
|
|
4
5
|
Select as PrimitiveSelect,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
typeStyle,
|
|
18
19
|
} from "./controlStyles";
|
|
19
20
|
import { palette } from "./palette";
|
|
21
|
+
import { selectItemA11yProps } from "./selectA11y";
|
|
20
22
|
import { useLargerText } from "./theme";
|
|
21
23
|
import { useScheme } from "./useScheme";
|
|
22
24
|
|
|
@@ -138,10 +140,11 @@ function Select<TValue = string | number | null>({
|
|
|
138
140
|
? `${testID}-${String(option.value ?? "null")}`
|
|
139
141
|
: undefined;
|
|
140
142
|
return (
|
|
143
|
+
// On web the item is a Radix DOM node: the name and test id
|
|
144
|
+
// go as `aria-label` and `data-testid` there.
|
|
141
145
|
<SelectItem
|
|
142
146
|
key={`${String(option.value)}-${option.label}`}
|
|
143
|
-
testID
|
|
144
|
-
accessibilityLabel={option.label}
|
|
147
|
+
{...selectItemA11yProps({ label: option.label, testID: optionId }, Platform.OS)}
|
|
145
148
|
label={option.label}
|
|
146
149
|
value={String(option.value ?? NULL_SENTINEL)}
|
|
147
150
|
/>
|
package/src/brand/AppIcons.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Platform, type StyleProp, type ViewStyle } from "react-native";
|
|
2
2
|
import Svg, { G, Path } from "react-native-svg";
|
|
3
|
+
import { svgA11yProps } from "../svgA11y";
|
|
3
4
|
import { tokens } from "../tokens";
|
|
4
5
|
import { useScheme } from "../useScheme";
|
|
5
6
|
import {
|
|
@@ -19,10 +20,11 @@ export type AppIconProps = {
|
|
|
19
20
|
color?: string;
|
|
20
21
|
/** Stroke width in rendered points, for stroke glyphs. Default: the glyph's own weight. Fill glyphs ignore it. */
|
|
21
22
|
stroke?: number;
|
|
22
|
-
/** Accessible name. Without it the icon is hidden from assistive tech. */
|
|
23
|
+
/** Accessible name. Without it the icon is hidden from assistive tech. On web: `role="img"` and `aria-label`, or `aria-hidden`. */
|
|
23
24
|
title?: string;
|
|
24
25
|
/** `"center"` (default) centres the glyph in its square. `"start"` left-aligns its ink, for vertical lists. */
|
|
25
26
|
align?: "center" | "start";
|
|
27
|
+
/** Set as `data-testid` on web, where `Svg` does not map `testID`. */
|
|
26
28
|
testID?: string;
|
|
27
29
|
style?: StyleProp<ViewStyle>;
|
|
28
30
|
};
|
|
@@ -70,12 +72,7 @@ function createAppIcon(app: AppName, displayName: string): AppIconComponent {
|
|
|
70
72
|
align === "start" ? APP_MARK_VIEWBOX_START[app] : APP_MARK_VIEWBOX[app]
|
|
71
73
|
}
|
|
72
74
|
preserveAspectRatio="xMidYMid meet"
|
|
73
|
-
|
|
74
|
-
accessibilityRole={title ? "image" : undefined}
|
|
75
|
-
accessibilityLabel={title}
|
|
76
|
-
accessibilityElementsHidden={!title}
|
|
77
|
-
importantForAccessibility={title ? "auto" : "no-hide-descendants"}
|
|
78
|
-
testID={testID}
|
|
75
|
+
{...svgA11yProps({ title, testID }, Platform.OS)}
|
|
79
76
|
style={style}
|
|
80
77
|
>
|
|
81
78
|
<G {...glyphProps}>
|
package/src/brand/AppMark.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Platform, type StyleProp, type ViewStyle } from "react-native";
|
|
2
2
|
import Svg, { G, Path, Rect } from "react-native-svg";
|
|
3
|
+
import { svgA11yProps } from "../svgA11y";
|
|
3
4
|
import { APP_ICONS } from "./AppIcons";
|
|
4
5
|
import {
|
|
5
6
|
APP_MARKS,
|
|
@@ -18,8 +19,9 @@ export type AppMarkProps = {
|
|
|
18
19
|
variant?: AppMarkVariant;
|
|
19
20
|
/** Width and height in points. Default 24. */
|
|
20
21
|
size?: number;
|
|
21
|
-
/** Accessible name. Without it the mark is hidden from assistive tech. */
|
|
22
|
+
/** Accessible name. Without it the mark is hidden from assistive tech. On web: `role="img"` and `aria-label`, or `aria-hidden`. */
|
|
22
23
|
title?: string;
|
|
24
|
+
/** Set as `data-testid` on web, where `Svg` does not map `testID`. */
|
|
23
25
|
testID?: string;
|
|
24
26
|
style?: StyleProp<ViewStyle>;
|
|
25
27
|
};
|
|
@@ -64,12 +66,7 @@ export function AppMark({
|
|
|
64
66
|
height={size}
|
|
65
67
|
viewBox={MARK_VIEWBOX}
|
|
66
68
|
preserveAspectRatio="xMidYMid meet"
|
|
67
|
-
|
|
68
|
-
accessibilityRole={title ? "image" : undefined}
|
|
69
|
-
accessibilityLabel={title}
|
|
70
|
-
accessibilityElementsHidden={!title}
|
|
71
|
-
importantForAccessibility={title ? "auto" : "no-hide-descendants"}
|
|
72
|
-
testID={testID}
|
|
69
|
+
{...svgA11yProps({ title, testID }, Platform.OS)}
|
|
73
70
|
style={style}
|
|
74
71
|
>
|
|
75
72
|
<Rect width={1024} height={1024} rx={TILE_RADIUS} fill={tone.bg} />
|
package/src/controlStyles.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* `Checkbox`, `Tabs`, `Card` and component text, taken from `palette` and
|
|
4
4
|
* `tokens` instead of NativeWind theme keys (`bg-primary`, `bg-input`,
|
|
5
5
|
* `text-foreground`) that apps do not define, or define without a dark value.
|
|
6
|
-
* Also text weights and sizes as styles, the
|
|
7
|
-
* compact window test for dialogs and the
|
|
8
|
-
* this file.
|
|
6
|
+
* Also text weights and sizes as styles, the page title type, the
|
|
7
|
+
* `SearchInput` metrics, the compact window test for dialogs and the
|
|
8
|
+
* touch-target maths. No React Native import, so the tests can load this file.
|
|
9
9
|
*/
|
|
10
10
|
import { palette, withAlpha } from "./palette";
|
|
11
11
|
import { tokens } from "./tokens";
|
|
@@ -148,6 +148,41 @@ export function sizeStyle(size: TextSize, leading?: "tight" | "snug") {
|
|
|
148
148
|
return { fontSize, lineHeight: leading ? fontSize * LEADING[leading] : lineHeight };
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** Tailwind `tracking-tight`, in em. */
|
|
152
|
+
const TRACKING = { tight: -0.025 } as const;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Letter spacing of a `tracking-*` class at `fontSize`, as a style in points.
|
|
156
|
+
* React Native has no `em`: `tracking-tight` (-0.025em) at 20 is -0.5.
|
|
157
|
+
*/
|
|
158
|
+
export function trackingStyle(fontSize: number, tracking: keyof typeof TRACKING) {
|
|
159
|
+
return { letterSpacing: fontSize * TRACKING[tracking] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The web page title, `text-xl font-semibold leading-tight tracking-tight` on
|
|
164
|
+
* the `Breadcrumb` nav (`breadcrumb.tsx`), with the Instrument Sans semibold
|
|
165
|
+
* face for `font-semibold`. `PageTitle` sets `pageTitleStyle()` beside it.
|
|
166
|
+
*/
|
|
167
|
+
export const PAGE_TITLE_CLASS = "font-instrument-sans-semibold text-xl leading-tight tracking-tight";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `PAGE_TITLE_CLASS` as a style: Instrument Sans 600 at 20 / 25 with -0.5
|
|
171
|
+
* letter spacing. On Android and Expo web the face class alone can draw
|
|
172
|
+
* Regular (see `typeStyle`).
|
|
173
|
+
*/
|
|
174
|
+
export function pageTitleStyle() {
|
|
175
|
+
const size = sizeStyle("text-xl", "tight");
|
|
176
|
+
return { ...size, ...typeStyle("semibold"), ...trackingStyle(size.fontSize, "tight") };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Height of the `PageHeader` row below the top inset, in points. A minimum,
|
|
181
|
+
* so the title sits at the same height with or without an action, and still
|
|
182
|
+
* grows with the system font size. Native only: the web title has no row.
|
|
183
|
+
*/
|
|
184
|
+
export const PAGE_HEADER_HEIGHT = 56;
|
|
185
|
+
|
|
151
186
|
/**
|
|
152
187
|
* The web `COMPACT_OVERLAY_QUERY`, `(max-width: 639px), (max-height: 500px)`:
|
|
153
188
|
* phones, and short landscape windows such as 844 × 390.
|
package/src/entries/layout.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// `@vvedantb/native-ui/layout`: screen containers and settings
|
|
1
|
+
// `@vvedantb/native-ui/layout`: screen containers, page headers and settings
|
|
2
|
+
// chrome.
|
|
2
3
|
export { SafeAreaContainer } from "../SafeAreaContainer";
|
|
3
4
|
export { ScreenContainer } from "../ScreenContainer";
|
|
5
|
+
export { PageTitle, PageHeader } from "../PageTitle";
|
|
6
|
+
export type { PageTitleProps, PageHeaderProps } from "../PageTitle";
|
|
4
7
|
export { SettingsSection } from "../settings/SettingsSection";
|
|
5
8
|
export { SettingsField } from "../settings/SettingsField";
|
|
6
9
|
export { SettingsToggleRow } from "../settings/SettingsToggleRow";
|
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
Label,
|
|
48
48
|
LabeledSwitchRow,
|
|
49
49
|
NativeUiProvider,
|
|
50
|
+
PageHeader,
|
|
51
|
+
PageTitle,
|
|
50
52
|
PricingModal,
|
|
51
53
|
Progress,
|
|
52
54
|
SearchInput,
|
|
@@ -187,6 +189,21 @@ function GalleryScreen() {
|
|
|
187
189
|
contentContainerClassName="gap-8 px-4 pt-4"
|
|
188
190
|
contentContainerStyle={{ paddingBottom: GALLERY_END_SPACE }}
|
|
189
191
|
>
|
|
192
|
+
<Section title="Page title">
|
|
193
|
+
<PageTitle>Updates</PageTitle>
|
|
194
|
+
{/* `safeTop={false}` and `px-0`: here the header is not at the top of the screen, and the scroll view pads the sides. */}
|
|
195
|
+
<PageHeader
|
|
196
|
+
title="Apps"
|
|
197
|
+
safeTop={false}
|
|
198
|
+
className="px-0"
|
|
199
|
+
action={
|
|
200
|
+
<Button variant="ghost" size="sm" fullWidth={false} onPress={() => toast("Updating all apps")}>
|
|
201
|
+
Update all
|
|
202
|
+
</Button>
|
|
203
|
+
}
|
|
204
|
+
/>
|
|
205
|
+
</Section>
|
|
206
|
+
|
|
190
207
|
<Section title="Brand">
|
|
191
208
|
{(["vibot", "verve", "velth", "vmem", "vhub"] as const).map((app) => (
|
|
192
209
|
<AppWordmark key={app} app={app} />
|
package/src/index.ts
CHANGED
|
@@ -137,6 +137,8 @@ export { DateTimeField } from "./DateTimeField";
|
|
|
137
137
|
export { BottomSheetContainer } from "./BottomSheetContainer";
|
|
138
138
|
export { SafeAreaContainer } from "./SafeAreaContainer";
|
|
139
139
|
export { ScreenContainer } from "./ScreenContainer";
|
|
140
|
+
export { PageTitle, PageHeader } from "./PageTitle";
|
|
141
|
+
export type { PageTitleProps, PageHeaderProps } from "./PageTitle";
|
|
140
142
|
export { SettingsSection } from "./settings/SettingsSection";
|
|
141
143
|
export { SettingsField } from "./settings/SettingsField";
|
|
142
144
|
export { SettingsToggleRow } from "./settings/SettingsToggleRow";
|
|
@@ -47,7 +47,9 @@ const buttonVariants = cva(
|
|
|
47
47
|
},
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// No weight: a class context cannot carry the `typeStyle` that a face class
|
|
51
|
+
// needs, so `Button` sets the medium face and its weight on its label.
|
|
52
|
+
const buttonTextVariants = cva("text-sm text-foreground", {
|
|
51
53
|
variants: {
|
|
52
54
|
variant: {
|
|
53
55
|
default: "text-primary-foreground",
|
package/src/primitives/card.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text, TextClassContext } from "./text";
|
|
4
4
|
import { cn } from "../cn";
|
|
5
|
+
import { typeStyle } from "../controlStyles";
|
|
5
6
|
|
|
6
7
|
type CardViewProps = React.ComponentPropsWithoutRef<typeof View> & {
|
|
7
8
|
className?: string;
|
|
@@ -40,13 +41,15 @@ const CardHeader = React.forwardRef<
|
|
|
40
41
|
const CardTitle = React.forwardRef<
|
|
41
42
|
React.ElementRef<typeof Text>,
|
|
42
43
|
React.ComponentPropsWithoutRef<typeof Text> & { className?: string }
|
|
43
|
-
>(({ className, ...props }, ref) => {
|
|
44
|
+
>(({ className, style, ...props }, ref) => {
|
|
45
|
+
// The web `font-semibold`: the semibold face, with its weight as a style.
|
|
44
46
|
return (
|
|
45
47
|
<Text
|
|
46
48
|
ref={ref}
|
|
47
49
|
role="heading"
|
|
48
50
|
aria-level={3}
|
|
49
|
-
className={cn("font-semibold leading-none", className)}
|
|
51
|
+
className={cn("font-instrument-sans-semibold leading-none", className)}
|
|
52
|
+
style={[typeStyle("semibold"), style]}
|
|
50
53
|
{...props}
|
|
51
54
|
/>
|
|
52
55
|
);
|
package/src/primitives/icon.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { IconProps as TablerIconProps } from "@tabler/icons-react-native";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Tabler, not Lucide: a Lucide icon always sets `testID` on its `Svg`, which
|
|
5
|
+
// on web is a DOM `<svg>`, so React logs it as an unknown DOM prop.
|
|
6
|
+
type IconProps = TablerIconProps & {
|
|
7
|
+
as: React.ComponentType<TablerIconProps>;
|
|
6
8
|
className?: string;
|
|
7
9
|
};
|
|
8
10
|
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as SelectPrimitive from "@rn-primitives/select";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
ChevronDownIcon,
|
|
6
|
-
ChevronUpIcon,
|
|
7
|
-
} from "lucide-react-native";
|
|
2
|
+
// Tabler, not Lucide: a Lucide icon always sets `testID` on its `Svg`, which
|
|
3
|
+
// on web is a DOM `<svg>`, so React logs it as an unknown DOM prop.
|
|
4
|
+
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react-native";
|
|
8
5
|
import * as React from "react";
|
|
9
6
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
10
7
|
import { FadeIn, FadeOut } from "react-native-reanimated";
|
|
@@ -72,7 +69,7 @@ const SelectTrigger = React.forwardRef<
|
|
|
72
69
|
{...props}
|
|
73
70
|
>
|
|
74
71
|
<>{children}</>
|
|
75
|
-
<
|
|
72
|
+
<IconChevronDown size={16} color={tokens.color.muted[scheme]} />
|
|
76
73
|
</SelectPrimitive.Trigger>
|
|
77
74
|
);
|
|
78
75
|
});
|
|
@@ -153,6 +150,11 @@ const SelectLabel = React.forwardRef<
|
|
|
153
150
|
);
|
|
154
151
|
});
|
|
155
152
|
|
|
153
|
+
/**
|
|
154
|
+
* On web `SelectPrimitive.Item` passes these props to a Radix DOM node, not a
|
|
155
|
+
* React Native view, so give it `aria-label` and `data-testid` there, not
|
|
156
|
+
* `accessibilityLabel` or `testID` (`selectItemA11yProps`).
|
|
157
|
+
*/
|
|
156
158
|
type SelectItemProps = React.ComponentPropsWithoutRef<
|
|
157
159
|
typeof SelectPrimitive.Item
|
|
158
160
|
> & {
|
|
@@ -177,7 +179,7 @@ const SelectItem = React.forwardRef<
|
|
|
177
179
|
>
|
|
178
180
|
<View className="absolute right-2 size-3.5 items-center justify-center">
|
|
179
181
|
<SelectPrimitive.ItemIndicator>
|
|
180
|
-
<
|
|
182
|
+
<IconCheck size={16} color={tokens.color.muted[scheme]} />
|
|
181
183
|
</SelectPrimitive.ItemIndicator>
|
|
182
184
|
</View>
|
|
183
185
|
<SelectPrimitive.ItemText
|
|
@@ -220,7 +222,7 @@ function SelectScrollUpButton(
|
|
|
220
222
|
className="items-center justify-center py-1"
|
|
221
223
|
{...props}
|
|
222
224
|
>
|
|
223
|
-
<
|
|
225
|
+
<IconChevronUp size={16} color="#737373" />
|
|
224
226
|
</SelectPrimitive.ScrollUpButton>
|
|
225
227
|
);
|
|
226
228
|
}
|
|
@@ -239,7 +241,7 @@ function SelectScrollDownButton(
|
|
|
239
241
|
className="items-center justify-center py-1"
|
|
240
242
|
{...props}
|
|
241
243
|
>
|
|
242
|
-
<
|
|
244
|
+
<IconChevronDown size={16} color="#737373" />
|
|
243
245
|
</SelectPrimitive.ScrollDownButton>
|
|
244
246
|
);
|
|
245
247
|
}
|
package/src/primitives/text.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Platform, Text as RNText, type Role } from "react-native";
|
|
5
5
|
import { cn } from "../cn";
|
|
6
|
+
import { typeStyle } from "../controlStyles";
|
|
6
7
|
|
|
7
8
|
const textVariants = cva(
|
|
8
9
|
cn(
|
|
@@ -16,27 +17,27 @@ const textVariants = cva(
|
|
|
16
17
|
variant: {
|
|
17
18
|
default: "",
|
|
18
19
|
h1: cn(
|
|
19
|
-
"text-4xl font-
|
|
20
|
+
"text-4xl font-instrument-sans-semibold tracking-tight",
|
|
20
21
|
Platform.select({ web: "scroll-m-20" }),
|
|
21
22
|
),
|
|
22
23
|
h2: cn(
|
|
23
|
-
"text-3xl font-semibold tracking-tight border-border border-b pb-2",
|
|
24
|
+
"text-3xl font-instrument-sans-semibold tracking-tight border-border border-b pb-2",
|
|
24
25
|
Platform.select({ web: "scroll-m-20" }),
|
|
25
26
|
),
|
|
26
27
|
h3: cn(
|
|
27
|
-
"text-2xl font-semibold tracking-tight",
|
|
28
|
+
"text-2xl font-instrument-sans-semibold tracking-tight",
|
|
28
29
|
Platform.select({ web: "scroll-m-20" }),
|
|
29
30
|
),
|
|
30
31
|
h4: cn(
|
|
31
|
-
"text-xl font-semibold tracking-tight",
|
|
32
|
+
"text-xl font-instrument-sans-semibold tracking-tight",
|
|
32
33
|
Platform.select({ web: "scroll-m-20" }),
|
|
33
34
|
),
|
|
34
35
|
p: "leading-7",
|
|
35
36
|
blockquote: "border-l-2 border-border pl-4 italic",
|
|
36
|
-
code: "bg-muted rounded px-[0.3rem] py-[0.2rem] text-sm font-semibold",
|
|
37
|
+
code: "bg-muted rounded px-[0.3rem] py-[0.2rem] text-sm font-instrument-sans-semibold",
|
|
37
38
|
lead: "text-xl text-muted-foreground",
|
|
38
|
-
large: "text-lg font-semibold",
|
|
39
|
-
small: "text-sm font-medium",
|
|
39
|
+
large: "text-lg font-instrument-sans-semibold",
|
|
40
|
+
small: "text-sm font-instrument-sans-medium",
|
|
40
41
|
muted: "text-sm text-muted-foreground",
|
|
41
42
|
},
|
|
42
43
|
},
|
|
@@ -65,6 +66,20 @@ const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
|
|
65
66
|
h4: "4",
|
|
66
67
|
};
|
|
67
68
|
|
|
69
|
+
// The weight of each variant's `font-instrument-sans-*` class, set as a style
|
|
70
|
+
// beside it (`typeStyle`): where an app maps every one of those classes to one
|
|
71
|
+
// family, the class alone draws Regular. Headings are semibold, as the web
|
|
72
|
+
// titles; `h1` was `font-extrabold` before 0.3.13.
|
|
73
|
+
const WEIGHT: Partial<Record<TextVariant, ReturnType<typeof typeStyle>>> = {
|
|
74
|
+
h1: typeStyle("semibold"),
|
|
75
|
+
h2: typeStyle("semibold"),
|
|
76
|
+
h3: typeStyle("semibold"),
|
|
77
|
+
h4: typeStyle("semibold"),
|
|
78
|
+
code: typeStyle("semibold"),
|
|
79
|
+
large: typeStyle("semibold"),
|
|
80
|
+
small: typeStyle("medium"),
|
|
81
|
+
};
|
|
82
|
+
|
|
68
83
|
const TextClassContext = React.createContext<string | undefined>(undefined);
|
|
69
84
|
|
|
70
85
|
type TextProps = React.ComponentPropsWithoutRef<typeof RNText> &
|
|
@@ -74,9 +89,10 @@ type TextProps = React.ComponentPropsWithoutRef<typeof RNText> &
|
|
|
74
89
|
};
|
|
75
90
|
|
|
76
91
|
const Text = React.forwardRef<React.ElementRef<typeof RNText>, TextProps>(
|
|
77
|
-
({ className, asChild = false, variant = "default", ...props }, ref) => {
|
|
92
|
+
({ className, asChild = false, variant = "default", style, ...props }, ref) => {
|
|
78
93
|
const textClass = React.useContext(TextClassContext);
|
|
79
94
|
const Component = asChild ? Slot.Text : RNText;
|
|
95
|
+
const weight = variant ? WEIGHT[variant] : undefined;
|
|
80
96
|
|
|
81
97
|
return (
|
|
82
98
|
<Component
|
|
@@ -84,6 +100,7 @@ const Text = React.forwardRef<React.ElementRef<typeof RNText>, TextProps>(
|
|
|
84
100
|
className={cn(textVariants({ variant }), textClass, className)}
|
|
85
101
|
role={variant ? ROLE[variant] : undefined}
|
|
86
102
|
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
|
103
|
+
style={weight ? [weight, style] : style}
|
|
87
104
|
{...props}
|
|
88
105
|
/>
|
|
89
106
|
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility and test props for a `Select` option. On web
|
|
3
|
+
* `@rn-primitives/select` passes the props of its `Item` through a `Slot` to
|
|
4
|
+
* the Radix `Select.Item`, a DOM `<div>`. React Native Web never sees them, so
|
|
5
|
+
* React logs `accessibilityLabel` and `testID` there as unknown DOM props. Web
|
|
6
|
+
* gets `aria-label` and `data-testid`; iOS and Android keep the native props.
|
|
7
|
+
* No React Native import, so the tests can load this file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SelectItemA11yProps =
|
|
11
|
+
| { "aria-label": string; "data-testid"?: string }
|
|
12
|
+
| { accessibilityLabel: string; testID?: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spread on the `SelectItem`. Pass `Platform.OS` as `os`. Without `testID` the
|
|
16
|
+
* result has no test id key, not one set to `undefined`.
|
|
17
|
+
*/
|
|
18
|
+
export function selectItemA11yProps(
|
|
19
|
+
{ label, testID }: { label: string; testID?: string },
|
|
20
|
+
os: string,
|
|
21
|
+
): SelectItemA11yProps {
|
|
22
|
+
if (os === "web") {
|
|
23
|
+
return testID === undefined ? { "aria-label": label } : { "aria-label": label, "data-testid": testID };
|
|
24
|
+
}
|
|
25
|
+
return testID === undefined ? { accessibilityLabel: label } : { accessibilityLabel: label, testID };
|
|
26
|
+
}
|
package/src/svgA11y.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility and test props for a `react-native-svg` `Svg`. React Native
|
|
3
|
+
* Web maps `accessible`, the `accessibility*` props and `testID` on `View`
|
|
4
|
+
* and `Text`, but on web `Svg` is a DOM `<svg>` that takes its props as they
|
|
5
|
+
* are, so React logs each one as an unknown DOM prop. Web gets ARIA and
|
|
6
|
+
* `data-testid`; iOS and Android keep the native props. No React Native
|
|
7
|
+
* import, so the tests can load this file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SvgA11yProps =
|
|
11
|
+
| { role: "img"; "aria-label": string; "data-testid"?: string }
|
|
12
|
+
| { "aria-hidden": true; "data-testid"?: string }
|
|
13
|
+
| { accessible: true; accessibilityRole: "image"; accessibilityLabel: string; testID?: string }
|
|
14
|
+
| {
|
|
15
|
+
accessible: false;
|
|
16
|
+
accessibilityElementsHidden: true;
|
|
17
|
+
importantForAccessibility: "no-hide-descendants";
|
|
18
|
+
testID?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spread on the `Svg`. With `title` it is an image with that name; without it,
|
|
23
|
+
* it is hidden from assistive tech. Pass `Platform.OS` as `os`. Without
|
|
24
|
+
* `testID` the result has no test id key, not one set to `undefined`.
|
|
25
|
+
*/
|
|
26
|
+
export function svgA11yProps(
|
|
27
|
+
{ title, testID }: { title?: string; testID?: string },
|
|
28
|
+
os: string,
|
|
29
|
+
): SvgA11yProps {
|
|
30
|
+
if (os === "web") {
|
|
31
|
+
const id = testID === undefined ? {} : { "data-testid": testID };
|
|
32
|
+
return title ? { role: "img", "aria-label": title, ...id } : { "aria-hidden": true, ...id };
|
|
33
|
+
}
|
|
34
|
+
const id = testID === undefined ? {} : { testID };
|
|
35
|
+
return title
|
|
36
|
+
? { accessible: true, accessibilityRole: "image", accessibilityLabel: title, ...id }
|
|
37
|
+
: {
|
|
38
|
+
accessible: false,
|
|
39
|
+
accessibilityElementsHidden: true,
|
|
40
|
+
importantForAccessibility: "no-hide-descendants",
|
|
41
|
+
...id,
|
|
42
|
+
};
|
|
43
|
+
}
|