panelui-native 0.46.0 → 0.51.1

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 (87) hide show
  1. package/README.md +12 -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/date-time-picker/index.js +23 -5
  11. package/lib/module/components/date-time-picker/index.js.map +1 -1
  12. package/lib/module/components/fab/index.js +514 -0
  13. package/lib/module/components/fab/index.js.map +1 -0
  14. package/lib/module/components/markdown-editor/index.js +406 -0
  15. package/lib/module/components/markdown-editor/index.js.map +1 -0
  16. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  17. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  18. package/lib/module/components/questionnaire/index.js +1312 -0
  19. package/lib/module/components/questionnaire/index.js.map +1 -0
  20. package/lib/module/components/sortable/index.js +806 -0
  21. package/lib/module/components/sortable/index.js.map +1 -0
  22. package/lib/module/components/swipe/index.js +140 -5
  23. package/lib/module/components/swipe/index.js.map +1 -1
  24. package/lib/module/components/tabs/index.js +94 -18
  25. package/lib/module/components/tabs/index.js.map +1 -1
  26. package/lib/module/components/time-picker/index.js +34 -6
  27. package/lib/module/components/time-picker/index.js.map +1 -1
  28. package/lib/module/components/tree/index.js +500 -0
  29. package/lib/module/components/tree/index.js.map +1 -0
  30. package/lib/module/icons/index.js +246 -0
  31. package/lib/module/icons/index.js.map +1 -1
  32. package/lib/module/index.js +8 -2
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/utils/haptics.js +19 -0
  35. package/lib/module/utils/haptics.js.map +1 -1
  36. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  37. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/button/index.d.ts +21 -0
  39. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  41. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  42. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  43. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/date-time-picker/index.d.ts +7 -1
  45. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  47. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  49. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  51. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  52. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  53. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  54. package/lib/typescript/src/components/sortable/index.d.ts +238 -0
  55. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/swipe/index.d.ts +35 -0
  57. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/tabs/index.d.ts +9 -0
  59. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  61. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  63. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  64. package/lib/typescript/src/icons/index.d.ts +30 -0
  65. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  66. package/lib/typescript/src/index.d.ts +10 -4
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/utils/haptics.d.ts +14 -0
  69. package/lib/typescript/src/utils/haptics.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/components/accordion/index.tsx +48 -6
  72. package/src/components/button/index.tsx +97 -15
  73. package/src/components/button-group/index.tsx +199 -0
  74. package/src/components/color-picker/index.tsx +140 -3
  75. package/src/components/date-time-picker/index.tsx +39 -3
  76. package/src/components/fab/index.tsx +583 -0
  77. package/src/components/markdown-editor/index.tsx +526 -0
  78. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  79. package/src/components/questionnaire/index.tsx +1615 -0
  80. package/src/components/sortable/index.tsx +1080 -0
  81. package/src/components/swipe/index.tsx +165 -3
  82. package/src/components/tabs/index.tsx +100 -28
  83. package/src/components/time-picker/index.tsx +42 -6
  84. package/src/components/tree/index.tsx +564 -0
  85. package/src/icons/index.tsx +176 -0
  86. package/src/index.ts +89 -1
  87. package/src/utils/haptics.ts +21 -0
@@ -1,4 +1,4 @@
1
- import { forwardRef, type ReactNode } from 'react';
1
+ import { createContext, forwardRef, useContext, type ReactNode } from 'react';
2
2
  import { View } from 'react-native';
3
3
  import { tv, type VariantProps } from 'tailwind-variants';
4
4
  import { useCSSVariable } from 'uniwind';
@@ -7,6 +7,7 @@ import {
7
7
  type AnimatedPressableProps,
8
8
  } from '../../primitives/animated-pressable';
9
9
  import { Text, textChildren } from '../../primitives/text';
10
+ import { cn } from '../../utils/cn';
10
11
  import { IconColorProvider } from '../../icons';
11
12
  import { getNativeUI, getSwiftUIModifiers } from '../../native';
12
13
  import { Spinner } from '../spinner';
