@thewhileloop/whileui 4.3.0 → 4.4.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
@@ -477,6 +477,10 @@ const icons = useIconColors();
477
477
  `FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never
478
478
  needs `hitSlop`.
479
479
 
480
+ A floating FAB covers whatever scrolls under it, so a list behind one ends with room for
481
+ it: `contentContainerStyle={{ paddingBottom: fabClearance() }}`, passing the FAB's `size`
482
+ when it is not the default.
483
+
480
484
  ## Layout Primitives (Stack, Row)
481
485
 
482
486
  Use `Stack` for vertical layouts, `Row` for horizontal layouts. Both support `gap`, `align`, and `justify` variants.
@@ -628,9 +632,24 @@ reported, never changed, because a brand colour is a brand decision. The built-i
628
632
  `WHILEUI_PRESET_SOFT` and `WHILEUI_PRESET_ATELIER`, go through `themePresetToCss` the same
629
633
  way.
630
634
 
631
- Switch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`). A named
632
- extra theme is registered with `extraThemes: ['name']` in `metro.config.js` and defined
633
- with `@variant name`. The full token list, with each default, is in `theme-contract.ts`,
635
+ Switch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`), or let
636
+ `useThemeBridge` hold the choice. It returns the chosen `mode`, the `resolvedTheme` on
637
+ screen, `setMode`, `toggleTheme` and `cycleMode`. A sun/moon button calls `toggleTheme`,
638
+ which always flips what is on screen; `cycleMode` steps light → dark → system and suits
639
+ a control that shows all three, because a step into `system` that resolves to the
640
+ current theme looks like a dead tap. Pass an `adapter` to keep the choice across launches:
641
+
642
+ ```tsx
643
+ const { resolvedTheme, toggleTheme } = useThemeBridge({
644
+ adapter: {
645
+ loadThemeMode: () => storage.getItem('theme') as ThemeMode | null,
646
+ saveThemeMode: (mode) => storage.setItem('theme', mode),
647
+ },
648
+ });
649
+ ```
650
+
651
+ A named extra theme is registered with `extraThemes: ['name']` in `metro.config.js` and
652
+ defined with `@variant name`. The full token list, with each default, is in `theme-contract.ts`,
634
653
  and agents can read it from the MCP server's `get_theme_tokens`.
635
654
 
636
655
  ### Frosted surfaces
@@ -816,24 +835,34 @@ Also accepts the props of React Native's `TextProps`.
816
835
  ## Input
817
836
 
818
837
  ```tsx
819
- import { Input } from '@thewhileloop/whileui';
838
+ import { Input, useIconColors } from '@thewhileloop/whileui';
839
+ import { Feather } from '@expo/vector-icons';
840
+
841
+ const icons = useIconColors();
820
842
 
821
843
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
844
+
845
+ <Input
846
+ placeholder="Search"
847
+ prefix={<Feather name="search" size={16} color={icons.muted} aria-hidden />}
848
+ />;
822
849
  ```
823
850
 
824
- On web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their
825
- themed background, foreground, and caret when the browser autofills them.
851
+ On iOS and Android, Input, Textarea, NumericInput and SmartInput draw the caret and
852
+ selection in the theme's primary colour; pass `selectionColor` (and `cursorColor` on
853
+ Android) to override it. On web, filled surfaces keep their themed background,
854
+ foreground and caret when the browser autofills them.
826
855
 
827
856
  <!-- api:Input -->
828
857
 
829
- | Prop | Type | Default | Description |
830
- | -------------- | --------------------------------- | ----------- | ----------- |
831
- | aria-invalid | `boolean` | — | |
832
- | className | `string` | — | |
833
- | prefix | `React.ReactNode` | — | |
834
- | suffix | `React.ReactNode` | — | |
835
- | inputClassName | `string` | — | |
836
- | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
858
+ | Prop | Type | Default | Description |
859
+ | -------------- | --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
860
+ | aria-invalid | `boolean` | — | |
861
+ | className | `string` | — | |
862
+ | prefix | `React.ReactNode` | — | Shown before the text. A decorative icon here needs `aria-hidden`; screen readers otherwise stop on it as a separate element and read out its glyph. |
863
+ | suffix | `React.ReactNode` | — | Shown after the text: a unit, or a control such as a clear button. |
864
+ | inputClassName | `string` | — | |
865
+ | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
837
866
 
838
867
  Also accepts the props of `TextInputProps`.
839
868
  <!-- /api:Input -->
@@ -1007,18 +1036,21 @@ import {
1007
1036
  </SegmentedControl>;
1008
1037
  ```
1009
1038
 
1039
+ When the labels do not fit a phone, `wrap="scroll"` keeps them on one row at full width
1040
+ and scrolls sideways, instead of wrapping or truncating them.
1041
+
1010
1042
  <!-- api:SegmentedControl -->
1011
1043
 
1012
- | Prop | Type | Default | Description |
1013
- | ------------- | ------------------------- | ----------- | -------------------------- |
1014
- | value | `string` | — | |
1015
- | defaultValue | `string` | `''` | |
1016
- | onValueChange | `(value: string) => void` | — | |
1017
- | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
1018
- | wrap | `boolean \| 'auto'` | `true` | |
1019
- | size | `'default' \| 'compact'` | `'default'` | |
1020
- | disabled | `boolean` | `false` | |
1021
- | className | `string` | — | |
1044
+ | Prop | Type | Default | Description |
1045
+ | ------------- | ------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1046
+ | value | `string` | — | |
1047
+ | defaultValue | `string` | `''` | |
1048
+ | onValueChange | `(value: string) => void` | — | |
1049
+ | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
1050
+ | wrap | `boolean \| 'auto' \| 'scroll'` | `'auto'` | `true` wraps segments onto more rows; `false` keeps one row and shares its width. `'scroll'` keeps one row with each segment as wide as its label and scrolls sideways when they do not fit — for labels a phone cannot show in full. `'auto'` wraps above three segments. |
1051
+ | size | `'default' \| 'compact'` | `'default'` | |
1052
+ | disabled | `boolean` | `false` | |
1053
+ | className | `string` | — | |
1022
1054
 
1023
1055
  Also accepts the props of `ViewProps`.
1024
1056
  <!-- /api:SegmentedControl -->
@@ -1333,6 +1365,10 @@ selected values stay on one line and truncate without displacing the trigger che
1333
1365
  For popover presentation on web, the options panel matches the trigger width, caps
1334
1366
  itself to the collision-available viewport, and wraps long option labels inside it.
1335
1367
 
1368
+ Opening a Select, or a Popover, closes the on-screen keyboard first, so options opened
1369
+ from a form are never hidden behind it. Both draw in a portal, where the platform would
1370
+ otherwise leave the keyboard up; the Modal-hosted overlays close it on their own.
1371
+
1336
1372
  `SelectContent` supports optional frosted props:
1337
1373
 
