@vvedantb/native-ui 0.4.0 → 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 CHANGED
@@ -61,18 +61,18 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
61
61
  | `@vvedantb/native-ui/tab-bar` | `FloatingTabBar`, `FloatingTabBarProps`, `FLOATING_INSET`, `FLOATING_TAB_BAR_HEIGHT`, `FLOATING_TAB_BAR_HEIGHT_LARGE`, `tabBarReserve` | `expo-blur`, `expo-haptics` (loaders) |
62
62
  | `@vvedantb/native-ui/brand` | `AppMark`, `AppWordmark`, `GoogleMark` (from 0.3.15), `APP_ICONS`, `IconVibot` and the other `Icon*`, the SVG helpers (`APP_MARKS`, `appMarkSvg`, `appTileSvg` and the rest), `FAMILY_APPS`, `familyApp`, `V_PRO_APP_BENEFITS` | none |
63
63
  | `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonSize`, `ButtonVariant` | none |
64
- | `@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`, `ThemeToggle` (from 0.3.16), `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster` | `expo-blur`, `expo-haptics` (loaders) |
64
+ | `@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`, `ThemeToggle` (from 0.3.16), `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster`. From 0.5.0: `Text` and `TextClassContext` (only here, not on the root), `ColorSwatches`, `StatGrid`, `SectionLabel`, `SheetRow`, `PageIndicator`, `IconButton`, `CaptureButton`, and the `ListRowProps` and `CardProps` types | `expo-blur`, `expo-haptics` (loaders) |
65
65
  | `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
66
- | `@vvedantb/native-ui/page-title` | `PageTitle`, `PageHeader`, `PageTitleProps`, `PageHeaderProps` (from 0.3.13) | none |
66
+ | `@vvedantb/native-ui/page-title` | `PageTitle`, `PageHeader`, `PageTitleProps`, `PageHeaderProps` (from 0.3.13), `StackPageHeader`, `StackPageHeaderProps` (from 0.5.0; it brings the Tabler package for its chevron) | none |
67
67
  | `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
68
68
  | `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `fontClass`, `cn`, `Colors` and the colour constants | none |
69
69
  | `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
70
- | `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader` and their props types, `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
70
+ | `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
71
71
  | `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
72
72
  | `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
73
73
  | `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
74
74
  | `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
75
- | `@vvedantb/native-ui/date-time` | `DateTimeField` | `@react-native-community/datetimepicker`, `dayjs` (import) |
75
+ | `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
76
76
  | `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry. | none |
77
77
  | `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
78
78
 
@@ -93,10 +93,11 @@ The entries re-export the same source files, so they can be mixed: `NativeUiProv
93
93
 
94
94
  ## Expo / NativeWind
95
95
 