@@ -72,6 +73,65 @@ const buttonVariants = tv({
72
73
 
73
74
  type ButtonVariantProps = VariantProps<typeof buttonVariants>;
74
75
 
76
+ /** How a button looks. */
77
+ export type ButtonVariant = NonNullable<ButtonVariantProps['variant']>;
78
+ /** How big a button is. `icon` is a square. */
79
+ export type ButtonSize = NonNullable<ButtonVariantProps['size']>;
80
+
81
+ /* -------------------------------------------------------------------------- *
82
+ * Grouping
83
+ *
84
+ * A button reads this; `ButtonGroup` writes it. The context lives here rather
85
+ * than beside the group because the button is the consumer, and a component
86
+ * should not have to import the thing that contains it to find out that it is
87
+ * contained — which would also make the two import each other.
88
+ * -------------------------------------------------------------------------- */
89
+
90
+ export interface ButtonGroupContextValue {
91
+ /** Fills in for a button that did not choose one. */
92
+ variant?: ButtonVariant;
93
+ /** Fills in for a button that did not choose one. */
94
+ size?: ButtonSize;
95
+ /** The buttons are joined into one shape, so each drops its own. */
96
+ attached: boolean;
97
+ /** Segments share the row equally. */
98
+ fullWidth: boolean;
99
+ }
100
+
101
+ const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null);
102
+
103
+ /** Announces to every button below it that it is part of one control. */
104
+ export function ButtonGroupProvider({
105
+ value,
106
+ children,
107
+ }: {
108
+ value: ButtonGroupContextValue;
109
+ children: ReactNode;
110
+ }) {
111
+ return <ButtonGroupContext.Provider value={value}>{children}</ButtonGroupContext.Provider>;
112
+ }
113
+
114
+ /** The enclosing group, if there is one. `null` for a button standing alone. */
115
+ export function useButtonGroup(): ButtonGroupContextValue | null {
116
+ return useContext(ButtonGroupContext);
117
+ }
118
+
119
+ /**
120
+ * What an attached button gives up so the group can draw the shape once.
121
+ *
122
+ * The radius and the shadow go because the group owns both — a rounded,
123
+ * shadowed segment inside a rounded, shadowed container is two shapes where
124
+ * there should be one. The border stays but turns transparent rather than
125
+ * being removed: it is holding a pixel of the button's height, and dropping it
126
+ * would make an `outline` segment a hair shorter than a `ghost` one beside it.
127
+ *
128
+ * The press feedback changes too. A button on its own shrinks slightly when
129
+ * pressed, which inside a joined run would pull the segment away from its
130
+ * neighbours and show the container through the gap. A background is the same
131
+ * signal without the movement.
132
+ */
133
+ const ATTACHED = 'rounded-none border-transparent shadow-none active:bg-accent';
134
+
75
135
  const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
76
136
 
77
137
  /**
@@ -240,10 +300,28 @@ export const Button = forwardRef<View, ButtonProps>(
240
300
  ) => {
241
301
  const isDisabled = disabled || loading;
242
302
  const nativeUI = native ? getNativeUI() : null;
303
+
304
+ /*
305
+ * A group fills in what a button did not say for itself, and never
306
+ * overrides what it did: a run of buttons should look like one control
307
+ * without every segment repeating the same two props, and the odd segment
308
+ * that wants to stand out — the selected one, the destructive one — has to
309
+ * be able to say so.
310
+ *
311
+ * `native` is deliberately outside all of this. The platform draws that
312
+ * button, so it has no border, radius or shadow for a group to take over,
313
+ * and joining several of them would produce a row of platform buttons with
314
+ * a border drawn around it rather than a segmented control.
315
+ */
316
+ const group = useButtonGroup();
317
+ const attached = !native && group?.attached === true;
318
+ const resolvedVariant = variant ?? group?.variant;
319
+ const resolvedSize = size ?? group?.size;
320
+
243
321
  const { root, label, spinner } = buttonVariants({
244
- variant,
245
- size,
246
- fullWidth,
322
+ variant: resolvedVariant,
323
+ size: resolvedSize,
324
+ fullWidth: fullWidth ?? (attached && group?.fullWidth),
247
325
  disabled: isDisabled,
248
326
  });
249
327
 
@@ -252,11 +330,11 @@ export const Button = forwardRef<View, ButtonProps>(
252
330
  // hardcode a hex that is wrong in one theme or the other.
253
331
  const themedColor = useCSSVariable(
254
332
  CONTENT_COLOR_VAR[
255
- variant === 'destructive' ? 'primary' : (variant ?? 'primary')
333
+ resolvedVariant === 'destructive' ? 'primary' : (resolvedVariant ?? 'primary')
256
334
  ]
257
335
  );
258
336
  const contentColor =
259
- variant === 'destructive'
337
+ resolvedVariant === 'destructive'
260
338
  ? '#ffffff'
261
339
  : typeof themedColor === 'string'
262
340
  ? themedColor
@@ -265,7 +343,7 @@ export const Button = forwardRef<View, ButtonProps>(
265
343
  if (nativeUI) {
266
344
  const { Host, Button: NativeButton, RNHostView } = nativeUI;
267
345
  const isStringLabel = typeof children === 'string';
268
- const prominent = variant === 'primary' || variant === 'destructive';
346
+ const prominent = resolvedVariant === 'primary' || resolvedVariant === 'destructive';
269
347
 
270
348
  /*
271
349
  * Looks the portable props cannot ask for.
@@ -285,8 +363,10 @@ export const Button = forwardRef<View, ButtonProps>(
285
363
  const nativeModifiers = swiftUI
286
364
  ? [
287
365
  glass ? swiftUI.buttonStyle(prominent ? 'glassProminent' : 'glass') : null,
288
- size === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
289
- isStringLabel ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[size ?? 'md']) : null,
366
+ resolvedSize === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
367
+ isStringLabel
368
+ ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[resolvedSize ?? 'md'])
369
+ : null,
290
370
  ].filter(Boolean)
291
371
  : [];
292
372
 
@@ -303,7 +383,7 @@ export const Button = forwardRef<View, ButtonProps>(
303
383
  <Host matchContents>
304
384
  <NativeButton
305
385
  label={isStringLabel ? children : undefined}
306
- variant={NATIVE_VARIANT[variant ?? 'primary']}
386
+ variant={NATIVE_VARIANT[resolvedVariant ?? 'primary']}
307
387
  disabled={isDisabled}
308
388
  /*
309
389
  * A square for an icon button, a height for a labelled one — and
@@ -316,9 +396,9 @@ export const Button = forwardRef<View, ButtonProps>(
316
396
  * the result.
317
397
  */
318
398
  style={
319
- size === 'icon'
399
+ resolvedSize === 'icon'
320
400
  ? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
321
- : { height: NATIVE_HEIGHT[size ?? 'md'] }
401
+ : { height: NATIVE_HEIGHT[resolvedSize ?? 'md'] }
322
402
  }
323
403
  modifiers={nativeModifiers.length ? nativeModifiers : undefined}
324
404
  onPress={props.onPress}
@@ -337,7 +417,7 @@ export const Button = forwardRef<View, ButtonProps>(
337
417
  sits on ends up off to one side of a circle that is not. */}
338
418
  <View
339
419
  style={
340
- size === 'icon'
420
+ resolvedSize === 'icon'
341
421
  ? {
342
422
  padding: NATIVE_ICON_PADDING,
343
423
  alignItems: 'center',
@@ -366,11 +446,13 @@ export const Button = forwardRef<View, ButtonProps>(
366
446
  accessibilityRole="button"
367
447
  accessibilityState={{ disabled: isDisabled, busy: loading }}
368
448
  disabled={isDisabled}
369
- className={root({ className })}
449
+ // Before the spread, so a caller can still ask for the scale back.
450
+ pressScale={attached ? 1 : undefined}
451
+ className={root({ className: cn(attached && ATTACHED, className) })}
370
452
  {...props}
371
453
  >
372
454
  {loading ? (
373
- <Spinner size={SPINNER_SIZE[size ?? 'md']} className={spinner()} />
455
+ <Spinner size={SPINNER_SIZE[resolvedSize ?? 'md']} className={spinner()} />
374
456
  ) : (
375
457
  startContent
376
458
  )}
@@ -0,0 +1,199 @@
1
+ /**
2
+ * ButtonGroup — several buttons drawn as one control.
3
+ *
4
+ * ```tsx
5
+ * <ButtonGroup>
6
+ * <Button startContent={<ListChecksIcon size={16} />}>Tasks</Button>
7
+ * <Button startContent={<CalendarIcon size={16} />}>Agenda</Button>
8
+ * <Button startContent={<ChartIcon size={16} />}>Board</Button>
9
+ * </ButtonGroup>
10
+ * ```
11
+ *
12
+ * The buttons stay buttons. Anything a `Button` does — an icon, a badge, a
13
+ * loading state, a disabled segment, opening a Popover — it still does inside a
14
+ * group, because the group is a container rather than a component that takes a
15
+ * list of items and renders them for you. A list-of-items API has to grow a
16
+ * prop for every one of those things; this one has none of them and can do all
17
+ * of them.
18
+ *
19
+ * ## Why the container draws the border
20
+ *
21
+ * A joined run could be built by giving the first and last segments their
22
+ * corners and squaring the ones between, then collapsing every shared edge with
23
+ * a negative margin. That works on the web and is a stack of off-by-one
24
+ * problems on a phone: the hairlines land on different fractions of a pixel per
25
+ * device, and a run that wraps has no first or last segment any more.
26
+ *
27
+ * So the group draws the shape once — one border, one radius, one shadow,
28
+ * clipped — and the buttons inside it draw none of their own. The dividers are
29
+ * real one-pixel views the group puts between its children, which is also why
30
+ * they are always exactly one pixel and always in the same place.
31
+ *
32
+ * ## What it passes down
33
+ *
34
+ * `variant` and `size` fill in for a button that did not choose its own, so a
35
+ * run of six does not repeat the same two props six times. A segment that wants
36
+ * to stand out — the selected one, the destructive one — sets its own and wins.
37
+ *
38
+ * ```tsx
39
+ * <ButtonGroup size="sm">
40
+ * <Button>Day</Button>
41
+ * <Button variant="secondary">Week</Button> {/* the selected one *\/}
42
+ * <Button>Month</Button>
43
+ * </ButtonGroup>
44
+ * ```
45
+ *
46
+ * ## Not a selection control
47
+ *
48
+ * This joins buttons; what they mean is yours. For a control that owns which
49
+ * one is on, reach for `ToggleButtonGroup`, and for switching between panels of
50
+ * content reach for `Tabs` — a segmented run of buttons that swaps a screen is
51
+ * navigation, and navigation should say so to a screen reader.
52
+ */
53
+ import { Children, Fragment, forwardRef, useMemo, type ReactNode } from 'react';
54
+ import { View, type ViewProps } from 'react-native';
55
+ import { tv, type VariantProps } from 'tailwind-variants';
56
+ import {
57
+ ButtonGroupProvider,
58
+ type ButtonGroupContextValue,
59
+ type ButtonSize,
60
+ type ButtonVariant,
61
+ } from '../button';
62
+
63
+ /** Which way the run reads. */
64
+ export type ButtonGroupOrientation = 'horizontal' | 'vertical';
65
+
66
+ const buttonGroupVariants = tv({
67
+ slots: {
68
+ root: 'items-stretch',
69
+ divider: 'bg-border',
70
+ },
71
+ variants: {
72
+ orientation: {
73
+ horizontal: { root: 'flex-row', divider: 'w-px' },
74
+ vertical: { root: 'flex-col', divider: 'h-px' },
75
+ },
76
+ attached: {
77
+ // `overflow-hidden` is what squares the inner corners and rounds the
78
+ // outer ones without any segment knowing which it is.
79
+ true: { root: 'overflow-hidden border border-input bg-popover shadow-sm' },
80
+ false: { root: 'gap-2' },
81
+ },
82
+ /* Matched to the height the buttons will be, since a radius that suited a
83
+ 36pt run reads as a rectangle around a 48pt one. */
84
+ size: {
85
+ sm: { root: 'rounded-lg' },
86
+ md: { root: 'rounded-xl' },
87
+ lg: { root: 'rounded-xl' },
88
+ icon: { root: 'rounded-xl' },
89
+ },
90
+ fullWidth: {
91
+ true: { root: 'w-full' },
92
+ },
93
+ },
94
+ compoundVariants: [
95
+ // Nothing is being joined, so the radius belongs to each button instead.
96
+ { attached: false, class: { root: 'rounded-none' } },
97
+ ],
98
+ defaultVariants: {
99
+ orientation: 'horizontal',
100
+ attached: true,
101
+ size: 'md',
102
+ },
103
+ });
104
+
105
+ type ButtonGroupVariantProps = VariantProps<typeof buttonGroupVariants>;
106
+
107
+ export interface ButtonGroupProps
108
+ extends ViewProps,
109
+ Omit<ButtonGroupVariantProps, 'size' | 'attached'> {
110
+ className?: string;
111
+ /** Which way the run reads. Vertical is the toolbar down the side of a canvas. */
112
+ orientation?: ButtonGroupOrientation;
113
+ /** Fills in for any button that did not choose its own. */
114
+ variant?: ButtonVariant;
115
+ /** Fills in for any button that did not choose its own, and sets the radius. */
116
+ size?: ButtonSize;
117
+ /**
118
+ * Draw the run as one joined shape.
119
+ *
120
+ * On by default — that is what a group is. Turn it off for a plain row of
121
+ * separate buttons that should still share a variant and a size, which is a
122
+ * toolbar rather than a segmented control.
123
+ */
124
+ attached?: boolean;
125
+ /**
126
+ * Span the container, with the segments sharing it equally.
127
+ *
128
+ * Equally, not by content: a row of segments at their natural widths is a row
129
+ * whose divisions move when the labels change, and a picker whose halves are
130
+ * different sizes reads as though one of them matters more.
131
+ */
132
+ fullWidth?: boolean;
133
+ children?: ReactNode;
134
+ }
135
+
136
+ const ButtonGroupRoot = forwardRef<View, ButtonGroupProps>(
137
+ (
138
+ {
139
+ className,
140
+ orientation = 'horizontal',
141
+ variant,
142
+ size = 'md',
143
+ attached = true,
144
+ fullWidth = false,
145
+ children,
146
+ ...props
147
+ },
148
+ ref
149
+ ) => {
150
+ const { root, divider } = buttonGroupVariants({
151
+ orientation,
152
+ attached,
153
+ size,
154
+ fullWidth,
155
+ });
156
+
157
+ const context = useMemo<ButtonGroupContextValue>(
158
+ () => ({ variant, size, attached, fullWidth }),
159
+ [variant, size, attached, fullWidth]
160
+ );
161
+
162
+ /*
163
+ * `Children.toArray` rather than the raw children: it drops the nulls a
164
+ * conditional segment leaves behind and flattens fragments, so a run built
165
+ * by a `map` or by `{canEdit && <Button/>}` gets its dividers in the right
166
+ * places instead of one before a segment that is not there.
167
+ */
168
+ const items = Children.toArray(children);
169
+
170
+ return (
171
+ <ButtonGroupProvider value={context}>
172
+ <View
173
+ ref={ref}
174
+ // A run of related actions is a toolbar; a screen reader announcing
175
+ // the group is what tells someone the buttons in it belong together.
176
+ accessibilityRole="toolbar"
177
+ className={root({ className })}
178
+ {...props}
179
+ >
180
+ {items.map((child, index) => (
181
+ // Index keys: these fragments have no identity of their own, and
182
+ // the children keep whatever keys they were already given.
183
+ <Fragment key={index}>
184
+ {index > 0 && attached ? <View className={divider()} /> : null}
185
+ {/* The share of the row is taken by a wrapper rather than by the
186
+ button, so a segment that is a Popover trigger — a button
187
+ inside something else — still gets one. */}
188
+ {fullWidth ? <View className="flex-1">{child}</View> : child}
189
+ </Fragment>
190
+ ))}
191
+ </View>
192
+ </ButtonGroupProvider>
193
+ );
194
+ }
195
+ );
196
+
197
+ ButtonGroupRoot.displayName = 'ButtonGroup';
198
+
199
+ export const ButtonGroup = ButtonGroupRoot;
@@ -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,