1338
1374
  <!-- api:SelectContent -->
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "package": "@thewhileloop/whileui",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "guides": {
5
5
  "quickReference": "## Quick Reference (AI / Code Generation)\n\n- **Full-screen:** `AppShell` + `Header` in `header` + `BottomNav` in `bottomNav` + content in `children`\n- **No-jump loading:** keep `AppShell` mounted, set `loading` + `skeleton` (e.g., `PageSkeleton`/`ScreenSkeleton`)\n- **Layout:** `Stack` (vertical), `Row` (horizontal) — both support `gap`, `align`, `justify`\n- **Auth callbacks:** Auth templates use objects: `onSubmit({ email, password })`, `onSubmit({ firstName, lastName, email, password })`, etc. Copy templates from `apps/showcase/catalog/templates/auth/`.\n- **PortalHost:** Add `<PortalHost />` at app root for Select, Popover, Tooltip, HoverCard.\n Inside any WhileUI overlay they render into that overlay's layer without configuration.\n- **Motion:** Wrap the root in `<MotionProvider>`; use `useMotionPolicy()` for semantic\n durations, the responsive spring, and reduced-motion behavior.\n- **Metro:** `withWhileUIMetro(config, { cssEntryFile: './global.css' })` from\n `@thewhileloop/whileui/metro` — wraps Uniwind and keeps icon fonts loading on device.\n `global.css` at app root, imported in `App.tsx`. Installing from npm also needs\n `@source './node_modules/@thewhileloop/whileui/dist';` or every component renders unstyled.\n- **States:** `LoadingState`, `EmptyState` and `ErrorState` share one shape; `ErrorState`\n takes `onRetry`. `Text variant=\"eyebrow\"` is the muted section label above a title.\n- **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/catalog/templates/` (auth, profile); flow patterns in README \"Flow Patterns\" section.\n\n### Composition grammar\n\n- Start with one page canvas and flat sections. Add a `Surface` only when grouping or\n elevation communicates something; do not wrap every section in a card.\n- Use `level=\"base\"` for grouped content, `raised` for selectable or floating regions,\n and `overlay` for content above the page. Avoid nesting more than one surfaced level.\n- Express hierarchy through `Text` variants before custom font-size classes. Keep one\n primary action per region and constrain long-form content with `Container`.\n- Forms use `FormField`; its disabled and invalid state flows into `Input`, `Textarea`,\n `NumericInput`, and `Select`. Pass a control prop only to override that inherited state.\n- Chat mechanics belong to `ChatConversation` + `SmartInput`. Message payloads, tools,\n reasoning, approvals, sources, and action rows are application-owned compositions.\n- Import Markdown and code rendering from their optional subpaths. Do not make the root\n package pay for content rendering when the screen does not use it.",
6
6
  "compositionGrammar": "### Composition grammar\n\n- Start with one page canvas and flat sections. Add a `Surface` only when grouping or\n elevation communicates something; do not wrap every section in a card.\n- Use `level=\"base\"` for grouped content, `raised` for selectable or floating regions,\n and `overlay` for content above the page. Avoid nesting more than one surfaced level.\n- Express hierarchy through `Text` variants before custom font-size classes. Keep one\n primary action per region and constrain long-form content with `Container`.\n- Forms use `FormField`; its disabled and invalid state flows into `Input`, `Textarea`,\n `NumericInput`, and `Select`. Pass a control prop only to override that inherited state.\n- Chat mechanics belong to `ChatConversation` + `SmartInput`. Message payloads, tools,\n reasoning, approvals, sources, and action rows are application-owned compositions.\n- Import Markdown and code rendering from their optional subpaths. Do not make the root\n package pay for content rendering when the screen does not use it.",
7
7
  "installation": "## Installation\n\n```bash\nbun add @thewhileloop/whileui\nbun add react@^19.3.0 react-native@^0.88.0 uniwind@^1.12.2 tailwindcss@^4.0.0 react-native-reanimated react-native-safe-area-context react-native-screens\n# Only if you use Select, Popover, Tooltip, or HoverCard:\nbun add @rn-primitives/portal @rn-primitives/hooks @rn-primitives/slot @rn-primitives/select @rn-primitives/popover @rn-primitives/tooltip @rn-primitives/hover-card\n# Only if you import @thewhileloop/whileui/markdown:\nbun add markdown-it\n```\n\nUse `npm install` instead of `bun add` if you prefer npm.\n\n### Setup Uniwind (required)\n\n1. **metro.config.js** (wrap with `withWhileUIMetro`):\n\n```js\nconst { getDefaultConfig } = require('expo/metro-config');\nconst { withWhileUIMetro } = require('@thewhileloop/whileui/metro');\n\n/** @type {import('expo/metro-config').MetroConfig} */\nconst config = getDefaultConfig(__dirname);\n\nmodule.exports = withWhileUIMetro(config, {\n cssEntryFile: './global.css',\n});\n```\n\n> `withWhileUIMetro` calls Uniwind's `withUniwindConfig(config, options)` for you — same\n> options, config first, `cssEntryFile` a relative path — and keeps `expo-asset`,\n> `expo-font` and `@expo/vector-icons` on the real `react-native`. Without that, Uniwind's\n> import rewrite leaves every icon glyph silently missing on iOS and Android while web\n> renders fine. Pass `exemptFromUniwindRewrite: ['pkg']` for any other package that\n> imports a `react-native` export Uniwind's wrapper lacks. It must be the outermost wrapper.\n\n2. **global.css** at app root, imported from `App.tsx`:\n\n```css\n@import 'tailwindcss';\n@import 'uniwind';\n\n/* Required when installed from npm: Tailwind does not scan node_modules, so without\n this line every component renders unstyled. */\n@source './node_modules/@thewhileloop/whileui/dist';\n\n/* Then your theme: generate it (Theming, below) rather than writing tokens by hand. */\n```\n\n3. **App.tsx**:\n\n```tsx\nimport './global.css';\nimport { MotionProvider, PortalHost } from '@thewhileloop/whileui';\nimport { Button, ButtonText } from '@thewhileloop/whileui';\n\nexport default function App() {\n return (\n <MotionProvider>\n {/* Your app content */}\n <PortalHost />\n </MotionProvider>\n );\n}\n```\n\n`PortalHost` (last child of the root) is where Select, Popover, Tooltip and HoverCard\nrender. Inside a Dialog, Sheet, menu or picker they use that overlay's own layer\nautomatically, and Android back or Escape closes the innermost layer first. Pass\n`portalHost` only to target a host you placed yourself, such as in a modal screen your\nnavigator presents.\n\n### Vite + React Native Web + WhileUI setup\n\nUse the WhileUI Vite compatibility helper when your app includes portal-based primitives.\n\n1. Install Vite + RN web basics:\n\n```bash\nbun add react-dom react-native-web\nbun add -d vite @vitejs/plugin-react\n```\n\n2. `vite.config.ts`:\n\n```ts\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport { withWhileUIViteCompat } from '@thewhileloop/whileui/vite';\n\nexport default defineConfig(withWhileUIViteCompat({ plugins: [react()] }));\n```\n\n3. Keep `PortalHost` at app root when using Select/Popover/Tooltip/HoverCard.\n\nTroubleshooting:\n\n| Symptom | Likely cause | Fix |\n| -------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |\n| `Unexpected token <` from `@rn-primitives/*/dist/*.mjs` | rn-primitives ships raw JSX in dist for some packages | Use `withWhileUIViteCompat(...)` in `vite.config.ts` |\n| `className` rejected on RN primitives in consumer TS app | RN `className` augmentation not loaded from package entry | Import components from `@thewhileloop/whileui` (or side-effect import the package entry before use) |\n| Theme token resolves differently on native/web | Missing `--app-color-*` fallback for non-RN-native values | Add `--app-color-*` fallbacks in `global.css` for any `oklch(...)` token used by native APIs |",
8
- "theming": "## Theming\n\nA theme is a set of CSS variables in `global.css`, in OKLCH, with a `light` and a `dark`\nvariant. Every component reads the same contract, so a theme must define **every** token\nin both modes: a missing one silently falls back to an unstyled value at runtime.\n\n### Generate a theme\n\n`themeFromSeeds` builds a complete light and dark theme from three colours, and\n`themePresetToCss` writes every token, including the hex fallbacks React Native APIs need,\nready to paste into `global.css`:\n\n```ts\nimport { checkThemeContrast, themeFromSeeds, themePresetToCss } from '@thewhileloop/whileui/tokens';\n\nconst theme = themeFromSeeds({\n name: 'studio',\n description: 'Bone paper and burnt amber',\n light: { background: '#f0ede9', foreground: '#13161c', primary: '#b65400' },\n // optional: `dark` seeds (otherwise a dark page with the brand lifted to read on it),\n // `overrides` for any base colour, `visual` for radius and density tokens\n});\n\ncheckThemeContrast(theme.light).filter((c) => !c.pass);\n// [{ pair: 'primary on background', ratio: 4.23, min: 4.5, pass: false }, …]\nconst css = themePresetToCss(theme); // paste into global.css\n```\n\nSeeds are kept as given; the colours it derives (muted text, text on each fill, status\ncolours) are tuned to WCAG AA. `checkThemeContrast` checks any palette, hand-written ones\nincluded: every text pairing at 4.5:1 and the focus ring at 3:1. A failing seed is\nreported, never changed, because a brand colour is a brand decision. The built-in presets,\n`WHILEUI_PRESET_SOFT` and `WHILEUI_PRESET_ATELIER`, go through `themePresetToCss` the same\nway.\n\nSwitch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`). A named\nextra theme is registered with `extraThemes: ['name']` in `metro.config.js` and defined\nwith `@variant name`. The full token list, with each default, is in `theme-contract.ts`,\nand agents can read it from the MCP server's `get_theme_tokens`.\n\n### Frosted surfaces\n\nCards and overlays take `frosted`, `blurIntensity` and `blurTintToken`\n(`'surfaceElevated' | 'surfaceTranslucent' | 'card' | 'popover'`). Without a blur view\nthey tint only; register `expo-blur` once for real blur:\n\n```tsx\nimport { BlurView } from 'expo-blur';\nimport { registerFrostedBlurView } from '@thewhileloop/whileui';\n\nregisterFrostedBlurView(BlurView);\n```\n\nFor a frosted look across a whole theme, make `surface-elevated`, `surface-border` and\n`surface-highlight` translucent, in your light and dark variants or in a separate theme.\nThe tuning tokens:\n\n| Token | Default | Effect |\n| ------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------- |\n| `--ui-blur-intensity-subtle` / `-medium` / `-strong` | 18 / 26 / 34 | The three blur presets |\n| `--ui-frosted-android-tint-alpha-scale` | 1.18 | Android needs denser tint; `1` matches iOS and web |\n| `--ui-frosted-android-experimental-blur` | 1 | Uses `expo-blur`'s experimental Android blur; `0` turns it off |\n| `--ui-frosted-highlight-height` | 2 | Top sheen in px; `0` removes it |\n| `--ui-blur-saturation-pct` | 145 | Web saturation multiplier |\n| `--ui-frosted-backdrop-blur-intensity`, `-scale` | 22, 0.72 | Backdrop blur, and its ratio when a component overrides blur |\n| `--ui-drawer-frosted-inset`, `-radius`, `--ui-drawer-content-top-padding` | 10, 34, 0 | Frosted drawer shell |\n\n### Colours for React Native APIs\n\nIcons, `ActivityIndicator` and other native APIs need hex or rgb, not OKLCH.\n`useThemeColors()` returns the whole palette as React Native colours, falling back to the\n`--app-color-*` tokens a generated theme includes; `useIconColors()` is the subset icons\nuse, with `muted` mapped to `mutedForeground`.\n\n```tsx\nconst icons = useIconColors();\n<Feather name=\"heart\" size={20} color={icons.muted} />;\n```\n\nInput, Textarea, NumericInput, SmartInput, Spinner and LoadingScreen pick theme colours\nwhen you omit `placeholderTextColor` or `spinnerColor`.\n\n### Interaction and motion\n\nPress opacity, disabled opacity, motion durations, the responsive spring, and the drawer\nopen and close durations are package constants on `INTERACTION_TOKENS`. They are not CSS\nvariables, so a theme does not change them.\n\nFor a custom control, read that table:\n\n```tsx\nimport { useInteractionTokens, withInteractivePressableStyle } from '@thewhileloop/whileui';\n```\n\nWrap the application in `MotionProvider` to coordinate system reduced motion, an explicit\npreview mode, and deterministic animation-free tests. The provider is optional. Without\nit `useMotionPolicy` reports the durations and spring from `INTERACTION_TOKENS`, and\nReanimated follows the system setting. `reduced` preserves non-spatial feedback while asking Reanimated to skip motion;\n`disabled` also resolves semantic durations to zero.\n\n```tsx\nimport { MotionProvider, useMotionPolicy } from '@thewhileloop/whileui';\n\n<MotionProvider mode=\"system\">{/* application */}</MotionProvider>;\n\nconst motion = useMotionPolicy();\nmotion.durations.normal;\nmotion.springs.responsive;\n```"
8
+ "theming": "## Theming\n\nA theme is a set of CSS variables in `global.css`, in OKLCH, with a `light` and a `dark`\nvariant. Every component reads the same contract, so a theme must define **every** token\nin both modes: a missing one silently falls back to an unstyled value at runtime.\n\n### Generate a theme\n\n`themeFromSeeds` builds a complete light and dark theme from three colours, and\n`themePresetToCss` writes every token, including the hex fallbacks React Native APIs need,\nready to paste into `global.css`:\n\n```ts\nimport { checkThemeContrast, themeFromSeeds, themePresetToCss } from '@thewhileloop/whileui/tokens';\n\nconst theme = themeFromSeeds({\n name: 'studio',\n description: 'Bone paper and burnt amber',\n light: { background: '#f0ede9', foreground: '#13161c', primary: '#b65400' },\n // optional: `dark` seeds (otherwise a dark page with the brand lifted to read on it),\n // `overrides` for any base colour, `visual` for radius and density tokens\n});\n\ncheckThemeContrast(theme.light).filter((c) => !c.pass);\n// [{ pair: 'primary on background', ratio: 4.23, min: 4.5, pass: false }, …]\nconst css = themePresetToCss(theme); // paste into global.css\n```\n\nSeeds are kept as given; the colours it derives (muted text, text on each fill, status\ncolours) are tuned to WCAG AA. `checkThemeContrast` checks any palette, hand-written ones\nincluded: every text pairing at 4.5:1 and the focus ring at 3:1. A failing seed is\nreported, never changed, because a brand colour is a brand decision. The built-in presets,\n`WHILEUI_PRESET_SOFT` and `WHILEUI_PRESET_ATELIER`, go through `themePresetToCss` the same\nway.\n\nSwitch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`), or let\n`useThemeBridge` hold the choice. It returns the chosen `mode`, the `resolvedTheme` on\nscreen, `setMode`, `toggleTheme` and `cycleMode`. A sun/moon button calls `toggleTheme`,\nwhich always flips what is on screen; `cycleMode` steps light → dark → system and suits\na control that shows all three, because a step into `system` that resolves to the\ncurrent theme looks like a dead tap. Pass an `adapter` to keep the choice across launches:\n\n```tsx\nconst { resolvedTheme, toggleTheme } = useThemeBridge({\n adapter: {\n loadThemeMode: () => storage.getItem('theme') as ThemeMode | null,\n saveThemeMode: (mode) => storage.setItem('theme', mode),\n },\n});\n```\n\nA named extra theme is registered with `extraThemes: ['name']` in `metro.config.js` and\ndefined with `@variant name`. The full token list, with each default, is in `theme-contract.ts`,\nand agents can read it from the MCP server's `get_theme_tokens`.\n\n### Frosted surfaces\n\nCards and overlays take `frosted`, `blurIntensity` and `blurTintToken`\n(`'surfaceElevated' | 'surfaceTranslucent' | 'card' | 'popover'`). Without a blur view\nthey tint only; register `expo-blur` once for real blur:\n\n```tsx\nimport { BlurView } from 'expo-blur';\nimport { registerFrostedBlurView } from '@thewhileloop/whileui';\n\nregisterFrostedBlurView(BlurView);\n```\n\nFor a frosted look across a whole theme, make `surface-elevated`, `surface-border` and\n`surface-highlight` translucent, in your light and dark variants or in a separate theme.\nThe tuning tokens:\n\n| Token | Default | Effect |\n| ------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------- |\n| `--ui-blur-intensity-subtle` / `-medium` / `-strong` | 18 / 26 / 34 | The three blur presets |\n| `--ui-frosted-android-tint-alpha-scale` | 1.18 | Android needs denser tint; `1` matches iOS and web |\n| `--ui-frosted-android-experimental-blur` | 1 | Uses `expo-blur`'s experimental Android blur; `0` turns it off |\n| `--ui-frosted-highlight-height` | 2 | Top sheen in px; `0` removes it |\n| `--ui-blur-saturation-pct` | 145 | Web saturation multiplier |\n| `--ui-frosted-backdrop-blur-intensity`, `-scale` | 22, 0.72 | Backdrop blur, and its ratio when a component overrides blur |\n| `--ui-drawer-frosted-inset`, `-radius`, `--ui-drawer-content-top-padding` | 10, 34, 0 | Frosted drawer shell |\n\n### Colours for React Native APIs\n\nIcons, `ActivityIndicator` and other native APIs need hex or rgb, not OKLCH.\n`useThemeColors()` returns the whole palette as React Native colours, falling back to the\n`--app-color-*` tokens a generated theme includes; `useIconColors()` is the subset icons\nuse, with `muted` mapped to `mutedForeground`.\n\n```tsx\nconst icons = useIconColors();\n<Feather name=\"heart\" size={20} color={icons.muted} />;\n```\n\nInput, Textarea, NumericInput, SmartInput, Spinner and LoadingScreen pick theme colours\nwhen you omit `placeholderTextColor` or `spinnerColor`.\n\n### Interaction and motion\n\nPress opacity, disabled opacity, motion durations, the responsive spring, and the drawer\nopen and close durations are package constants on `INTERACTION_TOKENS`. They are not CSS\nvariables, so a theme does not change them.\n\nFor a custom control, read that table:\n\n```tsx\nimport { useInteractionTokens, withInteractivePressableStyle } from '@thewhileloop/whileui';\n```\n\nWrap the application in `MotionProvider` to coordinate system reduced motion, an explicit\npreview mode, and deterministic animation-free tests. The provider is optional. Without\nit `useMotionPolicy` reports the durations and spring from `INTERACTION_TOKENS`, and\nReanimated follows the system setting. `reduced` preserves non-spatial feedback while asking Reanimated to skip motion;\n`disabled` also resolves semantic durations to zero.\n\n```tsx\nimport { MotionProvider, useMotionPolicy } from '@thewhileloop/whileui';\n\n<MotionProvider mode=\"system\">{/* application */}</MotionProvider>;\n\nconst motion = useMotionPolicy();\nmotion.durations.normal;\nmotion.springs.responsive;\n```"
9
9
  },
