panelui-native 0.44.0 → 0.49.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 (108) hide show
  1. package/README.md +6 -1
  2. package/lib/module/components/accordion/index.js +32 -4
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +83 -15
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/button-group/index.js +186 -0
  7. package/lib/module/components/button-group/index.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +110 -1
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/combobox/index.js +7 -1
  11. package/lib/module/components/combobox/index.js.map +1 -1
  12. package/lib/module/components/date-time-picker/index.js +272 -0
  13. package/lib/module/components/date-time-picker/index.js.map +1 -0
  14. package/lib/module/components/fab/index.js +514 -0
  15. package/lib/module/components/fab/index.js.map +1 -0
  16. package/lib/module/components/grid-item/index.js +486 -0
  17. package/lib/module/components/grid-item/index.js.map +1 -0
  18. package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
  19. package/lib/module/components/kpi/index.js.map +1 -0
  20. package/lib/module/components/line-chart/index.js +92 -10
  21. package/lib/module/components/line-chart/index.js.map +1 -1
  22. package/lib/module/components/markdown-editor/index.js +406 -0
  23. package/lib/module/components/markdown-editor/index.js.map +1 -0
  24. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  25. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  26. package/lib/module/components/pie-chart/index.js +625 -0
  27. package/lib/module/components/pie-chart/index.js.map +1 -0
  28. package/lib/module/components/questionnaire/index.js +1312 -0
  29. package/lib/module/components/questionnaire/index.js.map +1 -0
  30. package/lib/module/components/scatter-chart/index.js +1173 -0
  31. package/lib/module/components/scatter-chart/index.js.map +1 -0
  32. package/lib/module/components/tabs/index.js +359 -41
  33. package/lib/module/components/tabs/index.js.map +1 -1
  34. package/lib/module/components/time-picker/index.js +34 -6
  35. package/lib/module/components/time-picker/index.js.map +1 -1
  36. package/lib/module/components/tree/index.js +500 -0
  37. package/lib/module/components/tree/index.js.map +1 -0
  38. package/lib/module/icons/index.js +217 -0
  39. package/lib/module/icons/index.js.map +1 -1
  40. package/lib/module/index.js +11 -2
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/utils/chart.js +81 -0
  43. package/lib/module/utils/chart.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/button/index.d.ts +21 -0
  47. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  49. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  51. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
  54. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  56. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
  58. package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
  60. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
  62. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  64. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  66. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  67. package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
  68. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  70. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
  72. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/tabs/index.d.ts +29 -2
  74. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  76. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  78. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  79. package/lib/typescript/src/icons/index.d.ts +22 -0
  80. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  81. package/lib/typescript/src/index.d.ts +14 -5
  82. package/lib/typescript/src/index.d.ts.map +1 -1
  83. package/lib/typescript/src/utils/chart.d.ts +34 -0
  84. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/components/accordion/index.tsx +48 -6
  87. package/src/components/button/index.tsx +97 -15
  88. package/src/components/button-group/index.tsx +199 -0
  89. package/src/components/color-picker/index.tsx +140 -3
  90. package/src/components/combobox/index.tsx +7 -1
  91. package/src/components/date-time-picker/index.tsx +411 -0
  92. package/src/components/fab/index.tsx +583 -0
  93. package/src/components/grid-item/index.tsx +515 -0
  94. package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
  95. package/src/components/line-chart/index.tsx +98 -8
  96. package/src/components/markdown-editor/index.tsx +526 -0
  97. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  98. package/src/components/pie-chart/index.tsx +863 -0
  99. package/src/components/questionnaire/index.tsx +1615 -0
  100. package/src/components/scatter-chart/index.tsx +1401 -0
  101. package/src/components/tabs/index.tsx +392 -50
  102. package/src/components/time-picker/index.tsx +42 -6
  103. package/src/components/tree/index.tsx +564 -0
  104. package/src/icons/index.tsx +154 -0
  105. package/src/index.ts +142 -18
  106. package/src/utils/chart.ts +110 -0
  107. package/lib/module/components/kpi-chart/index.js.map +0 -1
  108. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +0 -1
@@ -28,6 +28,28 @@
28
28
  * a control with no alpha is one that has no `ColorPicker.Alpha`, not one with
29
29
  * a prop turned off.
30
30
  *
31
+ * ## Folding it away
32
+ *
33
+ * A picker is a page's worth of controls in service of one value, and that
34
+ * value is read far more often than it is changed — so `presentation` puts the
35
+ * controls behind the row that reads it out:
36
+ *
37
+ * ```tsx
38
+ * <ColorPicker value={accent} onValueChange={setAccent} presentation="popover">
39
+ * <ColorPicker.Trigger>
40
+ * <ColorPicker.Field label="Accent" />
41
+ * </ColorPicker.Trigger>
42
+ * <ColorPicker.Content>
43
+ * <ColorPicker.Area />
44
+ * <ColorPicker.Hue />
45
+ * </ColorPicker.Content>
46
+ * </ColorPicker>
47
+ * ```
48
+ *
49
+ * `Content` re-provides the picker's context around what it holds, because the
50
+ * panel is portalled above the rest of the screen and is therefore no longer
51
+ * below the picker in the tree by the time the parts inside it go looking.
52
+ *
31
53
  * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
32
54
  */
33
55
  import {
@@ -40,6 +62,7 @@ import {
40
62
  useMemo,
41
63
  useRef,
42
64
  useState,
65
+ type ReactElement,
43
66
  type ReactNode,
44
67
  } from 'react';
45
68
  import {
@@ -81,7 +104,9 @@ import {
81
104
  type ColorFormat,
82
105
  type HsvaColor,
83
106
  } from '../../utils/color';
107
+ import { cn } from '../../utils/cn';
84
108
  import { selectionTick } from '../../utils/haptics';
109
+ import { Popover, type PopoverContentProps } from '../popover';
85
110
 
86
111
  /** Settles a thumb that was moved by something other than a finger. */
87
112
  const TIMING = { duration: 140 } as const;
@@ -206,6 +231,16 @@ function useColorPicker(part: string) {
206
231
  /** Where a picker starts when it is handed a value it cannot read. */
207
232
  const FALLBACK: HsvaColor = { h: 0, s: 1, v: 1, a: 1 };
208
233
 
234
+ /** How the controls get onto the screen. */
235
+ export type ColorPickerPresentation = 'inline' | 'popover' | 'bottom-sheet';
236
+
237
+ /**
238
+ * Floor for the panel's width when it takes the trigger's. A swatch row is a
239
+ * narrow trigger, and a square you drag on is not a control that survives being
240
+ * squeezed to match one.
241
+ */
242
+ const CONTENT_MIN_WIDTH = 268;
243
+
209
244
  export interface ColorPickerProps extends Omit<ColorPickerVariantProps, 'disabled'> {
210
245
  className?: string;
211
246
  /** Controlled colour. Leave unset and pass `defaultValue` to run uncontrolled. */
@@ -233,6 +268,19 @@ export interface ColorPickerProps extends Omit<ColorPickerVariantProps, 'disable
233
268
  * buzz proportional to speed.
234
269
  */
235
270
  haptics?: boolean;
271
+ /**
272
+ * How the controls get onto the screen.
273
+ *
274
+ * `inline` stacks them where they are written, and is the default. The other
275
+ * two put them behind a `ColorPicker.Trigger` and draw them in a
276
+ * `ColorPicker.Content` — which is the arrangement a colour usually wants,
277
+ * since a picker is a page's worth of controls in service of one value that
278
+ * is looked at far more often than it is changed.
279
+ */
280
+ presentation?: ColorPickerPresentation;
281
+ /** Controlled open state of the panel. Ignored by `inline`. */
282
+ open?: boolean;
283
+ onOpenChange?: (open: boolean) => void;
236
284
  /** The parts, in the order they should stack. */
237
285
  children: ReactNode;
238
286
  }
@@ -249,6 +297,9 @@ const ColorPickerRoot = forwardRef<View, ColorPickerProps>(
249
297
  disabled = false,
250
298
  haptics = false,
251
299
  size = 'md',
300
+ presentation = 'inline',
301
+ open,
302
+ onOpenChange,
252
303
  children,
253
304
  },
254
305
  ref
@@ -319,6 +370,16 @@ const ColorPickerRoot = forwardRef<View, ColorPickerProps>(
319
370
  [hue, saturation, brightness, opacity, emit, format, disabled, haptics, size]
320
371
  );
321
372
 
373
+ if (presentation !== 'inline') {
374
+ return (
375
+ <ColorPickerContext.Provider value={context}>
376
+ <Popover open={open} onOpenChange={onOpenChange} presentation={presentation}>
377
+ {children}
378
+ </Popover>
379
+ </ColorPickerContext.Provider>
380
+ );
381
+ }
382
+
322
383
  return (
323
384
  <ColorPickerContext.Provider value={context}>
324
385
  <View ref={ref} className={slots.root({ className })} collapsable={false}>
@@ -331,6 +392,64 @@ const ColorPickerRoot = forwardRef<View, ColorPickerProps>(
331
392
 
332
393
  ColorPickerRoot.displayName = 'ColorPicker';
333
394
 
395
+ /* ------------------------------------------------------------------ *
396
+ * Trigger and Content — the picker folded away behind the value.
397
+ * ------------------------------------------------------------------ */
398
+
399
+ export interface ColorPickerTriggerProps {
400
+ /** One element, cloned with an `onPress` that opens the panel. */
401
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
402
+ }
403
+
404
+ /**
405
+ * What you press to open the picker. `ColorPicker.Field` is the obvious child —
406
+ * it already reads out the colour it would let you change — but anything that
407
+ * takes an `onPress` will do.
408
+ */
409
+ function ColorPickerTrigger({ children }: ColorPickerTriggerProps) {
410
+ useColorPicker('ColorPicker.Trigger');
411
+ return <Popover.Trigger>{children}</Popover.Trigger>;
412
+ }
413
+
414
+ ColorPickerTrigger.displayName = 'ColorPicker.Trigger';
415
+
416
+ export interface ColorPickerContentProps extends PopoverContentProps {}
417
+
418
+ /**
419
+ * The panel the controls are drawn in.
420
+ *
421
+ * It re-provides the picker's context around its children, and has to: the
422
+ * panel is rendered through a portal, above everything else on the screen, so
423
+ * by the time the parts inside it look for the picker they are no longer
424
+ * anywhere below it in the tree. Without this the first `ColorPicker.Area`
425
+ * inside a popover would throw.
426
+ *
427
+ * Defaults to the trigger's width, floored, so a panel opened from a labelled
428
+ * strip lines up under it rather than announcing itself as a different object.
429
+ */
430
+ function ColorPickerContent({
431
+ className,
432
+ width = 'trigger',
433
+ minWidth = CONTENT_MIN_WIDTH,
434
+ children,
435
+ ...props
436
+ }: ColorPickerContentProps) {
437
+ const ctx = useColorPicker('ColorPicker.Content');
438
+
439
+ return (
440
+ <Popover.Content
441
+ width={width}
442
+ minWidth={minWidth}
443
+ className={cn('gap-3', className)}
444
+ {...props}
445
+ >
446
+ <ColorPickerContext.Provider value={ctx}>{children}</ColorPickerContext.Provider>
447
+ </Popover.Content>
448
+ );
449
+ }
450
+
451
+ ColorPickerContent.displayName = 'ColorPicker.Content';
452
+
334
453
  /* ------------------------------------------------------------------ *
335
454
  * The checkerboard behind anything translucent.
336
455
  * ------------------------------------------------------------------ */
@@ -1083,6 +1202,14 @@ export interface ColorPickerFieldProps {
1083
1202
  showValue?: boolean;
1084
1203
  /** Extra classes for the swatch. */
1085
1204
  swatchClassName?: string;
1205
+ /**
1206
+ * Makes the strip pressable, and a button to a screen reader.
1207
+ *
1208
+ * Mostly you do not pass this yourself: `ColorPicker.Trigger` clones the
1209
+ * strip with one, which is what turns the row into the thing that opens the
1210
+ * picker.
1211
+ */
1212
+ onPress?: (...args: unknown[]) => void;
1086
1213
  /** Anything to put after the swatch — a copy button, a reset. */
1087
1214
  children?: ReactNode;
1088
1215
  }
@@ -1095,7 +1222,7 @@ export interface ColorPickerFieldProps {
1095
1222
  * A drag through a hundred frames of the same hex costs one render.
1096
1223
  */
1097
1224
  const ColorPickerField = forwardRef<View, ColorPickerFieldProps>(
1098
- ({ className, label, showValue = true, swatchClassName, children }, ref) => {
1225
+ ({ className, label, showValue = true, swatchClassName, onPress, children }, ref) => {
1099
1226
  const ctx = useColorPicker('ColorPicker.Field');
1100
1227
  const slots = colorPickerVariants({ size: ctx.size });
1101
1228
  const diameter = FIELD_SWATCH[ctx.size];
@@ -1145,13 +1272,21 @@ const ColorPickerField = forwardRef<View, ColorPickerFieldProps>(
1145
1272
  ),
1146
1273
  }));
1147
1274
 
1275
+ // Pressable only when it has somewhere to go. A row that highlights under
1276
+ // a finger and then does nothing is a worse lie than a row that does not
1277
+ // react at all.
1278
+ const Row = onPress ? Pressable : View;
1279
+
1148
1280
  return (
1149
- <View
1281
+ <Row
1150
1282
  ref={ref}
1283
+ onPress={onPress}
1284
+ disabled={onPress ? ctx.disabled : undefined}
1151
1285
  className={slots.field({ className })}
1152
1286
  // One thing being read out, not three: a label, a value and a swatch
1153
1287
  // announced separately are three stops that each say a third of it.
1154
1288
  accessible
1289
+ accessibilityRole={onPress ? 'button' : undefined}
1155
1290
  accessibilityLabel={label ? `${label}, ${printed}` : printed}
1156
1291
  >
1157
1292
  {label ? <Text className={slots.fieldLabel()}>{label}</Text> : null}
@@ -1168,7 +1303,7 @@ const ColorPickerField = forwardRef<View, ColorPickerFieldProps>(
1168
1303
  <Animated.View style={[StyleSheet.absoluteFill, fillStyle]} />
1169
1304
  </View>
1170
1305
  {children}
1171
- </View>
1306
+ </Row>
1172
1307
  );
1173
1308
  }
1174
1309
  );
@@ -1606,6 +1741,8 @@ const ColorPickerWheel = forwardRef<View, ColorPickerWheelProps>(
1606
1741
  ColorPickerWheel.displayName = 'ColorPicker.Wheel';
1607
1742
 
1608
1743
  export const ColorPicker = Object.assign(ColorPickerRoot, {
1744
+ Trigger: ColorPickerTrigger,
1745
+ Content: ColorPickerContent,
1609
1746
  Field: ColorPickerField,
1610
1747
  Area: ColorPickerArea,
1611
1748
  Wheel: ColorPickerWheel,
@@ -109,8 +109,14 @@ const comboboxVariants = tv({
109
109
  * have one (`h-6`) and two differently-sized boxes on a `items-center` row
110
110
  * centre to two different baselines. `py-0` clears the platform default,
111
111
  * which is not the same on iOS and Android.
112
+ *
113
+ * The size is a length rather than a `text-*` step for the reason Input
114
+ * gives: a step sets a size *and* a line height, and the extra leading
115
+ * lands above the glyphs, so inside a box of fixed height the text and the
116
+ * placeholder sit below its middle — a few pixels under the chips they are
117
+ * supposed to be level with. A length leaves the line box the font's own.
112
118
  */
113
- input: 'h-7 min-w-24 flex-1 py-0 text-base font-normal text-foreground',
119
+ input: 'h-7 min-w-24 flex-1 py-0 text-[16px] font-normal text-foreground',
114
120
  action: 'h-6 w-6 items-center justify-center rounded-full',
115
121
  list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm',
116
122
  item: 'flex-row items-center gap-2 rounded-lg px-3 py-3',
@@ -0,0 +1,411 @@
1
+ /**
2
+ * DateTimePicker — a day and a time of day, picked in one panel.
3
+ *
4
+ * ```tsx
5
+ * const [when, setWhen] = useState<Date>();
6
+ *
7
+ * <DateTimePicker value={when} onValueChange={setWhen} />
8
+ * ```
9
+ *
10
+ * ## Why it is one component and not two side by side
11
+ *
12
+ * A date field beside a time field is two decisions the reader has to make
13
+ * separately and then hold together — and the two halves can disagree, which is
14
+ * how a booking ends up on the right day at a time that has already passed. Here
15
+ * the calendar and the scale are the same panel over one `Date`, so what is on
16
+ * screen is the answer rather than two thirds of it.
17
+ *
18
+ * The layout follows from that: the calendar is the coarse choice and takes the
19
+ * room, the time sits under it behind a hairline, and one Done finishes both.
20
+ * The panel is a fixed width, which is what lets the two halves line up — a
21
+ * month grid and a time scale that measured themselves independently would be
22
+ * two boxes of slightly different widths stacked on each other.
23
+ *
24
+ * ## It does not close on the date
25
+ *
26
+ * `DatePicker` closes as soon as a single day is tapped, because at that point
27
+ * there is nothing left to say. Here there is: the day is half the value, and
28
+ * closing on it would hide the other half at the moment it became relevant. So
29
+ * the panel stays until Done, in every presentation including the popover — the
30
+ * one place `DatePicker` has no Done button at all.
31
+ *
32
+ * ## The time face
33
+ *
34
+ * `ruler` by default rather than the wheel. Under a month grid the panel is
35
+ * already tall, and the wheel is five rows of it; the ruler is one readout over
36
+ * a scale, reads at arm's length, and is the one face that fits under a calendar
37
+ * without the whole thing needing to scroll. The other two are a prop away.
38
+ *
39
+ * ## Picking the time before the day
40
+ *
41
+ * Allowed, and it means today. There has to be *some* day for a time to be a
42
+ * `Date` at all, and the day the reader is looking at is the only defensible
43
+ * guess — the alternative is refusing to emit a value until both halves have
44
+ * been touched, which is a form that silently does nothing when you use it in
45
+ * the order it did not expect.
46
+ */
47
+ import {
48
+ useCallback,
49
+ useMemo,
50
+ useState,
51
+ type ReactElement,
52
+ type ReactNode,
53
+ } from 'react';
54
+ import { View } from 'react-native';
55
+ import { CalendarIcon } from '../../icons';
56
+ import { Text } from '../../primitives/text';
57
+ import { cn } from '../../utils/cn';
58
+ import { calendarShortDate, resolveCalendar, type CalendarSystem } from '../../utils/date';
59
+ import {
60
+ clampTime,
61
+ formatTime,
62
+ roundToStep,
63
+ timeFromDate,
64
+ timeToDate,
65
+ type HourCycle,
66
+ type TimeValue,
67
+ } from '../../utils/time';
68
+ import { Button } from '../button';
69
+ import { Calendar, type CalendarCaptionLayout, type CalendarDisabled } from '../calendar';
70
+ import { Dialog } from '../dialog';
71
+ import { Popover } from '../popover';
72
+ import { TimePicker, type TimePickerLayout } from '../time-picker';
73
+
74
+ /** What a closed picker shows when nothing has been chosen yet. */
75
+ const DEFAULT_PLACEHOLDER = 'Pick a date and time';
76
+
77
+ /**
78
+ * The width both halves are laid out at.
79
+ *
80
+ * Fixed rather than measured, and shared, because that is the whole reason they
81
+ * read as one panel: a month grid and a time scale each sized to their own
82
+ * content are two boxes of slightly different widths stacked on each other, and
83
+ * the seam is visible at any size.
84
+ */
85
+ const PANEL_WIDTH = 308;
86
+
87
+ /** How a `Date` is written on the closed trigger. */
88
+ function describe(
89
+ value: Date,
90
+ system: 'gregory' | 'islamic',
91
+ hourCycle: HourCycle,
92
+ locale: string | undefined
93
+ ): string {
94
+ const day = calendarShortDate(value, system, locale);
95
+ const time = formatTime(timeFromDate(value), { hourCycle, locale });
96
+ return `${day} · ${time}`;
97
+ }
98
+
99
+ /** Where the panel is shown. `inline` renders it bare, for a Frame or a form. */
100
+ export type DateTimePickerPresentation = 'popover' | 'bottom-sheet' | 'dialog' | 'inline';
101
+
102
+ export interface DateTimePickerProps {
103
+ /** Controlled value. One `Date` carrying both halves. */
104
+ value?: Date;
105
+ /** Starting value when uncontrolled. */
106
+ defaultValue?: Date;
107
+ /**
108
+ * Fires on every change to either half, not on Done. Done closes the panel;
109
+ * it does not decide anything the caller has not already been told.
110
+ */
111
+ onValueChange?: (value: Date) => void;
112
+ /** Which face the time is picked on. `ruler` is the one that fits here. */
113
+ layout?: TimePickerLayout;
114
+ /** Anchored panel, a sheet, a dialog, or the panel with nothing around it. */
115
+ presentation?: DateTimePickerPresentation;
116
+ /** Controlled open state of the panel. */
117
+ open?: boolean;
118
+ onOpenChange?: (open: boolean) => void;
119
+ /** `12` shows a meridiem, `24` does not. The value is 24-hour either way. */
120
+ hourCycle?: HourCycle;
121
+ /** Minutes between one selectable time and the next. */
122
+ minuteStep?: number;
123
+ /** Earliest selectable time of day, inclusive. */
124
+ minTime?: TimeValue;
125
+ /** Latest selectable time of day, inclusive. */
126
+ maxTime?: TimeValue;
127
+ /** What the trigger reads when nothing has been chosen. */
128
+ placeholder?: string;
129
+ /** Override how the chosen value is written on the trigger. */
130
+ format?: (value: Date) => string;
131
+ /** Stop the trigger opening it, and the panel from being used. */
132
+ disabled?: boolean;
133
+ /** Days that cannot be picked: a list, a span, or a rule. */
134
+ disabledDates?: CalendarDisabled;
135
+ /** Earliest selectable day. */
136
+ minDate?: Date;
137
+ /** Latest selectable day. */
138
+ maxDate?: Date;
139
+ /** `dropdown` swaps the month caption for month and year pickers. */
140
+ captionLayout?: CalendarCaptionLayout;
141
+ /** `0` is Sunday. */
142
+ weekStartsOn?: number;
143
+ /** BCP 47 tag for the month names, the time and the trigger's own text. */
144
+ locale?: string;
145
+ /** Which calendar the months and day numbers are counted in. */
146
+ calendar?: CalendarSystem;
147
+ /** Label on the button that closes the panel. */
148
+ doneLabel?: string;
149
+ /**
150
+ * What the time half of the panel is called, above its face. The date half
151
+ * names itself with the month it is showing; the time half has nothing that
152
+ * would say what it is otherwise.
153
+ */
154
+ timeLabel?: string;
155
+ className?: string;
156
+ /**
157
+ * A trigger of your own. Given one, it is cloned with an `onPress` that opens
158
+ * the panel — so a field row or an icon button can stand in for the default
159
+ * button without this component knowing what either looks like.
160
+ *
161
+ * Ignored by `presentation="inline"`, which has no trigger.
162
+ */
163
+ children?: ReactElement<{ onPress?: () => void }> | ReactNode;
164
+ }
165
+
166
+ function DateTimePickerRoot({
167
+ value: valueProp,
168
+ defaultValue,
169
+ onValueChange,
170
+ layout = 'ruler',
171
+ presentation = 'popover',
172
+ open: openProp,
173
+ onOpenChange,
174
+ hourCycle = 12,
175
+ minuteStep,
176
+ minTime,
177
+ maxTime,
178
+ placeholder = DEFAULT_PLACEHOLDER,
179
+ format,
180
+ disabled = false,
181
+ disabledDates,
182
+ minDate,
183
+ maxDate,
184
+ captionLayout = 'label',
185
+ weekStartsOn = 0,
186
+ locale,
187
+ calendar = 'gregory',
188
+ doneLabel = 'Done',
189
+ timeLabel = 'Time',
190
+ className,
191
+ children,
192
+ }: DateTimePickerProps) {
193
+ // Resolved here as well as in the grid, because the trigger's text has to be
194
+ // written in the same calendar the cells were tapped in.
195
+ const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
196
+
197
+ const [internalValue, setInternalValue] = useState<Date | undefined>(defaultValue);
198
+ const [internalOpen, setInternalOpen] = useState(false);
199
+
200
+ const isValueControlled = valueProp !== undefined;
201
+ const isOpenControlled = openProp !== undefined;
202
+ const value = isValueControlled ? valueProp : internalValue;
203
+ const open = isOpenControlled ? openProp : internalOpen;
204
+
205
+ const setOpen = useCallback(
206
+ (next: boolean) => {
207
+ if (!isOpenControlled) setInternalOpen(next);
208
+ onOpenChange?.(next);
209
+ },
210
+ [isOpenControlled, onOpenChange]
211
+ );
212
+
213
+ const commit = useCallback(
214
+ (next: Date) => {
215
+ if (!isValueControlled) setInternalValue(next);
216
+ onValueChange?.(next);
217
+ },
218
+ [isValueControlled, onValueChange]
219
+ );
220
+
221
+ /*
222
+ * The time half, kept separately as well as inside the value.
223
+ *
224
+ * The value cannot hold it on its own: before a day is picked there is no
225
+ * `Date` to put a time in, and a time chosen first would be dropped on the
226
+ * way to the calendar. Held here, the two halves can be filled in either
227
+ * order and neither one loses the other.
228
+ */
229
+ const [draftTime, setDraftTime] = useState<TimeValue | undefined>(
230
+ defaultValue ? timeFromDate(defaultValue) : undefined
231
+ );
232
+ const time = value ? timeFromDate(value) : draftTime;
233
+
234
+ /** The time a day picked on its own is given: the top of the current hour. */
235
+ const fallbackTime = useMemo(
236
+ () =>
237
+ clampTime(
238
+ roundToStep({ hour: new Date().getHours(), minute: 0 }, minuteStep ?? 15),
239
+ minTime,
240
+ maxTime
241
+ ),
242
+ [minuteStep, minTime, maxTime]
243
+ );
244
+
245
+ const handleDay = useCallback(
246
+ (day: Date | undefined) => {
247
+ if (!day) return;
248
+ commit(timeToDate(time ?? fallbackTime, day));
249
+ },
250
+ [commit, time, fallbackTime]
251
+ );
252
+
253
+ const handleTime = useCallback(
254
+ (next: TimeValue) => {
255
+ setDraftTime(next);
256
+ // No day yet means today: a time is not a `Date` without one, and the day
257
+ // the reader is looking at is the only defensible guess.
258
+ commit(timeToDate(next, value ?? new Date()));
259
+ },
260
+ [commit, value]
261
+ );
262
+
263
+ const label = useMemo(() => {
264
+ if (!value) return null;
265
+ if (format) return format(value);
266
+ return describe(value, system, hourCycle, locale);
267
+ }, [format, value, system, hourCycle, locale]);
268
+
269
+ const panel = (
270
+ <View
271
+ style={{ width: PANEL_WIDTH, maxWidth: '100%' }}
272
+ className={cn('self-center', presentation === 'inline' ? className : undefined)}
273
+ >
274
+ {/*
275
+ Unbordered: the panel around it already draws one, and a card inside a
276
+ card is a seam. The calendar frames itself only when it is standing on
277
+ a page.
278
+ */}
279
+ <Calendar
280
+ bordered={false}
281
+ mode="single"
282
+ selected={value}
283
+ onSelect={handleDay}
284
+ disabled={disabledDates}
285
+ minDate={minDate}
286
+ maxDate={maxDate}
287
+ startMonth={minDate}
288
+ endMonth={maxDate}
289
+ captionLayout={captionLayout}
290
+ weekStartsOn={weekStartsOn}
291
+ locale={locale}
292
+ calendar={calendar}
293
+ />
294
+ {/* A hairline, not a gap. The two halves are one answer, and space alone
295
+ between them reads as two controls that happen to be stacked — but
296
+ the same margin above and below it, so the rule sits between them
297
+ rather than clinging to the calendar. */}
298
+ <View className="mt-3 gap-3 border-t border-border pt-3">
299
+ {/*
300
+ The time half names itself and states its own value, because the
301
+ faces below it will not.
302
+
303
+ `readout="none"` takes the ruler's big centred number off the panel.
304
+ On its own that number is the answer and is sized like it; under a
305
+ month grid whose caption is 16pt and whose dates are 14pt, a 36pt
306
+ time is the largest thing on screen standing for the smaller half of
307
+ the value. Here it is a row instead: what it is on one edge, what it
308
+ currently says on the other, at a size that sits under the date
309
+ rather than over it.
310
+ */}
311
+ <View className="flex-row items-center justify-between gap-3">
312
+ <Text size="sm" muted>
313
+ {timeLabel}
314
+ </Text>
315
+ {/* Before either half is filled in there is still a time the panel
316
+ would commit — the same one a day picked on its own is given — so
317
+ the row states that rather than going blank. */}
318
+ <Text className="text-lg font-semibold tabular-nums text-foreground">
319
+ {formatTime(time ?? fallbackTime, { hourCycle, locale })}
320
+ </Text>
321
+ </View>
322
+ <TimePicker
323
+ presentation="inline"
324
+ layout={layout}
325
+ readout="none"
326
+ value={time}
327
+ onValueChange={handleTime}
328
+ hourCycle={hourCycle}
329
+ minuteStep={minuteStep}
330
+ minTime={minTime}
331
+ maxTime={maxTime}
332
+ locale={locale}
333
+ disabled={disabled}
334
+ />
335
+ </View>
336
+ </View>
337
+ );
338
+
339
+ if (presentation === 'inline') return panel;
340
+
341
+ const trigger = (
342
+ children ?? (
343
+ <Button
344
+ variant="outline"
345
+ disabled={disabled}
346
+ className={cn('justify-start gap-2', className)}
347
+ >
348
+ <CalendarIcon size={16} />
349
+ <Text className={label ? undefined : 'text-muted-foreground'}>
350
+ {label ?? placeholder}
351
+ </Text>
352
+ </Button>
353
+ )
354
+ ) as ReactElement<{ onPress?: () => void }>;
355
+
356
+ if (presentation === 'dialog') {
357
+ return (
358
+ <Dialog open={open} onOpenChange={setOpen}>
359
+ <Dialog.Trigger>{trigger}</Dialog.Trigger>
360
+ {/* Blurred rather than dimmed: a dialog is what you reach for when the
361
+ appointment *is* the decision on the screen. */}
362
+ <Dialog.Content blur className="items-center gap-0 p-4">
363
+ {panel}
364
+ <Dialog.Close>
365
+ <Button className="mt-3 w-full">{doneLabel}</Button>
366
+ </Dialog.Close>
367
+ </Dialog.Content>
368
+ </Dialog>
369
+ );
370
+ }
371
+
372
+ const isSheet = presentation === 'bottom-sheet';
373
+
374
+ return (
375
+ <Popover
376
+ open={open}
377
+ onOpenChange={setOpen}
378
+ presentation={isSheet ? 'bottom-sheet' : 'popover'}
379
+ >
380
+ <Popover.Trigger>{trigger}</Popover.Trigger>
381
+ {/* The padding is on the inner view, not the panel: in sheet mode a
382
+ className on the panel is merged into the sheet's own padding and
383
+ would replace it. */}
384
+ <Popover.Content width={isSheet ? 'full' : 'content-fit'}>
385
+ <View className={isSheet ? 'w-full pb-2' : 'p-3'}>
386
+ {panel}
387
+ {/*
388
+ A Done button in the popover too, which `DatePicker` does not have.
389
+ There it is unnecessary — a single date finishes itself, and the
390
+ panel closes on the tap. Here the date is half the value, so
391
+ something has to say when both halves are settled, and tapping
392
+ outside is not that: it is how a popover is *abandoned*.
393
+ */}
394
+ <Popover.Close>
395
+ <Button
396
+ className="mt-3 w-full self-center"
397
+ style={isSheet ? undefined : { maxWidth: PANEL_WIDTH }}
398
+ >
399
+ {doneLabel}
400
+ </Button>
401
+ </Popover.Close>
402
+ </View>
403
+ </Popover.Content>
404
+ </Popover>
405
+ );
406
+ }
407
+ DateTimePickerRoot.displayName = 'DateTimePicker';
408
+
409
+ export const DateTimePicker = Object.assign(DateTimePickerRoot, {
410
+ Trigger: Popover.Trigger,
411
+ });