@thewhileloop/whileui 3.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.
package/README.md CHANGED
@@ -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
775
+ ### Interaction and motion
714
776
 
715
- WhileUI components also read optional `--ui-*` tokens for deeper control of press feedback, disabled states, and motion timing:
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.
716
782
 
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
- ```
747
-
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';
@@ -974,12 +1008,12 @@ import { Input } from '@thewhileloop/whileui';
974
1008
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
975
1009
  ```
976
1010
 
977
- | Prop | Type | Default | Description |
978
- | -------------------- | ---------------------- | ----------- | -------------------------------------------------------- |
979
- | variant | `'default' \| 'error'` | `'default'` | Input style variant |
980
- | placeholder | `string` | — | Placeholder text |
981
- | placeholderTextColor | `string` | — | Hex for placeholder; defaults to theme `mutedForeground` |
982
- | 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 |
983
1017
 
984
1018
  ## NumericInput
985
1019
 
@@ -1743,7 +1777,7 @@ const [value, setValue] = useState('');
1743
1777
  | `inputSafeArea` | `boolean` | SmartInput safe-area (default `true`) |
1744
1778
  | `keyboardVerticalOffset` | `number` | For header offset when keyboard opens |
1745
1779
 
1746
- ## DatePickerModal / DatePickerInline / DateRangePickerModal
1780
+ ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1747
1781
 
1748
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.
1749
1783
 
@@ -1753,6 +1787,8 @@ Date selection blocks using react-native-calendars. Theme-aware via `useCalendar
1753
1787
 
1754
1788
  **DateRangePickerModal** — Range selection with period marking. Use `DateRangePickerTrigger` as the trigger content.
1755
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
+
1756
1792
  ```tsx