10
10
  "components": [
11
11
  {
@@ -69,7 +69,7 @@
69
69
  "category": "Form Controls",
70
70
  "kind": "package",
71
71
  "summary": "TextInput wrapper with themed styling",
72
- "docs": "## Input\n\n```tsx\nimport { Input } from '@thewhileloop/whileui';\n\n<Input placeholder=\"Email\" variant=\"default\" value={value} onChangeText={setValue} />;\n```\n\nOn web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their\nthemed background, foreground, and caret when the browser autofills them.\n\n\n| Prop | Type | Default | Description |\n| -------------- | --------------------------------- | ----------- | ----------- |\n| aria-invalid | `boolean` | — | |\n| className | `string` | — | |\n| prefix | `React.ReactNode` | — | |\n| suffix | `React.ReactNode` | — | |\n| inputClassName | `string` | — | |\n| variant | `'default' \\| 'error' \\| 'plain'` | `'default'` | |\n\nAlso accepts the props of `TextInputProps`."
72
+ "docs": "## Input\n\n```tsx\nimport { Input, useIconColors } from '@thewhileloop/whileui';\nimport { Feather } from '@expo/vector-icons';\n\nconst icons = useIconColors();\n\n<Input placeholder=\"Email\" variant=\"default\" value={value} onChangeText={setValue} />;\n\n<Input\n placeholder=\"Search\"\n prefix={<Feather name=\"search\" size={16} color={icons.muted} aria-hidden />}\n/>;\n```\n\nOn iOS and Android, Input, Textarea, NumericInput and SmartInput draw the caret and\nselection in the theme's primary colour; pass `selectionColor` (and `cursorColor` on\nAndroid) to override it. On web, filled surfaces keep their themed background,\nforeground and caret when the browser autofills them.\n\n\n| Prop | Type | Default | Description |\n| -------------- | --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |\n| aria-invalid | `boolean` | — | |\n| className | `string` | — | |\n| prefix | `React.ReactNode` | — | Shown before the text. A decorative icon here needs `aria-hidden`; screen readers otherwise stop on it as a separate element and read out its glyph. |\n| suffix | `React.ReactNode` | — | Shown after the text: a unit, or a control such as a clear button. |\n| inputClassName | `string` | — | |\n| variant | `'default' \\| 'error' \\| 'plain'` | `'default'` | |\n\nAlso accepts the props of `TextInputProps`."
73
73
  },
74
74
  {
75
75
  "name": "NumericInput",
@@ -132,7 +132,7 @@
132
132
  "category": "Form Controls",
133
133
  "kind": "package",
134
134
  "summary": "Uses `SelectOption` type `{value, label}`. Includes SelectGroup, SelectLabel, SelectSeparator",
135
- "docs": "## Select\n\n```tsx\nimport {\n Select,\n SelectTrigger,\n SelectValue,\n SelectContent,\n SelectItem,\n} from '@thewhileloop/whileui';\n\n<Select value={value} onValueChange={setValue}>\n <SelectTrigger>\n <SelectValue placeholder=\"Select...\" />\n </SelectTrigger>\n <SelectContent frosted blurIntensity={24} blurTintToken=\"surfaceTranslucent\">\n <SelectItem label=\"Option 1\" value=\"1\" />\n <SelectItem label=\"Option 2\" value=\"2\" />\n </SelectContent>\n</Select>;\n```\n\nFocused and pressed options use the subtle interaction-state tokens; the primary token\nmarks the selected indicator without replacing the option's foreground colour. Long\nselected values stay on one line and truncate without displacing the trigger chevron.\nFor popover presentation on web, the options panel matches the trigger width, caps\nitself to the collision-available viewport, and wraps long option labels inside it.\n\n`SelectContent` supports optional frosted props:\n\n\n| Prop | Type | Default | Description |\n| ------------- | ------------------------------------------------------------------ | ------------- | ------------------------------------ |\n| className | `string` | — | |\n| portalHost | `string` | — | |\n| insets | `{ top?: number; bottom?: number; left?: number; right?: number }` | — | |\n| frosted | `boolean` | `false` | Enable the optional frosted overlay. |\n| blurIntensity | `number` | subtle preset | Override blur amount directly. |\n| blurTintToken | `FrostedTintToken` | `'popover'` | Tint token used for frosted mode. |\n\nAlso accepts the props of the underlying primitive.\n\n`SelectContent` keeps its listbox inside the safe area by default; pass `insets` only to\nchange that margin. A list taller than the screen scrolls inside the listbox on every\nplatform. Inside a Dialog, Sheet or other WhileUI overlay it renders in that\noverlay's layer automatically. Option values must be non-empty strings: the web\nprimitive rejects `\"\"`, so model \"none\" as an explicit value such as `'none'`."
135
+ "docs": "## Select\n\n```tsx\nimport {\n Select,\n SelectTrigger,\n SelectValue,\n SelectContent,\n SelectItem,\n} from '@thewhileloop/whileui';\n\n<Select value={value} onValueChange={setValue}>\n <SelectTrigger>\n <SelectValue placeholder=\"Select...\" />\n </SelectTrigger>\n <SelectContent frosted blurIntensity={24} blurTintToken=\"surfaceTranslucent\">\n <SelectItem label=\"Option 1\" value=\"1\" />\n <SelectItem label=\"Option 2\" value=\"2\" />\n </SelectContent>\n</Select>;\n```\n\nFocused and pressed options use the subtle interaction-state tokens; the primary token\nmarks the selected indicator without replacing the option's foreground colour. Long\nselected values stay on one line and truncate without displacing the trigger chevron.\nFor popover presentation on web, the options panel matches the trigger width, caps\nitself to the collision-available viewport, and wraps long option labels inside it.\n\nOpening a Select, or a Popover, closes the on-screen keyboard first, so options opened\nfrom a form are never hidden behind it. Both draw in a portal, where the platform would\notherwise leave the keyboard up; the Modal-hosted overlays close it on their own.\n\n`SelectContent` supports optional frosted props:\n\n\n| Prop | Type | Default | Description |\n| ------------- | ------------------------------------------------------------------ | ------------- | ------------------------------------ |\n| className | `string` | — | |\n| portalHost | `string` | — | |\n| insets | `{ top?: number; bottom?: number; left?: number; right?: number }` | — | |\n| frosted | `boolean` | `false` | Enable the optional frosted overlay. |\n| blurIntensity | `number` | subtle preset | Override blur amount directly. |\n| blurTintToken | `FrostedTintToken` | `'popover'` | Tint token used for frosted mode. |\n\nAlso accepts the props of the underlying primitive.\n\n`SelectContent` keeps its listbox inside the safe area by default; pass `insets` only to\nchange that margin. A list taller than the screen scrolls inside the listbox on every\nplatform. Inside a Dialog, Sheet or other WhileUI overlay it renders in that\noverlay's layer automatically. Option values must be non-empty strings: the web\nprimitive rejects `\"\"`, so model \"none\" as an explicit value such as `'none'`."
136
136
  },
137
137
  {
138
138
  "name": "Label",
@@ -146,7 +146,7 @@
146
146
  "category": "Form Controls",
147
147
  "kind": "package",
148
148
  "summary": "SegmentedControl, SegmentedControlItem, SegmentedControlItemText with wrapping layout support",
149
- "docs": "## SegmentedControl\n\n```tsx\nimport {\n SegmentedControl,\n SegmentedControlItem,\n SegmentedControlItemText,\n} from '@thewhileloop/whileui';\n\n<SegmentedControl value={unit} onValueChange={setUnit} variant=\"pill\" wrap>\n <SegmentedControlItem value=\"metric\">\n <SegmentedControlItemText>Metric</SegmentedControlItemText>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"imperial\">\n <SegmentedControlItemText>Imperial</SegmentedControlItemText>\n </SegmentedControlItem>\n</SegmentedControl>;\n```\n\n\n| Prop | Type | Default | Description |\n| ------------- | ------------------------- | ----------- | -------------------------- |\n| value | `string` | — | |\n| defaultValue | `string` | `''` | |\n| onValueChange | `(value: string) => void` | — | |\n| variant | `'default' \\| 'pill'` | `'default'` | Pill = rounded-full items. |\n| wrap | `boolean \\| 'auto'` | `true` | |\n| size | `'default' \\| 'compact'` | `'default'` | |\n| disabled | `boolean` | `false` | |\n| className | `string` | — | |\n\nAlso accepts the props of `ViewProps`."
149
+ "docs": "## SegmentedControl\n\n```tsx\nimport {\n SegmentedControl,\n SegmentedControlItem,\n SegmentedControlItemText,\n} from '@thewhileloop/whileui';\n\n<SegmentedControl value={unit} onValueChange={setUnit} variant=\"pill\" wrap>\n <SegmentedControlItem value=\"metric\">\n <SegmentedControlItemText>Metric</SegmentedControlItemText>\n </SegmentedControlItem>\n <SegmentedControlItem value=\"imperial\">\n <SegmentedControlItemText>Imperial</SegmentedControlItemText>\n </SegmentedControlItem>\n</SegmentedControl>;\n```\n\nWhen the labels do not fit a phone, `wrap=\"scroll\"` keeps them on one row at full width\nand scrolls sideways, instead of wrapping or truncating them.\n\n\n| Prop | Type | Default | Description |\n| ------------- | ------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| value | `string` | — | |\n| defaultValue | `string` | `''` | |\n| onValueChange | `(value: string) => void` | — | |\n| variant | `'default' \\| 'pill'` | `'default'` | Pill = rounded-full items. |\n| wrap | `boolean \\| 'auto' \\| 'scroll'` | `'auto'` | `true` wraps segments onto more rows; `false` keeps one row and shares its width. `'scroll'` keeps one row with each segment as wide as its label and scrolls sideways when they do not fit — for labels a phone cannot show in full. `'auto'` wraps above three segments. |\n| size | `'default' \\| 'compact'` | `'default'` | |\n| disabled | `boolean` | `false` | |\n| className | `string` | — | |\n\nAlso accepts the props of `ViewProps`."
150
150
  },
151
151
  {
152
152
  "name": "Toggle",
@@ -188,7 +188,7 @@
188
188
  "category": "Display",
189
189
  "kind": "package",
190
190
  "summary": "Floating action button. `label` required; pass your own icon as children",
191
- "docs": "### FAB\n\nOne primary action, raised above the content. It owns no icon dependency — pass the\nglyph as children and colour it from `useIconColors()`, because icon libraries need a\nconcrete colour rather than a class.\n\n```tsx\nimport { FAB, useIconColors } from '@thewhileloop/whileui';\nimport { Feather } from '@expo/vector-icons';\n\nconst icons = useIconColors();\n\n<FAB label=\"New group\" onPress={create}>\n <Feather name=\"plus\" size={24} color={icons.primaryForeground} />\n</FAB>;\n```\n\n| Prop | Type | Default | Notes |\n| ----------- | ------------------------------------------------ | ---------- | --------------------------------------- |\n| `label` | `string` | — | Required. The accessible name |\n| `variant` | `default \\| secondary \\| destructive \\| surface` | `default` | |\n| `size` | `sm \\| default \\| lg` | `default` | 48 / 56 / 64 — all clear touch minimums |\n| `placement` | `floating \\| floating-left \\| inline` | `floating` | `inline` lets the caller position it |\n\n`FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never\nneeds `hitSlop`."
191
+ "docs": "### FAB\n\nOne primary action, raised above the content. It owns no icon dependency — pass the\nglyph as children and colour it from `useIconColors()`, because icon libraries need a\nconcrete colour rather than a class.\n\n```tsx\nimport { FAB, useIconColors } from '@thewhileloop/whileui';\nimport { Feather } from '@expo/vector-icons';\n\nconst icons = useIconColors();\n\n<FAB label=\"New group\" onPress={create}>\n <Feather name=\"plus\" size={24} color={icons.primaryForeground} />\n</FAB>;\n```\n\n| Prop | Type | Default | Notes |\n| ----------- | ------------------------------------------------ | ---------- | --------------------------------------- |\n| `label` | `string` | — | Required. The accessible name |\n| `variant` | `default \\| secondary \\| destructive \\| surface` | `default` | |\n| `size` | `sm \\| default \\| lg` | `default` | 48 / 56 / 64 — all clear touch minimums |\n| `placement` | `floating \\| floating-left \\| inline` | `floating` | `inline` lets the caller position it |\n\n`FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never\nneeds `hitSlop`.\n\nA floating FAB covers whatever scrolls under it, so a list behind one ends with room for\nit: `contentContainerStyle={{ paddingBottom: fabClearance() }}`, passing the FAB's `size`\nwhen it is not the default."
192
192
  },
193
193
  {
194
194
  "name": "Alert",
@@ -1,4 +1,4 @@
1
- # WhileUI 4.3.0
1
+ # WhileUI 4.4.0
2
2
 
3
3
  > React Native components and blocks for iOS, Android and web, styled with Uniwind and
4
4
  > themed through a typed token contract. Package: `@thewhileloop/whileui`.
@@ -247,23 +247,33 @@ Also accepts the props of `PressableProps`.
247
247
  ## Input
248
248
 
249
249
  ```tsx
250
- import { Input } from '@thewhileloop/whileui';
250
+ import { Input, useIconColors } from '@thewhileloop/whileui';
251
+ import { Feather } from '@expo/vector-icons';
252
+
253
+ const icons = useIconColors();
251
254
 
252
255
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
256
+
257
+ <Input
258
+ placeholder="Search"
259
+ prefix={<Feather name="search" size={16} color={icons.muted} aria-hidden />}
260
+ />;
253
261
  ```
254
262
 
255
- On web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their
256
- themed background, foreground, and caret when the browser autofills them.
263
+ On iOS and Android, Input, Textarea, NumericInput and SmartInput draw the caret and
264
+ selection in the theme's primary colour; pass `selectionColor` (and `cursorColor` on
265
+ Android) to override it. On web, filled surfaces keep their themed background,
266
+ foreground and caret when the browser autofills them.
257
267
 
258
268
 
259
- | Prop | Type | Default | Description |
260
- | -------------- | --------------------------------- | ----------- | ----------- |
261
- | aria-invalid | `boolean` | — | |
262
- | className | `string` | — | |
263
- | prefix | `React.ReactNode` | — | |
264
- | suffix | `React.ReactNode` | — | |
265
- | inputClassName | `string` | — | |
266
- | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
269
+ | Prop | Type | Default | Description |
270
+ | -------------- | --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
271
+ | aria-invalid | `boolean` | — | |
272
+ | className | `string` | — | |
273
+ | prefix | `React.ReactNode` | — | Shown before the text. A decorative icon here needs `aria-hidden`; screen readers otherwise stop on it as a separate element and read out its glyph. |
274
+ | suffix | `React.ReactNode` | — | Shown after the text: a unit, or a control such as a clear button. |
275
+ | inputClassName | `string` | — | |
276
+ | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
267
277
 
268
278
  Also accepts the props of `TextInputProps`.
269
279
 
@@ -516,6 +526,10 @@ selected values stay on one line and truncate without displacing the trigger che
516
526
  For popover presentation on web, the options panel matches the trigger width, caps
517
527
  itself to the collision-available viewport, and wraps long option labels inside it.
518
528
 
529
+ Opening a Select, or a Popover, closes the on-screen keyboard first, so options opened
530
+ from a form are never hidden behind it. Both draw in a portal, where the platform would
531
+ otherwise leave the keyboard up; the Modal-hosted overlays close it on their own.
532
+
519
533
  `SelectContent` supports optional frosted props:
520
534
 
521
535
 
@@ -578,17 +592,20 @@ import {
578
592
  </SegmentedControl>;
579
593
  ```
580
594
 
595
+ When the labels do not fit a phone, `wrap="scroll"` keeps them on one row at full width
596
+ and scrolls sideways, instead of wrapping or truncating them.
581
597
 
582
- | Prop | Type | Default | Description |
583
- | ------------- | ------------------------- | ----------- | -------------------------- |
584
- | value | `string` | — | |
585
- | defaultValue | `string` | `''` | |
586
- | onValueChange | `(value: string) => void` | — | |
587
- | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
588
- | wrap | `boolean \| 'auto'` | `true` | |
589
- | size | `'default' \| 'compact'` | `'default'` | |
590
- | disabled | `boolean` | `false` | |
591
- | className | `string` | — | |
598
+
599
+ | Prop | Type | Default | Description |
600
+ | ------------- | ------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
601
+ | value | `string` | — | |
602
+ | defaultValue | `string` | `''` | |
603
+ | onValueChange | `(value: string) => void` | — | |
604
+ | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
605
+ | wrap | `boolean \| 'auto' \| 'scroll'` | `'auto'` | `true` wraps segments onto more rows; `false` keeps one row and shares its width. `'scroll'` keeps one row with each segment as wide as its label and scrolls sideways when they do not fit — for labels a phone cannot show in full. `'auto'` wraps above three segments. |
606
+ | size | `'default' \| 'compact'` | `'default'` | |
607
+ | disabled | `boolean` | `false` | |
608
+ | className | `string` | — | |
592
609
 
593
610
  Also accepts the props of `ViewProps`.
594
611
 
@@ -744,6 +761,10 @@ const icons = useIconColors();
744
761
  `FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never
745
762
  needs `hitSlop`.
746
763
 
764
+ A floating FAB covers whatever scrolls under it, so a list behind one ends with room for
765
+ it: `contentContainerStyle={{ paddingBottom: fabClearance() }}`, passing the FAB's `size`
766
+ when it is not the default.
767
+
747
768
  ---
748
769
 
749
770
  - **Alert** (Display) — AlertTitle & AlertDescription
@@ -477,6 +477,10 @@ const icons = useIconColors();
477
477
  `FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never
478
478
  needs `hitSlop`.
479
479
 
480
+ A floating FAB covers whatever scrolls under it, so a list behind one ends with room for
481
+ it: `contentContainerStyle={{ paddingBottom: fabClearance() }}`, passing the FAB's `size`
482
+ when it is not the default.
483
+
480
484
  ## Layout Primitives (Stack, Row)
481
485
 
482
486
  Use `Stack` for vertical layouts, `Row` for horizontal layouts. Both support `gap`, `align`, and `justify` variants.
@@ -624,9 +628,24 @@ reported, never changed, because a brand colour is a brand decision. The built-i
624
628
  `WHILEUI_PRESET_SOFT` and `WHILEUI_PRESET_ATELIER`, go through `themePresetToCss` the same
625
629
  way.
626
630
 
627
- Switch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`). A named
628
- extra theme is registered with `extraThemes: ['name']` in `metro.config.js` and defined
629
- with `@variant name`. The full token list, with each default, is in `theme-contract.ts`,
631
+ Switch at runtime with `Uniwind.setTheme('dark')` (or `'light'`, `'system'`), or let
632
+ `useThemeBridge` hold the choice. It returns the chosen `mode`, the `resolvedTheme` on
633
+ screen, `setMode`, `toggleTheme` and `cycleMode`. A sun/moon button calls `toggleTheme`,
634
+ which always flips what is on screen; `cycleMode` steps light → dark → system and suits
635
+ a control that shows all three, because a step into `system` that resolves to the
636
+ current theme looks like a dead tap. Pass an `adapter` to keep the choice across launches:
637
+
638
+ ```tsx
639
+ const { resolvedTheme, toggleTheme } = useThemeBridge({
640
+ adapter: {
641
+ loadThemeMode: () => storage.getItem('theme') as ThemeMode | null,
642
+ saveThemeMode: (mode) => storage.setItem('theme', mode),
643
+ },
644
+ });
645
+ ```
646
+
647
+ A named extra theme is registered with `extraThemes: ['name']` in `metro.config.js` and
648
+ defined with `@variant name`. The full token list, with each default, is in `theme-contract.ts`,
630
649
  and agents can read it from the MCP server's `get_theme_tokens`.
631
650
 
632
651
  ### Frosted surfaces
@@ -806,23 +825,33 @@ Also accepts the props of React Native's `TextProps`.
806
825
  ## Input
807
826
 
808
827
  ```tsx
809
- import { Input } from '@thewhileloop/whileui';
828
+ import { Input, useIconColors } from '@thewhileloop/whileui';
829
+ import { Feather } from '@expo/vector-icons';
830
+
831
+ const icons = useIconColors();
810
832
 
811
833
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
834
+
835
+ <Input
836
+ placeholder="Search"
837
+ prefix={<Feather name="search" size={16} color={icons.muted} aria-hidden />}
838
+ />;
812
839
  ```
813
840
 
814
- On web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their
815
- themed background, foreground, and caret when the browser autofills them.
841
+ On iOS and Android, Input, Textarea, NumericInput and SmartInput draw the caret and
842
+ selection in the theme's primary colour; pass `selectionColor` (and `cursorColor` on
843
+ Android) to override it. On web, filled surfaces keep their themed background,
844
+ foreground and caret when the browser autofills them.
816
845
 
