@thewhileloop/whileui 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +244 -80
  2. package/dist/blocks/chat/chat-conversation.d.ts +8 -0
  3. package/dist/blocks/chat/chat-conversation.js +36 -0
  4. package/dist/blocks/chat/chat.js +5 -11
  5. package/dist/blocks/chat/index.d.ts +1 -0
  6. package/dist/blocks/chat/index.js +1 -0
  7. package/dist/blocks/datepicker/index.d.ts +1 -0
  8. package/dist/blocks/datepicker/index.js +1 -0
  9. package/dist/blocks/datepicker/time-picker-modal.d.ts +43 -0
  10. package/dist/blocks/datepicker/time-picker-modal.js +187 -0
  11. package/dist/blocks/layout/smart-input.js +2 -1
  12. package/dist/code.d.ts +1 -0
  13. package/dist/code.js +1 -0
  14. package/dist/components/card/card.d.ts +1 -1
  15. package/dist/components/card/card.js +4 -25
  16. package/dist/components/code-block/code-block.d.ts +12 -0
  17. package/dist/components/code-block/code-block.js +13 -0
  18. package/dist/components/code-block/index.d.ts +1 -0
  19. package/dist/components/code-block/index.js +1 -0
  20. package/dist/components/fab/fab.d.ts +87 -0
  21. package/dist/components/fab/fab.js +59 -0
  22. package/dist/components/fab/index.d.ts +1 -0
  23. package/dist/components/fab/index.js +1 -0
  24. package/dist/components/form-field/form-field.d.ts +8 -0
  25. package/dist/components/form-field/form-field.js +3 -0
  26. package/dist/components/form-field/index.d.ts +1 -1
  27. package/dist/components/form-field/index.js +1 -1
  28. package/dist/components/input/input.d.ts +7 -0
  29. package/dist/components/input/input.js +26 -9
  30. package/dist/components/markdown/index.d.ts +1 -0
  31. package/dist/components/markdown/index.js +1 -0
  32. package/dist/components/markdown/markdown.d.ts +14 -0
  33. package/dist/components/markdown/markdown.js +94 -0
  34. package/dist/components/numeric-input/numeric-input.d.ts +1 -0
  35. package/dist/components/numeric-input/numeric-input.js +19 -6
  36. package/dist/components/select/select.d.ts +2 -1
  37. package/dist/components/select/select.js +18 -15
  38. package/dist/components/surface/index.d.ts +1 -0
  39. package/dist/components/surface/index.js +1 -0
  40. package/dist/components/surface/surface.d.ts +14 -0
  41. package/dist/components/surface/surface.js +35 -0
  42. package/dist/components/text/text.d.ts +42 -42
  43. package/dist/components/text/text.js +39 -17
  44. package/dist/components/textarea/textarea.d.ts +1 -0
  45. package/dist/components/textarea/textarea.js +8 -4
  46. package/dist/index.d.ts +4 -2
  47. package/dist/index.js +4 -2
  48. package/dist/lib/focus-ring.d.ts +10 -0
  49. package/dist/lib/focus-ring.js +10 -0
  50. package/dist/lib/interaction-tokens.d.ts +24 -1
  51. package/dist/lib/interaction-tokens.js +26 -44
  52. package/dist/lib/motion-policy.js +2 -2
  53. package/dist/lib/theme-presets.d.ts +26 -0
  54. package/dist/lib/theme-presets.js +28 -0
  55. package/dist/lib/web-autofill.d.ts +9 -0
  56. package/dist/lib/web-autofill.js +9 -0
  57. package/dist/markdown.d.ts +1 -0
  58. package/dist/markdown.js +1 -0
  59. package/package.json +16 -2
package/README.md CHANGED
@@ -18,6 +18,8 @@ bun add @thewhileloop/whileui
18
18
  bun add react@^19.3.0 react-native@^0.88.0 uniwind@^1.12.2 tailwindcss@^4.0.0 react-native-reanimated react-native-safe-area-context react-native-screens
19
19
  # Only if you use Select, Popover, Tooltip, or HoverCard:
20
20
  bun add @rn-primitives/portal @rn-primitives/hooks @rn-primitives/slot @rn-primitives/select @rn-primitives/popover @rn-primitives/tooltip @rn-primitives/hover-card
21
+ # Only if you import @thewhileloop/whileui/markdown:
22
+ bun add markdown-it
21
23
  ```
22
24
 
23
25
  Use `npm install` instead of `bun add` if you prefer npm.
@@ -27,16 +29,20 @@ Use `npm install` instead of `bun add` if you prefer npm.
27
29
  1. **metro.config.js** (wrap with withUniwindConfig):
28
30
 
29
31
  ```js