1757
1793
  import {
1758
1794
  DatePickerModal,
@@ -1760,6 +1796,8 @@ import {
1760
1796
  DatePickerInline,
1761
1797
  DateRangePickerModal,
1762
1798
  DateRangePickerTrigger,
1799
+ TimePickerModal,
1800
+ TimePickerTrigger,
1763
1801
  type DateRange,
1764
1802
  } from '@thewhileloop/whileui';
1765
1803
 
@@ -1789,6 +1827,18 @@ const [range, setRange] = useState<DateRange | null>(null);
1789
1827
  onOpenChange={setRangeOpen}
1790
1828
  trigger={<DateRangePickerTrigger value={range} placeholder="Pick range" />}
1791
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
+ />;
1792
1842
  ```
1793
1843
 
1794
1844
  | Prop | Type | Description |
@@ -1799,6 +1849,8 @@ const [range, setRange] = useState<DateRange | null>(null);
1799
1849
  | `trigger` | `ReactNode` | Custom trigger (modal variants) |
1800
1850
  | `minDate` / `maxDate` | `string` | YYYY-MM-DD bounds |
1801
1851
  | `theme` | `CalendarTheme` | Override calendar colors |
1852
+ | `hourCycle` | `'h12' \| 'h23'` | Time picker wheels; defaults to locale |
1853
+ | `minuteStep` | `number` | Minutes between options (default 1) |
1802
1854
  | `frosted` | `boolean` | Enable frosted surface for modal panel |
1803
1855
  | `blurIntensity` | `number` | Override blur amount |
1804
1856
  | `blurTintToken` | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | Tint token for frosted panel |
@@ -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 };
@@ -0,0 +1,187 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { Modal, Platform, Pressable, ScrollView, View, } from 'react-native';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { Text } from '../../components/text';
6
+ import { Button, ButtonText } from '../../components/button';
7
+ import { cn } from '../../lib/cn';
8
+ import { focusRingInset } from '../../lib/focus-ring';
9
+ import { useThemeColors } from '../../lib/theme-colors';
10
+ import { useVisualTokens } from '../../lib/visual-tokens';
11
+ import { fieldRecipe, surfacePadding, surfaceRadius, typographyStyle } from '../../lib/recipes';
12
+ import { useFrostedSurface } from '../../lib/frosted-surface';
13
+ const ITEM_HEIGHT = 44;
14
+ /** Rows visible in a wheel. Odd, so one row sits centred under the selection band. */
15
+ const VISIBLE_ROWS = 5;
16
+ const WHEEL_HEIGHT = ITEM_HEIGHT * VISIBLE_ROWS;
17
+ const WHEEL_PADDING = ITEM_HEIGHT * Math.floor(VISIBLE_ROWS / 2);
18
+ const pad2 = (n) => String(n).padStart(2, '0');
19
+ /** The locale's clock, so a US device gets AM/PM and most of the world does not. */
20
+ function localeHourCycle() {
21
+ try {
22
+ const cycle = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions()
23
+ .hourCycle;
24
+ return cycle === 'h11' || cycle === 'h12' ? 'h12' : 'h23';
25
+ }
26
+ catch {
27
+ return 'h23';
28
+ }
29
+ }
30
+ function parseTime(value) {
31
+ if (!value)
32
+ return null;
33
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
34
+ if (!match)
35
+ return null;
36
+ const hour = Number(match[1]);
37
+ const minute = Number(match[2]);
38
+ if (hour > 23 || minute > 59)
39
+ return null;
40
+ return { hour, minute };
41
+ }
42
+ /** `HH:MM` as the locale writes it — "10:30 PM" or "22:30". */
43
+ function formatDisplayTime(value, hourCycle) {
44
+ const parsed = parseTime(value);
45
+ if (!parsed)
46
+ return 'Select time';
47
+ const date = new Date(2000, 0, 1, parsed.hour, parsed.minute);
48
+ return date.toLocaleTimeString(undefined, {
49
+ hour: 'numeric',
50
+ minute: '2-digit',
51
+ ...(hourCycle ? { hour12: hourCycle === 'h12' } : {}),
52
+ });
53
+ }
54
+ /**
55
+ * One scrolling column. Scrolls snap to a row, a tap selects a row, and the whole
56
+ * column is a single adjustable element for assistive tech and the keyboard — sixty
57
+ * minute rows as sixty tab stops would be nobody's idea of operable.
58
+ */
59
+ function Wheel({ label, options, selected, onSelect }) {
60
+ const visual = useVisualTokens();
61
+ const colors = useThemeColors();
62
+ const ref = useRef(null);
63
+ const index = Math.max(0, options.findIndex((option) => option.value === selected));
64
+ // Keep the band on the selected row when the value changes from outside the wheel
65
+ // (a tap, a keyboard step, the initial value), not after the user's own scroll.
66
+ const lastScrolledIndex = useRef(-1);
67
+ useEffect(() => {
68
+ if (lastScrolledIndex.current === index)
69
+ return;
70
+ lastScrolledIndex.current = index;
71
+ ref.current?.scrollTo({ y: index * ITEM_HEIGHT, animated: Platform.OS !== 'web' });
72
+ }, [index]);
73
+ const settle = useCallback((event) => {
74
+ const next = Math.round(event.nativeEvent.contentOffset.y / ITEM_HEIGHT);
75
+ const clamped = Math.min(options.length - 1, Math.max(0, next));
76
+ lastScrolledIndex.current = clamped;
77
+ const option = options[clamped];
78
+ if (option && option.value !== selected)
79
+ onSelect(option.value);
80
+ }, [onSelect, options, selected]);
81
+ const step = useCallback((delta) => {
82
+ const next = (index + delta + options.length) % options.length;
83
+ onSelect(options[next].value);
84
+ }, [index, onSelect, options]);
85
+ const current = options[index]?.label ?? '';
86
+ return (_jsxs(View, { accessible: true, accessibilityRole: "adjustable", accessibilityLabel: label, accessibilityValue: { text: current }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: (event) => {
87
+ if (event.nativeEvent.actionName === 'increment')
88
+ step(1);
89
+ if (event.nativeEvent.actionName === 'decrement')
90
+ step(-1);
91
+ }, ...(Platform.OS === 'web'
92
+ ? {
93
+ focusable: true,
94
+ onKeyDown: (event) => {
95
+ if (event.nativeEvent.key === 'ArrowUp') {
96
+ event.preventDefault();
97
+ step(-1);
98
+ }
99
+ else if (event.nativeEvent.key === 'ArrowDown') {
100
+ event.preventDefault();
101
+ step(1);
102
+ }
103
+ },
104
+ }
105
+ : null), className: cn('flex-1 overflow-hidden', focusRingInset), style: { height: WHEEL_HEIGHT, borderRadius: surfaceRadius(visual, 'lg') }, children: [_jsx(View, { pointerEvents: "none", className: "absolute left-0 right-0 bg-muted", style: {
106
+ top: WHEEL_PADDING,
107
+ height: ITEM_HEIGHT,
108
+ borderRadius: surfaceRadius(visual, 'md'),
109
+ } }), _jsx(ScrollView, { ref: ref, showsVerticalScrollIndicator: false, snapToInterval: ITEM_HEIGHT, decelerationRate: "fast", nestedScrollEnabled: true, contentContainerStyle: { paddingVertical: WHEEL_PADDING }, contentOffset: { x: 0, y: index * ITEM_HEIGHT }, onMomentumScrollEnd: settle, onScrollEndDrag: Platform.OS === 'web' ? settle : undefined, importantForAccessibility: "no-hide-descendants", accessibilityElementsHidden: true, children: options.map((option) => {
110
+ const on = option.value === selected;
111
+ return (_jsx(Pressable, { onPress: () => onSelect(option.value), focusable: false, className: "items-center justify-center", style: { height: ITEM_HEIGHT }, children: _jsx(Text, { className: on ? 'font-medium text-foreground' : 'text-muted-foreground', style: {
112
+ ...typographyStyle(visual, on ? 'title' : 'body'),
113
+ color: on ? colors.foreground : colors.mutedForeground,
114
+ }, children: option.label }) }, option.value));
115
+ }) })] }));
116
+ }
117
+ function TimePickerModalSession({ value, onValueChange, open, onOpenChange, minuteStep = 1, hourCycle: hourCycleProp, trigger, title = 'Select time', confirmLabel = 'Done', className, frosted = false, blurIntensity, blurTintToken, }) {
118
+ const insets = useSafeAreaInsets();
119
+ const colors = useThemeColors();
120
+ const visual = useVisualTokens();
121
+ const frostedSurface = useFrostedSurface({
122
+ frosted,
123
+ blurIntensity,
124
+ blurTintToken,
125
+ defaultTintToken: 'surfaceTranslucent',
126
+ defaultBlurPreset: 'medium',
127
+ });
128
+ const hourCycle = hourCycleProp ?? localeHourCycle();
129
+ const step = Math.min(30, Math.max(1, Math.round(minuteStep)));
130
+ // The draft opens on the current value, or on the current clock time rounded to the
131
+ // step, so the first confirm without touching anything records "now".
132
+ const initial = (() => {
133
+ const parsed = parseTime(value);
134
+ if (parsed)
135
+ return parsed;
136
+ const now = new Date();
137
+ return { hour: now.getHours(), minute: Math.floor(now.getMinutes() / step) * step };
138
+ })();
139
+ const [hour, setHour] = useState(initial.hour);
140
+ const [minute, setMinute] = useState(initial.minute);
141
+ const hours = hourCycle === 'h12'
142
+ ? Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: String(i + 1) }))
143
+ : Array.from({ length: 24 }, (_, i) => ({ value: i, label: pad2(i) }));
144
+ const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => ({
145
+ value: i * step,
146
+ label: pad2(i * step),
147
+ }));
148
+ const periods = [
149
+ { value: 0, label: 'AM' },
150
+ { value: 1, label: 'PM' },
151
+ ];
152
+ const period = hour >= 12 ? 1 : 0;
153
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
154
+ const handleConfirm = useCallback(() => {
155
+ onValueChange?.(`${pad2(hour)}:${pad2(minute)}`);
156
+ onOpenChange(false);
157
+ }, [hour, minute, onOpenChange, onValueChange]);
158
+ const handleDismiss = useCallback(() => onOpenChange(false), [onOpenChange]);
159
+ const handleOpen = useCallback(() => onOpenChange(true), [onOpenChange]);
160
+ return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: handleDismiss, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleDismiss, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
161
+ {
162
+ borderTopLeftRadius: surfaceRadius(visual, 'xl'),
163
+ borderTopRightRadius: surfaceRadius(visual, 'xl'),
164
+ paddingBottom: Math.max(insets.bottom, surfacePadding(visual, 'sm')),
165
+ },
166
+ frostedSurface.surfaceStyle,
167
+ ], onPress: (e) => e.stopPropagation(), children: [frostedSurface.overlay, _jsxs(View, { className: "flex-row items-center justify-between border-b border-border", style: {
168
+ paddingHorizontal: surfacePadding(visual, 'sm'),
169
+ paddingVertical: visual.controlPaddingYDefault,
170
+ }, children: [_jsx(Text, { accessibilityRole: "header", className: "font-medium text-foreground", style: typographyStyle(visual, 'body'), children: title }), _jsx(Button, { size: "sm", onPress: handleConfirm, children: _jsx(ButtonText, { children: confirmLabel }) })] }), _jsxs(View, { className: "flex-row items-center", style: { padding: surfacePadding(visual, 'sm'), gap: visual.controlPaddingYDefault }, children: [_jsx(Wheel, { label: "Hour", options: hours, selected: hourCycle === 'h12' ? hour12 : hour, onSelect: (next) => setHour(hourCycle === 'h12' ? (next % 12) + (period === 1 ? 12 : 0) : next) }), _jsx(Text, { className: "text-muted-foreground", style: typographyStyle(visual, 'title'), accessibilityElementsHidden: true, importantForAccessibility: "no", children: ":" }), _jsx(Wheel, { label: "Minute", options: minutes, selected: minute, onSelect: setMinute }), hourCycle === 'h12' ? (_jsx(Wheel, { label: "AM or PM", options: periods, selected: period, onSelect: (next) => setHour((hour % 12) + (next === 1 ? 12 : 0)) })) : null] })] }) }) })] }));
171
+ }
172
+ /**
173
+ * A time chosen on two wheels — three with AM/PM — in the same bottom sheet as
174
+ * `DatePickerModal`, so a form's date and time fields open the same way.
175
+ */
176
+ export function TimePickerModal(props) {
177
+ // Remount per opening so the draft starts from the value each time, as the date
178
+ // picker does; a draft that outlives a cancel would reopen on the abandoned pick.
179
+ const sessionKey = props.open ? 'open' : 'closed';
180
+ return _jsx(TimePickerModalSession, { ...props }, sessionKey);
181
+ }
182
+ /** Compact trigger showing the formatted time. Use as TimePickerModal's trigger prop. */
183
+ export function TimePickerTrigger({ value, placeholder = 'Select time', hourCycle, className, }) {
184
+ const visual = useVisualTokens();
185
+ return (_jsxs(View, { className: cn('flex-1 flex-row items-center min-w-0', className), children: [_jsx(Text, { className: cn('flex-1', value ? 'text-foreground' : 'text-muted-foreground'), style: typographyStyle(visual, 'body'), numberOfLines: 1, children: value ? formatDisplayTime(value, hourCycle) : placeholder }), _jsx(View, { className: "ml-2 shrink-0 h-2 w-2 rotate-45 border-b-2 border-r-2 border-muted-foreground" })] }));
186
+ }
187
+ export { formatDisplayTime, parseTime };
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { View, type PressableInstance, type ViewProps } from 'react-native';
3
+ import { type PressableProps } from '../pressable';
4
+ import { type VariantProps } from '../../lib/tv';
5
+ import type { ForwardRefComponent } from '../../lib/ref-types';
6
+ declare const fabVariants: import("tailwind-variants").TVReturnType<{
7
+ variant: {
8
+ default: "bg-primary";
9
+ secondary: "bg-secondary";
10
+ destructive: "bg-destructive";
11
+ surface: "border border-border bg-card";
12
+ };
13
+ size: {
14
+ default: "h-14 w-14";
15
+ sm: "h-12 w-12";
16
+ lg: "h-16 w-16";
17
+ };
18
+ placement: {
19
+ /** Positioned by the caller — inside a toolbar, a row, anywhere. */
20
+ inline: "";
21
+ /** Floating over the nearest positioned ancestor, bottom-right. */
22
+ floating: "absolute bottom-6 right-6";
23
+ 'floating-left': "absolute bottom-6 left-6";
24
+ };
25
+ }, undefined, "items-center justify-center rounded-full", {
26
+ variant: {
27
+ default: "bg-primary";
28
+ secondary: "bg-secondary";
29
+ destructive: "bg-destructive";
30
+ surface: "border border-border bg-card";
31
+ };
32
+ size: {
33
+ default: "h-14 w-14";
34
+ sm: "h-12 w-12";
35
+ lg: "h-16 w-16";
36
+ };
37
+ placement: {
38
+ /** Positioned by the caller — inside a toolbar, a row, anywhere. */
39
+ inline: "";
40
+ /** Floating over the nearest positioned ancestor, bottom-right. */
41
+ floating: "absolute bottom-6 right-6";
42
+ 'floating-left': "absolute bottom-6 left-6";
43
+ };
44
+ }, undefined, import("tailwind-variants").TVReturnTypeLike<{
45
+ variant: {
46
+ default: "bg-primary";
47
+ secondary: "bg-secondary";
48
+ destructive: "bg-destructive";
49
+ surface: "border border-border bg-card";
50
+ };
51
+ size: {
52
+ default: "h-14 w-14";
53
+ sm: "h-12 w-12";
54
+ lg: "h-16 w-16";
55
+ };
56
+ placement: {
57
+ /** Positioned by the caller — inside a toolbar, a row, anywhere. */
58
+ inline: "";
59
+ /** Floating over the nearest positioned ancestor, bottom-right. */
60
+ floating: "absolute bottom-6 right-6";
61
+ 'floating-left': "absolute bottom-6 left-6";
62
+ };
63
+ }, undefined>>;
64
+ export interface FabProps extends Omit<PressableProps, 'children'>, VariantProps<typeof fabVariants> {
65
+ /**
66
+ * What the action does, for screen readers. Required: a FAB is an icon with no
67
+ * text, so without this it announces as an unlabelled button.
68
+ */
69
+ label: string;
70
+ /** The glyph. Icon libraries need a concrete colour, so read it from `useIconColors`. */
71
+ children?: React.ReactNode;
72
+ className?: string;
73
+ }
74
+ /**
75
+ * A floating action button: one primary action, raised above the content.
76
+ *
77
+ * It owns no icon dependency. `@expo/vector-icons` and friends need hex colours
78
+ * rather than classes, so the caller passes the glyph and colours it from
79
+ * `useIconColors()` or `useThemeColors()`.
80
+ */
81
+ declare const FAB: ForwardRefComponent<PressableInstance, FabProps>;
82
+ export interface FabGroupProps extends ViewProps {
83
+ className?: string;
84
+ }
85
+ /** Stacks secondary actions above a FAB, bottom-aligned. */
86
+ declare const FABGroup: ForwardRefComponent<React.ComponentRef<typeof View>, FabGroupProps>;
87
+ export { FAB, FABGroup, fabVariants };
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { View } from 'react-native';
4
+ import { Pressable } from '../pressable';
5
+ import { cn } from '../../lib/cn';
6
+ import { tv } from '../../lib/tv';
7
+ import { useVisualTokens } from '../../lib/visual-tokens';
8
+ import { shadowStyle } from '../../lib/recipes';
9
+ import { useThemeColors } from '../../lib/theme-colors';
10
+ const fabVariants = tv({
11
+ base: 'items-center justify-center rounded-full',
12
+ variants: {
13
+ variant: {
14
+ default: 'bg-primary',
15
+ secondary: 'bg-secondary',
16
+ destructive: 'bg-destructive',
17
+ surface: 'border border-border bg-card',
18
+ },
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.
22
+ default: 'h-14 w-14',
23
+ sm: 'h-12 w-12',
24
+ lg: 'h-16 w-16',
25
+ },
26
+ placement: {
27
+ /** Positioned by the caller — inside a toolbar, a row, anywhere. */
28
+ inline: '',
29
+ /** Floating over the nearest positioned ancestor, bottom-right. */
30
+ floating: 'absolute bottom-6 right-6',
31
+ 'floating-left': 'absolute bottom-6 left-6',
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: 'default',
36
+ size: 'default',
37
+ placement: 'floating',
38
+ },
39
+ });
40
+ /**
41
+ * A floating action button: one primary action, raised above the content.
42
+ *
43
+ * It owns no icon dependency. `@expo/vector-icons` and friends need hex colours
44
+ * rather than classes, so the caller passes the glyph and colours it from
45
+ * `useIconColors()` or `useThemeColors()`.
46
+ */
47
+ const FAB = React.forwardRef(({ className, variant, size, placement, label, children, style, ...props }, ref) => {
48
+ const visual = useVisualTokens();
49
+ const colors = useThemeColors();
50
+ return (_jsx(Pressable, { ref: ref, accessibilityRole: "button", accessibilityLabel: label, className: cn(fabVariants({ variant, size, placement }), className), style: (state) => [
51
+ shadowStyle(visual, colors, 'lg'),
52
+ typeof style === 'function' ? style(state) : style,
53
+ ], ...props, children: children }));
54
+ });
55
+ FAB.displayName = 'FAB';
56
+ /** Stacks secondary actions above a FAB, bottom-aligned. */
57
+ const FABGroup = React.forwardRef(({ className, ...props }, ref) => (_jsx(View, { ref: ref, className: cn('absolute bottom-6 right-6 items-end gap-3', className), ...props })));
58
+ FABGroup.displayName = 'FABGroup';
59
+ export { FAB, FABGroup, fabVariants };
@@ -0,0 +1 @@
1
+ export { FAB, FABGroup, fabVariants, type FabProps, type FabGroupProps } from './fab';
@@ -0,0 +1 @@
1
+ export { FAB, FABGroup, fabVariants } from './fab';
@@ -6,16 +6,22 @@ declare const inputVariants: import("tailwind-variants").TVReturnType<{
6
6
  variant: {
7
7
  default: "border-border";
8
8
  error: "border-destructive";
9
+ /** No border, no fill — for one large figure that is the screen's subject. */
10
+ plain: "border-transparent bg-transparent";
9
11
  };
10
12
  }, undefined, "w-full border-border bg-muted text-foreground", {
11
13
  variant: {
12
14
  default: "border-border";
13
15
  error: "border-destructive";
16
+ /** No border, no fill — for one large figure that is the screen's subject. */
17
+ plain: "border-transparent bg-transparent";
14
18
  };
15
19
  }, undefined, import("tailwind-variants").TVReturnTypeLike<{
16
20
  variant: {
17
21
  default: "border-border";
18
22
  error: "border-destructive";
23
+ /** No border, no fill — for one large figure that is the screen's subject. */
24
+ plain: "border-transparent bg-transparent";
19
25
  };
20
26
  }, undefined>>;
21
27
  export interface InputProps extends TextInputProps, VariantProps<typeof inputVariants> {
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from 'react';
2
+ import React, { useState } from 'react';
3
3
  import { View, TextInput } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
- import { focusRing } from '../../lib/focus-ring';
5
+ import { focusFieldWrapper } from '../../lib/focus-ring';
6
6
  import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
@@ -14,29 +14,41 @@ const inputVariants = tv({
14
14
  variant: {
15
15
  default: 'border-border',
16
16
  error: 'border-destructive',
17
+ /** No border, no fill — for one large figure that is the screen's subject. */
18
+ plain: 'border-transparent bg-transparent',
17
19
  },
18
20
  },
19
21
  defaultVariants: {
20
22
  variant: 'default',
21
23
  },
22
24
  });
23
- const Input = React.forwardRef(({ className, prefix, suffix, inputClassName, variant, editable = true, placeholderTextColor, ...props }, ref) => {
25
+ const Input = React.forwardRef(({ className, prefix, suffix, inputClassName, variant, editable = true, placeholderTextColor, onFocus, onBlur, ...props }, ref) => {
24
26
  const colors = useThemeColors();
25
27
  const interaction = useInteractionTokens();
26
28
  const visual = useVisualTokens();
27
- return (_jsxs(View, { className: cn('flex-row items-center border-border bg-muted', inputVariants({ variant }), className), style: [
29
+ // The border is on this wrapper and the focus is on the TextInput inside it, so the
30
+ // wrapper learns about focus from the events rather than a pseudo-class.
31
+ const [focused, setFocused] = useState(false);
32
+ const plain = variant === 'plain';
33
+ return (_jsxs(View, { className: cn('flex-row items-center border-border bg-muted', inputVariants({ variant }), focused && !plain && focusFieldWrapper, className), style: [
28
34
  {
29
35
  minHeight: visual.controlHeightDefault,
30
- borderWidth: visual.borderWidthControl,
36
+ borderWidth: plain ? 0 : visual.borderWidthControl,
31
37
  borderRadius: visual.radiusLg,
32
- paddingHorizontal: visual.controlPaddingXDefault,
38
+ paddingHorizontal: plain ? 0 : visual.controlPaddingXDefault,
33
39
  },
34
40
  !editable ? { opacity: interaction.disabledOpacity } : undefined,
35
- ], children: [prefix && (_jsx(View, { style: { paddingRight: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: prefix })), _jsx(TextInput, { ref: ref, className: cn('flex-1 text-foreground placeholder:text-muted-foreground', focusRing, inputClassName), style: {
41
+ ], children: [prefix && (_jsx(View, { style: { paddingRight: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: prefix })), _jsx(TextInput, { ref: ref, className: cn('flex-1 text-foreground placeholder:text-muted-foreground web:outline-none', inputClassName), style: {
36
42
  ...typographyStyle(visual, 'label'),
37
43
  minHeight: visual.controlHeightDefault - 2,
38
44
  paddingVertical: visual.controlPaddingYDefault,
39
- }, editable: editable, placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...props }), suffix && (_jsx(View, { style: { paddingLeft: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: suffix }))] }));
45
+ }, editable: editable, placeholderTextColor: placeholderTextColor ?? colors.placeholder, onFocus: (event) => {
46
+ setFocused(true);
47
+ onFocus?.(event);
48
+ }, onBlur: (event) => {
49
+ setFocused(false);
50
+ onBlur?.(event);
51
+ }, ...props }), suffix && (_jsx(View, { style: { paddingLeft: Math.max(8, Math.round(visual.controlPaddingXDefault * 0.5)) }, children: suffix }))] }));
40
52
  });
41
53
  Input.displayName = 'Input';
42
54
  export { Input, inputVariants };
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useMemo, useState } from 'react';
3
3
  import { Platform, Pressable, Text, TextInput, View, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
- import { focusRing } from '../../lib/focus-ring';
5
+ import { focusFieldWrapper } from '../../lib/focus-ring';
6
6
  import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
@@ -135,10 +135,12 @@ function NumericSteppers({ visible, inset, size, radius, model, interaction, vis
135
135
  return null;
136
136
  return (_jsxs(View, { className: "shrink-0 flex-row items-center", style: { gap: inset, paddingLeft: Math.max(6, inset) }, children: [_jsx(NumericStepperButton, { delta: -1, enabled: model.canDecrease, label: "Decrease value", symbol: "\u2212", size: size, radius: radius, model: model, interaction: interaction, visual: visual }), _jsx(NumericStepperButton, { delta: 1, enabled: model.canIncrease, label: "Increase value", symbol: "+", size: size, radius: radius, model: model, interaction: interaction, visual: visual })] }));
137
137
  }
138
- const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suffix, value, defaultValue, onValueChange, min, max, step = 1, showSteppers = false, unstyled = false, variant = 'default', size = 'default', editable = true, placeholderTextColor, onBlur, style: styleProp, ...props }, ref) => {
138
+ const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suffix, value, defaultValue, onValueChange, min, max, step = 1, showSteppers = false, unstyled = false, variant = 'default', size = 'default', editable = true, placeholderTextColor, onFocus, onBlur, style: styleProp, ...props }, ref) => {
139
139
  const colors = useThemeColors();
140
140
  const interaction = useInteractionTokens();
141
141
  const visual = useVisualTokens();
142
+ // The border is on the wrapper and the focus on the field inside it.
143
+ const [focused, setFocused] = useState(false);
142
144
  const model = useNumericInputModel({
143
145
  value,
144
146
  defaultValue,
@@ -154,7 +156,7 @@ const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suff
154
156
  const stepInset = Math.max(3, Math.round(controlHeight * 0.1));
155
157
  const stepSize = controlHeight - stepInset * 2;
156
158
  const stepRadius = Math.max(3, Math.min(visual.radiusMd, Math.round(stepSize / 3)));
157
- return (_jsxs(View, { className: cn(numericInputVariants({ variant, size }), unstyled && 'bg-transparent', className), style: [
159
+ return (_jsxs(View, { className: cn(numericInputVariants({ variant, size }), unstyled && 'bg-transparent', focused && !unstyled && focusFieldWrapper, className), style: [
158
160
  {
159
161
  // Definite height (not minHeight) so inner elements can center reliably.
160
162
  height: controlHeight,
@@ -164,7 +166,13 @@ const NumericInput = React.forwardRef(({ className, inputClassName, prefix, suff
164
166
  paddingRight: unstyled ? 0 : showSteppers ? stepInset : 0,
165
167
  },
166
168
  !editable ? { opacity: interaction.disabledOpacity } : undefined,
167
- ], children: [_jsx(NumericAffix, { side: "prefix", visual: visual, children: prefix }), _jsx(View, { className: "min-w-0 flex-1", style: Platform.OS === 'web' ? { minWidth: 0, flex: 1 } : undefined, children: _jsx(TextInput, { ref: ref, className: cn(numericInputTextVariants({ size }), 'w-full', focusRing, inputClassName), style: numericInputTextStyle(visual, size, unstyled, styleProp), value: model.textValue, onChangeText: model.handleTextChange, onBlur: model.handleBlur, keyboardType: "decimal-pad", editable: editable, 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 })] }));
169
+ ], children: [_jsx(NumericAffix, { side: "prefix", visual: visual, children: prefix }), _jsx(View, { className: "min-w-0 flex-1", style: Platform.OS === 'web' ? { minWidth: 0, flex: 1 } : undefined, children: _jsx(TextInput, { ref: ref, className: cn(numericInputTextVariants({ size }), 'w-full web:outline-none', inputClassName), style: numericInputTextStyle(visual, size, unstyled, styleProp), value: model.textValue, onChangeText: model.handleTextChange, onFocus: (event) => {
170
+ setFocused(true);
171
+ onFocus?.(event);
172
+ }, onBlur: (event) => {
173
+ setFocused(false);
174
+ model.handleBlur(event);
175
+ }, keyboardType: "decimal-pad", editable: editable, 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 })] }));
168
176
  });
169
177
  NumericInput.displayName = 'NumericInput';
170
178
  export { NumericInput, numericInputVariants, numericInputTextVariants };
@@ -19,11 +19,6 @@ const SelectTrigger = React.forwardRef(({ className, children, style: styleProp,
19
19
  const interaction = useInteractionTokens();
20
20
  const visual = useVisualTokens();
21
21
  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', focusRing, props.disabled && '', className),
22
- // Pass an array unless the caller actually gave a callback. On web this primitive
23
- // is wrapped in a Slot that merges `style` by object spread, which turns a
24
- // function into `{}` and silently drops every style — the trigger rendered 24px
25
- // tall with no padding, border or radius. A callback is still honoured on native,
26
- // where dropping it was the original bug.
27
22
  // Flattened, not an array. On web this primitive is merged through a Slot that
28
23
  // spreads `style`, and spreading an array yields { 0: …, 1: … } — numeric keys
29
24
  // that reach the DOM as indexed style properties and throw. The Select rendered
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { TextInput, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
- import { focusRing } from '../../lib/focus-ring';
5
+ import { focusField } from '../../lib/focus-ring';
6
6
  import { tv } from '../../lib/tv';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useInteractionTokens } from '../../lib/interaction-tokens';
@@ -44,7 +44,7 @@ const Textarea = React.forwardRef(({ className, size = 'default', placeholderTex
44
44
  paddingVertical: visual.controlPaddingYDefault,
45
45
  ...(props.editable === false ? { opacity: interaction.disabledOpacity } : null),
46
46
  };
47
- return (_jsx(TextInput, { ref: ref, className: cn(textareaVariants({ size }), focusRing, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...props }));
47
+ return (_jsx(TextInput, { ref: ref, className: cn(textareaVariants({ size }), focusField, className), style: style, multiline: true, textAlignVertical: "top", placeholderTextColor: placeholderTextColor ?? colors.placeholder, ...props }));
48
48
  });
49
49
  Textarea.displayName = 'Textarea';
50
50
  export { Textarea, textareaVariants };
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { PortalHost } from './lib/portal';
7
7
  export { ThemeBridge, applyThemeMode, resolveThemeMode, syncThemeMode, useThemeBridge, type ThemeMode, type ThemeBridgeAdapter, type ThemeBridgeProps, type UseThemeBridgeOptions, type UseThemeBridgeResult, } from './lib/theme-bridge';
8
8
  export { useThemeColors, useThemeTokens, useIconColors, type ThemeColors, } from './lib/theme-colors';
9
9
  export { THEME_TOKEN_SPECS, THEME_TOKEN_GROUPS, WHILEUI_LIGHT_THEME_COLORS, WHILEUI_DARK_THEME_COLORS, type ThemeTokenGroup, type ThemeTokenKey, type ThemeTokenSpec, } from './lib/theme-contract';
10
- export { useInteractionTokens, withInteractivePressableStyle, type InteractionTokens, type InteractivePressableOptions, type PressedVariant, type DisabledVariant, } from './lib/interaction-tokens';
10
+ export { INTERACTION_TOKENS, useInteractionTokens, withInteractivePressableStyle, type InteractionTokens, type InteractivePressableOptions, type PressedVariant, type DisabledVariant, } from './lib/interaction-tokens';
11
11
  export { MotionProvider, useMotionPolicy, type MotionMode, type MotionDuration, type MotionSpring, type MotionSpringConfig, type MotionPolicy, type MotionProviderProps, } from './lib/motion-policy';
12
12
  export { useVisualTokens, type VisualTokens } from './lib/visual-tokens';
13
13
  export { VISUAL_TOKEN_SPECS, VISUAL_TOKEN_GROUPS, type VisualTokenGroup, type VisualTokenKey, type VisualTokenKind, type VisualTokenSpec, } from './lib/visual-token-contract';
@@ -28,6 +28,7 @@ export { OTPInput, otpCellVariants, type OTPInputProps } from './components/otp-
28
28
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, formFieldVariants, formControlVariants, type FormFieldProps, type FormLabelProps, type FormControlProps, type FormHintProps, type FormMessageProps, } from './components/form-field';
29
29
  export { LabeledField, LabeledFieldControl, type LabeledFieldProps, type LabeledFieldControlProps, } from './components/labeled-field';
30
30
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, type BadgeProps, type BadgeTextProps, } from './components/badge';
31
+ export { FAB, FABGroup, fabVariants, type FabProps, type FabGroupProps } from './components/fab';
31
32
  export { Alert, AlertTitle, AlertDescription, alertVariants, type AlertProps, type AlertTitleProps, type AlertDescriptionProps, } from './components/alert';
32
33
  export { Switch, type SwitchProps } from './components/switch';
33
34
  export { Checkbox, type CheckboxProps } from './components/checkbox';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { PortalHost } from './lib/portal';
7
7
  export { ThemeBridge, applyThemeMode, resolveThemeMode, syncThemeMode, useThemeBridge, } from './lib/theme-bridge';
8
8
  export { useThemeColors, useThemeTokens, useIconColors, } from './lib/theme-colors';
9
9
  export { THEME_TOKEN_SPECS, THEME_TOKEN_GROUPS, WHILEUI_LIGHT_THEME_COLORS, WHILEUI_DARK_THEME_COLORS, } from './lib/theme-contract';
10
- export { useInteractionTokens, withInteractivePressableStyle, } from './lib/interaction-tokens';
10
+ export { INTERACTION_TOKENS, useInteractionTokens, withInteractivePressableStyle, } from './lib/interaction-tokens';
11
11
  export { MotionProvider, useMotionPolicy, } from './lib/motion-policy';
12
12
  export { useVisualTokens } from './lib/visual-tokens';
13
13
  export { VISUAL_TOKEN_SPECS, VISUAL_TOKEN_GROUPS, } from './lib/visual-token-contract';
@@ -28,6 +28,7 @@ export { OTPInput, otpCellVariants } from './components/otp-input';
28
28
  export { FormField, FormLabel, FormControl, FormHint, FormMessage, formFieldVariants, formControlVariants, } from './components/form-field';
29
29
  export { LabeledField, LabeledFieldControl, } from './components/labeled-field';
30
30
  export { Badge, BadgeText, badgeVariants, badgeTextVariants, } from './components/badge';
31
+ export { FAB, FABGroup, fabVariants } from './components/fab';
31
32
  export { Alert, AlertTitle, AlertDescription, alertVariants, } from './components/alert';
32
33
  export { Switch } from './components/switch';
33
34
  export { Checkbox } from './components/checkbox';
@@ -23,3 +23,13 @@ export declare const focusRing = "web:outline-none web:focus-visible:ring-2 web:
23
23
  * would be clipped by the parent or collide with a neighbour.
24
24
  */
25
25
  export declare const focusRingInset = "web:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring";
26
+ /**
27
+ * For text fields. Browsers treat every focus on a text field as keyboard focus, so a
28
+ * `:focus-visible` ring fires on a tap too — and an offset ring around a rounded field
29
+ * reads as a second, squarer border. A field shows focus the way the platforms do:
30
+ * its own border takes the ring colour. The class carries the colour; a field whose
31
+ * border lives on a wrapper (`Input`, `NumericInput`) applies it there from focus state.
32
+ */
33
+ export declare const focusField = "web:outline-none web:focus-visible:border-ring";
34
+ /** The wrapper half of `focusField`: the border colour while the inner field has focus. */
35
+ export declare const focusFieldWrapper = "border-ring";
@@ -23,3 +23,13 @@ export const focusRing = 'web:outline-none web:focus-visible:ring-2 web:focus-vi
23
23
  * would be clipped by the parent or collide with a neighbour.
24
24
  */
25
25
  export const focusRingInset = 'web:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring';
26
+ /**
27
+ * For text fields. Browsers treat every focus on a text field as keyboard focus, so a
28
+ * `:focus-visible` ring fires on a tap too — and an offset ring around a rounded field
29
+ * reads as a second, squarer border. A field shows focus the way the platforms do:
30
+ * its own border takes the ring colour. The class carries the colour; a field whose
31
+ * border lives on a wrapper (`Input`, `NumericInput`) applies it there from focus state.
32
+ */
33
+ export const focusField = 'web:outline-none web:focus-visible:border-ring';
34
+ /** The wrapper half of `focusField`: the border colour while the inner field has focus. */
35
+ export const focusFieldWrapper = 'border-ring';
@@ -1,5 +1,28 @@
1
1
  import { type PressableProps } from 'react-native';
2
- /** Motion values used when a `--ui-motion-*` token is absent, and by the provider-less motion policy. */
2
+ /**
3
+ * Press, disabled, and motion timing for the whole package.
4
+ *
5
+ * These are not theme tokens. A generated preset does not emit them, and reading the
6
+ * matching `--ui-*` variables warned in every app on that preset while resolving to
7
+ * these same numbers. One table, no CSS lookup.
8
+ */
9
+ export declare const INTERACTION_TOKENS: {
10
+ readonly pressOpacity: 0.82;
11
+ readonly pressOpacityStrong: 0.92;
12
+ readonly disabledOpacity: 0.46;
13
+ readonly disabledOpacitySoft: 0.56;
14
+ readonly disabledOpacitySubtle: 0.36;
15
+ readonly inactiveOpacity: 0.54;
16
+ readonly motionFast: 140;
17
+ readonly motionNormal: 200;
18
+ readonly motionSlow: 280;
19
+ readonly motionSpringDamping: 20;
20
+ readonly motionSpringStiffness: 260;
21
+ readonly motionSpringMass: 0.8;
22
+ readonly drawerOpenDuration: 260;
23
+ readonly drawerCloseDuration: 180;
24
+ };
25
+ /** Motion slice of {@link INTERACTION_TOKENS}, for the provider-less policy. */
3
26
  export declare const MOTION_TOKEN_DEFAULTS: {
4
27
  readonly motionFast: 140;
5
28
  readonly motionNormal: 200;
@@ -1,55 +1,37 @@
1
- import { useCSSVariable } from 'uniwind';
2
- function parseNumber(value, fallback) {
3
- if (value === undefined || value === null)
4
- return fallback;
5
- const parsed = Number.parseFloat(String(value));
6
- return Number.isFinite(parsed) ? parsed : fallback;
7
- }
8
- function clampOpacity(value) {
9
- return Math.max(0, Math.min(1, value));
10
- }
11
- /** Motion values used when a `--ui-motion-*` token is absent, and by the provider-less motion policy. */
12
- export const MOTION_TOKEN_DEFAULTS = {
1
+ /**
2
+ * Press, disabled, and motion timing for the whole package.
3
+ *
4
+ * These are not theme tokens. A generated preset does not emit them, and reading the
5
+ * matching `--ui-*` variables warned in every app on that preset while resolving to
6
+ * these same numbers. One table, no CSS lookup.
7
+ */
8
+ export const INTERACTION_TOKENS = {
9
+ pressOpacity: 0.82,
10
+ pressOpacityStrong: 0.92,
11
+ disabledOpacity: 0.46,
12
+ disabledOpacitySoft: 0.56,
13
+ disabledOpacitySubtle: 0.36,
14
+ inactiveOpacity: 0.54,
13
15
  motionFast: 140,
14
16
  motionNormal: 200,
15
17
  motionSlow: 280,
16
18
  motionSpringDamping: 20,
17
19
  motionSpringStiffness: 260,
18
20
  motionSpringMass: 0.8,
21
+ drawerOpenDuration: 260,
22
+ drawerCloseDuration: 180,
23
+ };
24
+ /** Motion slice of {@link INTERACTION_TOKENS}, for the provider-less policy. */
25
+ export const MOTION_TOKEN_DEFAULTS = {
26
+ motionFast: INTERACTION_TOKENS.motionFast,
27
+ motionNormal: INTERACTION_TOKENS.motionNormal,
28
+ motionSlow: INTERACTION_TOKENS.motionSlow,
29
+ motionSpringDamping: INTERACTION_TOKENS.motionSpringDamping,
30
+ motionSpringStiffness: INTERACTION_TOKENS.motionSpringStiffness,
31
+ motionSpringMass: INTERACTION_TOKENS.motionSpringMass,
19
32
  };
20
33
  export function useInteractionTokens() {
21
- const [pressOpacity, pressOpacityStrong, disabledOpacity, disabledOpacitySoft, disabledOpacitySubtle, inactiveOpacity, motionFast, motionNormal, motionSlow, motionSpringDamping, motionSpringStiffness, motionSpringMass, drawerOpenDuration, drawerCloseDuration,] = useCSSVariable([
22
- '--ui-press-opacity',
23
- '--ui-press-opacity-strong',
24
- '--ui-disabled-opacity',
25
- '--ui-disabled-opacity-soft',
26
- '--ui-disabled-opacity-subtle',
27
- '--ui-inactive-opacity',
28
- '--ui-motion-fast',
29
- '--ui-motion-normal',
30
- '--ui-motion-slow',
31
- '--ui-motion-spring-damping',
32
- '--ui-motion-spring-stiffness',
33
- '--ui-motion-spring-mass',
34
- '--ui-drawer-open-duration',
35
- '--ui-drawer-close-duration',
36
- ]);
37
- return {
38
- pressOpacity: clampOpacity(parseNumber(pressOpacity, 0.82)),
39
- pressOpacityStrong: clampOpacity(parseNumber(pressOpacityStrong, 0.92)),
40
- disabledOpacity: clampOpacity(parseNumber(disabledOpacity, 0.46)),
41
- disabledOpacitySoft: clampOpacity(parseNumber(disabledOpacitySoft, 0.56)),
42
- disabledOpacitySubtle: clampOpacity(parseNumber(disabledOpacitySubtle, 0.36)),
43
- inactiveOpacity: clampOpacity(parseNumber(inactiveOpacity, 0.54)),
44
- motionFast: Math.max(0, Math.round(parseNumber(motionFast, MOTION_TOKEN_DEFAULTS.motionFast))),
45
- motionNormal: Math.max(0, Math.round(parseNumber(motionNormal, MOTION_TOKEN_DEFAULTS.motionNormal))),
46
- motionSlow: Math.max(0, Math.round(parseNumber(motionSlow, MOTION_TOKEN_DEFAULTS.motionSlow))),
47
- motionSpringDamping: Math.max(0, parseNumber(motionSpringDamping, MOTION_TOKEN_DEFAULTS.motionSpringDamping)),
48
- motionSpringStiffness: Math.max(0, parseNumber(motionSpringStiffness, MOTION_TOKEN_DEFAULTS.motionSpringStiffness)),
49
- motionSpringMass: Math.max(0.01, parseNumber(motionSpringMass, MOTION_TOKEN_DEFAULTS.motionSpringMass)),
50
- drawerOpenDuration: Math.max(0, Math.round(parseNumber(drawerOpenDuration, 260))),
51
- drawerCloseDuration: Math.max(0, Math.round(parseNumber(drawerCloseDuration, 180))),
52
- };
34
+ return INTERACTION_TOKENS;
53
35
  }
54
36
  function resolveDisabledOpacity(tokens, variant) {
55
37
  if (variant === 'none')
@@ -4,8 +4,8 @@ import { AccessibilityInfo } from 'react-native';
4
4
  import { ReduceMotion, useReducedMotion } from 'react-native-reanimated';
5
5
  import { MOTION_TOKEN_DEFAULTS, useInteractionTokens } from './interaction-tokens';
6
6
  // Without a provider, components still render and Reanimated still follows the system
7
- // setting. Only `--ui-motion-*` overrides and the non-Reanimated reduced-motion paths
8
- // need `MotionProvider` at the root.
7
+ // setting. The provider is what switches the non-Reanimated paths to reduced or
8
+ // disabled motion. Durations and the spring come from `INTERACTION_TOKENS` either way.
9
9
  const DEFAULT_MOTION_POLICY = {
10
10
  mode: 'system',
11
11
  reduceMotion: false,
@@ -1,4 +1,5 @@
1
1
  import { type ThemeColors } from './theme-contract';
2
+ import { type ThemeBaseColors } from './theme-derive';
2
3
  import { type VisualTokens } from './visual-token-contract';
3
4
  /**
4
5
  * Canonical full-package theme presets.
@@ -15,6 +16,31 @@ export interface ThemePreset {
15
16
  /** Visual token overrides; unset keys keep contract fallbacks. */
16
17
  visual: Partial<VisualTokens>;
17
18
  }
19
+ /**
20
+ * Build a preset from the 25 base colors, deriving the other 27.
21
+ *
22
+ * Prefer this to writing a `ThemePreset` literal. `visual` has always been
23
+ * `Partial`, but `light` and `dark` are the full 52-key `ThemeColors`, so the
24
+ * literal form asks an author for every active variant, soft variant, overlay,
25
+ * surface, interaction state and shadow — values `deriveThemeColors` already
26
+ * computes from the palette. Nothing in the literal's type points at that helper,
27
+ * and the predictable result is a hand-written derivation that drifts from the
28
+ * library's own. The Atelier preset below is 110 lines for this reason.
29
+ *
30
+ * Override a derived value by spreading the result:
31
+ *
32
+ * ```ts
33
+ * const preset = createThemePreset({ name, description, light, dark });
34
+ * preset.dark.overlay = 'rgba(0, 0, 0, 0.9)';
35
+ * ```
36
+ */
37
+ export declare function createThemePreset(input: {
38
+ name: string;
39
+ description: string;
40
+ light: ThemeBaseColors;
41
+ dark: ThemeBaseColors;
42
+ visual?: Partial<VisualTokens>;
43
+ }): ThemePreset;
18
44
  export declare const WHILEUI_PRESET_SOFT: ThemePreset;
19
45
  export declare const WHILEUI_PRESET_ATELIER: ThemePreset;
20
46
  export declare const WHILEUI_THEME_PRESETS: readonly ThemePreset[];
@@ -1,5 +1,33 @@
1
1
  import { THEME_TOKEN_SPECS, WHILEUI_DARK_THEME_COLORS, WHILEUI_LIGHT_THEME_COLORS, } from './theme-contract';
2
+ import { deriveThemeColors } from './theme-derive';
2
3
  import { VISUAL_TOKEN_SPECS } from './visual-token-contract';
4
+ /**
5
+ * Build a preset from the 25 base colors, deriving the other 27.
6
+ *
7
+ * Prefer this to writing a `ThemePreset` literal. `visual` has always been
8
+ * `Partial`, but `light` and `dark` are the full 52-key `ThemeColors`, so the
9
+ * literal form asks an author for every active variant, soft variant, overlay,
10
+ * surface, interaction state and shadow — values `deriveThemeColors` already
11
+ * computes from the palette. Nothing in the literal's type points at that helper,
12
+ * and the predictable result is a hand-written derivation that drifts from the
13
+ * library's own. The Atelier preset below is 110 lines for this reason.
14
+ *
15
+ * Override a derived value by spreading the result:
16
+ *
17
+ * ```ts
18
+ * const preset = createThemePreset({ name, description, light, dark });
19
+ * preset.dark.overlay = 'rgba(0, 0, 0, 0.9)';
20
+ * ```
21
+ */
22
+ export function createThemePreset(input) {
23
+ return {
24
+ name: input.name,
25
+ description: input.description,
26
+ light: deriveThemeColors(input.light, 'light'),
27
+ dark: deriveThemeColors(input.dark, 'dark'),
28
+ visual: input.visual ?? {},
29
+ };
30
+ }
3
31
  // Rounded, airy, frosted. Matches the contract fallbacks.
4
32
  export const WHILEUI_PRESET_SOFT = {
5
33
  name: 'soft',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {