@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 +117 -65
- package/dist/blocks/datepicker/index.d.ts +1 -0
- package/dist/blocks/datepicker/index.js +1 -0
- package/dist/blocks/datepicker/time-picker-modal.d.ts +43 -0
- package/dist/blocks/datepicker/time-picker-modal.js +187 -0
- package/dist/components/fab/fab.d.ts +87 -0
- package/dist/components/fab/fab.js +59 -0
- package/dist/components/fab/index.d.ts +1 -0
- package/dist/components/fab/index.js +1 -0
- package/dist/components/input/input.d.ts +6 -0
- package/dist/components/input/input.js +20 -8
- package/dist/components/numeric-input/numeric-input.js +12 -4
- package/dist/components/select/select.js +0 -5
- package/dist/components/textarea/textarea.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/focus-ring.d.ts +10 -0
- package/dist/lib/focus-ring.js +10 -0
- package/dist/lib/interaction-tokens.d.ts +24 -1
- package/dist/lib/interaction-tokens.js +26 -44
- package/dist/lib/motion-policy.js +2 -2
- package/dist/lib/theme-presets.d.ts +26 -0
- package/dist/lib/theme-presets.js +28 -0
- package/package.json +1 -1
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
|
-
|
|
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`
|
|
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`
|
|
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
|
|
263
|
-
| --------------- |
|
|
264
|
-
| **Card** | padding (none, sm, default, lg), unstyled
|
|
265
|
-
| **Badge** | default, secondary, destructive, outline, success
|
|
266
|
-
| **
|
|
267
|
-
| **
|
|
268
|
-
| **
|
|
269
|
-
| **
|
|
270
|
-
| **
|
|
271
|
-
| **
|
|
272
|
-
| **
|
|
273
|
-
| **
|
|
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
|
|
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
|
|
775
|
+
### Interaction and motion
|
|
714
776
|
|
|
715
|
-
|
|
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
|
-
|
|
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
|
|
756
|
-
|
|
757
|
-
system setting
|
|
758
|
-
|
|
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
|
|
978
|
-
| -------------------- |
|
|
979
|
-
| variant | `'default' \| 'error'` | `'default'` | Input style
|
|
980
|
-
| placeholder | `string`
|
|
981
|
-
| placeholderTextColor | `string`
|
|
982
|
-
| editable | `boolean`
|
|
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 {
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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 {
|
|
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',
|
|
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 {
|
|
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 }),
|
|
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';
|
package/dist/lib/focus-ring.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/focus-ring.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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.
|
|
8
|
-
//
|
|
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',
|