32
+ const { getDefaultConfig } = require('expo/metro-config');
30
33
  const { withUniwindConfig } = require('uniwind/metro');
31
34
 
32
- module.exports = withUniwindConfig({
35
+ /** @type {import('expo/metro-config').MetroConfig} */
36
+ const config = getDefaultConfig(__dirname);
37
+
38
+ module.exports = withUniwindConfig(config, {
33
39
  cssEntryFile: './global.css',
34
- })({
35
- // your metro config
36
40
  });
37
41
  ```
38
42
 
39
- > `withUniwindConfig` must be the outermost wrapper. `cssEntryFile` must be a relative path string.
43
+ > `withUniwindConfig(config, options)` takes the config first and returns the wrapped
44
+ > config — it is not curried. It must wrap the outermost config object, and
45
+ > `cssEntryFile` must be a relative path string.
40
46
 
41
47
  2. **global.css** at app root:
42
48
 
@@ -44,6 +50,11 @@ module.exports = withUniwindConfig({
44
50
  @import 'tailwindcss';
45
51
  @import 'uniwind';
46
52
 
53
+ /* Required when WhileUI is installed from npm. Tailwind does not scan
54
+ node_modules, so without this its class names are never detected and every
55
+ component renders unstyled. */
56
+ @source './node_modules/@thewhileloop/whileui/dist';
57
+
47
58
  /* WhileUI Noir theme - copy this or create your own */
48
59
  @layer theme {
49
60
  :root {
@@ -216,20 +227,38 @@ function MyScreen() {
216
227
  - **PortalHost:** Add `<PortalHost />` at app root for Select, Popover, Tooltip, HoverCard.
217
228
  - **Motion:** Wrap the root in `<MotionProvider>`; use `useMotionPolicy()` for semantic
218
229
  durations, the responsive spring, and reduced-motion behavior.
219
- - **Uniwind:** `withUniwindConfig` must wrap metro config. `global.css` at app root, imported in `App.tsx`.
230
+ - **Uniwind:** `withUniwindConfig(config, options)` wraps metro config — config first, not
231
+ curried. `global.css` at app root, imported in `App.tsx`. Installing from npm also needs
232
+ `@source './node_modules/@thewhileloop/whileui/dist';` or every component renders unstyled.
220
233
  - **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/templates/` (auth, profile); flow patterns in README "Flow Patterns" section.
221
234
 
235
+ ### Composition grammar
236
+
237
+ - Start with one page canvas and flat sections. Add a `Surface` only when grouping or
238
+ elevation communicates something; do not wrap every section in a card.
239
+ - Use `level="base"` for grouped content, `raised` for selectable or floating regions,
240
+ and `overlay` for content above the page. Avoid nesting more than one surfaced level.
241
+ - Express hierarchy through `Text` variants before custom font-size classes. Keep one
242
+ primary action per region and constrain long-form content with `Container`.
243
+ - Forms use `FormField`; its disabled and invalid state flows into `Input`, `Textarea`,
244
+ `NumericInput`, and `Select`. Pass a control prop only to override that inherited state.
245
+ - Chat mechanics belong to `ChatConversation` + `SmartInput`. Message payloads, tools,
246
+ reasoning, approvals, sources, and action rows are application-owned compositions.
247
+ - Import Markdown and code rendering from their optional subpaths. Do not make the root
248
+ package pay for content rendering when the screen does not use it.
249
+
222
250
  ## Components
223
251
 
224
252
  ### Primitives
225
253
 
226
- | Component | Notes |
227
- | ------------- | -------------------------------- |
228
- | **Text** | Themed text with variant support |
229
- | **View** | Themed view wrapper |
230
- | **Pressable** | Themed pressable wrapper |
231
- | **Stack** | Vertical flex layout with gap |
232
- | **Row** | Horizontal flex layout with gap |
254
+ | Component | Notes |
255
+ | ------------- | ------------------------------------------------ |
256
+ | **Text** | Themed text with variant support |
257
+ | **Surface** | Token-backed base, raised, and overlay container |
258
+ | **View** | Themed view wrapper |
259
+ | **Pressable** | Themed pressable wrapper |
260
+ | **Stack** | Vertical flex layout with gap |
261
+ | **Row** | Horizontal flex layout with gap |
233
262
 
234
263
  ### Root Providers
235
264
 
@@ -259,18 +288,20 @@ function MyScreen() {
259
288
 
260
289
  ### Display
261
290
 
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 |
291
+ | Component | Variants | Notes |
292
+ | --------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
293
+ | **Card** | padding (none, sm, default, lg), unstyled | Compound: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
294
+ | **Surface** | level (base, raised, overlay) | Shared radius, padding, border, shadow, and optional frosted treatment |
295
+ | **Badge** | default, secondary, destructive, outline, success | BadgeText sub-component |
296
+ | **FAB** | default, secondary, destructive, surface; sm/default/lg | Floating action button. `label` required; pass your own icon as children |
297
+ | **Alert** | default, destructive, success, warning | AlertTitle & AlertDescription |
298
+ | **Avatar** | sm, default, lg | AvatarImage + AvatarFallback |
299
+ | **DataRow** | default, compact | DataRow, DataRowLeft/Center/Right, DataRowLabel/Description/Value |
300
+ | **Separator** | horizontal, vertical | Themed divider |
301
+ | **Progress** | sm, default, lg | Value-based progress bar with accessibility |
302
+ | **Spinner** | sm, default, lg | ActivityIndicator wrapper |
303
+ | **Skeleton** | pulse, shimmer | Loading placeholder (pulse = opacity fade, shimmer = sweep) |
304
+ | **AspectRatio** | — | Maintain aspect ratio container |
274
305
 
275
306
  ### Layout
276
307
 
@@ -352,11 +383,12 @@ Copy from `apps/showcase/templates/auth/`:
352
383
 
353
384
  ### Chat
354
385
 
355
- | Block | Description |
356
- | --------------------- | ------------------------------------------------ |
357
- | **Chat** | AI-style chat: messages, suggestions, SmartInput |
358
- | **ChatMessageBubble** | Message bubble (user/assistant, big/small text) |
359
- | **ChatSuggestions** | Suggestion chips when empty |
386
+ | Block | Description |
387
+ | --------------------- | --------------------------------------------------------------- |
388
+ | **Chat** | Opinionated messages, suggestions, and SmartInput composition |
389
+ | **ChatConversation** | Constrained conversation viewport that follows streaming output |
390
+ | **ChatMessageBubble** | Message bubble (user/assistant, big/small text) |
391
+ | **ChatSuggestions** | Suggestion chips when empty |
360
392
 
361
393
  ### Profile & Settings (Copy from showcase templates)
362
394
 
@@ -404,6 +436,7 @@ Copy from `apps/showcase/templates/profile/`:
404
436
  | **DatePickerModal** | Bottom sheet modal with calendar, compact trigger |
405
437
  | **DatePickerInline** | Inline calendar for forms or dashboards |
406
438
  | **DateRangePickerModal** | Range selection modal with period marking |
439
+ | **TimePickerModal** | Hour and minute wheels in the same bottom sheet |
407
440
 
408
441
  **Tokens without a renderer.** `@thewhileloop/whileui/tokens` exports the theme
409
442
  and visual contracts, the presets, and `deriveThemeColors(base, scheme)` — give it
@@ -412,6 +445,60 @@ as the default presets. Safe in a build script (no React, RN or Uniwind
412
445
  imports); `useThemeColors()` uses it too, so a missing `--color-primary-soft`
413
446
  derives from the app's own primary instead of falling back to the default palette.
414
447
 
448
+ **Building a product preset.** Use `createThemePreset` rather than a `ThemePreset`
449
+ literal — it takes the 25 base colours per scheme and derives the rest:
450
+
451
+ ```ts
452
+ import { createThemePreset, themePresetToCss } from '@thewhileloop/whileui/tokens';
453
+
454
+ const preset = createThemePreset({
455
+ name: 'acme',
456
+ description: 'Acme brand palette.',
457
+ light: { background: '...', foreground: '...', primary: '...' /* 25 base keys */ },
458
+ dark: { background: '...', foreground: '...', primary: '...' },
459
+ visual: { radiusLg: 12 }, // optional, partial
460
+ });
461
+
462
+ writeFileSync('theme.generated.css', themePresetToCss(preset));
463
+ ```
464
+
465
+ The literal form asks for all 52 colours per scheme, including every active variant,
466
+ soft variant, overlay, surface, interaction state and shadow. Those are exactly what
467
+ `deriveThemeColors` computes, so hand-writing them produces a second derivation that
468
+ drifts from the library's. Override a derived value by assigning to the result.
469
+
470
+ `WHILEUI_PRESET_SOFT` is the airy default. `WHILEUI_PRESET_ATELIER` is the stronger
471
+ product preset: tighter geometry, opaque surfaces, firmer borders, real shadows, and no
472
+ blur. Use it as the acceptance check that a screen still has enough visual weight when
473
+ the soft treatment is removed.
474
+
475
+ ### FAB
476
+
477
+ One primary action, raised above the content. It owns no icon dependency — pass the
478
+ glyph as children and colour it from `useIconColors()`, because icon libraries need a
479
+ concrete colour rather than a class.
480
+
481
+ ```tsx
482
+ import { FAB, useIconColors } from '@thewhileloop/whileui';
483
+ import { Feather } from '@expo/vector-icons';
484
+
485
+ const icons = useIconColors();
486
+
487
+ <FAB label="New group" onPress={create}>
488
+ <Feather name="plus" size={24} color={icons.primaryForeground} />
489
+ </FAB>;
490
+ ```
491
+
492
+ | Prop | Type | Default | Notes |
493
+ | ----------- | ------------------------------------------------ | ---------- | --------------------------------------- |
494
+ | `label` | `string` | — | Required. The accessible name |
495
+ | `variant` | `default \| secondary \| destructive \| surface` | `default` | |
496
+ | `size` | `sm \| default \| lg` | `default` | 48 / 56 / 64 — all clear touch minimums |
497
+ | `placement` | `floating \| floating-left \| inline` | `floating` | `inline` lets the caller position it |
498
+
499
+ `FABGroup` stacks secondary actions above one. Sizes start at 48dp, so a FAB never
500
+ needs `hitSlop`.
501
+
415
502
  ## Layout Primitives (Stack, Row)
416
503
 
417
504
  Use `Stack` for vertical layouts, `Row` for horizontal layouts. Both support `gap`, `align`, and `justify` variants.
@@ -496,7 +583,7 @@ import { AppShell, Header, BottomNav, ScrollView } from '@thewhileloop/whileui';
496
583
  | Forms | FormField + Input/NumericInput/LabeledField + DatePickerModal + FormModalScreen |
497
584
  | Settings | ProfileHeader + SettingsSection + SettingsItem (+ FormModalScreen for edits) |
498
585
  | E-commerce | ProductCard list → CheckoutSummary + ActionBar |
499
- | Chat | Chat + ChatSuggestions + SmartInput (attach, send). Extensible for images/tags |
586
+ | Chat | Chat, or ChatConversation + SmartInput for custom message/tool/source compositions |
500
587
  | App shell | AppShell + Header + BottomNav + content |
501
588
  | Loading | AppShell (`loading`) + PageSkeleton/ScreenSkeleton (keep header mounted) |
502
589
 
@@ -592,7 +679,7 @@ The WhileUI token contract is strict for cross-app reuse. Define these in **ever
592
679
  - 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
680
  - Optional status tokens: `success`, `success-foreground`, `warning`, `warning-foreground`, `info`, `info-foreground`
594
681
  - 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`
682
+ - 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
683
  - Optional scale tokens: spacing (`--spacing`, `--spacing-*`), typography (`--text-*`, `--leading-*`, `--tracking-*`), radius (`--radius-*`), elevation (`--shadow-*`)
597
684
 
598
685
  Minimal contract example:
@@ -710,53 +797,25 @@ const iconColors = useIconColors();
710
797
 
711
798
  Input, Textarea, NumericInput, SmartInput, Spinner, and LoadingScreen default to theme colors when you omit `placeholderTextColor` or `spinnerColor`.
712
799
 
713
- ### Interaction + Motion Tokens
800
+ ### Interaction and motion
714
801
 
715
- WhileUI components also read optional `--ui-*` tokens for deeper control of press feedback, disabled states, and motion timing:
802
+ Press opacity, disabled opacity, motion durations, the responsive spring, and the drawer
803
+ open and close durations are package constants on `INTERACTION_TOKENS`. They are not CSS
804
+ variables and they do not change with a preset. Delete any `--ui-press-*`,
805
+ `--ui-disabled-*`, `--ui-inactive-opacity`, `--ui-motion-*`, or `--ui-drawer-open-duration`
806
+ / `--ui-drawer-close-duration` declarations from a theme: nothing reads them.
716
807
 
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:
808
+ For a custom control, read that table:
749
809
 
750
810
  ```tsx
751
811
  import { useInteractionTokens, withInteractivePressableStyle } from '@thewhileloop/whileui';
752
812
  ```
753
813
 
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.
814
+ Wrap the application in `MotionProvider` to coordinate system reduced motion, an explicit
815
+ preview mode, and deterministic animation-free tests. The provider is optional. Without
816
+ it `useMotionPolicy` reports the durations and spring from `INTERACTION_TOKENS`, and
817
+ Reanimated follows the system setting. `reduced` preserves non-spatial feedback while asking Reanimated to skip motion;
818
+ `disabled` also resolves semantic durations to zero.
760
819
 
761
820
  ```tsx
762
821
  import { MotionProvider, useMotionPolicy } from '@thewhileloop/whileui';
@@ -966,6 +1025,17 @@ import { Button, ButtonText, ButtonIcon } from '@thewhileloop/whileui';
966
1025
  | loading | `boolean` | `false` | Show a spinner, expose busy state, and block input |
967
1026
  | className | `string` | — | Additional Tailwind classes |
968
1027
 
1028
+ ## Text
1029
+
1030
+ `Text` maps semantic roles to the active visual typography tokens; custom font-size
1031
+ classes are unnecessary for normal hierarchy. `heading` also sets
1032
+ `accessibilityRole="header"` unless the caller supplies another role.
1033
+
1034
+ | Prop | Type | Default |
1035
+ | ------- | --------------------------------------------------------------------------------------------- | ----------- |
1036
+ | variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'code'` | `'default'` |
1037
+ | size | `'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | by variant |
1038
+
969
1039
  ## Input
970
1040
 
971
1041
  ```tsx
@@ -974,12 +1044,15 @@ import { Input } from '@thewhileloop/whileui';
974
1044
  <Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
975
1045
  ```
976
1046
 
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 |
1047
+ On web, filled Input, Textarea, NumericInput, and SmartInput surfaces preserve their
1048
+ themed background, foreground, and caret when the browser autofills them.
1049
+
1050
+ | Prop | Type | Default | Description |
1051
+ | -------------------- | --------------------------------- | ----------- | --------------------------------------------------------------------- |
1052
+ | variant | `'default' \| 'error' \| 'plain'` | `'default'` | Input style; `plain` drops border and fill for a large display figure |
1053
+ | placeholder | `string` | — | Placeholder text |
1054
+ | placeholderTextColor | `string` | — | Hex for placeholder; defaults to theme `mutedForeground` |
1055
+ | editable | `boolean` | `true` | Whether input is editable |
983
1056
 
984
1057
  ## NumericInput
985
1058
 
@@ -1054,6 +1127,30 @@ import {
1054
1127
  </FormField>;
1055
1128
  ```
1056
1129
 
1130
+ `invalid` and `disabled` are inherited by `Input`, `Textarea`, `NumericInput`, and
1131
+ `Select`. An explicit control prop wins. `useFormFieldState()` exposes the same state for
1132
+ custom controls; labels, hints, messages, density, and required indicators remain part
1133
+ of the compound field rather than each input.
1134
+
1135
+ ## Surface
1136
+
1137
+ ```tsx
1138
+ import { Surface } from '@thewhileloop/whileui';
1139
+
1140
+ <Surface level="raised" padding="default">
1141
+ {/* grouped content */}
1142
+ </Surface>;
1143
+ ```
1144
+
1145
+ | Prop | Type | Default | Description |
1146
+ | -------- | ------------------------------------- | ----------- | ----------------------------------- |
1147
+ | level | `'base' \| 'raised' \| 'overlay'` | `'base'` | Semantic background and depth tier |
1148
+ | padding | `'none' \| 'sm' \| 'default' \| 'lg'` | `'default'` | Token-backed interior spacing |
1149
+ | radius | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'xl'` | Token-backed corner radius |
1150
+ | bordered | `boolean` | `true` | Draw the theme hairline border |
1151
+ | shadow | `'none' \| 'sm' \| 'md' \| 'lg'` | by level | Override the level's depth |
1152
+ | frosted | `boolean` | `false` | Enable the optional frosted overlay |
1153
+
1057
1154
  ## LabeledField
1058
1155
 
1059
1156
  ```tsx
@@ -1346,6 +1443,10 @@ import {
1346
1443
  </Select>;
1347
1444
  ```
1348
1445
 
1446
+ Focused and pressed options use the subtle interaction-state tokens; the primary token
1447
+ marks the selected indicator without replacing the option's foreground colour. Long
1448
+ selected values stay on one line and truncate without displacing the trigger chevron.
1449
+
1349
1450
  `SelectContent` supports optional frosted props:
1350
1451
 
1351
1452
  | Prop | Type | Default |
@@ -1704,9 +1805,34 @@ import { SmartInput, Button, ButtonText } from '@thewhileloop/whileui';
1704
1805
  />;
1705
1806
  ```
1706
1807
 
1707
- ## Chat
1808
+ ## ChatConversation / Chat
1809
+
1810
+ `ChatConversation` is the low-level viewport for custom conversation screens. Its content stays constrained on wide windows, follows new or streaming output while the reader is near the bottom, and stops following after the reader scrolls upward. Compose it with `SmartInput`; the application owns message data, streaming, tools, approvals, and sources.
1811
+
1812
+ It is a `ScrollView` for a bounded active transcript. Paginate or window historical messages; use a virtualized list when the full history can grow without a bound.
1813
+
1814
+ ```tsx
1815
+ import { ChatConversation, SmartInput, View } from '@thewhileloop/whileui';
1816
+
1817
+ <View className="flex-1">
1818
+ <ChatConversation contentContainerClassName="gap-4 px-4 py-6">
1819
+ {messages.map((message) => (
1820
+ <ProductMessage key={message.id} message={message} />
1821
+ ))}
1822
+ </ChatConversation>
1823
+ <SmartInput
1824
+ value={value}
1825
+ onChangeText={setValue}
1826
+ onSubmitEditing={send}
1827
+ submitBehavior="submit"
1828
+ leftSlot={<AttachButton />}
1829
+ centerSlot={<ModelSelect />}
1830
+ rightSlot={<SendButton />}
1831
+ />
1832
+ </View>;
1833
+ ```
1708
1834
 
1709
- AI-style chat: message list, suggestion chips when empty, SmartInput with attach/send slots. Uses semantic tokens; theme via `global.css`. Copy-paste block — edit directly. `renderMessage` for markdown/code, `loadingIndicator` for typing state.
1835
+ `Chat` is the opinionated convenience composition: message bubbles, suggestion chips, and `SmartInput` with attach/send slots. It uses semantic tokens; theme via `global.css`. Use `renderMessage` for markdown, code, tool calls, reasoning, or sources, and `loadingIndicator` for streaming feedback.
1710
1836
 
1711
1837
  ```tsx
1712
1838
  import { Chat, type ChatMessage } from '@thewhileloop/whileui';
@@ -1743,7 +1869,27 @@ const [value, setValue] = useState('');
1743
1869
  | `inputSafeArea` | `boolean` | SmartInput safe-area (default `true`) |
1744
1870
  | `keyboardVerticalOffset` | `number` | For header offset when keyboard opens |
1745
1871
 
1746
- ## DatePickerModal / DatePickerInline / DateRangePickerModal
1872
+ `ChatConversation` extends React Native `ScrollViewProps`, forwards its ref, and adds `contentContainerClassName` to style the constrained inner column. Caller `onScroll` and `onContentSizeChange` callbacks are preserved.
1873
+
1874
+ ### Optional Markdown and code
1875
+
1876
+ These are explicit subpaths so the root package remains independent of a Markdown
1877
+ parser. Install `markdown-it` only in applications that render Markdown.
1878
+
1879
+ ```tsx
1880
+ import { Markdown } from '@thewhileloop/whileui/markdown';
1881
+ import { CodeBlock } from '@thewhileloop/whileui/code';
1882
+
1883
+ <Markdown onLinkPress={openLink}>{assistantText}</Markdown>
1884
+ <CodeBlock code={source} language="TypeScript" actions={<CopyButton />} />
1885
+ ```
1886
+
1887
+ `Markdown` supports headings, paragraphs, emphasis, links, lists, quotes, rules, inline
1888
+ code, and fenced code. It never opens links or writes to the clipboard itself:
1889
+ `onLinkPress` and `codeActions` keep those capabilities in the application. `CodeBlock`
1890
+ is dependency-free and accepts an application-owned `actions` slot.
1891
+
1892
+ ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1747
1893
 
1748
1894
  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
1895
 
@@ -1753,6 +1899,8 @@ Date selection blocks using react-native-calendars. Theme-aware via `useCalendar
1753
1899
 
1754
1900
  **DateRangePickerModal** — Range selection with period marking. Use `DateRangePickerTrigger` as the trigger content.
1755
1901
 
1902
+ **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.
1903
+
1756
1904
  ```tsx
1757
1905
  import {
1758
1906
  DatePickerModal,
@@ -1760,6 +1908,8 @@ import {
1760
1908
  DatePickerInline,
1761
1909
  DateRangePickerModal,
1762
1910
  DateRangePickerTrigger,
1911
+ TimePickerModal,
1912
+ TimePickerTrigger,
1763
1913
  type DateRange,
1764
1914
  } from '@thewhileloop/whileui';
1765
1915
 
@@ -1789,6 +1939,18 @@ const [range, setRange] = useState<DateRange | null>(null);
1789
1939
  onOpenChange={setRangeOpen}
1790
1940
  trigger={<DateRangePickerTrigger value={range} placeholder="Pick range" />}
1791
1941
  />;
1942
+
1943
+ // Time (modal) — "HH:MM", 24-hour, whatever the wheels show
1944
+ const [time, setTime] = useState<string | null>(null);
1945
+
1946
+ <TimePickerModal
1947
+ value={time}
1948
+ onValueChange={setTime}
1949
+ open={timeOpen}
1950
+ onOpenChange={setTimeOpen}
1951
+ minuteStep={5}
1952
+ trigger={<TimePickerTrigger value={time} placeholder="Pick a time" />}
1953
+ />;
1792
1954
  ```
1793
1955
 
1794
1956
  | Prop | Type | Description |
@@ -1799,6 +1961,8 @@ const [range, setRange] = useState<DateRange | null>(null);
1799
1961
  | `trigger` | `ReactNode` | Custom trigger (modal variants) |
1800
1962
  | `minDate` / `maxDate` | `string` | YYYY-MM-DD bounds |
1801
1963
  | `theme` | `CalendarTheme` | Override calendar colors |
1964
+ | `hourCycle` | `'h12' \| 'h23'` | Time picker wheels; defaults to locale |
1965
+ | `minuteStep` | `number` | Minutes between options (default 1) |
1802
1966
  | `frosted` | `boolean` | Enable frosted surface for modal panel |
1803
1967
  | `blurIntensity` | `number` | Override blur amount |
1804
1968
  | `blurTintToken` | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | Tint token for frosted panel |
@@ -0,0 +1,8 @@
1
+ import { type ScrollViewInstance, type ScrollViewProps } from 'react-native';
2
+ import type { ForwardRefComponent } from '../../lib/ref-types';
3
+ /** ScrollView viewport for a bounded active transcript; virtualize unbounded history. */
4
+ export interface ChatConversationProps extends ScrollViewProps {
5
+ contentContainerClassName?: string;
6
+ }
7
+ declare const ChatConversation: ForwardRefComponent<ScrollViewInstance, ChatConversationProps>;
8
+ export { ChatConversation };
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useCallback, useRef } from 'react';
3
+ import { ScrollView, View, } from 'react-native';
4
+ import { cn } from '../../lib/cn';
5
+ const BOTTOM_THRESHOLD = 48;
6
+ function assignRef(ref, value) {
7
+ if (typeof ref === 'function') {
8
+ ref(value);
9
+ }
10
+ else if (ref) {
11
+ ref.current = value;
12
+ }
13
+ }
14
+ const ChatConversation = React.forwardRef(({ className, contentContainerClassName, contentContainerStyle, keyboardShouldPersistTaps = 'handled', onContentSizeChange, onScroll, scrollEventThrottle = 64, children, ...props }, forwardedRef) => {
15
+ const scrollRef = useRef(null);
16
+ const followsOutputRef = useRef(true);
17
+ const setRef = useCallback((instance) => {
18
+ scrollRef.current = instance;
19
+ assignRef(forwardedRef, instance);
20
+ }, [forwardedRef]);
21
+ const handleScroll = useCallback((event) => {
22
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
23
+ const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
24
+ followsOutputRef.current = distanceFromBottom <= BOTTOM_THRESHOLD;
25
+ onScroll?.(event);
26
+ }, [onScroll]);
27
+ const handleContentSizeChange = useCallback((width, height) => {
28
+ onContentSizeChange?.(width, height);
29
+ if (followsOutputRef.current) {
30
+ scrollRef.current?.scrollToEnd({ animated: true });
31
+ }
32
+ }, [onContentSizeChange]);
33
+ return (_jsx(ScrollView, { ref: setRef, className: cn('flex-1', className), contentContainerStyle: { flexGrow: 1 }, keyboardShouldPersistTaps: keyboardShouldPersistTaps, onContentSizeChange: handleContentSizeChange, onScroll: handleScroll, scrollEventThrottle: scrollEventThrottle, ...props, children: _jsx(View, { className: cn('w-full max-w-3xl flex-1 self-center', contentContainerClassName), style: contentContainerStyle, children: children }) }));
34
+ });
35
+ ChatConversation.displayName = 'ChatConversation';
36
+ export { ChatConversation };
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useRef, useEffect } from 'react';
3
- import { View, ScrollView, Pressable, KeyboardAvoidingView, Platform, useWindowDimensions, } from 'react-native';
2
+ import React from 'react';
3
+ import { View, Pressable, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native';
4
4
  import { Text } from '../../components/text';
5
5
  import { SmartInput } from '../layout/smart-input';
6
+ import { ChatConversation } from './chat-conversation';
6
7
  import { cn } from '../../lib/cn';
7
8
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
8
9
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -49,23 +50,16 @@ export function ChatSuggestions({ suggestions, onSelect, className }) {
49
50
  }
50
51
  const EMPTY_SUGGESTIONS = [];
51
52
  export function Chat({ messages, value, onChangeText, onSend, placeholder = 'Message...', suggestions = EMPTY_SUGGESTIONS, onSuggestionPress, leftSlot, rightSlot, loading = false, header, emptyTitle, emptyDescription, exampleMessage, renderMessage, loadingIndicator, inputSafeArea = true, keyboardVerticalOffset = 0, className, messagesClassName, }) {
52
- const scrollRef = useRef(null);
53
53
  const visual = useVisualTokens();
54
54
  const emptyTitleTypography = typographyStyle(visual, 'emphasis');
55
55
  const emptyDescriptionTypography = typographyStyle(visual, 'label');
56
56
  const isEmpty = messages.length === 0;
57
57
  const showSuggestions = isEmpty && suggestions.length > 0;
58
- useEffect(() => {
59
- if (!showSuggestions && messages.length > 0) {
60
- scrollRef.current?.scrollToEnd({ animated: true });
61
- }
62
- }, [messages.length, showSuggestions]);
63
58
  const defaultRightSlot = rightSlot ?? (_jsx(ChatSendButton, { onPress: onSend, disabled: loading || !value.trim() }));
64
- return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, keyboardVerticalOffset: keyboardVerticalOffset, style: { flex: 1 }, className: className, children: [header, _jsx(ScrollView, { ref: scrollRef, className: cn('flex-1', messagesClassName), contentContainerStyle: {
65
- flexGrow: 1,
59
+ return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, keyboardVerticalOffset: keyboardVerticalOffset, style: { flex: 1 }, className: className, children: [header, _jsx(ChatConversation, { className: cn('flex-1', messagesClassName), contentContainerStyle: {
66
60
  paddingBottom: 8,
67
61
  justifyContent: showSuggestions ? 'center' : 'flex-end',
68
- }, keyboardShouldPersistTaps: "handled", children: showSuggestions ? (_jsxs(View, { className: "flex-1 justify-center", children: [exampleMessage && (_jsx(View, { className: "mb-6 px-4", children: renderMessage ? (renderMessage(exampleMessage)) : (_jsx(ChatMessageBubble, { message: exampleMessage })) })), emptyTitle && (_jsx(Text, { className: "mb-1 text-center font-medium text-foreground", style: emptyTitleTypography, children: emptyTitle })), emptyDescription && (_jsx(Text, { className: "mb-4 text-center text-muted-foreground", style: emptyDescriptionTypography, children: emptyDescription })), _jsx(ChatSuggestions, { suggestions: suggestions, onSelect: onSuggestionPress ?? (() => { }) })] })) : (_jsxs(View, { className: "gap-3 px-4 pt-4", children: [messages.map((msg) => renderMessage ? (_jsx(React.Fragment, { children: renderMessage(msg) }, msg.id)) : (_jsx(ChatMessageBubble, { message: msg }, msg.id))), loading && loadingIndicator && _jsx(View, { className: "px-4", children: loadingIndicator })] })) }), _jsx(SmartInput, { value: value, onChangeText: onChangeText, placeholder: placeholder, leftSlot: leftSlot, rightSlot: defaultRightSlot, editable: !loading, safeArea: inputSafeArea })] }));
62
+ }, children: showSuggestions ? (_jsxs(View, { className: "flex-1 justify-center", children: [exampleMessage && (_jsx(View, { className: "mb-6 px-4", children: renderMessage ? (renderMessage(exampleMessage)) : (_jsx(ChatMessageBubble, { message: exampleMessage })) })), emptyTitle && (_jsx(Text, { className: "mb-1 text-center font-medium text-foreground", style: emptyTitleTypography, children: emptyTitle })), emptyDescription && (_jsx(Text, { className: "mb-4 text-center text-muted-foreground", style: emptyDescriptionTypography, children: emptyDescription })), _jsx(ChatSuggestions, { suggestions: suggestions, onSelect: onSuggestionPress ?? (() => { }) })] })) : (_jsxs(View, { className: "gap-3 px-4 pt-4", children: [messages.map((msg) => renderMessage ? (_jsx(React.Fragment, { children: renderMessage(msg) }, msg.id)) : (_jsx(ChatMessageBubble, { message: msg }, msg.id))), loading && loadingIndicator && _jsx(View, { className: "px-4", children: loadingIndicator })] })) }), _jsx(SmartInput, { value: value, onChangeText: onChangeText, placeholder: placeholder, leftSlot: leftSlot, rightSlot: defaultRightSlot, editable: !loading, safeArea: inputSafeArea })] }));
69
63
  }
70
64
  function ChatSendButton({ onPress, disabled }) {
71
65
  const interaction = useInteractionTokens();
@@ -1 +1,2 @@
1
1
  export { Chat, ChatMessageBubble, ChatSuggestions, type ChatMessage, type ChatProps, type ChatSuggestionsProps, } from './chat';
2
+ export { ChatConversation, type ChatConversationProps } from './chat-conversation';
@@ -1 +1,2 @@
1
1
  export { Chat, ChatMessageBubble, ChatSuggestions, } from './chat';
2
+ export { ChatConversation } from './chat-conversation';
@@ -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';