96
- Source-exported. Point Tailwind at the package so class names are generated:
96
+ Source-exported. Point Tailwind at the package so class names are generated, and set `darkMode: "class"` (see [Theme](#theme)):
97
97
 
98
98
  ```js
99
99
  // tailwind.config.js
100
+ darkMode: "class",
100
101
  content: [
101
102
  "./app/**/*.{js,ts,tsx}",
102
103
  "./components/**/*.{js,ts,tsx}",
@@ -115,10 +116,26 @@ config.resolver.nodeModulesPaths = [
115
116
  ];
116
117
  ```
117
118
 
118
- 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.
119
+ 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. From 0.5.0 the `ListRow`, `DateTimeField` and `HeaderIconButton` fills are styles too, so `ThemeToggle` and the `StackPageHeader` back button follow the scheme.
119
120
 
120
121
  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`.
121
122
 
123
+ The app loads the font; the package does not. On Expo web, one variable family covers every weight. `@vvedantb/ui` ships the woff2 files (400–700, latin and latin-ext) in `fonts/`. Expo web CSS does not bundle relative `url()`s, so copy the files to the app's `public/fonts/` and copy the `@font-face` rules from `@vvedantb/ui/fonts/instrument-sans.css` with `url("/fonts/…")`. Then map all four classes to that family with a sans-serif fallback, as the components set the weight:
124
+
125
+ ```js
126
+ // tailwind.config.js (Expo web)
127
+ const instrumentSans = ["Instrument Sans", "system-ui", "sans-serif"];
128
+ // theme.extend.fontFamily:
129
+ fontFamily: {
130
+ "instrument-sans": instrumentSans,
131
+ "instrument-sans-medium": instrumentSans,
132
+ "instrument-sans-semibold": instrumentSans,
133
+ "instrument-sans-bold": instrumentSans,
134
+ },
135
+ ```
136
+
137
+ A class mapped to a family the browser does not have, such as `["System"]`, with no generic fallback draws in the browser default, often a serif.
138
+
122
139
  ## Theme
123
140
 
124
141
  Wrap the tree (inside your colour-scheme provider):
@@ -133,16 +150,20 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
133
150
  </NativeUiProvider>
134
151
  ```
135
152
 
153
+ **Dark mode needs `darkMode: "class"`.** Every component reads the scheme with NativeWind's `useColorScheme()` (`useScheme` in `src/useScheme.ts`) and takes its colours from `tokens` and `palette` for it; the AI composer and code block do too. `ThemeToggle`, and any `setColorScheme` call in the app, need `darkMode: "class"` in `tailwind.config.js`. Under Tailwind's default, `media`, NativeWind's `setColorScheme` throws ("Unable to manually set color scheme without using darkMode: class"), on web and native. The scheme then stays on the system setting, and the components keep the light colours, even where the app paints its own page dark.
154
+
155
+ **Switch the scheme through NativeWind only.** Call `useColorScheme().setColorScheme("dark"|"light")` (or use `ThemeToggle`). That updates `useScheme` and, with `darkMode: "class"`, the `<html>` class together. Do **not** toggle `document.documentElement.classList` (or a page-level CSS `.dark` rule) by hand while leaving NativeWind on light: the page background can go dark while every token-styled part stays on the light set. That dual-owner fault is an app bug, not a native-ui colour bug.
156
+
136
157
  `useLargerText()` steps type and tap targets up. Without it, `Button`, `Input`, `Textarea` and the `Select` trigger have the web sizes (`Button` 32 / 40 / 44 high, fields 40); with it they keep the larger 0.3.2 sizes. `accentColor` tints FABs, card icons and progress bars (checked checkboxes use the palette primary, as on the web). Surfaces use the tokens in `src/tokens.ts` for fill, radius (card 24, button 16, field 10, pill 999; rows 20) and spacing (16).
137
158
 
138
159
  ## Components (0.3.0)
139
160
 
140
161
  | Area | Exports |
141
162
  | --- | --- |
142
- | Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`), `Input`, `SearchInput` (0.3.9), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `DateTimeField` |
143
- | Display | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`), `Badge`, `StatusDot`, `Avatar` (+ `AvatarImage`, `AvatarFallback`, `initials()`), `Separator`, `Progress`, `Spinner`, `CenteredSpinner`, `Skeleton`, `EmptyState` (+ `EmptyState.Root` and parts), `ListRow`, `Chip` |
144
- | Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16) |
145
- | Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
163
+ | Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`), `IconButton` (0.5.0), `CaptureButton` (0.5.0), `Input`, `SearchInput` (0.3.9), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `ColorSwatches` (0.5.0), `DateTimeField` (date-only, empty and clearable from 0.5.0) |
164
+ | Display | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`; `onPress` from 0.5.0), `Badge`, `StatusDot` (`info` from 0.5.0), `Avatar` (+ `AvatarImage`, `AvatarFallback`, `initials()`), `Separator`, `Progress`, `Spinner`, `CenteredSpinner`, `Skeleton`, `EmptyState` (+ `EmptyState.Root` and parts), `ListRow` (`leading`, `titleEnd`, `control`, `onLongPress` from 0.5.0), `StatGrid` (0.5.0), `SectionLabel` (0.5.0), `Chip`, `Text` (0.5.0, `/primitives` only) |
165
+ | Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `PageIndicator` (0.5.0), `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16), `StackPageHeader` (0.5.0) |
166
+ | Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `SheetRow` (0.5.0), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
146
167
  | Feedback | `toast()` and `<Toaster />` |
147
168
  | Brand and upgrade | `APP_ICONS`, `IconVibot` and the other `Icon*`, `AppMark`, `AppWordmark`, `GoogleMark` (0.3.15), `FAMILY_APPS`, `V_PRO_APP_BENEFITS`, `UpgradeToProCard`, `PricingModal` |
148
169
  | Settings and layout | `PageTitle`, `PageHeader` (0.3.13), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `ScreenContainer`, `SafeAreaContainer`, `FloatingSurface`, `AmbientBackground` |
@@ -197,6 +218,44 @@ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density wi
197
218
 
198
219
  `@vvedantb/native-ui/gallery` exports `ParityGallery`, a screen that renders these components. Put it in a dev-only route to check them on a device.
199
220
 
221
+ ## App pieces (0.5.0)
222
+
223
+ Components and props that the Verve, vibot and Velth apps need. They follow the rest of the package: Instrument Sans with its weight as a style, `tokens` and `palette` colours as styles in light and dark, a step up with larger text, 44 pt targets, and roles, names and states for VoiceOver and TalkBack.
224
+
225
+ | Part | Notes |
226
+ | --- | --- |
227
+ | `ListRow` `leading`, `titleEnd`, `control`, `onLongPress`, `accessibilityLabel` | `leading` (any node, such as an avatar with an unread dot) replaces `icon`. `titleEnd` sits at the end of the title line. `control` sits outside the pressable area, so a `Switch` there does not fire `onPress`; `right` stays inside it. With a `control`, `testID` is on the pressable body. |
228
+ | `Card` `onPress` | The whole card is a button that fades while pressed, named by `title` or `accessibilityLabel`. It is one element for assistive tech, so put no other control in it. |
229
+ | `DateTimeField` | `mode="date"` for the date button only. `value` may be `null`: the buttons show `placeholder`, and the picker opens on today (or an earlier `maximumDate`). `onClear` adds a 44 clear button while there is a value. |
230
+ | `ColorSwatches` | A radio group of round swatches (`sm` 24, `md` 32, `lg` 40) with a foreground ring and a black or white check on the selected one. `labels` names each swatch. |
231
+ | `StatGrid` | Label and value tiles on the flat card, in rows of 2 or 3. |
232
+ | `SectionLabel` | The muted `text-xs` medium label above a group of rows; `uppercase` for capitals. Add `className="px-4"` to line it up with rows. |
233
+ | `SheetRow` | An action row for a `Sheet`: icon, title, detail; `active` adds a check, `danger` uses the danger colour, `disabled` dims it. |
234
+ | `PageIndicator` | The web carousel dots. With `onChange` each dot is a 44 button named "Page 2 of 5", so the dots sit 44 apart. |
235
+ | `StatusDot` `info` | A blue dot (`#3b82f6`, `#60a5fa` in dark), native only. |
236
+ | `StackPageHeader` | A see-through stack header for screens over `AmbientBackground`: back chevron, centred `PageTitle` with an optional description, and `headerRight`. Pads the top inset unless `safeTop={false}`. |
237
+ | `IconButton`, `CaptureButton` | A round icon button (`default` bordered surface, or `overlay` translucent black for camera chrome) and a 76 white camera shutter. `IconButton` is not the AI chat button. |
238
+
239
+ ```tsx
240
+ <ListRow
241
+ title="Ada Lovelace"
242
+ meta="See you at nine"
243
+ leading={<Avatar><AvatarFallback>AL</AvatarFallback></Avatar>}
244
+ titleEnd={<Badge variant="secondary">09:41</Badge>}
245
+ onPress={openThread}
246
+ onLongPress={showActions}
247
+ control={<Switch checked={pinned} onCheckedChange={setPinned} accessibilityLabel="Pin" />}
248
+ />
249
+
250
+ <DateTimeField mode="date" value={due} onChange={setDue} placeholder="No due date" onClear={() => setDue(null)} />
251
+
252
+ <AmbientBackground>
253
+ <StackPageHeader title="Details" onBack={router.back} headerRight={<ThemeToggle />} />
254
+ </AmbientBackground>
255
+ ```
256
+
257
+ `Text` from `@vvedantb/native-ui/primitives` is the variant text (`h1`–`h4`, `p`, `lead`, `large`, `small`, `muted`, `code`, `blockquote`). It is not on the root entry. Unlike the components, it takes its colour from NativeWind classes (`text-foreground`, `text-muted-foreground`); where the app defines `foreground` with a nested `dark` key, give it `style={{ color: tokens.color.foreground[scheme] }}` in dark mode.
258
+
200
259
  ## AI chat (0.4.0)
201
260
 
202
261
  `@vvedantb/native-ui/ai` ports the `@vvedantb/ui` AI elements for touch: the thread, messages, reasoning and agent trace folds, the composer, suggestions, sources, attachments and code. They are generic (no model names, no app logic) and say "the AI", "Thinking…", "Thought for 12s" and "Worked for 12s". They add no package.
@@ -269,13 +328,13 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
269
328
 
270
329
  ## Phones and accessibility
271
330
 
272
- The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
331
+ The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). From 0.5.0 so do `ColorSwatches` swatches and `IconButton` (32 and 40); the `DateTimeField` clear button and the pressable `PageIndicator` dots are 44 squares. Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
273
332
 
274
333
  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`, `Skeleton` and the `FloatingTabBar` pill 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.
275
334
 
276
335
  The brand marks (`APP_ICONS`, the `Icon*` exports, `AppMark` and `GoogleMark`) 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.
277
336
 
278
- `ThemeToggle` (from 0.3.16) is a `HeaderIconButton` that switches between light and dark with NativeWind's `setColorScheme`, the scheme every component reads through `useColorScheme`. It shows and names the scheme it switches to: a moon and "Switch to dark mode" in light, a sun and "Switch to light mode" in dark. It stores nothing: to restore the choice on the next launch, store `useColorScheme().colorScheme` in the app. It takes `testID`.
337
+ `ThemeToggle` (from 0.3.16) is a `HeaderIconButton` that switches between light and dark with NativeWind's `setColorScheme`, the scheme every component reads through `useColorScheme`. It shows and names the scheme it switches to: a moon and "Switch to dark mode" in light, a sun and "Switch to light mode" in dark. It stores nothing: to restore the choice on the next launch, store `useColorScheme().colorScheme` in the app. It takes `testID`. It needs `darkMode: "class"` (see [Theme](#theme)). Without it, from 0.5.0, a press logs that fix with `console.error` and leaves the scheme as it is, instead of throwing from the press handler.
279
338
 
280
339
  `GoogleMark` (from 0.3.15) is the four-colour Google "G", 18 by default, for a "Continue with Google" button: `<Button variant="outline" leftIcon={<GoogleMark />}>Continue with Google</Button>`. Its colours are fixed, so it takes no `color`. Leave it without a `title` there, as the label names the button.
281
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts and the family app marks.",
6
6
  "type": "module",
@@ -0,0 +1,62 @@
1
+ import { Pressable, View } from "react-native";
2
+ import { cn } from "./cn";
3
+ import { withAlpha } from "./palette";
4
+ import { tokens } from "./tokens";
5
+
6
+ export type CaptureButtonProps = {
7
+ onPress?: () => void;
8
+ disabled?: boolean;
9
+ testID?: string;
10
+ /** Default "Take photo". */
11
+ accessibilityLabel?: string;
12
+ };
13
+
14
+ /** Shutter metrics: outer diameter, ring width and the gap inside the ring. */
15
+ export const CAPTURE_BUTTON = { size: 76, ring: 4, gap: 4 } as const;
16
+
17
+ /**
18
+ * A camera shutter: a 76 white ring around a white disc, for a live preview in
19
+ * light and dark alike. The disc shrinks to 90% while pressed. Larger than the
20
+ * 44 target, so it needs no hit slop. A labelled button, dimmed and ignoring
21
+ * presses when `disabled`.
22
+ */
23
+ export function CaptureButton({
24
+ onPress,
25
+ disabled = false,
26
+ testID,
27
+ accessibilityLabel = "Take photo",
28
+ }: CaptureButtonProps) {
29
+ const { size, ring, gap } = CAPTURE_BUTTON;
30
+ const inner = size - 2 * (ring + gap);
31
+ return (
32
+ <Pressable
33
+ testID={testID}
34
+ accessibilityRole="button"
35
+ accessibilityLabel={accessibilityLabel}
36
+ accessibilityState={{ disabled }}
37
+ disabled={disabled}
38
+ onPress={onPress}
39
+ className={cn("items-center justify-center", disabled && "opacity-50")}
40
+ style={{
41
+ width: size,
42
+ height: size,
43
+ borderRadius: size / 2,
44
+ borderWidth: ring,
45
+ borderColor: tokens.color.white,
46
+ backgroundColor: withAlpha(tokens.color.black, 0.15),
47
+ }}
48
+ >
49
+ {({ pressed }) => (
50
+ <View
51
+ style={{
52
+ width: inner,
53
+ height: inner,
54
+ borderRadius: inner / 2,
55
+ backgroundColor: tokens.color.white,
56
+ transform: [{ scale: pressed && !disabled ? 0.9 : 1 }],
57
+ }}
58
+ />
59
+ )}
60
+ </Pressable>
61
+ );
62
+ }
package/src/Card.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import type { IconProps } from "@tabler/icons-react-native";
3
- import { Text, View, type TextProps, type ViewProps } from "react-native";
3
+ import { Pressable, Text, View, type TextProps, type ViewProps } from "react-native";
4
4
  import {
5
5
  Card as PrimitiveCard,
6
6
  CardContent as PrimitiveCardContent,
@@ -12,15 +12,7 @@ import { cardStyle, sizeStyle, textStyle, typeStyle } from "./controlStyles";
12
12
  import { useAccentColor, useLargerText } from "./theme";
13
13
  import { useScheme } from "./useScheme";
14
14
 
15
- export function Card({
16
- children,
17
- title,
18
- description,
19
- action,
20
- leftIcon: Icon,
21
- className,
22
- testID,
23
- }: {
15
+ export type CardProps = {
24
16
  children: ReactNode;
25
17
  title?: string;
26
18
  /** Muted line under the title (the web `CardDescription`). */
@@ -29,8 +21,28 @@ export function Card({
29
21
  leftIcon?: ComponentType<IconProps>;
30
22
  /** Classes on the card surface. */
31
23
  className?: string;
24
+ /**
25
+ * Makes the whole card a button that fades while pressed (from 0.5.0). It is
26
+ * then one element for VoiceOver and TalkBack, so put no other control in it.
27
+ */
28
+ onPress?: () => void;
29
+ /** Names the pressable card. Default `title`; pass one when there is no title. */
30
+ accessibilityLabel?: string;
31
+ /** On the button when there is `onPress`, otherwise on the card surface. */
32
32
  testID?: string;
33
- }) {
33
+ };
34
+
35
+ export function Card({
36
+ children,
37
+ title,
38
+ description,
39
+ action,
40
+ leftIcon: Icon,
41
+ className,
42
+ onPress,
43
+ accessibilityLabel,
44
+ testID,
45
+ }: CardProps) {
34
46
  const largerText = useLargerText();
35
47
  const accentColor = useAccentColor();
36
48
  const scheme = useScheme();
@@ -38,9 +50,9 @@ export function Card({
38
50
  const titleSize = largerText ? "text-2xl" : "text-base";
39
51
  // The web card: a flat `surfaceCard` fill at radius 16, with no border, no
40
52
  // shadow and no blur. Dialogs, sheets and toasts float, as on the web.
41
- return (
53
+ const card = (
42
54
  <PrimitiveCard
43
- testID={testID}
55
+ testID={onPress ? undefined : testID}
44
56
  className={cn("gap-4 overflow-hidden border-0 py-4", className)}
45
57
  style={cardStyle(scheme)}
46
58
  >
@@ -74,6 +86,18 @@ export function Card({
74
86
  </PrimitiveCardContent>
75
87
  </PrimitiveCard>
76
88
  );
89
+ if (!onPress) return card;
90
+ return (
91
+ <Pressable
92
+ testID={testID}
93
+ accessibilityRole="button"
94
+ accessibilityLabel={accessibilityLabel ?? title}
95
+ onPress={onPress}
96
+ className="active:opacity-70"
97
+ >
98
+ {card}
99
+ </Pressable>
100
+ );
77
101
  }
78
102
 
79
103
  // Parts for composing a card body with the web part names. They sit inside
@@ -0,0 +1,133 @@
1
+ import { Pressable, View } from "react-native";
2
+ import { IconCheck } from "@tabler/icons-react-native";
3
+ import { cn } from "./cn";
4
+ import { touchSlop } from "./controlStyles";
5
+ import { selectionHaptic } from "./haptics";
6
+ import { withAlpha } from "./palette";
7
+ import { useLargerText } from "./theme";
8
+ import { tokens } from "./tokens";
9
+ import { useScheme } from "./useScheme";
10
+
11
+ export type ColorSwatchesSize = "sm" | "md" | "lg";
12
+
13
+ export type ColorSwatchesProps = {
14
+ /** The selected colour: one of `colors`, compared without case. */
15
+ value: string;
16
+ onChange: (color: string) => void;
17
+ /** `#rgb` or `#rrggbb` colours. */
18
+ colors: string[];
19
+ /** Swatch diameter: `sm` 24, `md` 32, `lg` 40. Default `md`. */
20
+ size?: ColorSwatchesSize;
21
+ /** Per-swatch accessibility labels; falls back to the colour string */
22
+ labels?: string[];
23
+ /** Names the group, such as "Accent colour". */
24
+ accessibilityLabel?: string;
25
+ testID?: string;
26
+ disabled?: boolean;
27
+ };
28
+
29
+ /** Swatch diameters, one step up with larger text. */
30
+ const DIAMETER = {
31
+ regular: { sm: 24, md: 32, lg: 40 },
32
+ larger: { sm: 32, md: 40, lg: 48 },
33
+ } as const;
34
+
35
+ /** Selection ring width and the gap between the ring and the swatch. */
36
+ const RING = 2;
37
+ const RING_GAP = 2;
38
+
39
+ /**
40
+ * The check colour on a swatch: black or white, whichever has the higher WCAG
41
+ * contrast with it. `null` when the colour is not `#rgb` or `#rrggbb`, and the
42
+ * ring alone marks the selection.
43
+ */
44
+ export function swatchCheckColor(color: string): string | null {
45
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color);
46
+ const hex = short ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}` : color;
47
+ if (!/^#[0-9a-f]{6}$/i.test(hex)) return null;
48
+ const n = Number.parseInt(hex.slice(1), 16);
49
+ const linear = (channel: number) => {
50
+ const c = channel / 255;
51
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
52
+ };
53
+ const luminance =
54
+ 0.2126 * linear((n >> 16) & 255) + 0.7152 * linear((n >> 8) & 255) + 0.0722 * linear(n & 255);
55
+ // Contrast with white is 1.05 / (L + 0.05); with black, (L + 0.05) / 0.05.
56
+ return 1.05 / (luminance + 0.05) >= (luminance + 0.05) / 0.05 ? tokens.color.white : tokens.color.black;
57
+ }
58
+
59
+ /**
60
+ * Pick one colour from a row of round swatches that wraps. The selected swatch
61
+ * has a ring in the foreground colour and a check in black or white, whichever
62
+ * reads on it. Each swatch has a faint outline, so white shows on a white
63
+ * surface. A radio group for VoiceOver and TalkBack: each swatch is a radio
64
+ * named by `labels` (or its colour string). A `md` swatch is 32 across and 40
65
+ * with its ring, and reaches 44 with its hit slop; the 12 gap keeps the slops
66
+ * of neighbouring swatches apart.
67
+ */
68
+ export function ColorSwatches({
69
+ value,
70
+ onChange,
71
+ colors,
72
+ size = "md",
73
+ labels,
74
+ accessibilityLabel,
75
+ testID,
76
+ disabled = false,
77
+ }: ColorSwatchesProps) {
78
+ const scheme = useScheme();
79
+ const largerText = useLargerText();
80
+ const diameter = DIAMETER[largerText ? "larger" : "regular"][size];
81
+ const outer = diameter + 2 * (RING + RING_GAP);
82
+ const selected = value.toLowerCase();
83
+ return (
84
+ <View
85
+ testID={testID}
86
+ accessibilityRole="radiogroup"
87
+ accessibilityLabel={accessibilityLabel}
88
+ className={cn("flex-row flex-wrap gap-3", disabled && "opacity-50")}
89
+ >
90
+ {colors.map((color, index) => {
91
+ const isSelected = color.toLowerCase() === selected;
92
+ const check = isSelected ? swatchCheckColor(color) : null;
93
+ return (
94
+ <Pressable
95
+ key={`${color}-${index}`}
96
+ testID={testID ? `${testID}-${index}` : undefined}
97
+ accessibilityRole="radio"
98
+ accessibilityLabel={labels?.[index] ?? color}
99
+ accessibilityState={{ checked: isSelected, disabled }}
100
+ hitSlop={touchSlop(outer)}
101
+ disabled={disabled}
102
+ onPress={() => {
103
+ selectionHaptic();
104
+ onChange(color);
105
+ }}
106
+ className="items-center justify-center active:opacity-70"
107
+ style={{
108
+ width: outer,
109
+ height: outer,
110
+ borderRadius: outer / 2,
111
+ borderWidth: RING,
112
+ borderColor: isSelected ? tokens.color.foreground[scheme] : "transparent",
113
+ }}
114
+ >
115
+ <View
116
+ className="items-center justify-center"
117
+ style={{
118
+ width: diameter,
119
+ height: diameter,
120
+ borderRadius: diameter / 2,
121
+ backgroundColor: color,
122
+ borderWidth: 1,
123
+ borderColor: withAlpha(tokens.color.foreground[scheme], 0.12),
124
+ }}
125
+ >
126
+ {check ? <IconCheck size={Math.round(diameter / 2)} color={check} strokeWidth={2} /> : null}
127
+ </View>
128
+ </Pressable>
129
+ );
130
+ })}
131
+ </View>
132
+ );
133
+ }
@@ -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 { textStyle, typeStyle } from "./controlStyles";
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 timeButton: PickerButton = {
37
- mode: "time",
38
- label: dayjs(value).format("HH:mm"),
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
- ? [{ mode: "date", label: dayjs(value).format("D MMM YYYY") }, timeButton]
43
- : [timeButton];
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 bg-secondary px-4 py-2 active:opacity-70`}
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={[textStyle("foreground", scheme), typeStyle("semibold")]}
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}
@@ -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 border-white/40 bg-surface dark:border-white/10`}
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}