817
846
 
818
- | Prop | Type | Default | Description |
819
- | -------------- | --------------------------------- | ----------- | ----------- |
820
- | aria-invalid | `boolean` | — | |
821
- | className | `string` | — | |
822
- | prefix | `React.ReactNode` | — | |
823
- | suffix | `React.ReactNode` | — | |
824
- | inputClassName | `string` | — | |
825
- | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
847
+ | Prop | Type | Default | Description |
848
+ | -------------- | --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
849
+ | aria-invalid | `boolean` | — | |
850
+ | className | `string` | — | |
851
+ | prefix | `React.ReactNode` | — | Shown before the text. A decorative icon here needs `aria-hidden`; screen readers otherwise stop on it as a separate element and read out its glyph. |
852
+ | suffix | `React.ReactNode` | — | Shown after the text: a unit, or a control such as a clear button. |
853
+ | inputClassName | `string` | — | |
854
+ | variant | `'default' \| 'error' \| 'plain'` | `'default'` | |
826
855
 
827
856
  Also accepts the props of `TextInputProps`.
828
857
 
@@ -989,17 +1018,20 @@ import {
989
1018
  </SegmentedControl>;
990
1019
  ```
991
1020
 
1021
+ When the labels do not fit a phone, `wrap="scroll"` keeps them on one row at full width
1022
+ and scrolls sideways, instead of wrapping or truncating them.
1023
+
992
1024
 
993
- | Prop | Type | Default | Description |
994
- | ------------- | ------------------------- | ----------- | -------------------------- |
995
- | value | `string` | — | |
996
- | defaultValue | `string` | `''` | |
997
- | onValueChange | `(value: string) => void` | — | |
998
- | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
999
- | wrap | `boolean \| 'auto'` | `true` | |
1000
- | size | `'default' \| 'compact'` | `'default'` | |
1001
- | disabled | `boolean` | `false` | |
1002
- | className | `string` | — | |
1025
+ | Prop | Type | Default | Description |
1026
+ | ------------- | ------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1027
+ | value | `string` | — | |
1028
+ | defaultValue | `string` | `''` | |
1029
+ | onValueChange | `(value: string) => void` | — | |
1030
+ | variant | `'default' \| 'pill'` | `'default'` | Pill = rounded-full items. |
1031
+ | wrap | `boolean \| 'auto' \| 'scroll'` | `'auto'` | `true` wraps segments onto more rows; `false` keeps one row and shares its width. `'scroll'` keeps one row with each segment as wide as its label and scrolls sideways when they do not fit — for labels a phone cannot show in full. `'auto'` wraps above three segments. |
1032
+ | size | `'default' \| 'compact'` | `'default'` | |
1033
+ | disabled | `boolean` | `false` | |
1034
+ | className | `string` | — | |
1003
1035
 
1004
1036
  Also accepts the props of `ViewProps`.
1005
1037
 
@@ -1299,6 +1331,10 @@ selected values stay on one line and truncate without displacing the trigger che
1299
1331
  For popover presentation on web, the options panel matches the trigger width, caps
1300
1332
  itself to the collision-available viewport, and wraps long option labels inside it.
1301
1333
 
1334
+ Opening a Select, or a Popover, closes the on-screen keyboard first, so options opened
1335
+ from a form are never hidden behind it. Both draw in a portal, where the platform would
1336
+ otherwise leave the keyboard up; the Modal-hosted overlays close it on their own.
1337
+
1302
1338
  `SelectContent` supports optional frosted props:
1303
1339
 
1304
1340
 
@@ -1,4 +1,4 @@
1
- # WhileUI 4.3.0
1
+ # WhileUI 4.4.0
2
2
 
3
3
  > React Native components and blocks for iOS, Android and web, styled with Uniwind and
4
4
  > themed through a typed token contract. Package: `@thewhileloop/whileui`.
@@ -8,7 +8,7 @@ import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
10
  import { webAutofillField } from '../../lib/web-autofill';
11
- import { controlRecipe, shadowStyle, surfaceRadius, typographyStyle } from '../../lib/recipes';
11
+ import { controlRecipe, shadowStyle, surfaceRadius, textCursorColors, typographyStyle, } from '../../lib/recipes';
12
12
  // The examples list is joined into one string so it can be an effect dependency without
13
13
  // re-running on every render. A NUL byte cannot appear in a placeholder.
14
14
  const SEPARATOR = '\0';
@@ -144,5 +144,5 @@ export const SmartInput = forwardRef(function SmartInput({ leftSlot, centerSlot,
144
144
  { maxHeight: visual.smartInputMaxHeight },
145
145
  !editable ? { opacity: interaction.disabledOpacity } : null,
146
146
  style,
147
- ], className: cn('flex-1 text-foreground placeholder:text-muted-foreground', focusRing, variant === 'bar' && 'border-border bg-muted', variant === 'bar' && webAutofillField, variant === 'card' && 'bg-transparent', inputClassName), multiline: true, submitBehavior: submitBehavior, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, placeholder: placeholderState.placeholder, value: value, defaultValue: defaultValue, onChangeText: placeholderState.onChangeText, editable: editable, ...props }), _jsx(SmartInputSlot, { children: rightSlot })] }));
147
+ ], className: cn('flex-1 text-foreground placeholder:text-muted-foreground', focusRing, variant === 'bar' && 'border-border bg-muted', variant === 'bar' && webAutofillField, variant === 'card' && 'bg-transparent', inputClassName), multiline: true, submitBehavior: submitBehavior, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...textCursorColors(colors), placeholder: placeholderState.placeholder, value: value, defaultValue: defaultValue, onChangeText: placeholderState.onChangeText, editable: editable, ...props }), _jsx(SmartInputSlot, { children: rightSlot })] }));
148
148
  });
@@ -61,6 +61,18 @@ declare const fabVariants: import("tailwind-variants").TVReturnType<{
61
61
  'floating-left': "absolute bottom-6 left-6";
62
62
  };
63
63
  }, undefined>>;
64
+ /** The `size` classes in points, and the `floating` placement's `bottom-6` inset. */
65
+ declare const FAB_SIZE: {
66
+ readonly default: 56;
67
+ readonly sm: 48;
68
+ readonly lg: 64;
69
+ };
70
+ /**
71
+ * Bottom padding that keeps a floating FAB off the last row of a scrolling screen: its
72
+ * inset from the edge, its height and a gap. Nothing stops the FAB covering content, so a
73
+ * list under one ends with `contentContainerStyle={{ paddingBottom: fabClearance() }}`.
74
+ */
75
+ export declare function fabClearance(size?: keyof typeof FAB_SIZE): number;
64
76
  export interface FabProps extends Omit<PressableProps, 'children'>, VariantProps<typeof fabVariants> {
65
77
  /**
66
78
  * What the action does, for screen readers. Required: a FAB is an icon with no
@@ -17,8 +17,8 @@ const fabVariants = tv({
17
17
  surface: 'border border-border bg-card',
18
18
  },
19
19
  size: {
20
- // 56 and 40 are Material's FAB and small-FAB sizes. Both clear the iOS 44pt
21
- // and Android 48dp minimums on their own, so a FAB never needs hitSlop.
20
+ // 56, 48 and 64pt, as FAB_SIZE below. 56 is Material's FAB size; all three clear
21
+ // the iOS 44pt and Android 48dp minimums on their own, so a FAB never needs hitSlop.
22
22
  default: 'h-14 w-14',
23
23
  sm: 'h-12 w-12',
24
24
  lg: 'h-16 w-16',
@@ -37,6 +37,18 @@ const fabVariants = tv({
37
37
  placement: 'floating',
38
38
  },
39
39
  });
40
+ /** The `size` classes in points, and the `floating` placement's `bottom-6` inset. */
41
+ const FAB_SIZE = { default: 56, sm: 48, lg: 64 };
42
+ const FAB_EDGE_INSET = 24;
43
+ const FAB_CONTENT_GAP = 16;
44
+ /**
45
+ * Bottom padding that keeps a floating FAB off the last row of a scrolling screen: its
46
+ * inset from the edge, its height and a gap. Nothing stops the FAB covering content, so a
47
+ * list under one ends with `contentContainerStyle={{ paddingBottom: fabClearance() }}`.
48
+ */
49
+ export function fabClearance(size = 'default') {
50
+ return FAB_EDGE_INSET + FAB_SIZE[size] + FAB_CONTENT_GAP;
51
+ }
40
52
  /**
41
53
  * A floating action button: one primary action, raised above the content.
42
54
  *
@@ -1 +1 @@
1
- export { FAB, FABGroup, fabVariants, type FabProps, type FabGroupProps } from './fab';
1
+ export { FAB, FABGroup, fabClearance, fabVariants, type FabProps, type FabGroupProps } from './fab';
@@ -1 +1 @@
1
- export { FAB, FABGroup, fabVariants } from './fab';
1
+ export { FAB, FABGroup, fabClearance, fabVariants } from './fab';
@@ -27,7 +27,12 @@ declare const inputVariants: import("tailwind-variants").TVReturnType<{
27
27
  export interface InputProps extends TextInputProps, VariantProps<typeof inputVariants> {
28
28
  'aria-invalid'?: boolean;
29
29
  className?: string;
30
+ /**
31
+ * Shown before the text. A decorative icon here needs `aria-hidden`; screen readers
32
+ * otherwise stop on it as a separate element and read out its glyph.
33
+ */
30
34
  prefix?: React.ReactNode;
35
+ /** Shown after the text: a unit, or a control such as a clear button. */
31
36
  suffix?: React.ReactNode;
32
37
  inputClassName?: string;
33
38
  }
@@ -7,7 +7,7 @@ import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
- import { typographyStyle } from '../../lib/recipes';
10
+ import { textCursorColors, typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
12
  import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const inputVariants = tv({
@@ -47,7 +47,7 @@ const Input = React.forwardRef(({ className, prefix, suffix, inputClassName, var
47
47
  ...typographyStyle(visual, 'label'),
48
48
  minHeight: visual.controlHeightDefault - 2,
49
49
  paddingVertical: visual.controlPaddingYDefault,
50
- }, editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, placeholderTextColor: placeholderTextColor ?? colors.placeholder, onFocus: (event) => {
50
+ }, editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...textCursorColors(colors), onFocus: (event) => {
51
51
  setFocused(true);
52
52
  onFocus?.(event);
53
53
  }, onBlur: (event) => {
@@ -7,7 +7,7 @@ import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
- import { typographyStyle } from '../../lib/recipes';
10
+ import { textCursorColors, typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
12
  import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const numericInputVariants = tv({
@@ -177,7 +177,7 @@ const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suff
177
177
  }, onBlur: (event) => {
178
178
  setFocused(false);
179
179
  model.handleBlur(event);
180
- }, keyboardType: "decimal-pad", editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...props }) }), _jsx(NumericAffix, { side: "suffix", visual: visual, children: suffix }), _jsx(NumericSteppers, { visible: showSteppers, inset: stepInset, size: stepSize, radius: stepRadius, model: model, interaction: interaction, visual: visual })] }));
180
+ }, keyboardType: "decimal-pad", editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...textCursorColors(colors), ...props }) }), _jsx(NumericAffix, { side: "suffix", visual: visual, children: suffix }), _jsx(NumericSteppers, { visible: showSteppers, inset: stepInset, size: stepSize, radius: stepRadius, model: model, interaction: interaction, visual: visual })] }));
181
181
  });
182
182
  NumericInput.displayName = 'NumericInput';
183
183
  export { NumericInput, numericInputVariants, numericInputTextVariants };
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
3
  import { Platform, StyleSheet } from 'react-native';
4
+ import { useComposedRefs } from '@rn-primitives/hooks';
4
5
  import * as PopoverPrimitive from '@rn-primitives/popover';
5
6
  import { cn } from '../../lib/cn';
7
+ import { useDismissKeyboardOnOpen } from '../../lib/dismiss-keyboard-on-open';
6
8
  import { useFrostedSurface } from '../../lib/frosted-surface';
7
9
  import { useNestedOverlay, useOverlayPortalTarget } from '../../lib/overlay-host';
8
10
  import { useOverlayInsets } from '../../lib/overlay-insets';
@@ -12,7 +14,13 @@ import { shadowStyle, surfacePadding, surfaceRadius } from '../../lib/recipes';
12
14
  function Popover({ onOpenChange, children }) {
13
15
  return _jsx(PopoverPrimitive.Root, { onOpenChange: onOpenChange, children: children });
14
16
  }
15
- const PopoverTrigger = React.forwardRef(({ className, ...props }, ref) => (_jsx(PopoverPrimitive.Trigger, { ref: ref, className: cn(className), ...props })));
17
+ const PopoverTrigger = React.forwardRef(({ className, ...props }, ref) => {
18
+ const { open, setTriggerPosition } = PopoverPrimitive.useRootContext();
19
+ const triggerRef = React.useRef(null);
20
+ const composedRef = useComposedRefs(triggerRef, ref);
21
+ useDismissKeyboardOnOpen(open, triggerRef, setTriggerPosition);
22
+ return _jsx(PopoverPrimitive.Trigger, { ref: composedRef, className: cn(className), ...props });
23
+ });
16
24
  PopoverTrigger.displayName = 'PopoverTrigger';
17
25
  const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffset = 4, insets, portalHost, children, style, frosted = false, blurIntensity, blurTintToken, ...props }, ref) => {
18
26
  const frostedSurface = useFrostedSurface({
@@ -9,6 +9,11 @@ declare const segmentedControlVariants: import("tailwind-variants").TVReturnType
9
9
  true: "flex-wrap justify-center gap-1";
10
10
  false: "flex-nowrap gap-1";
11
11
  };
12
+ /** Sized to its segments inside a horizontal scroll view. */
13
+ scroll: {
14
+ true: "w-auto";
15
+ false: "";
16
+ };
12
17
  size: {
13
18
  default: "";
14
19
  compact: "";
@@ -22,6 +27,11 @@ declare const segmentedControlVariants: import("tailwind-variants").TVReturnType
22
27
  true: "flex-wrap justify-center gap-1";
23
28
  false: "flex-nowrap gap-1";
24
29
  };
30
+ /** Sized to its segments inside a horizontal scroll view. */
31
+ scroll: {
32
+ true: "w-auto";
33
+ false: "";
34
+ };
25
35
  size: {
26
36
  default: "";
27
37
  compact: "";
@@ -35,6 +45,11 @@ declare const segmentedControlVariants: import("tailwind-variants").TVReturnType
35
45
  true: "flex-wrap justify-center gap-1";
36
46
  false: "flex-nowrap gap-1";
37
47
  };
48
+ /** Sized to its segments inside a horizontal scroll view. */
49
+ scroll: {
50
+ true: "w-auto";
51
+ false: "";
52
+ };
38
53
  size: {
39
54
  default: "";
40
55
  compact: "";
@@ -61,6 +76,11 @@ declare const segmentedControlItemVariants: import("tailwind-variants").TVReturn
61
76
  true: "grow-0";
62
77
  false: "flex-1";
63
78
  };
79
+ /** As wide as its label, so segments overflow into the scroll view rather than shrink. */
80
+ scroll: {
81
+ true: "flex-none";
82
+ false: "";
83
+ };
64
84
  }, undefined, "min-w-0 flex-row items-center justify-center", {
65
85
  variant: {
66
86
  default: "";
@@ -82,6 +102,11 @@ declare const segmentedControlItemVariants: import("tailwind-variants").TVReturn
82
102
  true: "grow-0";
83
103
  false: "flex-1";
84
104
  };
105
+ /** As wide as its label, so segments overflow into the scroll view rather than shrink. */
106
+ scroll: {
107
+ true: "flex-none";
108
+ false: "";
109
+ };
85
110
  }, undefined, import("tailwind-variants").TVReturnTypeLike<{
86
111
  variant: {
87
112
  default: "";
@@ -103,6 +128,11 @@ declare const segmentedControlItemVariants: import("tailwind-variants").TVReturn
103
128
  true: "grow-0";
104
129
  false: "flex-1";
105
130
  };
131
+ /** As wide as its label, so segments overflow into the scroll view rather than shrink. */
132
+ scroll: {
133
+ true: "flex-none";
134
+ false: "";
135
+ };
106
136
  }, undefined>>;
107
137
  declare const segmentedControlItemTextVariants: import("tailwind-variants").TVReturnType<{
108
138
  selected: {
@@ -138,7 +168,14 @@ export interface SegmentedControlProps extends ViewProps {
138
168
  onValueChange?: (value: string) => void;
139
169
  /** Pill = rounded-full items. */
140
170
  variant?: 'default' | 'pill';
141
- wrap?: boolean | 'auto';
171
+ /**
172
+ * `true` wraps segments onto more rows; `false` keeps one row and shares its width.
173
+ * `'scroll'` keeps one row with each segment as wide as its label and scrolls
174
+ * sideways when they do not fit — for labels a phone cannot show in full.
175
+ * `'auto'` wraps above three segments.
176
+ * @default 'auto'
177
+ */
178
+ wrap?: boolean | 'auto' | 'scroll';
142
179
  size?: 'default' | 'compact';
143
180
  disabled?: boolean;
144
181
  className?: string;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
3
- import { Pressable, Text, View, } from 'react-native';
3
+ import { Pressable, ScrollView, Text, View, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { focusRingInset } from '../../lib/focus-ring';
6
6
  import { tv } from '../../lib/tv';
@@ -19,6 +19,11 @@ const segmentedControlVariants = tv({
19
19
  true: 'flex-wrap justify-center gap-1',
20
20
  false: 'flex-nowrap gap-1',
21
21
  },
22
+ /** Sized to its segments inside a horizontal scroll view. */
23
+ scroll: {
24
+ true: 'w-auto',
25
+ false: '',
26
+ },
22
27
  size: {
23
28
  default: '',
24
29
  compact: '',
@@ -27,6 +32,7 @@ const segmentedControlVariants = tv({
27
32
  defaultVariants: {
28
33
  variant: 'default',
29
34
  wrap: true,
35
+ scroll: false,
30
36
  size: 'default',
31
37
  },
32
38
  });
@@ -53,6 +59,11 @@ const segmentedControlItemVariants = tv({
53
59
  true: 'grow-0',
54
60
  false: 'flex-1',
55
61
  },
62
+ /** As wide as its label, so segments overflow into the scroll view rather than shrink. */
63
+ scroll: {
64
+ true: 'flex-none',
65
+ false: '',
66
+ },
56
67
  },
57
68
  defaultVariants: {
58
69
  variant: 'default',
@@ -60,6 +71,7 @@ const segmentedControlItemVariants = tv({
60
71
  size: 'default',
61
72
  disabled: false,
62
73
  wrap: true,
74
+ scroll: false,
63
75
  },
64
76
  });
65
77
  const segmentedControlItemTextVariants = tv({
@@ -105,6 +117,7 @@ const SegmentedControlContext = createContext({
105
117
  variant: 'default',
106
118
  size: 'default',
107
119
  wrap: true,
120
+ scroll: false,
108
121
  disabled: false,
109
122
  });
110
123
  const SegmentedControlItemContext = createContext({
@@ -117,7 +130,8 @@ function SegmentedControl({ value: controlledValue, defaultValue = '', onValueCh
117
130
  const [internalValue, setInternalValue] = useState(defaultValue);
118
131
  const value = controlledValue ?? internalValue;
119
132
  const itemCount = useMemo(() => React.Children.toArray(children).filter((child) => React.isValidElement(child)).length, [children]);
120
- const shouldWrap = wrap === 'auto' ? itemCount > 3 : wrap;
133
+ const scroll = wrap === 'scroll';
134
+ const shouldWrap = wrap === 'auto' ? itemCount > 3 : wrap === true;
121
135
  const handleValueChange = useCallback((nextValue) => {
122
136
  if (disabled || value === nextValue) {
123
137
  return;
@@ -125,11 +139,20 @@ function SegmentedControl({ value: controlledValue, defaultValue = '', onValueCh
125
139
  setInternalValue(nextValue);
126
140
  onValueChange?.(nextValue);
127
141
  }, [disabled, onValueChange, value]);
128
- const contextValue = useMemo(() => ({ value, onValueChange: handleValueChange, variant, size, wrap: shouldWrap, disabled }), [disabled, handleValueChange, shouldWrap, size, value, variant]);
129
- return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsx(View, { className: cn(segmentedControlVariants({ variant, wrap: shouldWrap, size }), className), style: [getSegmentedContainerStyle(visual, colors, variant), styleProp], accessibilityRole: "radiogroup", ...props, children: children }) }));
142
+ const contextValue = useMemo(() => ({
143
+ value,
144
+ onValueChange: handleValueChange,
145
+ variant,
146
+ size,
147
+ wrap: shouldWrap,
148
+ scroll,
149
+ disabled,
150
+ }), [disabled, handleValueChange, scroll, shouldWrap, size, value, variant]);
151
+ const group = (_jsx(View, { className: cn(segmentedControlVariants({ variant, wrap: shouldWrap, scroll, size }), className), style: [getSegmentedContainerStyle(visual, colors, variant), styleProp], accessibilityRole: "radiogroup", ...props, children: children }));
152
+ return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: scroll ? (_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, children: group })) : (group) }));
130
153
  }
131
154
  function SegmentedControlItem({ value: itemValue, className, children, disabled: itemDisabled, style: styleProp, ...props }) {
132
- const { value, onValueChange, variant, size, wrap, disabled } = useContext(SegmentedControlContext);
155
+ const { value, onValueChange, variant, size, wrap, scroll, disabled } = useContext(SegmentedControlContext);
133
156
  const selected = value === itemValue;
134
157
  const finalDisabled = disabled || Boolean(itemDisabled);
135
158
  const interaction = useInteractionTokens();
@@ -150,6 +173,7 @@ function SegmentedControlItem({ value: itemValue, className, children, disabled:
150
173
  size,
151
174
  disabled: finalDisabled,
152
175
  wrap,
176
+ scroll,
153
177
  }), focusRingInset, className), onPress: () => onValueChange(itemValue), accessibilityRole: "radio",
154
178
  // A radio is checked, not selected. `selected` maps to `aria-selected`, which is
155
179
  // not a valid attribute on `role="radio"` — assistive technology could not tell
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
3
  import { Platform, ScrollView, StyleSheet, useWindowDimensions, View, } from 'react-native';
4
+ import { useComposedRefs } from '@rn-primitives/hooks';
4
5
  import * as SelectPrimitive from '@rn-primitives/select';
5
6
  import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens';
6
7
  import { Text } from '../text';
7
8
  import { cn } from '../../lib/cn';
9
+ import { useDismissKeyboardOnOpen } from '../../lib/dismiss-keyboard-on-open';
8
10
  import { focusRing } from '../../lib/focus-ring';
9
11
  import { useInteractionTokens } from '../../lib/interaction-tokens';
10
12
  import { useFrostedSurface } from '../../lib/frosted-surface';
@@ -26,7 +28,11 @@ const SelectTrigger = React.forwardRef(({ className, children, style: styleProp,
26
28
  const visual = useVisualTokens();
27
29
  const field = useFormFieldState();
28
30
  const effectiveDisabled = disabled ?? field.disabled;
29
- return (_jsxs(SelectPrimitive.Trigger, { ref: ref, className: cn('border-border bg-muted flex w-full flex-row items-center justify-between gap-2 shadow-sm active:bg-state-pressed', field.invalid && 'border-destructive', focusRing, effectiveDisabled && '', className),
31
+ const { open, setTriggerPosition } = SelectPrimitive.useRootContext();
32
+ const triggerRef = React.useRef(null);
33
+ const composedRef = useComposedRefs(triggerRef, ref);
34
+ useDismissKeyboardOnOpen(open, triggerRef, setTriggerPosition);
35
+ return (_jsxs(SelectPrimitive.Trigger, { ref: composedRef, className: cn('border-border bg-muted flex w-full flex-row items-center justify-between gap-2 shadow-sm active:bg-state-pressed', field.invalid && 'border-destructive', focusRing, effectiveDisabled && '', className),
30
36
  // Flattened, not an array. On web this primitive is merged through a Slot that
31
37
  // spreads `style`, and spreading an array yields { 0: …, 1: … } — numeric keys
32
38
  // that reach the DOM as indexed style properties and throw. The Select rendered
@@ -7,7 +7,7 @@ import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
9
9
  import { useVisualTokens } from '../../lib/visual-tokens';
10
- import { typographyStyle } from '../../lib/recipes';
10
+ import { textCursorColors, typographyStyle } from '../../lib/recipes';
11
11
  import { webAutofillField } from '../../lib/web-autofill';
12
12
  import { fieldAccessibilityProps, useFormFieldState } from '../form-field/form-field';
13
13
  const textareaVariants = tv({
@@ -48,7 +48,7 @@ const Textarea = React.forwardRef(({ className, size = 'default', placeholderTex
48
48
  paddingVertical: visual.controlPaddingYDefault,
49
49
  ...(!effectiveEditable ? { opacity: interaction.disabledOpacity } : null),
50
50
  };
51
- return (_jsx(TextInput, { ref: ref, ...fieldAccessibilityProps(field, props), className: cn(textareaVariants({ size }), field.invalid && 'border-destructive', focusField, webAutofillField, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, ...props }));
51
+ return (_jsx(TextInput, { ref: ref, ...fieldAccessibilityProps(field, props), className: cn(textareaVariants({ size }), field.invalid && 'border-destructive', focusField, webAutofillField, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...textCursorColors(colors), editable: effectiveEditable, "aria-invalid": ariaInvalid ?? field.invalid, ...props }));
52
52
  });
53
53
  Textarea.displayName = 'Textarea';
54
54
  export { Textarea, textareaVariants };
package/dist/index.d.ts CHANGED
@@ -30,7 +30,7 @@ export { OTPInput, otpCellVariants, type OTPInputProps } from './components/otp-
30
30
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, useFormFieldState, formFieldVariants, formControlVariants, type FormFieldProps, type FormLabelProps, type FormControlProps, type FormHintProps, type FormMessageProps, type FormFieldState, } from './components/form-field';
31
31
  export { LabeledField, LabeledFieldControl, type LabeledFieldProps, type LabeledFieldControlProps, } from './components/labeled-field';
32
32
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, type BadgeProps, type BadgeTextProps, } from './components/badge';
33
- export { FAB, FABGroup, fabVariants, type FabProps, type FabGroupProps } from './components/fab';
33
+ export { FAB, FABGroup, fabClearance, fabVariants, type FabProps, type FabGroupProps, } from './components/fab';
34
34
  export { Alert, AlertTitle, AlertDescription, alertVariants, type AlertProps, type AlertTitleProps, type AlertDescriptionProps, } from './components/alert';
35
35
  export { Switch, type SwitchProps } from './components/switch';
36
36
  export { Checkbox, type CheckboxProps } from './components/checkbox';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { OTPInput, otpCellVariants } from './components/otp-input';
30
30
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, useFormFieldState, formFieldVariants, formControlVariants, } from './components/form-field';
31
31
  export { LabeledField, LabeledFieldControl, } from './components/labeled-field';
32
32
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, } from './components/badge';
33
- export { FAB, FABGroup, fabVariants } from './components/fab';
33
+ export { FAB, FABGroup, fabClearance, fabVariants, } from './components/fab';
34
34
  export { Alert, AlertTitle, AlertDescription, alertVariants, } from './components/alert';
35
35
  export { Switch } from './components/switch';
36
36
  export { Checkbox } from './components/checkbox';
@@ -0,0 +1,16 @@
1
+ import { type RefObject } from 'react';
2
+ import type { LayoutPosition } from '@rn-primitives/hooks';
3
+ interface Measurable {
4
+ measure: (callback: (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void) => void;
5
+ }
6
+ /**
7
+ * Closes the keyboard when a portal overlay opens, then measures its trigger again.
8
+ *
9
+ * `Select` and `Popover` draw in a portal rather than a Modal, so iOS and Android leave the
10
+ * keyboard up and it covers the lower half of the content. Closing it moves any
11
+ * keyboard-avoiding screen, which leaves the trigger away from where it was measured on
12
+ * press, so the position is taken again once the keyboard has gone. Modal-hosted overlays
13
+ * need none of this: the platform closes the keyboard when the Modal's window takes focus.
14
+ */
15
+ export declare function useDismissKeyboardOnOpen(open: boolean, triggerRef: RefObject<Measurable | null>, setTriggerPosition: (position: LayoutPosition) => void): void;
16
+ export {};
@@ -0,0 +1,22 @@
1
+ import { useEffect } from 'react';
2
+ import { Keyboard, Platform } from 'react-native';
3
+ /**
4
+ * Closes the keyboard when a portal overlay opens, then measures its trigger again.
5
+ *
6
+ * `Select` and `Popover` draw in a portal rather than a Modal, so iOS and Android leave the
7
+ * keyboard up and it covers the lower half of the content. Closing it moves any
8
+ * keyboard-avoiding screen, which leaves the trigger away from where it was measured on
9
+ * press, so the position is taken again once the keyboard has gone. Modal-hosted overlays
10
+ * need none of this: the platform closes the keyboard when the Modal's window takes focus.
11
+ */
12
+ export function useDismissKeyboardOnOpen(open, triggerRef, setTriggerPosition) {
13
+ useEffect(() => {
14
+ if (!open || Platform.OS === 'web' || !Keyboard.isVisible())
15
+ return;
16
+ const subscription = Keyboard.addListener('keyboardDidHide', () => {
17
+ triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => setTriggerPosition({ width, height, pageX, pageY }));
18
+ });
19
+ Keyboard.dismiss();
20
+ return () => subscription.remove();
21
+ }, [open, triggerRef, setTriggerPosition]);
22
+ }
@@ -22,6 +22,26 @@ export declare function surfacePadding(visual: VisualTokens, padding?: SurfacePa
22
22
  export declare function surfaceRadius(visual: VisualTokens, radius?: 'sm' | 'md' | 'lg' | 'xl'): number;
23
23
  export declare function surfaceRecipe(visual: VisualTokens, options?: SurfaceRecipeOptions): ViewStyle;
24
24
  export declare function shadowStyle(visual: VisualTokens, colors: ThemeColors, tier: ShadowTier): ViewStyle;
25
+ /**
26
+ * Caret and selection colours for a text field: the theme's primary, where React Native
27
+ * would otherwise use the platform blue. iOS tints the caret, handles and highlight from
28
+ * `selectionColor` alone. Android paints `selectionColor` as the highlight at full
29
+ * strength, which would hide the selected text, so it gets a translucent highlight and
30
+ * the caret and handles take the solid colour. The web keeps the browser's caret.
31
+ */
32
+ export declare function textCursorColors(colors: ThemeColors): {
33
+ cursorColor: string;
34
+ selectionHandleColor: string;
35
+ selectionColor: string;
36
+ } | {
37
+ selectionColor: string;
38
+ cursorColor?: undefined;
39
+ selectionHandleColor?: undefined;
40
+ } | {
41
+ cursorColor?: undefined;
42
+ selectionHandleColor?: undefined;
43
+ selectionColor?: undefined;
44
+ };
25
45
  export declare function typographyStyle(visual: VisualTokens, role: TypographyRole): TextStyle;
26
46
  export declare function useControlRecipe(size?: ControlSize): ViewStyle;
27
47
  export declare function useFieldRecipe(size?: ControlSize): ViewStyle;
@@ -108,17 +108,34 @@ export function shadowStyle(visual, colors, tier) {
108
108
  offsetX: 0,
109
109
  offsetY,
110
110
  blurRadius: radius * 2,
111
- color: shadowColorWithOpacity(colors.shadow, opacity),
111
+ color: colorWithOpacity(colors.shadow, opacity),
112
112
  },
113
113
  ],
114
114
  };
115
115
  }
116
- function shadowColorWithOpacity(color, opacity) {
116
+ function colorWithOpacity(color, opacity) {
117
117
  const parsed = parse(color);
118
118
  if (!parsed)
119
119
  return color;
120
120
  return formatRgb({ ...parsed, alpha: opacity });
121
121
  }
122
+ /**
123
+ * Caret and selection colours for a text field: the theme's primary, where React Native
124
+ * would otherwise use the platform blue. iOS tints the caret, handles and highlight from
125
+ * `selectionColor` alone. Android paints `selectionColor` as the highlight at full
126
+ * strength, which would hide the selected text, so it gets a translucent highlight and
127
+ * the caret and handles take the solid colour. The web keeps the browser's caret.
128
+ */
129
+ export function textCursorColors(colors) {
130
+ if (Platform.OS === 'android') {
131
+ return {
132
+ cursorColor: colors.primary,
133
+ selectionHandleColor: colors.primary,
134
+ selectionColor: colorWithOpacity(colors.primary, 0.3),
135
+ };
136
+ }
137
+ return Platform.OS === 'ios' ? { selectionColor: colors.primary } : {};
138
+ }
122
139
  // Component-owned text sizes. Font family/weight stays on className
123
140
  // (font-medium etc.) so app font mappings keep working.
124
141
  export function typographyStyle(visual, role) {
@@ -14,10 +14,19 @@ export interface UseThemeBridgeOptions {
14
14
  adapter?: ThemeBridgeAdapter;
15
15
  }
16
16
  export interface UseThemeBridgeResult {
17
+ /** The chosen mode, `system` included. */
17
18
  mode: ThemeMode;
19
+ /** The theme on screen: `light`, `dark`, or a registered custom theme. */
18
20
  resolvedTheme: string;
19
21
  setMode: (mode: ThemeMode) => void;
22
+ /**
23
+ * Steps light → dark → system. For a control that shows all three modes; a two-state
24
+ * sun/moon button wants `toggleTheme`, because a step into `system` that resolves to
25
+ * the theme already on screen looks like a tap that did nothing.
26
+ */
20
27
  cycleMode: () => void;
28
+ /** Switches to the opposite of the theme on screen: dark from light, light from anything else. */
29
+ toggleTheme: () => void;
21
30
  }
22
31
  export declare function applyThemeMode(mode: ThemeMode): void;
23
32
  export declare function resolveThemeMode(adapter?: ThemeBridgeAdapter, fallback?: ThemeMode): Promise<ThemeMode>;
@@ -101,12 +101,14 @@ export function useThemeBridge({ initialMode = 'system', adapter, } = {}) {
101
101
  dispatch({ type: 'select', mode });
102
102
  }, []);
103
103
  const cycleMode = useCallback(() => dispatch({ type: 'cycle' }), []);
104
+ const toggleTheme = useCallback(() => dispatch({ type: 'select', mode: theme === 'light' ? 'dark' : 'light' }), [theme]);
104
105
  return useMemo(() => ({
105
106
  mode: state.mode,
106
107
  resolvedTheme: theme,
107
108
  setMode,
108
109
  cycleMode,
109
- }), [cycleMode, setMode, state.mode, theme]);
110
+ toggleTheme,
111
+ }), [cycleMode, setMode, state.mode, theme, toggleTheme]);
110
112
  }
111
113
  function ControlledThemeBridge({ mode, adapter, }) {
112
114
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {