@vvedantb/native-ui 0.7.0 → 0.9.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 +107 -7
- package/package.json +35 -8
- package/src/BulkActionBar.tsx +303 -0
- package/src/DividedList.tsx +37 -0
- package/src/DropdownMenu.tsx +439 -0
- package/src/FloatingFAB.tsx +4 -1
- package/src/ai/activity-step-detail.tsx +95 -0
- package/src/ai/choice-question.tsx +297 -0
- package/src/ai/code-block.tsx +50 -7
- package/src/ai/index.ts +10 -0
- package/src/ai/message.tsx +59 -12
- package/src/ai/prompt-input.tsx +79 -28
- package/src/ai/trace.tsx +32 -1
- package/src/configureTheme.ts +60 -0
- package/src/entries/layout.ts +18 -0
- package/src/entries/markdown.ts +5 -0
- package/src/entries/primitives.ts +22 -0
- package/src/entries/theme.ts +2 -0
- package/src/examples/ParityGallery.tsx +67 -0
- package/src/floating.ts +2 -4
- package/src/highlight.ts +117 -0
- package/src/index.ts +42 -0
- package/src/markdown/Markdown.tsx +191 -0
- package/src/push.ts +186 -0
- package/src/settings/MoreFromProjectV.tsx +105 -0
- package/src/settings/projectVMobileApps.ts +145 -0
package/README.md
CHANGED
|
@@ -30,14 +30,17 @@ Optional and loaded only when installed, so the package imports and runs without
|
|
|
30
30
|
|
|
31
31
|
- `expo-blur` — glass blur on `FloatingSurface` and the chrome built on it. Without it, those surfaces draw a solid fill.
|
|
32
32
|
- `expo-haptics` — the selection haptic on `FloatingTabBar`, `Tabs` and `Checkbox` presses. Without it, presses are silent.
|
|
33
|
+
- `expo-notifications`, `expo-constants`, `expo-device` — only for `@vvedantb/native-ui/push` (0.9.0). Without them, `useExpoPushRegistration` does nothing and `getExpoPushToken` returns `null`.
|
|
33
34
|
- `expo-linear-gradient` — the gradient in `AmbientBackground`. Without it, the background is the solid `tokens.color.background`.
|
|
35
|
+
- `prism-react-renderer` (`>=2.0.0`) — syntax highlighting in `CodeBlock` and the `Markdown` code fences. Without it, code is plain text.
|
|
34
36
|
|
|
35
|
-
The package loads these
|
|
37
|
+
The package loads these with a `require` inside `try`. Metro treats that as an optional dependency when `transformer.allowOptionalDependencies` is on, which is the default in the Expo and React Native Metro configs. `@vvedantb/native-ui/blur` exports the loader (`loadBlur`) and `@vvedantb/native-ui/haptics` exports `loadHaptics` and `selectionHaptic`. TypeScript still resolves their types: an app that type-checks without them reports a missing module in `src/blur.ts`, `src/haptics.ts`, `src/gradient.ts` or `src/highlight.ts`. Declare the missing ones in a `.d.ts` file in the app:
|
|
36
38
|
|
|
37
39
|
```ts
|
|
38
40
|
declare module "expo-blur";
|
|
39
41
|
declare module "expo-haptics";
|
|
40
42
|
declare module "expo-linear-gradient";
|
|
43
|
+
declare module "prism-react-renderer";
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
`expo-router` stays an optional peer, but the package does not import it. `FloatingTabBar` declares the props it reads (`FloatingTabBarProps`) and fits the `tabBar` prop of Expo Router's `Tabs` on Expo SDK 57. Expo SDK 58 passes `emitter` and `navigateToTab` to `tabBar` in place of `navigation`, so the bar does not fit it yet.
|
|
@@ -62,16 +65,17 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
62
65
|
| `@vvedantb/native-ui/tab-bar` | `FloatingTabBar`, `FloatingTabBarProps`, `FLOATING_INSET`, `FLOATING_TAB_BAR_HEIGHT`, `FLOATING_TAB_BAR_HEIGHT_LARGE`, `tabBarReserve` | `expo-blur`, `expo-haptics` (loaders) |
|
|
63
66
|
| `@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 |
|
|
64
67
|
| `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonShape` (0.6.0), `ButtonSize`, `ButtonVariant` | none |
|
|
65
|
-
| `@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. From 0.6.0: the `ButtonShape` type | `expo-blur`, `expo-haptics` (loaders) |
|
|
68
|
+
| `@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. From 0.6.0: the `ButtonShape` type. Unreleased: `DropdownMenu` and its parts, `BulkActionBar` | `expo-blur`, `expo-haptics` (loaders) |
|
|
66
69
|
| `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
|
|
67
70
|
| `@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 |
|
|
68
71
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
69
|
-
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
72
|
+
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `configureTheme` (with the `ThemeConfig`, `ThemeColors` and `ThemePalette` types), `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
70
73
|
| `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
|
|
71
|
-
| `@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) |
|
|
74
|
+
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0) | `expo-blur` (loader) |
|
|
72
75
|
| `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
|
|
73
76
|
| `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
|
|
74
77
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
78
|
+
| `@vvedantb/native-ui/push` | `useExpoPushRegistration`, `getExpoPushToken`, `showPushesInForeground`, the three loaders and the types (0.9.0). Pass `register` from your backend (for `@vvedantb/server`: `useMutation(api.push.register)`) and route taps in `onNotificationTap` | `expo-notifications`, `expo-constants`, `expo-device` (loaders) |
|
|
75
79
|
| `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
|
|
76
80
|
| `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
|
|
77
81
|
| `@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 |
|
|
@@ -229,6 +233,34 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
|
|
|
229
233
|
|
|
230
234
|
**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.
|
|
231
235
|
|
|
236
|
+
### App colours (`configureTheme`)
|
|
237
|
+
|
|
238
|
+
An app with its own palette calls `configureTheme` once at start, before the first render: at the top of the root `_layout.tsx`, outside the component. It writes into the shared `tokens.color` and `palette` objects, which every component reads at render. Keys you leave out keep the package value. Each pair needs both schemes. On the root entry and `/theme`.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { configureTheme } from "@vvedantb/native-ui/theme";
|
|
242
|
+
|
|
243
|
+
// Eva's web theme (`globals.css`, RGB channels as hex).
|
|
244
|
+
configureTheme({
|
|
245
|
+
color: {
|
|
246
|
+
background: { light: "#f4f5f6", dark: "#020202" }, // --background
|
|
247
|
+
foreground: { light: "#09090b", dark: "#fafafa" }, // --foreground
|
|
248
|
+
muted: { light: "#646567", dark: "#9fa0a2" }, // --muted-foreground
|
|
249
|
+
border: { light: "#dddedf", dark: "#242424" }, // --border
|
|
250
|
+
surface: { light: "#ffffff", dark: "#101010" }, // --card
|
|
251
|
+
surfaceSecondary: { light: "#eaebec", dark: "#202020" }, // --secondary
|
|
252
|
+
},
|
|
253
|
+
palette: {
|
|
254
|
+
primary: { light: "#09090b", dark: "#fafafa" }, // --primary
|
|
255
|
+
primaryForeground: { light: "#fafafa", dark: "#09090b" }, // --primary-foreground
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The keys map to the shadcn-style variables as follows. `tokens.color`: `background` (`--background`), `foreground` (`--foreground`), `muted` (`--muted-foreground`), `border` and `separator` (`--border`), `surface` and `surfaceCard` (`--card`), `surfaceSecondary` (`--secondary`). `palette`: `primary` (`--primary`), `danger` (`--destructive`), `default` (`--secondary`), `overlay` (`--popover`), each with its `Foreground`, and `fieldPlaceholder` (`--muted-foreground`).
|
|
261
|
+
|
|
262
|
+
Limits: a call after the first render does not redraw mounted components. Only the JS colours change. NativeWind classes keep the app's Tailwind theme, and `nativeUiPreset({ injectCssVariables: true })` still writes the package values. Constants copied out at import (`NEUTRAL_500`) keep the package value; `floatingFill`, `floatingHairline` and `floatingSolidFill` follow. The `tokens` and `palette` types still name the package hex values.
|
|
263
|
+
|
|
232
264
|
`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, field 10, pill 999; rows 20) and spacing (16). `Button` is a pill (999) by default, and 16 with `shape="rounded"`.
|
|
233
265
|
|
|
234
266
|
## Components (0.3.0)
|
|
@@ -238,7 +270,8 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
|
|
|
238
270
|
| Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`; `shape` `pill`/`rounded` from 0.6.0), `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) |
|
|
239
271
|
| 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) |
|
|
240
272
|
| Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `PageIndicator` (0.5.0), `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16), `StackPageHeader` (0.5.0) |
|
|
241
|
-
| Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `SheetRow` (0.5.0), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
|
|
273
|
+
| Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `SheetRow` (0.5.0), `BottomSheetContainer` (`@gorhom/bottom-sheet`), `DropdownMenu` (Unreleased; see below) |
|
|
274
|
+
| Selection | `BulkActionBar` (Unreleased; see below) |
|
|
242
275
|
| Feedback | `toast()` and `<Toaster />` |
|
|
243
276
|
| Brand and upgrade | `APP_ICONS`, `IconVibot` and the other `Icon*`, `AppMark`, `AppWordmark`, `GoogleMark` (0.3.15), `FAMILY_APPS`, `V_PRO_APP_BENEFITS`, `UpgradeToProCard`, `PricingModal` |
|
|
244
277
|
| Settings and layout | `PageTitle`, `PageHeader` (0.3.13), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `ScreenContainer`, `SafeAreaContainer`, `FloatingSurface`, `AmbientBackground` |
|
|
@@ -302,6 +335,10 @@ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density wi
|
|
|
302
335
|
|
|
303
336
|
`@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.
|
|
304
337
|
|
|
338
|
+
## More from Project V (0.8.0)
|
|
339
|
+
|
|
340
|
+
`<MoreFromProjectV current="velth" />` is a settings section listing the other Project V phone apps (vibot, Verve, Velth, vmem, V Store). A row opens the app if installed, else V Store's home (`vstore://`), else its website. Add the other apps' schemes to `LSApplicationQueriesSchemes` (iOS) and `<queries>` (Android 11+), or `canOpenURL` returns `false`. `<FromProjectV />` (or `variant="footer"`) is a small "from Project V" line. `openProjectVApp(id, Linking, Platform.OS)` and `PROJECT_V_MOBILE_APPS` are exported for custom rows.
|
|
341
|
+
|
|
305
342
|
## App pieces (0.5.0)
|
|
306
343
|
|
|
307
344
|
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.
|
|
@@ -314,6 +351,7 @@ Components and props that the Verve, vibot and Velth apps need. They follow the
|
|
|
314
351
|
| `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. |
|
|
315
352
|
| `StatGrid` | Label and value tiles on the flat card, in rows of 2 or 3. |
|
|
316
353
|
| `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. |
|
|
354
|
+
| `DividedList` | Flat rows with a hairline between each pair, as the web `divide-y divide-border` lists. `inset` starts the dividers under the row text. |
|
|
317
355
|
| `SheetRow` | An action row for a `Sheet`: icon, title, detail; `active` adds a check, `danger` uses the danger colour, `disabled` dims it. |
|
|
318
356
|
| `PageIndicator` | The web carousel dots. With `onChange` each dot is a 44 button named "Page 2 of 5", so the dots sit 44 apart. |
|
|
319
357
|
| `StatusDot` `info` | A blue dot (`#3b82f6`, `#60a5fa` in dark), native only. |
|
|
@@ -395,14 +433,21 @@ import {
|
|
|
395
433
|
| `ActivityStep` | One tool step as a trace line: icon, label, a spinner while `running`, a check when `complete`, a warning on `error`, and a description such as `` `Worked for ${formatElapsed(12)}` ``. |
|
|
396
434
|
| `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
|
|
397
435
|
| `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls. Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
|
|
436
|
+
| `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
|
|
398
437
|
| `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
|
|
399
438
|
| `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
|
|
400
439
|
| `FileChip`, `Attachments`, `Attachment` | A monospace file chip (a button with `onOpenFile`), and attachment chips with a media icon or image thumbnail and a labelled remove button. No hover cards and no grid previews. |
|
|
401
|
-
| `
|
|
440
|
+
| `ChoiceQuestion`, `choiceAnswers` | An agent's blocking multiple-choice question, as Eva's web question dock: question with a header badge and step count, lettered option rows with descriptions (a check on the primary fill when picked), "Other..." with a text field, then Back and Next or Submit. `onSubmit` gets question text to label (multi-select joined with ", "), the AskUserQuestion answer shape. `framed` (default) puts it in the dock card. |
|
|
441
|
+
| `AgentTraceFold` | A finished turn folded behind "Worked for 1m 5s" over a hairline, as the web `ActivityTasks` folded mode; the steps show in an `AgentTrace` when open. |
|
|
442
|
+
| `MessageMeta` | The muted clock after a message's actions ("10:21 PM · 1m"), caption size, tabular figures. |
|
|
443
|
+
| `ActivityStepDetail` | A tool step's body, as the web: exit code and "Truncated" badges, the shell transcript in a `CodeBlock`, red/green edit blocks, a write preview, and "Loading output..." while trimmed detail loads. |
|
|
444
|
+
| `CodeBlock`, `CodeBlockCopyButton` | Monospace code on the flat panel, scrolling sideways, with the language and a copy button in a header. With the optional peer `prism-react-renderer` and a `language`, tokens take the GitHub light or dark colours of the web shiki block; `highlight={false}` turns that off. Without the peer, plain text. |
|
|
402
445
|
| `ResponseText` | Plain, selectable reply text with newlines kept. It takes the colour of the `MessageContent` it is in. |
|
|
403
446
|
| `Shimmer` | "Live" text: the foreground fades to 0.45 and back on the UI thread. With Reduce Motion it holds still, muted. |
|
|
404
447
|
|
|
405
|
-
**Markdown.**
|
|
448
|
+
**Markdown.** `ResponseText` draws text as it is. For markdown, install the optional peer `react-native-marked` and use `Markdown` from `@vvedantb/native-ui/markdown` in place of `ResponseText`: `<Markdown onCopyCode={copy}>{reply}</Markdown>`. It follows the web `markdown.css`: compact headings (1.43, 1.29, 1.14 em), primary links underlined at 30%, inline code on a 6% foreground wash, and fenced code as `CodeBlock`. Text takes the `MessageContent` colour. It renders through `useMarkdown`, so it nests in `ConversationContent` without a `FlatList`. Fences with a language are highlighted when `prism-react-renderer` is installed. Other markdown packages (`react-native-markdown-display`, `@ronradtke/react-native-markdown-display`) still work in place of `ResponseText`.
|
|
449
|
+
|
|
450
|
+
**Eva's tinted bubble.** `MessageContent bubble="tint"` draws a user message as Eva's web chat does (`rounded-surface bg-primary/10 px-3 py-2 text-foreground`): a 10% wash of the primary colour with foreground text, radius 16 on every corner. The default, `"primary"`, stays the web `MessageContent` bubble.
|
|
406
451
|
|
|
407
452
|
**Clipboard.** React Native's own `Clipboard` is deprecated (moved out of core), and the package adds no clipboard package. `MessageAction kind="copy"` calls your `onPress`, and `CodeBlock` shows its copy button when given `onCopy(code)`. Pass your own clipboard call, for example `expo-clipboard`'s `setStringAsync`.
|
|
408
453
|
|
|
@@ -410,6 +455,61 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
|
|
|
410
455
|
|
|
411
456
|
`@vvedantb/native-ui/ai-gallery` exports `AiChatGallery`, a full chat screen with a sample thread: a user message, a finished assistant message with folded reasoning and trace, code, sources and actions, and a second answer that streams with its reasoning open until you press Stop. It also has suggestions and the composer with sample attachments. Put it in a dev-only route with a `SafeAreaProvider` above it. `ParityGallery` shows the same thread (`AiChatSamples`) in its "AI chat" section.
|
|
412
457
|
|
|
458
|
+
## DropdownMenu (Unreleased)
|
|
459
|
+
|
|
460
|
+
A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root.
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
<DropdownMenu>
|
|
464
|
+
<DropdownMenuTrigger accessibilityLabel="List options">
|
|
465
|
+
<IconDots size={18} color={tokens.color.muted[scheme]} />
|
|
466
|
+
</DropdownMenuTrigger>
|
|
467
|
+
<DropdownMenuContent align="end">
|
|
468
|
+
<DropdownMenuLabel>Sort</DropdownMenuLabel>
|
|
469
|
+
<DropdownMenuRadioItem checked={sort === "new"} onSelect={() => setSort("new")}>Newest first</DropdownMenuRadioItem>
|
|
470
|
+
<DropdownMenuRadioItem checked={sort === "old"} onSelect={() => setSort("old")}>Oldest first</DropdownMenuRadioItem>
|
|
471
|
+
<DropdownMenuSeparator />
|
|
472
|
+
<DropdownMenuItem icon={IconPencil} onSelect={rename}>Rename</DropdownMenuItem>
|
|
473
|
+
<DropdownMenuItem icon={IconTrash} destructive onSelect={remove}>Delete</DropdownMenuItem>
|
|
474
|
+
</DropdownMenuContent>
|
|
475
|
+
</DropdownMenu>
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
| Part | Props |
|
|
479
|
+
| --- | --- |
|
|
480
|
+
| `DropdownMenu` | `open`, `defaultOpen`, `onOpenChange`. Holds the open state; renders no view. |
|
|
481
|
+
| `DropdownMenuTrigger` | `Pressable` props and `asChild`. On press it measures itself (`measureInWindow`) and opens the menu; pressed again it closes it. Reports `expanded`. With `asChild` the child must pass `ref` on to its native view (`Button` and `IconButton` do). |
|
|
482
|
+
| `DropdownMenuContent` | `align` (`"start"`, default, or `"end"`), `sideOffset` (default 4), `View` props. A transparent RN `Modal`; the menu sits under the trigger and flips above it when it does not fit below. |
|
|
483
|
+
| `DropdownMenuLabel` | `children`. |
|
|
484
|
+
| `DropdownMenuItem` | `children`, `onSelect`, `icon` (a Tabler icon), `destructive`, `disabled`, `closeOnSelect` (default `true`). |
|
|
485
|
+
| `DropdownMenuRadioItem` | The same without `icon` and `destructive`, plus `checked`. |
|
|
486
|
+
| `DropdownMenuSeparator` | none. |
|
|
487
|
+
|
|
488
|
+
Sizes come from the web menu classes: content at least 192 wide, `p-1.5`, radius 16, on the opaque overlay surface of `Dialog` and `Sheet` (`FloatingSurface` without blur); items `px-2.5 py-2` at radius 8, `text-sm` with a 16 icon, at least 44 high; radio items `pl-8` with a 16 check; the label `text-xs` semibold, uppercase, `tracking-widest`, muted; the separator a 1-high `separator` line. Items are `palette.overlayForeground` (`palette.danger` when `destructive`) and take the `default` fill while pressed. A tap outside, Android back and the VoiceOver escape gesture close the menu. Roles: `menu`, `menuitem`, `radio` (with `checked`), `header`. With larger text, items are `text-lg` and 56 high. No sub-menus, checkbox items, shortcuts or `align="center"`.
|
|
489
|
+
|
|
490
|
+
## BulkActionBar (Unreleased)
|
|
491
|
+
|
|
492
|
+
The floating selection bar, as the web `BulkActionBar`. On `/primitives` and the root. Put it last in a full-size container: it sits at the bottom, centred.
|
|
493
|
+
|
|
494
|
+
```tsx
|
|
495
|
+
<BulkActionBar
|
|
496
|
+
isSelecting={selecting}
|
|
497
|
+
selectedCount={selected.size}
|
|
498
|
+
totalCount={rows.length}
|
|
499
|
+
onExitSelect={exit}
|
|
500
|
+
onSelectAll={selectAll}
|
|
501
|
+
onClearSelection={clear}
|
|
502
|
+
primaryActions={[{ key: "archive", label: "Archive tasks", shortLabel: "Archive", icon: IconArchive, onClick: archive }]}
|
|
503
|
+
moreActions={[{ key: "move", label: "Move to project", icon: IconFolder, onClick: move }]}
|
|
504
|
+
destructiveAction={{ key: "delete", label: "Delete tasks", shortLabel: "Delete", icon: IconTrash, destructive: true, onClick: remove }}
|
|
505
|
+
bottomOffset={tabBarReserve(largerText, insets.bottom)}
|
|
506
|
+
/>
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Props are the web names: `isSelecting`, `selectedCount`, `totalCount`, `onExitSelect`, `onSelectAll`, `onClearSelection`, `primaryActions`, `moreActions`, `destructiveAction` and `barKey` (here the `testID`). Each `BulkBarAction` has `key`, `label`, `shortLabel`, `icon`, `destructive`, `showSkipHint` (no effect on a phone) and `onClick`, plus a native-only `disabled`. Native also takes `bottomOffset`: the space under the bar before its 12 gap, by default the bottom safe-area inset. Above a `FloatingTabBar`, pass `tabBarReserve(largerText, insets.bottom)`.
|
|
510
|
+
|
|
511
|
+
The bar is the glass `FloatingSurface` at radius 16 with `px-2.5 py-2`. It holds the count chip (`text-xs` semibold, tabular figures, on `surface-secondary`), "Select all (n)" or "Clear", a divider, the actions, a More (dots) `DropdownMenu`, the destructive action after a divider, and "Cancel selection" (X). Buttons are the web `h-9` at radius 16, at least 44 wide and slopped to 44 high, with a 17 icon. Below 640 wide (the web `sm`) the actions are icon-only and "selected" is hidden, as on the web; labels stay as the accessible names. Actions are off (30% opacity) with no selection. It fades in from below over 150 ms (off with Reduce Motion) and scrolls sideways when it does not fit. With larger text, buttons are 48 high with a 22 icon and `text-lg`. No count pop animation or tooltip.
|
|
512
|
+
|
|
413
513
|
## Phones and accessibility
|
|
414
514
|
|
|
415
515
|
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`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
|
|
6
6
|
"type": "module",
|
|
@@ -93,10 +93,18 @@
|
|
|
93
93
|
"types": "./src/haptics.ts",
|
|
94
94
|
"default": "./src/haptics.ts"
|
|
95
95
|
},
|
|
96
|
+
"./push": {
|
|
97
|
+
"types": "./src/push.ts",
|
|
98
|
+
"default": "./src/push.ts"
|
|
99
|
+
},
|
|
96
100
|
"./auth": {
|
|
97
101
|
"types": "./src/entries/auth.ts",
|
|
98
102
|
"default": "./src/entries/auth.ts"
|
|
99
103
|
},
|
|
104
|
+
"./markdown": {
|
|
105
|
+
"types": "./src/entries/markdown.ts",
|
|
106
|
+
"default": "./src/entries/markdown.ts"
|
|
107
|
+
},
|
|
100
108
|
"./expo-web-root": {
|
|
101
109
|
"types": "./src/entries/expo-web-root.ts",
|
|
102
110
|
"default": "./src/entries/expo-web-root.ts"
|
|
@@ -114,12 +122,6 @@
|
|
|
114
122
|
"LICENSE"
|
|
115
123
|
],
|
|
116
124
|
"sideEffects": false,
|
|
117
|
-
"scripts": {
|
|
118
|
-
"build": "node -e \"require('node:fs').accessSync('src/index.ts')\"",
|
|
119
|
-
"test": "vitest run",
|
|
120
|
-
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
121
|
-
"prepack": "node -e \"require('node:fs').accessSync('src/index.ts')\""
|
|
122
|
-
},
|
|
123
125
|
"peerDependencies": {
|
|
124
126
|
"@clerk/clerk-expo": "*",
|
|
125
127
|
"@expo/vector-icons": "*",
|
|
@@ -135,14 +137,19 @@
|
|
|
135
137
|
"dayjs": ">=1.11.0",
|
|
136
138
|
"expo-auth-session": "*",
|
|
137
139
|
"expo-blur": "*",
|
|
140
|
+
"expo-constants": "*",
|
|
141
|
+
"expo-device": "*",
|
|
138
142
|
"expo-haptics": "*",
|
|
139
143
|
"expo-linear-gradient": "*",
|
|
144
|
+
"expo-notifications": "*",
|
|
140
145
|
"expo-router": "*",
|
|
141
146
|
"expo-web-browser": "*",
|
|
142
147
|
"lucide-react-native": "*",
|
|
143
148
|
"nativewind": ">=4.0.0",
|
|
149
|
+
"prism-react-renderer": ">=2.0.0",
|
|
144
150
|
"react": ">=19.0.0",
|
|
145
151
|
"react-native": ">=0.76.0",
|
|
152
|
+
"react-native-marked": ">=8.0.0",
|
|
146
153
|
"react-native-reanimated": "*",
|
|
147
154
|
"react-native-safe-area-context": "*",
|
|
148
155
|
"react-native-screens": "*",
|
|
@@ -171,18 +178,33 @@
|
|
|
171
178
|
"expo-blur": {
|
|
172
179
|
"optional": true
|
|
173
180
|
},
|
|
181
|
+
"expo-constants": {
|
|
182
|
+
"optional": true
|
|
183
|
+
},
|
|
184
|
+
"expo-device": {
|
|
185
|
+
"optional": true
|
|
186
|
+
},
|
|
174
187
|
"expo-haptics": {
|
|
175
188
|
"optional": true
|
|
176
189
|
},
|
|
177
190
|
"expo-linear-gradient": {
|
|
178
191
|
"optional": true
|
|
179
192
|
},
|
|
193
|
+
"expo-notifications": {
|
|
194
|
+
"optional": true
|
|
195
|
+
},
|
|
180
196
|
"expo-router": {
|
|
181
197
|
"optional": true
|
|
182
198
|
},
|
|
183
199
|
"expo-web-browser": {
|
|
184
200
|
"optional": true
|
|
185
201
|
},
|
|
202
|
+
"prism-react-renderer": {
|
|
203
|
+
"optional": true
|
|
204
|
+
},
|
|
205
|
+
"react-native-marked": {
|
|
206
|
+
"optional": true
|
|
207
|
+
},
|
|
186
208
|
"react-native-svg": {
|
|
187
209
|
"optional": true
|
|
188
210
|
}
|
|
@@ -192,5 +214,10 @@
|
|
|
192
214
|
"react": "^19.1.0",
|
|
193
215
|
"typescript": "^5.9.0",
|
|
194
216
|
"vitest": "^3.2.0"
|
|
217
|
+
},
|
|
218
|
+
"scripts": {
|
|
219
|
+
"build": "node -e \"require('node:fs').accessSync('src/index.ts')\"",
|
|
220
|
+
"test": "vitest run",
|
|
221
|
+
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
195
222
|
}
|
|
196
|
-
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Pressable, ScrollView, Text, View, useWindowDimensions, type ViewStyle } from "react-native";
|
|
3
|
+
import { FadeInUp, FadeOutDown } from "react-native-reanimated";
|
|
4
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
|
+
import { IconDots, IconX, type IconProps } from "@tabler/icons-react-native";
|
|
6
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./DropdownMenu";
|
|
7
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
8
|
+
import { MIN_TOUCH_TARGET, sizeStyle, touchSlop, typeStyle } from "./controlStyles";
|
|
9
|
+
import { palette, withAlpha } from "./palette";
|
|
10
|
+
import { useLargerText } from "./theme";
|
|
11
|
+
import { tokens } from "./tokens";
|
|
12
|
+
import { useScheme } from "./useScheme";
|
|
13
|
+
import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One control in the bar, as the web `BulkBarAction`. Each action carries the
|
|
17
|
+
* handler it runs, so confirm dialogs stay with the caller.
|
|
18
|
+
*/
|
|
19
|
+
export interface BulkBarAction {
|
|
20
|
+
key: string;
|
|
21
|
+
/** Full label: the More menu item and the accessible name. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Shorter label shown in the bar on wide screens (defaults to `label`). */
|
|
24
|
+
shortLabel?: string;
|
|
25
|
+
/** A Tabler icon, drawn at 17 in the button colour. */
|
|
26
|
+
icon: ComponentType<IconProps>;
|
|
27
|
+
/** Label and icon in the danger colour. */
|
|
28
|
+
destructive?: boolean;
|
|
29
|
+
/** The web Alt-to-skip hint. No effect on a phone (no Alt key). */
|
|
30
|
+
showSkipHint?: boolean;
|
|
31
|
+
/** Native only: turns this action off even with a selection. */
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
onClick: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BulkActionBarProps {
|
|
37
|
+
isSelecting: boolean;
|
|
38
|
+
selectedCount: number;
|
|
39
|
+
totalCount: number;
|
|
40
|
+
onExitSelect: () => void;
|
|
41
|
+
onSelectAll: () => void;
|
|
42
|
+
onClearSelection: () => void;
|
|
43
|
+
primaryActions: ReadonlyArray<BulkBarAction>;
|
|
44
|
+
/** Behind a More (dots) button, in a `DropdownMenu`. */
|
|
45
|
+
moreActions?: ReadonlyArray<BulkBarAction>;
|
|
46
|
+
/** On the right, after a divider. */
|
|
47
|
+
destructiveAction?: BulkBarAction;
|
|
48
|
+
/** The web motion key. Here the bar's `testID`. */
|
|
49
|
+
barKey?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Space under the bar, in points, before its 12 gap. Default: the bottom
|
|
52
|
+
* safe-area inset. Above a `FloatingTabBar`, pass
|
|
53
|
+
* `tabBarReserve(largerText, insets.bottom)`.
|
|
54
|
+
*/
|
|
55
|
+
bottomOffset?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The web `sm` breakpoint: below it, bar buttons are icon-only and "selected" is hidden. */
|
|
59
|
+
export const BULK_BAR_LABEL_MIN_WIDTH = 640;
|
|
60
|
+
|
|
61
|
+
/** Web `h-9` buttons with a 17 icon and `text-sm`; with larger text 48 high, a 22 icon and `text-lg`. */
|
|
62
|
+
function barMetrics(largerText: boolean) {
|
|
63
|
+
return largerText
|
|
64
|
+
? { height: 48, icon: 22, text: sizeStyle("text-lg"), caption: sizeStyle("text-base") }
|
|
65
|
+
: { height: 36, icon: 17, text: sizeStyle("text-sm"), caption: sizeStyle("text-xs") };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The floating selection bar, as the web `BulkActionBar`: a pill-ended glass
|
|
70
|
+
* bar (`FloatingSurface`, radius 16, `px-2.5 py-2`, `gap-1`) centred at the
|
|
71
|
+
* bottom of its container, 12 above `bottomOffset`. It holds the selected
|
|
72
|
+
* count chip, Select all (n) / Clear, a divider, the primary actions, a More
|
|
73
|
+
* menu, the destructive action, and Cancel selection (X). Actions are off with
|
|
74
|
+
* no selection. Below 640 wide (the web `sm`) the actions are icon-only. Put it
|
|
75
|
+
* last in a full-size container; it scrolls sideways when it does not fit.
|
|
76
|
+
*/
|
|
77
|
+
export function BulkActionBar({
|
|
78
|
+
isSelecting,
|
|
79
|
+
selectedCount,
|
|
80
|
+
totalCount,
|
|
81
|
+
onExitSelect,
|
|
82
|
+
onSelectAll,
|
|
83
|
+
onClearSelection,
|
|
84
|
+
primaryActions,
|
|
85
|
+
moreActions,
|
|
86
|
+
destructiveAction,
|
|
87
|
+
barKey,
|
|
88
|
+
bottomOffset,
|
|
89
|
+
}: BulkActionBarProps) {
|
|
90
|
+
const scheme = useScheme();
|
|
91
|
+
const metrics = barMetrics(useLargerText());
|
|
92
|
+
const insets = useSafeAreaInsets();
|
|
93
|
+
const { width } = useWindowDimensions();
|
|
94
|
+
if (!isSelecting) return null;
|
|
95
|
+
const wide = width >= BULK_BAR_LABEL_MIN_WIDTH;
|
|
96
|
+
const hasSelection = selectedCount > 0;
|
|
97
|
+
const allSelected = totalCount > 0 && selectedCount >= totalCount;
|
|
98
|
+
const muted = tokens.color.muted[scheme];
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<NativeOnlyAnimatedView
|
|
102
|
+
testID={barKey}
|
|
103
|
+
pointerEvents="box-none"
|
|
104
|
+
entering={FadeInUp.duration(150)}
|
|
105
|
+
exiting={FadeOutDown.duration(150)}
|
|
106
|
+
style={{
|
|
107
|
+
position: "absolute",
|
|
108
|
+
left: 0,
|
|
109
|
+
right: 0,
|
|
110
|
+
bottom: 12 + (bottomOffset ?? insets.bottom),
|
|
111
|
+
alignItems: "center",
|
|
112
|
+
paddingHorizontal: 16,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<FloatingSurface radius={tokens.radius.lg} style={{ maxWidth: width - 32 }}>
|
|
116
|
+
<ScrollView
|
|
117
|
+
horizontal
|
|
118
|
+
bounces={false}
|
|
119
|
+
showsHorizontalScrollIndicator={false}
|
|
120
|
+
contentContainerStyle={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 10, paddingVertical: 8 }}
|
|
121
|
+
>
|
|
122
|
+
<View
|
|
123
|
+
accessible
|
|
124
|
+
accessibilityLabel={`${selectedCount} selected`}
|
|
125
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingLeft: 4, paddingRight: 2 }}
|
|
126
|
+
>
|
|
127
|
+
<View
|
|
128
|
+
style={{
|
|
129
|
+
borderRadius: tokens.radius.md,
|
|
130
|
+
paddingHorizontal: 8,
|
|
131
|
+
paddingVertical: 2,
|
|
132
|
+
backgroundColor: tokens.color.surfaceSecondary[scheme],
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<Text
|
|
136
|
+
className="font-instrument-sans-semibold"
|
|
137
|
+
style={[metrics.caption, typeStyle("semibold"), { fontVariant: ["tabular-nums"], color: tokens.color.foreground[scheme] }]}
|
|
138
|
+
>
|
|
139
|
+
{selectedCount}
|
|
140
|
+
</Text>
|
|
141
|
+
</View>
|
|
142
|
+
{wide ? (
|
|
143
|
+
<Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
|
|
144
|
+
selected
|
|
145
|
+
</Text>
|
|
146
|
+
) : null}
|
|
147
|
+
</View>
|
|
148
|
+
|
|
149
|
+
{totalCount > 0 ? (
|
|
150
|
+
<BarPressable height={metrics.height} paddingX={8} onPress={allSelected ? onClearSelection : onSelectAll}>
|
|
151
|
+
<Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
|
|
152
|
+
{allSelected ? "Clear" : `Select all (${totalCount})`}
|
|
153
|
+
</Text>
|
|
154
|
+
</BarPressable>
|
|
155
|
+
) : null}
|
|
156
|
+
|
|
157
|
+
<BarDivider />
|
|
158
|
+
|
|
159
|
+
{primaryActions.map((action) => (
|
|
160
|
+
<BarButton key={action.key} action={action} disabled={!hasSelection} wide={wide} />
|
|
161
|
+
))}
|
|
162
|
+
|
|
163
|
+
{moreActions && moreActions.length > 0 ? (
|
|
164
|
+
<DropdownMenu>
|
|
165
|
+
<DropdownMenuTrigger
|
|
166
|
+
accessibilityLabel="More actions"
|
|
167
|
+
disabled={!hasSelection}
|
|
168
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, metrics.height)}
|
|
169
|
+
style={({ pressed }) => barButtonStyle(metrics.height, 0, pressed, !hasSelection, false, scheme)}
|
|
170
|
+
>
|
|
171
|
+
<IconDots size={metrics.icon} color={muted} />
|
|
172
|
+
</DropdownMenuTrigger>
|
|
173
|
+
<DropdownMenuContent align="end" sideOffset={8}>
|
|
174
|
+
{moreActions.map((action) => (
|
|
175
|
+
<DropdownMenuItem
|
|
176
|
+
key={action.key}
|
|
177
|
+
icon={action.icon}
|
|
178
|
+
destructive={action.destructive}
|
|
179
|
+
disabled={!hasSelection || Boolean(action.disabled)}
|
|
180
|
+
onSelect={action.onClick}
|
|
181
|
+
>
|
|
182
|
+
{action.label}
|
|
183
|
+
</DropdownMenuItem>
|
|
184
|
+
))}
|
|
185
|
+
</DropdownMenuContent>
|
|
186
|
+
</DropdownMenu>
|
|
187
|
+
) : null}
|
|
188
|
+
|
|
189
|
+
{destructiveAction ? (
|
|
190
|
+
<>
|
|
191
|
+
<BarDivider />
|
|
192
|
+
<BarButton action={destructiveAction} disabled={!hasSelection} wide={wide} />
|
|
193
|
+
</>
|
|
194
|
+
) : null}
|
|
195
|
+
|
|
196
|
+
<BarDivider />
|
|
197
|
+
|
|
198
|
+
<BarPressable height={metrics.height} paddingX={0} label="Cancel selection" onPress={onExitSelect}>
|
|
199
|
+
<IconX size={metrics.icon} color={muted} />
|
|
200
|
+
</BarPressable>
|
|
201
|
+
</ScrollView>
|
|
202
|
+
</FloatingSurface>
|
|
203
|
+
</NativeOnlyAnimatedView>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A bar button box: the web `h-9 rounded-lg`, at least 44 wide and slopped to
|
|
209
|
+
* 44 high, `surface-secondary` while pressed (a 10% danger tint for a
|
|
210
|
+
* destructive one), faded to 30% when off.
|
|
211
|
+
*/
|
|
212
|
+
function barButtonStyle(
|
|
213
|
+
height: number,
|
|
214
|
+
paddingX: number,
|
|
215
|
+
pressed: boolean,
|
|
216
|
+
disabled: boolean,
|
|
217
|
+
destructive: boolean,
|
|
218
|
+
scheme: "light" | "dark",
|
|
219
|
+
): ViewStyle {
|
|
220
|
+
const pressedFill = destructive ? withAlpha(palette.danger[scheme], 0.1) : tokens.color.surfaceSecondary[scheme];
|
|
221
|
+
return {
|
|
222
|
+
height,
|
|
223
|
+
minWidth: MIN_TOUCH_TARGET,
|
|
224
|
+
paddingHorizontal: paddingX,
|
|
225
|
+
flexDirection: "row",
|
|
226
|
+
alignItems: "center",
|
|
227
|
+
justifyContent: "center",
|
|
228
|
+
gap: 6,
|
|
229
|
+
borderRadius: tokens.radius.lg,
|
|
230
|
+
backgroundColor: pressed && !disabled ? pressedFill : "transparent",
|
|
231
|
+
opacity: disabled ? 0.3 : 1,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function BarPressable({
|
|
236
|
+
height,
|
|
237
|
+
paddingX,
|
|
238
|
+
label,
|
|
239
|
+
disabled = false,
|
|
240
|
+
destructive = false,
|
|
241
|
+
onPress,
|
|
242
|
+
children,
|
|
243
|
+
}: {
|
|
244
|
+
height: number;
|
|
245
|
+
paddingX: number;
|
|
246
|
+
label?: string;
|
|
247
|
+
disabled?: boolean;
|
|
248
|
+
destructive?: boolean;
|
|
249
|
+
onPress: () => void;
|
|
250
|
+
children: ReactNode;
|
|
251
|
+
}) {
|
|
252
|
+
const scheme = useScheme();
|
|
253
|
+
return (
|
|
254
|
+
<Pressable
|
|
255
|
+
accessibilityRole="button"
|
|
256
|
+
accessibilityLabel={label}
|
|
257
|
+
accessibilityState={{ disabled }}
|
|
258
|
+
disabled={disabled}
|
|
259
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, height)}
|
|
260
|
+
onPress={onPress}
|
|
261
|
+
style={({ pressed }) => barButtonStyle(height, paddingX, pressed, disabled, destructive, scheme)}
|
|
262
|
+
>
|
|
263
|
+
{children}
|
|
264
|
+
</Pressable>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One action: the icon, and on wide screens the short label (`text-sm` medium). */
|
|
269
|
+
function BarButton({ action, disabled, wide }: { action: BulkBarAction; disabled: boolean; wide: boolean }) {
|
|
270
|
+
const scheme = useScheme();
|
|
271
|
+
const metrics = barMetrics(useLargerText());
|
|
272
|
+
const Icon = action.icon;
|
|
273
|
+
const color = action.destructive ? palette.danger[scheme] : tokens.color.muted[scheme];
|
|
274
|
+
return (
|
|
275
|
+
<BarPressable
|
|
276
|
+
height={metrics.height}
|
|
277
|
+
paddingX={10}
|
|
278
|
+
label={action.label}
|
|
279
|
+
disabled={disabled || Boolean(action.disabled)}
|
|
280
|
+
destructive={action.destructive}
|
|
281
|
+
onPress={action.onClick}
|
|
282
|
+
>
|
|
283
|
+
<Icon size={metrics.icon} color={color} />
|
|
284
|
+
{wide ? (
|
|
285
|
+
<Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color }]}>
|
|
286
|
+
{action.shortLabel ?? action.label}
|
|
287
|
+
</Text>
|
|
288
|
+
) : null}
|
|
289
|
+
</BarPressable>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The web vertical `Separator`: `mx-1.5 h-5 bg-border`. */
|
|
294
|
+
function BarDivider() {
|
|
295
|
+
const scheme = useScheme();
|
|
296
|
+
return (
|
|
297
|
+
<View
|
|
298
|
+
accessible={false}
|
|
299
|
+
importantForAccessibility="no"
|
|
300
|
+
style={{ width: 1, height: 20, marginHorizontal: 6, backgroundColor: tokens.color.border[scheme] }}
|
|
301
|
+
/>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { tokens } from "./tokens";
|
|
4
|
+
import { useScheme } from "./useScheme";
|
|
5
|
+
|
|
6
|
+
export type DividedListProps = ViewProps & {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Left inset of each divider, to start it under the row text. Default 0 (edge to edge). */
|
|
9
|
+
inset?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Flat rows with a hairline between each pair, as the web `divide-y
|
|
15
|
+
* divide-border` lists (Eva's inbox rows). Rows keep their own padding; the
|
|
16
|
+
* list draws no fill, radius or outer border. `null` children are skipped, so
|
|
17
|
+
* no divider is left hanging.
|
|
18
|
+
*/
|
|
19
|
+
export function DividedList({ children, inset = 0, className, style, ...props }: DividedListProps) {
|
|
20
|
+
const scheme = useScheme();
|
|
21
|
+
const rows = Children.toArray(children).filter(isValidElement);
|
|
22
|
+
return (
|
|
23
|
+
<View className={className} style={style} {...props}>
|
|
24
|
+
{rows.map((row, index) => (
|
|
25
|
+
<Fragment key={row.key ?? index}>
|
|
26
|
+
{index > 0 ? (
|
|
27
|
+
<View
|
|
28
|
+
accessible={false}
|
|
29
|
+
style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }}
|
|
30
|
+
/>
|
|
31
|
+
) : null}
|
|
32
|
+
{row}
|
|
33
|
+
</Fragment>
|
|
34
|
+
))}
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
}
|