@thewhileloop/whileui 2.0.0 → 4.0.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.
Files changed (74) hide show
  1. package/README.md +135 -70
  2. package/dist/blocks/commerce/metric-card.d.ts +9 -29
  3. package/dist/blocks/datepicker/date-range-picker-modal.d.ts +1 -1
  4. package/dist/blocks/datepicker/date-range-picker-modal.js +6 -14
  5. package/dist/blocks/datepicker/datepicker-modal.d.ts +1 -1
  6. package/dist/blocks/datepicker/datepicker-modal.js +6 -12
  7. package/dist/blocks/datepicker/index.d.ts +1 -0
  8. package/dist/blocks/datepicker/index.js +1 -0
  9. package/dist/blocks/datepicker/time-picker-modal.d.ts +43 -0
  10. package/dist/blocks/datepicker/time-picker-modal.js +187 -0
  11. package/dist/blocks/layout/smart-input.d.ts +3 -2
  12. package/dist/blocks/layout/smart-input.js +33 -18
  13. package/dist/blocks/media/smart-image.d.ts +39 -98
  14. package/dist/blocks/navigation/drawer-menu.js +88 -72
  15. package/dist/components/alert/alert.d.ts +23 -55
  16. package/dist/components/alert/alert.js +1 -1
  17. package/dist/components/alert-dialog/alert-dialog.js +1 -4
  18. package/dist/components/avatar/avatar.d.ts +18 -45
  19. package/dist/components/avatar/avatar.js +1 -1
  20. package/dist/components/badge/badge.d.ts +84 -212
  21. package/dist/components/button/button.d.ts +69 -188
  22. package/dist/components/card/card.d.ts +29 -73
  23. package/dist/components/card/card.js +1 -1
  24. package/dist/components/container/container.d.ts +24 -96
  25. package/dist/components/data-row/data-row.d.ts +18 -38
  26. package/dist/components/data-row/data-row.js +1 -1
  27. package/dist/components/fab/fab.d.ts +87 -0
  28. package/dist/components/fab/fab.js +59 -0
  29. package/dist/components/fab/index.d.ts +1 -0
  30. package/dist/components/fab/index.js +1 -0
  31. package/dist/components/form-field/form-field.d.ts +31 -87
  32. package/dist/components/form-field/form-field.js +1 -1
  33. package/dist/components/hover-card/hover-card.d.ts +3 -2
  34. package/dist/components/input/input.d.ts +18 -31
  35. package/dist/components/input/input.js +20 -8
  36. package/dist/components/label/label.d.ts +3 -3
  37. package/dist/components/labeled-field/labeled-field.d.ts +3 -2
  38. package/dist/components/numeric-input/numeric-input.d.ts +27 -82
  39. package/dist/components/numeric-input/numeric-input.js +79 -80
  40. package/dist/components/otp-input/otp-input.d.ts +21 -65
  41. package/dist/components/popover/popover.d.ts +4 -3
  42. package/dist/components/pressable/pressable.d.ts +3 -3
  43. package/dist/components/progress/progress.d.ts +15 -39
  44. package/dist/components/row/row.d.ts +63 -163
  45. package/dist/components/segmented-control/segmented-control.d.ts +69 -241
  46. package/dist/components/select/select.d.ts +10 -22
  47. package/dist/components/select/select.js +2 -7
  48. package/dist/components/separator/separator.d.ts +3 -3
  49. package/dist/components/skeleton/skeleton.d.ts +9 -29
  50. package/dist/components/spinner/spinner.d.ts +12 -36
  51. package/dist/components/stack/stack.d.ts +63 -163
  52. package/dist/components/tabs/tabs.d.ts +9 -29
  53. package/dist/components/text/text.d.ts +51 -131
  54. package/dist/components/textarea/textarea.d.ts +15 -39
  55. package/dist/components/textarea/textarea.js +3 -3
  56. package/dist/components/toast/toast.d.ts +12 -36
  57. package/dist/components/toggle/toggle.d.ts +42 -138
  58. package/dist/components/toggle-group/toggle-group.d.ts +42 -138
  59. package/dist/components/tooltip/tooltip.d.ts +3 -2
  60. package/dist/components/view/view.d.ts +3 -3
  61. package/dist/index.d.ts +2 -1
  62. package/dist/index.js +2 -1
  63. package/dist/lib/focus-ring.d.ts +10 -0
  64. package/dist/lib/focus-ring.js +10 -0
  65. package/dist/lib/interaction-tokens.d.ts +24 -1
  66. package/dist/lib/interaction-tokens.js +26 -44
  67. package/dist/lib/motion-policy.js +2 -2
  68. package/dist/lib/ref-types.d.ts +3 -0
  69. package/dist/lib/ref-types.js +1 -0
  70. package/dist/lib/theme-bridge.d.ts +4 -2
  71. package/dist/lib/theme-bridge.js +13 -9
  72. package/dist/lib/theme-presets.d.ts +26 -0
  73. package/dist/lib/theme-presets.js +28 -0
  74. package/package.json +29 -28
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Beautiful, accessible, themeable components built with [Uniwind](https://uniwind.dev) + Tailwind CSS v4.
6
6
 
7
- **Requirements:** React 19+, React Native 0.81+, Expo 52+ (if using Expo). Supports React Native Web.
7
+ **Requirements:** React 19.3+, React Native 0.88+, Expo 58+ (if using Expo). Supports React Native Web.
8
8
 
9
9
  WhileUI has one renderer: React Native. iOS, Android and React Native Web are all
10
10
  production targets — iOS and Android set the interaction baseline. See
@@ -15,7 +15,7 @@ production targets — iOS and Android set the interaction baseline. See
15
15
 
16
16
  ```bash
17
17
  bun add @thewhileloop/whileui
18
- bun add react@^19.0.0 react-native@^0.81.0 uniwind@^1.0.0 tailwindcss@^4.0.0 react-native-reanimated react-native-safe-area-context react-native-screens
18
+ bun 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
19
19
  # Only if you use Select, Popover, Tooltip, or HoverCard:
20
20
  bun add @rn-primitives/portal @rn-primitives/hooks @rn-primitives/slot @rn-primitives/select @rn-primitives/popover @rn-primitives/tooltip @rn-primitives/hover-card
21
21
  ```
@@ -27,16 +27,20 @@ Use `npm install` instead of `bun add` if you prefer npm.
27
27
  1. **metro.config.js** (wrap with withUniwindConfig):
28
28
 
29
29
  ```js
30
+ const { getDefaultConfig } = require('expo/metro-config');
30
31
  const { withUniwindConfig } = require('uniwind/metro');
31
32
 
32
- module.exports = withUniwindConfig({
33
+ /** @type {import('expo/metro-config').MetroConfig} */
34
+ const config = getDefaultConfig(__dirname);
35
+
36
+ module.exports = withUniwindConfig(config, {
33
37
  cssEntryFile: './global.css',
34
- })({
35
- // your metro config
36
38
  });
37
39
  ```
38
40
 
39
- > `withUniwindConfig` must be the outermost wrapper. `cssEntryFile` must be a relative path string.
41
+ > `withUniwindConfig(config, options)` takes the config first and returns the wrapped
42
+ > config — it is not curried. It must wrap the outermost config object, and
43
+ > `cssEntryFile` must be a relative path string.
40
44
 
41
45
  2. **global.css** at app root:
42
46
 
@@ -44,6 +48,11 @@ module.exports = withUniwindConfig({
44
48
  @import 'tailwindcss';
45
49
  @import 'uniwind';
46
50
 
51
+ /* Required when WhileUI is installed from npm. Tailwind does not scan
52
+ node_modules, so without this its class names are never detected and every
53
+ component renders unstyled. */
54
+ @source './node_modules/@thewhileloop/whileui/dist';
55
+
47
56
  /* WhileUI Noir theme - copy this or create your own */
48
57
  @layer theme {
49
58
  :root {
@@ -216,7 +225,9 @@ function MyScreen() {
216
225
  - **PortalHost:** Add `<PortalHost />` at app root for Select, Popover, Tooltip, HoverCard.
217
226
  - **Motion:** Wrap the root in `<MotionProvider>`; use `useMotionPolicy()` for semantic
218
227
  durations, the responsive spring, and reduced-motion behavior.
219
- - **Uniwind:** `withUniwindConfig` must wrap metro config. `global.css` at app root, imported in `App.tsx`.
228
+ - **Uniwind:** `withUniwindConfig(config, options)` wraps metro config — config first, not
229
+ curried. `global.css` at app root, imported in `App.tsx`. Installing from npm also needs
230
+ `@source './node_modules/@thewhileloop/whileui/dist';` or every component renders unstyled.
220
231
  - **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/templates/` (auth, profile); flow patterns in README "Flow Patterns" section.
221
232
 
222
233
  ## Components
@@ -259,18 +270,19 @@ function MyScreen() {
259
270
 
260
271
  ### Display
261
272
 
262
- | Component | Variants | Notes |
263
- | --------------- | ------------------------------------------------- | ------------------------------------------------------------------------------- |
264
- | **Card** | padding (none, sm, default, lg), unstyled | Compound: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
265
- | **Badge** | default, secondary, destructive, outline, success | BadgeText sub-component |
266
- | **Alert** | default, destructive, success, warning | AlertTitle & AlertDescription |
267
- | **Avatar** | sm, default, lg | AvatarImage + AvatarFallback |
268
- | **DataRow** | default, compact | DataRow, DataRowLeft/Center/Right, DataRowLabel/Description/Value |
269
- | **Separator** | horizontal, vertical | Themed divider |
270
- | **Progress** | sm, default, lg | Value-based progress bar with accessibility |
271
- | **Spinner** | sm, default, lg | ActivityIndicator wrapper |
272
- | **Skeleton** | pulse, shimmer | Loading placeholder (pulse = opacity fade, shimmer = sweep) |
273
- | **AspectRatio** | — | Maintain aspect ratio container |
273
+ | Component | Variants | Notes |
274
+ | --------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
275
+ | **Card** | padding (none, sm, default, lg), unstyled | Compound: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
276
+ | **Badge** | default, secondary, destructive, outline, success | BadgeText sub-component |
277
+ | **FAB** | default, secondary, destructive, surface; sm/default/lg | Floating action button. `label` required; pass your own icon as children |
278
+ | **Alert** | default, destructive, success, warning | AlertTitle & AlertDescription |
279
+ | **Avatar** | sm, default, lg | AvatarImage + AvatarFallback |
280
+ | **DataRow** | default, compact | DataRow, DataRowLeft/Center/Right, DataRowLabel/Description/Value |
281
+ | **Separator** | horizontal, vertical | Themed divider |
282
+ | **Progress** | sm, default, lg | Value-based progress bar with accessibility |
283
+ | **Spinner** | sm, default, lg | ActivityIndicator wrapper |
284
+ | **Skeleton** | pulse, shimmer | Loading placeholder (pulse = opacity fade, shimmer = sweep) |
285
+ | **AspectRatio** | — | Maintain aspect ratio container |
274
286
 
275
287
  ### Layout
276
288
 
@@ -404,6 +416,7 @@ Copy from `apps/showcase/templates/profile/`:
404
416
  | **DatePickerModal** | Bottom sheet modal with calendar, compact trigger |
405
417
  | **DatePickerInline** | Inline calendar for forms or dashboards |
406
418
  | **DateRangePickerModal** | Range selection modal with period marking |
419
+ | **TimePickerModal** | Hour and minute wheels in the same bottom sheet |
407
420
 
408
421
  **Tokens without a renderer.** `@thewhileloop/whileui/tokens` exports the theme
409
422
  and visual contracts, the presets, and `deriveThemeColors(base, scheme)` — give it
@@ -412,6 +425,55 @@ as the default presets. Safe in a build script (no React, RN or Uniwind
412
425
  imports); `useThemeColors()` uses it too, so a missing `--color-primary-soft`
413
426
  derives from the app's own primary instead of falling back to the default palette.
414
427
 
428
+ **Building a product preset.** Use `createThemePreset` rather than a `ThemePreset`
429
+ literal — it takes the 25 base colours per scheme and derives the rest:
430
+
431
+ ```ts
432
+ import { createThemePreset, themePresetToCss } from '@thewhileloop/whileui/tokens';
433
+
434
+ const preset = createThemePreset({
435
+ name: 'acme',
436
+ description: 'Acme brand palette.',
437
+ light: { background: '...', foreground: '...', primary: '...' /* 25 base keys */ },
438
+ dark: { background: '...', foreground: '...', primary: '...' },
439
+ visual: { radiusLg: 12 }, // optional, partial
440
+ });
441
+
442
+ writeFileSync('theme.generated.css', themePresetToCss(preset));
443
+ ```
444
+
445
+ The literal form asks for all 52 colours per scheme, including every active variant,
446
+ soft variant, overlay, surface, interaction state and shadow. Those are exactly what
447
+ `deriveThemeColors` computes, so hand-writing them produces a second derivation that
448
+ drifts from the library's. Override a derived value by assigning to the result.
449
+
450
+ ### FAB
451
+
452
+ One primary action, raised above the content. It owns no icon dependency — pass the
453
+ glyph as children and colour it from `useIconColors()`, because icon libraries need a
454
+ concrete colour rather than a class.
455
+
456
+ ```tsx
457
+ import { FAB, useIconColors } from '@thewhileloop/whileui';
458
+ import { Feather } from '@expo/vector-icons';
459
+
460
+ const icons = useIconColors();
461
+
462
+ <FAB label="New group" onPress={create}>
463
+ <Feather name="plus" size={24} color={icons.primaryForeground} />
464
+ </FAB>;
465
+ ```
466
+
467
+ | Prop | Type | Default | Notes |
468
+ | ----------- | ------------------------------------------------ | ---------- | --------------------------------------- |
469
+ | `label` | `string` | — | Required. The accessible name |
470
+ | `variant` | `default \| secondary \| destructive \| surface` | `default` | |
471
+ | `size` | `sm \| default \| lg` | `default` | 48 / 56 / 64 — all clear touch minimums |
472
+ | `placement` | `floating \| floating-left \| inline` | `floating` | `inline` lets the caller position it |
473
+
474
+ `FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never
475
+ needs `hitSlop`.
476
+
415
477
  ## Layout Primitives (Stack, Row)
416
478
 
417
479
  Use `Stack` for vertical layouts, `Row` for horizontal layouts. Both support `gap`, `align`, and `justify` variants.
@@ -592,7 +654,7 @@ The WhileUI token contract is strict for cross-app reuse. Define these in **ever
592
654
  - Required core tokens: `background`, `foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `muted`, `muted-foreground`, `accent`, `accent-foreground`, `destructive`, `destructive-foreground`, `border`, `input`, `ring`
593
655
  - Optional status tokens: `success`, `success-foreground`, `warning`, `warning-foreground`, `info`, `info-foreground`
594
656
  - Optional effect tokens: `overlay`, `overlay-strong`, `surface-elevated`, `surface-border`, `surface-highlight`, `surface-translucent`, `surface-translucent-border`, `state-hover`, `state-pressed`, `state-disabled`
595
- - Optional interaction/motion tokens: `--ui-press-opacity`, `--ui-press-opacity-strong`, `--ui-disabled-opacity`, `--ui-disabled-opacity-soft`, `--ui-disabled-opacity-subtle`, `--ui-inactive-opacity`, `--ui-motion-fast`, `--ui-motion-normal`, `--ui-motion-slow`, `--ui-motion-spring-damping`, `--ui-motion-spring-stiffness`, `--ui-motion-spring-mass`, `--ui-drawer-open-duration`, `--ui-drawer-close-duration`, `--ui-blur-intensity-subtle`, `--ui-blur-intensity-medium`, `--ui-blur-intensity-strong`, `--ui-blur-saturation-pct`, `--ui-frosted-highlight-height`, `--ui-frosted-backdrop-blur-intensity`, `--ui-frosted-backdrop-blur-scale`, `--ui-frosted-android-tint-alpha-scale`, `--ui-frosted-android-experimental-blur`, `--ui-drawer-frosted-inset`, `--ui-drawer-frosted-radius`, `--ui-drawer-content-top-padding`
657
+ - Optional visual tokens, generated from a preset: `--ui-blur-intensity-subtle`, `--ui-blur-intensity-medium`, `--ui-blur-intensity-strong`, `--ui-blur-saturation-pct`, `--ui-frosted-highlight-height`, `--ui-frosted-backdrop-blur-intensity`, `--ui-frosted-backdrop-blur-scale`, `--ui-frosted-android-tint-alpha-scale`, `--ui-frosted-android-experimental-blur`, `--ui-drawer-frosted-inset`, `--ui-drawer-frosted-radius`, `--ui-drawer-content-top-padding`
596
658
  - Optional scale tokens: spacing (`--spacing`, `--spacing-*`), typography (`--text-*`, `--leading-*`, `--tracking-*`), radius (`--radius-*`), elevation (`--shadow-*`)
597
659
 
598
660
  Minimal contract example:
@@ -710,53 +772,25 @@ const iconColors = useIconColors();
710
772
 
711
773
  Input, Textarea, NumericInput, SmartInput, Spinner, and LoadingScreen default to theme colors when you omit `placeholderTextColor` or `spinnerColor`.
712
774
 
713
- ### Interaction + Motion Tokens
714
-
715
- WhileUI components also read optional `--ui-*` tokens for deeper control of press feedback, disabled states, and motion timing:
775
+ ### Interaction and motion
716
776
 
717
- ```css
718
- @theme {
719
- --ui-press-opacity: 0.72;
720
- --ui-press-opacity-strong: 0.9;
721
- --ui-disabled-opacity: 0.5;
722
- --ui-disabled-opacity-soft: 0.6;
723
- --ui-disabled-opacity-subtle: 0.4;
724
- --ui-inactive-opacity: 0.5;
725
- --ui-motion-fast: 160;
726
- --ui-motion-normal: 220;
727
- --ui-motion-slow: 300;
728
- --ui-motion-spring-damping: 20;
729
- --ui-motion-spring-stiffness: 260;
730
- --ui-motion-spring-mass: 0.8;
731
- --ui-drawer-open-duration: 300;
732
- --ui-drawer-close-duration: 220;
733
- --ui-blur-intensity-subtle: 18;
734
- --ui-blur-intensity-medium: 22;
735
- --ui-blur-intensity-strong: 28;
736
- --ui-blur-saturation-pct: 185;
737
- --ui-frosted-highlight-height: 0;
738
- --ui-frosted-backdrop-blur-intensity: 14;
739
- --ui-frosted-backdrop-blur-scale: 0.55;
740
- --ui-frosted-android-tint-alpha-scale: 1.22;
741
- --ui-frosted-android-experimental-blur: 1;
742
- --ui-drawer-frosted-inset: 0;
743
- --ui-drawer-frosted-radius: 28;
744
- --ui-drawer-content-top-padding: 0;
745
- }
746
- ```
777
+ Press opacity, disabled opacity, motion durations, the responsive spring, and the drawer
778
+ open and close durations are package constants on `INTERACTION_TOKENS`. They are not CSS
779
+ variables and they do not change with a preset. Delete any `--ui-press-*`,
780
+ `--ui-disabled-*`, `--ui-inactive-opacity`, `--ui-motion-*`, or `--ui-drawer-open-duration`
781
+ / `--ui-drawer-close-duration` declarations from a theme: nothing reads them.
747
782
 
748
- For custom components, use:
783
+ For a custom control, read that table:
749
784
 
750
785
  ```tsx
751
786
  import { useInteractionTokens, withInteractivePressableStyle } from '@thewhileloop/whileui';
752
787
  ```
753
788
 
754
- Wrap the application in `MotionProvider` to coordinate system reduced motion, explicit
755
- preview overrides, and deterministic animation-free tests. The provider is optional:
756
- without it `useMotionPolicy` returns the token defaults with Reanimated following the
757
- system setting, and `--ui-motion-*` overrides are not read. `reduced` preserves
758
- non-spatial feedback while asking Reanimated to skip motion; `disabled` also resolves
759
- semantic durations to zero.
789
+ Wrap the application in `MotionProvider` to coordinate system reduced motion, an explicit
790
+ preview mode, and deterministic animation-free tests. The provider is optional. Without
791
+ it `useMotionPolicy` reports the durations and spring from `INTERACTION_TOKENS`, and
792
+ Reanimated follows the system setting. `reduced` preserves non-spatial feedback while asking Reanimated to skip motion;
793
+ `disabled` also resolves semantic durations to zero.
760
794
 
761
795
  ```tsx
762
796
  import { MotionProvider, useMotionPolicy } from '@thewhileloop/whileui';
@@ -851,7 +885,8 @@ Optional RN fallback tokens (hex/rgb/hsl/named):
851
885
  }
852
886
  ```
853
887
 
854
- Or use the first-party ThemeBridge helper with optional persistence:
888
+ Or use the first-party theme bridge with optional persistence. The hook owns theme
889
+ state and exposes event-driven setters:
855
890
 
856
891
  ```tsx
857
892
  import { useThemeBridge, type ThemeBridgeAdapter } from '@thewhileloop/whileui';
@@ -866,6 +901,16 @@ const adapter: ThemeBridgeAdapter = {
866
901
  const { mode, resolvedTheme, setMode, cycleMode } = useThemeBridge({ adapter });
867
902
  ```
868
903
 
904
+ When another store already owns the mode, mount the controlled bridge once near the
905
+ application root. Change `mode` in the owning event handler; the bridge applies and
906
+ persists it without mirroring state back through a callback effect.
907
+
908
+ ```tsx
909
+ import { ThemeBridge } from '@thewhileloop/whileui';
910
+
911
+ <ThemeBridge mode={mode} adapter={adapter} />;
912
+ ```
913
+
869
914
  ## Focus rings
870
915
 
871
916
  Every interactive component draws a themed focus ring on web. It uses `:focus-visible`,
@@ -963,12 +1008,12 @@ import { Input } from '@thewhileloop/whileui';
963
1008
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
964
1009
  ```
965
1010
 
966
- | Prop | Type | Default | Description |
967
- | -------------------- | ---------------------- | ----------- | -------------------------------------------------------- |
968
- | variant | `'default' \| 'error'` | `'default'` | Input style variant |
969
- | placeholder | `string` | — | Placeholder text |
970
- | placeholderTextColor | `string` | — | Hex for placeholder; defaults to theme `mutedForeground` |
971
- | editable | `boolean` | `true` | Whether input is editable |
1011
+ | Prop | Type | Default | Description |
1012
+ | -------------------- | --------------------------------- | ----------- | --------------------------------------------------------------------- |
1013
+ | variant | `'default' \| 'error' \| 'plain'` | `'default'` | Input style; `plain` drops border and fill for a large display figure |
1014
+ | placeholder | `string` | — | Placeholder text |
1015
+ | placeholderTextColor | `string` | — | Hex for placeholder; defaults to theme `mutedForeground` |
1016
+ | editable | `boolean` | `true` | Whether input is editable |
972
1017
 
973
1018
  ## NumericInput
974
1019
 
@@ -1550,7 +1595,8 @@ announces as a heading to assistive technology — as do `DropdownMenuLabel` and
1550
1595
 
1551
1596
  ## SignInForm
1552
1597
 
1553
- Copy from `apps/showcase/templates/auth/sign-in-form.tsx`, then:
1598
+ Copy `sign-in-form.tsx` and its sibling `auth-form-footer.tsx` from
1599
+ `apps/showcase/templates/auth/`, then:
1554
1600
 
1555
1601
  ```tsx
1556
1602
  import { SignInForm } from './templates/auth'; // or your path
@@ -1574,7 +1620,8 @@ import { SignInForm } from './templates/auth'; // or your path
1574
1620
 
1575
1621
  ## SignUpForm
1576
1622
 
1577
- Copy from `apps/showcase/templates/auth/sign-up-form.tsx`, then:
1623
+ Copy `sign-up-form.tsx` and its sibling `auth-form-footer.tsx` from
1624
+ `apps/showcase/templates/auth/`, then:
1578
1625
 
1579
1626
  ```tsx
1580
1627
  import { SignUpForm } from './templates/auth'; // or your path
@@ -1730,7 +1777,7 @@ const [value, setValue] = useState('');
1730
1777
  | `inputSafeArea` | `boolean` | SmartInput safe-area (default `true`) |
1731
1778
  | `keyboardVerticalOffset` | `number` | For header offset when keyboard opens |
1732
1779
 
1733
- ## DatePickerModal / DatePickerInline / DateRangePickerModal
1780
+ ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1734
1781
 
1735
1782
  Date selection blocks using react-native-calendars. Theme-aware via `useCalendarTheme` (Uniwind light/dark). Optional `theme` prop for custom RN color strings (hex/rgb/hsl/named). `CalendarTheme` maps to react-native-calendars Theme and supports `arrowColor`, `disabledArrowColor`, and optional font keys.
1736
1783
 
@@ -1740,6 +1787,8 @@ Date selection blocks using react-native-calendars. Theme-aware via `useCalendar
1740
1787
 
1741
1788
  **DateRangePickerModal** — Range selection with period marking. Use `DateRangePickerTrigger` as the trigger content.
1742
1789
 
1790
+ **TimePickerModal** — Hour and minute wheels (plus AM/PM under a 12-hour `hourCycle`) in the same bottom sheet, so a form's date and time fields open the same way. `value` is always `HH:MM` in 24-hour form. Each wheel is one `adjustable` element for assistive tech and the keyboard. Use `TimePickerTrigger` as the trigger content.
1791
+
1743
1792
  ```tsx
1744
1793
  import {
1745
1794
  DatePickerModal,
@@ -1747,6 +1796,8 @@ import {
1747
1796
  DatePickerInline,
1748
1797
  DateRangePickerModal,
1749
1798
  DateRangePickerTrigger,
1799
+ TimePickerModal,
1800
+ TimePickerTrigger,
1750
1801
  type DateRange,
1751
1802
  } from '@thewhileloop/whileui';
1752
1803
 
@@ -1776,6 +1827,18 @@ const [range, setRange] = useState<DateRange | null>(null);
1776
1827
  onOpenChange={setRangeOpen}
1777
1828
  trigger={<DateRangePickerTrigger value={range} placeholder="Pick range" />}
1778
1829
  />;
1830
+
1831
+ // Time (modal) — "HH:MM", 24-hour, whatever the wheels show
1832
+ const [time, setTime] = useState<string | null>(null);
1833
+
1834
+ <TimePickerModal
1835
+ value={time}
1836
+ onValueChange={setTime}
1837
+ open={timeOpen}
1838
+ onOpenChange={setTimeOpen}
1839
+ minuteStep={5}
1840
+ trigger={<TimePickerTrigger value={time} placeholder="Pick a time" />}
1841
+ />;
1779
1842
  ```
1780
1843
 
1781
1844
  | Prop | Type | Description |
@@ -1786,6 +1849,8 @@ const [range, setRange] = useState<DateRange | null>(null);
1786
1849
  | `trigger` | `ReactNode` | Custom trigger (modal variants) |
1787
1850
  | `minDate` / `maxDate` | `string` | YYYY-MM-DD bounds |
1788
1851
  | `theme` | `CalendarTheme` | Override calendar colors |
1852
+ | `hourCycle` | `'h12' \| 'h23'` | Time picker wheels; defaults to locale |
1853
+ | `minuteStep` | `number` | Minutes between options (default 1) |
1789
1854
  | `frosted` | `boolean` | Enable frosted surface for modal panel |
1790
1855
  | `blurIntensity` | `number` | Override blur amount |
1791
1856
  | `blurTintToken` | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | Tint token for frosted panel |
@@ -3,40 +3,20 @@ import { type ViewProps } from 'react-native';
3
3
  import { type VariantProps } from '../../lib/tv';
4
4
  declare const metricCardVariants: import("tailwind-variants").TVReturnType<{
5
5
  variant: {
6
- default: string;
7
- outlined: string;
6
+ default: "bg-card";
7
+ outlined: "bg-transparent border border-border";
8
8
  };
9
- }, undefined, "", import("tailwind-variants/dist/config.js").TVConfig<{
9
+ }, undefined, "", {
10
10
  variant: {
11
- default: string;
12
- outlined: string;
11
+ default: "bg-card";
12
+ outlined: "bg-transparent border border-border";
13
13
  };
14
- }, {
14
+ }, undefined, import("tailwind-variants").TVReturnTypeLike<{
15
15
  variant: {
16
- default: string;
17
- outlined: string;
16
+ default: "bg-card";
17
+ outlined: "bg-transparent border border-border";
18
18
  };
19
- }>, {
20
- variant: {
21
- default: string;
22
- outlined: string;
23
- };
24
- }, undefined, import("tailwind-variants").TVReturnType<{
25
- variant: {
26
- default: string;
27
- outlined: string;
28
- };
29
- }, undefined, "", import("tailwind-variants/dist/config.js").TVConfig<{
30
- variant: {
31
- default: string;
32
- outlined: string;
33
- };
34
- }, {
35
- variant: {
36
- default: string;
37
- outlined: string;
38
- };
39
- }>, unknown, unknown, undefined>>;
19
+ }, undefined>>;
40
20
  export interface MetricCardProps extends ViewProps, VariantProps<typeof metricCardVariants> {
41
21
  className?: string;
42
22
  label: string;
@@ -29,7 +29,7 @@ export interface DateRangePickerModalProps extends FrostedSurfaceProps {
29
29
  className?: string;
30
30
  }
31
31
  declare function formatRangeDisplay(range: DateRange | null | undefined): string;
32
- export declare function DateRangePickerModal({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title, confirmLabel, className, frosted, blurIntensity, blurTintToken, }: DateRangePickerModalProps): React.JSX.Element;
32
+ export declare function DateRangePickerModal(props: DateRangePickerModalProps): React.JSX.Element;
33
33
  /** Compact trigger showing formatted range. Use as DateRangePickerModal's trigger prop. */
34
34
  export declare function DateRangePickerTrigger({ value, placeholder, className, }: {
35
35
  value?: DateRange | null;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useCallback, useMemo, useState } from 'react';
3
3
  import { Modal, Pressable, View } from 'react-native';
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
5
  import { useUniwind } from 'uniwind';
@@ -47,7 +47,7 @@ function buildMarkedDates(start, end, theme) {
47
47
  }
48
48
  return marked;
49
49
  }
50
- export function DateRangePickerModal({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title = 'Select date range', confirmLabel = 'Done', className, frosted = false, blurIntensity, blurTintToken, }) {
50
+ function DateRangePickerModalSession({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title = 'Select date range', confirmLabel = 'Done', className, frosted = false, blurIntensity, blurTintToken, }) {
51
51
  const insets = useSafeAreaInsets();
52
52
  const { theme } = useUniwind();
53
53
  const colors = useThemeColors();
@@ -68,18 +68,6 @@ export function DateRangePickerModal({ value, onValueChange, open, onOpenChange,
68
68
  const renderArrow = useCallback((direction) => (_jsx(Text, { className: "font-medium", style: { color: arrowColor, fontSize: arrowFontSize }, children: direction === 'left' ? '<' : '>' })), [arrowColor, arrowFontSize]);
69
69
  const [draftStart, setDraftStart] = useState(value?.start ?? null);
70
70
  const [draftEnd, setDraftEnd] = useState(value?.end ?? null);
71
- const prevOpenRef = useRef(open);
72
- useEffect(() => {
73
- if (open && !prevOpenRef.current) {
74
- setDraftStart(value?.start ?? null);
75
- setDraftEnd(value?.end ?? null);
76
- }
77
- else if (!open && prevOpenRef.current) {
78
- setDraftStart(null);
79
- setDraftEnd(null);
80
- }
81
- prevOpenRef.current = open;
82
- }, [open, value]);
83
71
  const markedDates = useMemo(() => {
84
72
  const color = calendarTheme.selectedDayBackgroundColor;
85
73
  const textColor = calendarTheme.selectedDayTextColor;
@@ -151,6 +139,10 @@ export function DateRangePickerModal({ value, onValueChange, open, onOpenChange,
151
139
  borderWidth: visual.borderWidthHairline,
152
140
  }, children: _jsx(Calendar, { current: draftStart ?? value?.start ?? undefined, onDayPress: handleDayPress, markedDates: markedDates, markingType: "period", minDate: minDate, maxDate: maxDate, theme: calendarTheme, renderArrow: renderArrow, enableSwipeMonths: true }, theme) }) })] }) }) })] }));
153
141
  }
142
+ export function DateRangePickerModal(props) {
143
+ const sessionKey = props.open ? 'open' : 'closed';
144
+ return _jsx(DateRangePickerModalSession, { ...props }, sessionKey);
145
+ }
154
146
  /** Compact trigger showing formatted range. Use as DateRangePickerModal's trigger prop. */
155
147
  export function DateRangePickerTrigger({ value, placeholder = 'Select date range', className, }) {
156
148
  const visual = useVisualTokens();
@@ -25,7 +25,7 @@ export interface DatePickerModalProps extends FrostedSurfaceProps {
25
25
  className?: string;
26
26
  }
27
27
  declare function formatDisplayDate(dateString: string | null | undefined): string;
28
- export declare function DatePickerModal({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title, confirmLabel, className, frosted, blurIntensity, blurTintToken, }: DatePickerModalProps): React.JSX.Element;
28
+ export declare function DatePickerModal(props: DatePickerModalProps): React.JSX.Element;
29
29
  /** Compact trigger showing formatted date. Use as DatePickerModal's trigger prop. */
30
30
  export declare function DatePickerTrigger({ value, placeholder, className, }: {
31
31
  value?: string | null;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useCallback, useMemo, useState } from 'react';
3
3
  import { Modal, Pressable, View } from 'react-native';
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
5
  import { useUniwind } from 'uniwind';
@@ -23,7 +23,7 @@ function formatDisplayDate(dateString) {
23
23
  year: 'numeric',
24
24
  });
25
25
  }
26
- export function DatePickerModal({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title = 'Select date', confirmLabel = 'Done', className, frosted = false, blurIntensity, blurTintToken, }) {
26
+ function DatePickerModalSession({ value, onValueChange, open, onOpenChange, minDate, maxDate, theme: customTheme, trigger, title = 'Select date', confirmLabel = 'Done', className, frosted = false, blurIntensity, blurTintToken, }) {
27
27
  const insets = useSafeAreaInsets();
28
28
  const { theme } = useUniwind();
29
29
  const colors = useThemeColors();
@@ -43,16 +43,6 @@ export function DatePickerModal({ value, onValueChange, open, onOpenChange, minD
43
43
  const arrowFontSize = typographyStyle(visual, 'body').fontSize;
44
44
  const renderArrow = useCallback((direction) => (_jsx(Text, { className: "font-medium", style: { color: arrowColor, fontSize: arrowFontSize }, children: direction === 'left' ? '<' : '>' })), [arrowColor, arrowFontSize]);
45
45
  const [draftDate, setDraftDate] = useState(value ?? null);
46
- const prevOpenRef = useRef(open);
47
- useEffect(() => {
48
- if (open && !prevOpenRef.current) {
49
- setDraftDate(value ?? null);
50
- }
51
- else if (!open && prevOpenRef.current) {
52
- setDraftDate(null);
53
- }
54
- prevOpenRef.current = open;
55
- }, [open, value]);
56
46
  const markedDates = useMemo(() => {
57
47
  if (!draftDate)
58
48
  return undefined;
@@ -95,6 +85,10 @@ export function DatePickerModal({ value, onValueChange, open, onOpenChange, minD
95
85
  borderWidth: visual.borderWidthHairline,
96
86
  }, children: _jsx(Calendar, { current: draftDate ?? value ?? undefined, onDayPress: handleDayPress, markedDates: markedDates, minDate: minDate, maxDate: maxDate, theme: calendarTheme, renderArrow: renderArrow, enableSwipeMonths: true }, theme) }) })] }) }) })] }));
97
87
  }
88
+ export function DatePickerModal(props) {
89
+ const sessionKey = props.open ? 'open' : 'closed';
90
+ return _jsx(DatePickerModalSession, { ...props }, sessionKey);
91
+ }
98
92
  /** Compact trigger showing formatted date. Use as DatePickerModal's trigger prop. */
99
93
  export function DatePickerTrigger({ value, placeholder = 'Select date', className, }) {
100
94
  const visual = useVisualTokens();
@@ -2,3 +2,4 @@ export { DatePickerModal, DatePickerTrigger, formatDisplayDate, type DatePickerM
2
2
  export { DatePickerInline, type DatePickerInlineProps } from './datepicker-inline';
3
3
  export { DateRangePickerModal, DateRangePickerTrigger, formatRangeDisplay, type DateRangePickerModalProps, type DateRange, } from './date-range-picker-modal';
4
4
  export { useCalendarTheme, type CalendarTheme } from './use-calendar-theme';
5
+ export { TimePickerModal, TimePickerTrigger, formatDisplayTime, parseTime, type TimePickerModalProps, type HourCycle, } from './time-picker-modal';
@@ -2,3 +2,4 @@ export { DatePickerModal, DatePickerTrigger, formatDisplayDate, } from './datepi
2
2
  export { DatePickerInline } from './datepicker-inline';
3
3
  export { DateRangePickerModal, DateRangePickerTrigger, formatRangeDisplay, } from './date-range-picker-modal';
4
4
  export { useCalendarTheme } from './use-calendar-theme';
5
+ export { TimePickerModal, TimePickerTrigger, formatDisplayTime, parseTime, } from './time-picker-modal';
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import { type FrostedSurfaceProps } from '../../lib/frosted-surface';
3
+ export type HourCycle = 'h12' | 'h23';
4
+ export interface TimePickerModalProps extends FrostedSurfaceProps {
5
+ /** Controlled: selected time as `HH:MM`, 24-hour, or null for none. */
6
+ value?: string | null;
7
+ /** Controlled: change handler. Always receives `HH:MM` in 24-hour form. */
8
+ onValueChange?: (time: string | null) => void;
9
+ /** Modal open state */
10
+ open: boolean;
11
+ /** Modal open change handler */
12
+ onOpenChange: (open: boolean) => void;
13
+ /** Minutes between options. 1 by default; 5 or 15 for a coarser pick. */
14
+ minuteStep?: number;
15
+ /** 12-hour with AM/PM, or 24-hour. Defaults to the device locale's preference. */
16
+ hourCycle?: HourCycle;
17
+ /** Trigger element. If not provided, no trigger is rendered. */
18
+ trigger?: React.ReactNode;
19
+ /** Title shown in modal header */
20
+ title?: string;
21
+ /** Button label for confirm */
22
+ confirmLabel?: string;
23
+ className?: string;
24
+ }
25
+ declare function parseTime(value: string | null | undefined): {
26
+ hour: number;
27
+ minute: number;
28
+ } | null;
29
+ /** `HH:MM` as the locale writes it — "10:30 PM" or "22:30". */
30
+ declare function formatDisplayTime(value: string | null | undefined, hourCycle?: HourCycle): string;
31
+ /**
32
+ * A time chosen on two wheels — three with AM/PM — in the same bottom sheet as
33
+ * `DatePickerModal`, so a form's date and time fields open the same way.
34
+ */
35
+ export declare function TimePickerModal(props: TimePickerModalProps): React.JSX.Element;
36
+ /** Compact trigger showing the formatted time. Use as TimePickerModal's trigger prop. */
37
+ export declare function TimePickerTrigger({ value, placeholder, hourCycle, className, }: {
38
+ value?: string | null;
39
+ placeholder?: string;
40
+ hourCycle?: HourCycle;
41
+ className?: string;
42
+ }): React.JSX.Element;
43
+ export { formatDisplayTime, parseTime };