panelui-native 0.40.0 → 0.43.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 (92) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/card/index.js +172 -3
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/color-picker/index.js +1395 -0
  5. package/lib/module/components/color-picker/index.js.map +1 -0
  6. package/lib/module/components/combobox/index.js +87 -19
  7. package/lib/module/components/combobox/index.js.map +1 -1
  8. package/lib/module/components/drawer/index.js +13 -4
  9. package/lib/module/components/drawer/index.js.map +1 -1
  10. package/lib/module/components/kpi-chart/index.js +648 -0
  11. package/lib/module/components/kpi-chart/index.js.map +1 -0
  12. package/lib/module/components/map/index.js +72 -12
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +40 -10
  15. package/lib/module/components/menu/index.js.map +1 -1
  16. package/lib/module/components/pagination/index.js +19 -9
  17. package/lib/module/components/pagination/index.js.map +1 -1
  18. package/lib/module/components/panelside/index.js +68 -18
  19. package/lib/module/components/panelside/index.js.map +1 -1
  20. package/lib/module/components/radar-chart/index.js +704 -0
  21. package/lib/module/components/radar-chart/index.js.map +1 -0
  22. package/lib/module/components/section-rail/index.js +32 -7
  23. package/lib/module/components/section-rail/index.js.map +1 -1
  24. package/lib/module/components/slider/index.js +240 -39
  25. package/lib/module/components/slider/index.js.map +1 -1
  26. package/lib/module/components/tabs/index.js +194 -10
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/text-animation/index.js +857 -0
  29. package/lib/module/components/text-animation/index.js.map +1 -0
  30. package/lib/module/icons/index.js +25 -7
  31. package/lib/module/icons/index.js.map +1 -1
  32. package/lib/module/index.js +5 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/theme/use-theme.js +1 -1
  35. package/lib/module/utils/chart.js +44 -0
  36. package/lib/module/utils/chart.js.map +1 -1
  37. package/lib/module/utils/color.js +268 -0
  38. package/lib/module/utils/color.js.map +1 -0
  39. package/lib/typescript/src/components/card/index.d.ts +20 -0
  40. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  42. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  43. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/drawer/index.d.ts +13 -1
  45. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  47. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/map/index.d.ts +37 -2
  49. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/panelside/index.d.ts +50 -4
  53. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  55. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  57. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
  63. package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
  64. package/lib/typescript/src/icons/index.d.ts +6 -0
  65. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  66. package/lib/typescript/src/index.d.ts +8 -3
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/utils/chart.d.ts +21 -0
  69. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/color.d.ts +74 -0
  71. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  72. package/package.json +2 -1
  73. package/src/components/card/index.tsx +227 -2
  74. package/src/components/color-picker/index.tsx +1618 -0
  75. package/src/components/combobox/index.tsx +89 -19
  76. package/src/components/drawer/index.tsx +31 -9
  77. package/src/components/kpi-chart/index.tsx +719 -0
  78. package/src/components/map/index.tsx +158 -14
  79. package/src/components/menu/index.tsx +51 -6
  80. package/src/components/pagination/index.tsx +18 -6
  81. package/src/components/panelside/index.tsx +134 -19
  82. package/src/components/radar-chart/index.tsx +910 -0
  83. package/src/components/section-rail/index.tsx +42 -8
  84. package/src/components/slider/index.tsx +283 -41
  85. package/src/components/tabs/index.tsx +263 -8
  86. package/src/components/text-animation/index.tsx +1093 -0
  87. package/src/icons/index.tsx +23 -4
  88. package/src/index.ts +73 -1
  89. package/src/theme/use-theme.ts +1 -1
  90. package/src/utils/chart.ts +52 -0
  91. package/src/utils/color.ts +246 -0
  92. package/theme.css +125 -112
@@ -0,0 +1,1618 @@
1
+ /**
2
+ * ColorPicker — a colour chosen by dragging, not by typing.
3
+ *
4
+ * The controls are the colour model made visible: a square where saturation
5
+ * runs across and brightness runs up, under a hue picked on its own scale.
6
+ * That is why the component stores hue, saturation, value and alpha rather
7
+ * than the string it hands back — a fully black colour is `#000` whatever hue
8
+ * and saturation produced it, so a picker that stored the output would lose
9
+ * the thumb the moment you dragged into a corner and could not put it back.
10
+ *
11
+ * Nothing about a drag crosses to JavaScript. The four channels are shared
12
+ * values; the hue behind the square, both thumb fills and the preview swatch
13
+ * are `backgroundColor`s computed from them on the UI thread, and the
14
+ * translucency ramp on the alpha track is a gradient used as a mask over a
15
+ * solid fill rather than a gradient whose colours have to be re-declared from
16
+ * React on every frame. The picked colour bridges back on change and again on
17
+ * release, the same as any other input here.
18
+ *
19
+ * ```tsx
20
+ * <ColorPicker defaultValue="#22c55e" onValueCommit={setBrand}>
21
+ * <ColorPicker.Area />
22
+ * <ColorPicker.Hue />
23
+ * <ColorPicker.Preview showValue />
24
+ * </ColorPicker>
25
+ * ```
26
+ *
27
+ * Composition is the API: a picker is whichever of the parts you put in it, so
28
+ * a control with no alpha is one that has no `ColorPicker.Alpha`, not one with
29
+ * a prop turned off.
30
+ *
31
+ * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
32
+ */
33
+ import {
34
+ createContext,
35
+ forwardRef,
36
+ useCallback,
37
+ useContext,
38
+ useEffect,
39
+ useId,
40
+ useMemo,
41
+ useRef,
42
+ useState,
43
+ type ReactNode,
44
+ } from 'react';
45
+ import {
46
+ Pressable,
47
+ StyleSheet,
48
+ View,
49
+ type AccessibilityActionEvent,
50
+ type LayoutChangeEvent,
51
+ } from 'react-native';
52
+ import MaskedView from '@react-native-masked-view/masked-view';
53
+ import { LinearGradient } from 'expo-linear-gradient';
54
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
55
+ import Animated, {
56
+ runOnJS,
57
+ useAnimatedReaction,
58
+ useAnimatedStyle,
59
+ useSharedValue,
60
+ withTiming,
61
+ type SharedValue,
62
+ } from 'react-native-reanimated';
63
+ import Svg, {
64
+ Circle,
65
+ Defs,
66
+ Path,
67
+ Pattern,
68
+ RadialGradient,
69
+ Rect,
70
+ Stop,
71
+ } from 'react-native-svg';
72
+ import { tv, type VariantProps } from 'tailwind-variants';
73
+ import { useCSSVariable } from 'uniwind';
74
+ import { useDirectionSign } from '../../hooks/use-direction';
75
+ import { Text } from '../../primitives/text';
76
+ import {
77
+ formatColor,
78
+ hsvToCss,
79
+ hsvToHex,
80
+ parseColor,
81
+ type ColorFormat,
82
+ type HsvaColor,
83
+ } from '../../utils/color';
84
+ import { selectionTick } from '../../utils/haptics';
85
+
86
+ /** Settles a thumb that was moved by something other than a finger. */
87
+ const TIMING = { duration: 140 } as const;
88
+
89
+ /**
90
+ * The hue scale, drawn once as a gradient rather than sampled per pixel. Six
91
+ * stops plus the wrap back to red — fewer would bend the ramp, more would not
92
+ * change it, because hue is piecewise linear in RGB and these are the corners.
93
+ */
94
+ const HUE_STOPS: readonly [string, string, ...string[]] = [
95
+ '#ff0000',
96
+ '#ffff00',
97
+ '#00ff00',
98
+ '#00ffff',
99
+ '#0000ff',
100
+ '#ff00ff',
101
+ '#ff0000',
102
+ ];
103
+
104
+ /** The same ramp for a right-to-left picker, where hue runs the other way. */
105
+ const HUE_STOPS_FLIPPED = [...HUE_STOPS].reverse() as unknown as readonly [
106
+ string,
107
+ string,
108
+ ...string[],
109
+ ];
110
+
111
+ /** How far one screen-reader increment moves a channel. */
112
+ const NUDGE = 0.05;
113
+ /** How far one screen-reader increment moves the hue, in degrees. */
114
+ const HUE_NUDGE = 10;
115
+
116
+ const colorPickerVariants = tv({
117
+ slots: {
118
+ root: 'w-full gap-3',
119
+ area: 'overflow-hidden rounded-2xl border border-border',
120
+ wheel: 'overflow-hidden rounded-full border border-border',
121
+ // Both thumbs are a white ring around a fill of the colour under them, so
122
+ // the ring reads against the dark end of the square as well as the light.
123
+ // Two points, not three: the ring is there to separate the fill from what
124
+ // is behind it, and any more of it starts hiding the colour it is showing.
125
+ thumb: 'absolute start-0 top-0 rounded-full border-2 border-white shadow-md',
126
+ thumbFill: 'flex-1 rounded-full',
127
+ track: 'w-full overflow-hidden rounded-full border border-border',
128
+ swatches: 'flex-row flex-wrap items-center gap-2',
129
+ swatch: 'items-center justify-center rounded-full',
130
+ swatchRing: 'absolute rounded-full border-2 border-foreground',
131
+ preview: 'flex-row items-center gap-3',
132
+ previewSwatch: 'overflow-hidden rounded-full border border-border',
133
+ previewValue: 'text-sm font-medium text-foreground',
134
+ // The header strip: a name on the leading edge, the value and a swatch on
135
+ // the trailing one.
136
+ field: 'w-full flex-row items-center gap-3 rounded-2xl bg-surface px-4 py-3',
137
+ fieldLabel: 'flex-1 text-base font-medium text-foreground',
138
+ fieldValue: 'text-base font-normal text-muted-foreground',
139
+ fieldSwatch: 'overflow-hidden rounded-full border border-border',
140
+ // The readout above a track — the number on one side, what it names on the
141
+ // other.
142
+ channel: 'w-full flex-row items-center justify-between gap-3',
143
+ channelValue: 'text-base font-normal text-muted-foreground',
144
+ channelLabel: 'text-base font-medium text-foreground',
145
+ },
146
+ variants: {
147
+ size: {
148
+ sm: {},
149
+ md: {},
150
+ lg: {},
151
+ },
152
+ disabled: {
153
+ true: { root: 'opacity-50' },
154
+ },
155
+ },
156
+ defaultVariants: {
157
+ size: 'md',
158
+ },
159
+ });
160
+
161
+ type ColorPickerVariantProps = VariantProps<typeof colorPickerVariants>;
162
+ export type ColorPickerSize = NonNullable<ColorPickerVariantProps['size']>;
163
+
164
+ /** Height of the saturation/brightness square, per size. */
165
+ const AREA_HEIGHT: Record<ColorPickerSize, number> = { sm: 140, md: 180, lg: 220 };
166
+ /**
167
+ * Height of a channel track, per size. Tall enough that the knob sits *in* the
168
+ * track rather than on top of a hairline — a slider whose thumb overhangs a
169
+ * thin rule reads as a control laid over a decoration.
170
+ */
171
+ const TRACK_HEIGHT: Record<ColorPickerSize, number> = { sm: 16, md: 20, lg: 24 };
172
+ /** Thumb diameter on a channel track — wider than the track, so it reads as a knob. */
173
+ const TRACK_THUMB: Record<ColorPickerSize, number> = { sm: 22, md: 26, lg: 30 };
174
+ /** Thumb diameter on the square. */
175
+ const AREA_THUMB: Record<ColorPickerSize, number> = { sm: 20, md: 24, lg: 28 };
176
+ /** Preset swatch diameter. */
177
+ const SWATCH: Record<ColorPickerSize, number> = { sm: 26, md: 30, lg: 34 };
178
+ /** The swatch beside the value in `Preview`. */
179
+ const PREVIEW_SWATCH: Record<ColorPickerSize, number> = { sm: 28, md: 36, lg: 44 };
180
+ /** The smaller swatch on the end of the `Field` strip. */
181
+ const FIELD_SWATCH: Record<ColorPickerSize, number> = { sm: 22, md: 28, lg: 34 };
182
+ /** Diameter of the wheel, per size. */
183
+ const WHEEL_SIZE: Record<ColorPickerSize, number> = { sm: 180, md: 240, lg: 300 };
184
+
185
+ interface ColorPickerContextValue {
186
+ hue: SharedValue<number>;
187
+ saturation: SharedValue<number>;
188
+ brightness: SharedValue<number>;
189
+ opacity: SharedValue<number>;
190
+ /** Called from a worklet with the channels that produced the change. */
191
+ emit: (h: number, s: number, v: number, a: number, commit: boolean) => void;
192
+ format: ColorFormat;
193
+ disabled: boolean;
194
+ haptics: boolean;
195
+ size: ColorPickerSize;
196
+ }
197
+
198
+ const ColorPickerContext = createContext<ColorPickerContextValue | null>(null);
199
+
200
+ function useColorPicker(part: string) {
201
+ const ctx = useContext(ColorPickerContext);
202
+ if (!ctx) throw new Error(`${part} must be used inside <ColorPicker>.`);
203
+ return ctx;
204
+ }
205
+
206
+ /** Where a picker starts when it is handed a value it cannot read. */
207
+ const FALLBACK: HsvaColor = { h: 0, s: 1, v: 1, a: 1 };
208
+
209
+ export interface ColorPickerProps extends Omit<ColorPickerVariantProps, 'disabled'> {
210
+ className?: string;
211
+ /** Controlled colour. Leave unset and pass `defaultValue` to run uncontrolled. */
212
+ value?: string;
213
+ /** Starting colour when uncontrolled. */
214
+ defaultValue?: string;
215
+ /**
216
+ * Fires on every frame of a drag — cheap updates only. The string is written
217
+ * in `format`.
218
+ */
219
+ onValueChange?: (color: string) => void;
220
+ /** Fires once when a drag ends. The place for expensive side effects. */
221
+ onValueCommit?: (color: string) => void;
222
+ /**
223
+ * How the colour is written on the way out. `hex` gains an `#rrggbbaa` alpha
224
+ * pair, and the other two switch to their `a` forms, only when the colour is
225
+ * actually translucent.
226
+ */
227
+ format?: ColorFormat;
228
+ disabled?: boolean;
229
+ /**
230
+ * A tick when a drag ends and when a preset is picked. Off by default — needs
231
+ * the optional `expo-haptics`, and is silent without it. There is no tick
232
+ * during a drag: a colour has no steps to cross, so a tick could only be a
233
+ * buzz proportional to speed.
234
+ */
235
+ haptics?: boolean;
236
+ /** The parts, in the order they should stack. */
237
+ children: ReactNode;
238
+ }
239
+
240
+ const ColorPickerRoot = forwardRef<View, ColorPickerProps>(
241
+ (
242
+ {
243
+ className,
244
+ value: valueProp,
245
+ defaultValue = '#ff0000',
246
+ onValueChange,
247
+ onValueCommit,
248
+ format = 'hex',
249
+ disabled = false,
250
+ haptics = false,
251
+ size = 'md',
252
+ children,
253
+ },
254
+ ref
255
+ ) => {
256
+ const slots = colorPickerVariants({ size, disabled });
257
+
258
+ // Parsed once. After this the channels are the truth and the prop is only
259
+ // consulted when it changes to something we did not ourselves emit.
260
+ const initial = useMemo(
261
+ () => parseColor(valueProp ?? defaultValue) ?? FALLBACK,
262
+ // eslint-disable-next-line react-hooks/exhaustive-deps
263
+ []
264
+ );
265
+
266
+ const hue = useSharedValue(initial.h);
267
+ const saturation = useSharedValue(initial.s);
268
+ const brightness = useSharedValue(initial.v);
269
+ const opacity = useSharedValue(initial.a);
270
+
271
+ const changeRef = useRef(onValueChange);
272
+ changeRef.current = onValueChange;
273
+ const commitRef = useRef(onValueCommit);
274
+ commitRef.current = onValueCommit;
275
+ const formatRef = useRef(format);
276
+ formatRef.current = format;
277
+
278
+ /*
279
+ * The last string this picker produced. A controlled parent hands it
280
+ * straight back, and re-parsing it into the channels mid-drag would fight
281
+ * the finger: the round trip is lossy at the edges of the square, so the
282
+ * thumb would be pulled a pixel back on every frame.
283
+ */
284
+ const lastEmitted = useRef<string | null>(null);
285
+
286
+ const emit = useCallback((h: number, s: number, v: number, a: number, commit: boolean) => {
287
+ const next = formatColor({ h, s, v, a }, formatRef.current);
288
+ lastEmitted.current = next;
289
+ changeRef.current?.(next);
290
+ if (commit) commitRef.current?.(next);
291
+ }, []);
292
+
293
+ // Keep the channels in step with a controlled value changed elsewhere. Set
294
+ // rather than animated: an external change is not a gesture, and springing
295
+ // the hue from 350° to 10° would run the long way round the wheel.
296
+ useEffect(() => {
297
+ if (valueProp === undefined) return;
298
+ if (valueProp === lastEmitted.current) return;
299
+ const parsed = parseColor(valueProp);
300
+ if (!parsed) return;
301
+ hue.value = parsed.h;
302
+ saturation.value = parsed.s;
303
+ brightness.value = parsed.v;
304
+ opacity.value = parsed.a;
305
+ }, [valueProp, hue, saturation, brightness, opacity]);
306
+
307
+ const context = useMemo<ColorPickerContextValue>(
308
+ () => ({
309
+ hue,
310
+ saturation,
311
+ brightness,
312
+ opacity,
313
+ emit,
314
+ format,
315
+ disabled,
316
+ haptics,
317
+ size: size ?? 'md',
318
+ }),
319
+ [hue, saturation, brightness, opacity, emit, format, disabled, haptics, size]
320
+ );
321
+
322
+ return (
323
+ <ColorPickerContext.Provider value={context}>
324
+ <View ref={ref} className={slots.root({ className })} collapsable={false}>
325
+ {children}
326
+ </View>
327
+ </ColorPickerContext.Provider>
328
+ );
329
+ }
330
+ );
331
+
332
+ ColorPickerRoot.displayName = 'ColorPicker';
333
+
334
+ /* ------------------------------------------------------------------ *
335
+ * The checkerboard behind anything translucent.
336
+ * ------------------------------------------------------------------ */
337
+
338
+ /**
339
+ * The "nothing here" backing. A tiled SVG pattern rather than a grid of views:
340
+ * a 340-point track at eight-point squares is 170 squares, and every one of
341
+ * them would be a view laid out by Yoga on the frame the picker mounts.
342
+ */
343
+ function Checkerboard({ square = 7 }: { square?: number }) {
344
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '');
345
+ // The pattern is themed rather than the usual grey-on-white: a checkerboard
346
+ // is a statement that there is nothing there, and "nothing" is the page.
347
+ const light = String(useCSSVariable('--color-background') ?? '#ffffff');
348
+ const dark = String(useCSSVariable('--color-muted') ?? '#e5e5e5');
349
+
350
+ return (
351
+ <Svg style={StyleSheet.absoluteFill} pointerEvents="none">
352
+ <Defs>
353
+ <Pattern
354
+ id={id}
355
+ width={square * 2}
356
+ height={square * 2}
357
+ patternUnits="userSpaceOnUse"
358
+ >
359
+ <Rect width={square * 2} height={square * 2} fill={light} />
360
+ <Rect width={square} height={square} fill={dark} />
361
+ <Rect x={square} y={square} width={square} height={square} fill={dark} />
362
+ </Pattern>
363
+ </Defs>
364
+ <Rect width="100%" height="100%" fill={`url(#${id})`} />
365
+ </Svg>
366
+ );
367
+ }
368
+
369
+ /* ------------------------------------------------------------------ *
370
+ * Area — saturation across, brightness up.
371
+ * ------------------------------------------------------------------ */
372
+
373
+ export interface ColorPickerAreaProps {
374
+ className?: string;
375
+ /** Height of the square in points. Defaults to the picker's size. */
376
+ height?: number;
377
+ /** Extra classes for the draggable thumb. */
378
+ thumbClassName?: string;
379
+ }
380
+
381
+ const ColorPickerArea = forwardRef<View, ColorPickerAreaProps>(
382
+ ({ className, height, thumbClassName }, ref) => {
383
+ const ctx = useColorPicker('ColorPicker.Area');
384
+ const slots = colorPickerVariants({ size: ctx.size });
385
+ const sign = useDirectionSign();
386
+
387
+ const boxHeight = height ?? AREA_HEIGHT[ctx.size];
388
+ const thumbSize = AREA_THUMB[ctx.size];
389
+ const width = useSharedValue(0);
390
+
391
+ /*
392
+ * The screen reader's copy of the value. Updated when a drag ends rather
393
+ * than while it runs: a `now` that re-rendered on every frame would be a
394
+ * hundred announcements for one gesture, and nobody is listening to the
395
+ * middle of a drag.
396
+ */
397
+ const [announced, setAnnounced] = useState(() => ({
398
+ s: ctx.saturation.value,
399
+ v: ctx.brightness.value,
400
+ }));
401
+
402
+ const settle = useCallback((s: number, v: number) => {
403
+ setAnnounced({ s, v });
404
+ }, []);
405
+
406
+ const apply = (x: number, y: number, commit: boolean) => {
407
+ 'worklet';
408
+ const w = Math.max(width.value, 1);
409
+ // `x` is measured from the physical left edge either way, so a
410
+ // right-to-left picker counts saturation back from the far side.
411
+ const along = sign === 1 ? x : w - x;
412
+ const s = Math.min(Math.max(along / w, 0), 1);
413
+ const v = 1 - Math.min(Math.max(y / boxHeight, 0), 1);
414
+ ctx.saturation.value = s;
415
+ ctx.brightness.value = v;
416
+ runOnJS(ctx.emit)(ctx.hue.value, s, v, ctx.opacity.value, commit);
417
+ if (commit) {
418
+ runOnJS(settle)(s, v);
419
+ if (ctx.haptics) runOnJS(selectionTick)();
420
+ }
421
+ };
422
+
423
+ // Both axes belong to the square, so it takes the gesture on touch-down
424
+ // rather than after a threshold — a pad that needed ten points of travel
425
+ // before it responded would feel broken.
426
+ const pan = Gesture.Pan()
427
+ .enabled(!ctx.disabled)
428
+ .minDistance(0)
429
+ .shouldCancelWhenOutside(false)
430
+ .onBegin((event) => apply(event.x, event.y, false))
431
+ .onUpdate((event) => apply(event.x, event.y, false))
432
+ .onFinalize((event) => apply(event.x, event.y, true));
433
+
434
+ const hueStyle = useAnimatedStyle(() => ({
435
+ backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),
436
+ }));
437
+
438
+ const thumbStyle = useAnimatedStyle(() => {
439
+ // A transform is not laid out, so Yoga does not mirror it — the thumb
440
+ // has to travel the other way itself.
441
+ const along = ctx.saturation.value * width.value - thumbSize / 2;
442
+ return {
443
+ transform: [
444
+ { translateX: along * sign },
445
+ { translateY: (1 - ctx.brightness.value) * boxHeight - thumbSize / 2 },
446
+ ],
447
+ };
448
+ });
449
+
450
+ const thumbFillStyle = useAnimatedStyle(() => ({
451
+ backgroundColor: hsvToCss(
452
+ ctx.hue.value,
453
+ ctx.saturation.value,
454
+ ctx.brightness.value,
455
+ 1
456
+ ),
457
+ }));
458
+
459
+ const nudge = (channel: 'saturation' | 'brightness', dir: 1 | -1) => {
460
+ const next = Math.min(Math.max(ctx[channel].value + dir * NUDGE, 0), 1);
461
+ ctx[channel].value = withTiming(next, TIMING);
462
+ const s = channel === 'saturation' ? next : ctx.saturation.value;
463
+ const v = channel === 'brightness' ? next : ctx.brightness.value;
464
+ settle(s, v);
465
+ ctx.emit(ctx.hue.value, s, v, ctx.opacity.value, true);
466
+ };
467
+
468
+ const onAccessibilityAction = (event: AccessibilityActionEvent) => {
469
+ const action = event.nativeEvent.actionName;
470
+ if (action === 'increment') nudge('saturation', 1);
471
+ else if (action === 'decrement') nudge('saturation', -1);
472
+ else if (action === 'brighter') nudge('brightness', 1);
473
+ else if (action === 'darker') nudge('brightness', -1);
474
+ };
475
+
476
+ return (
477
+ <GestureDetector gesture={pan}>
478
+ {/* The outer box is not clipped, so the thumb can sit centred on a
479
+ colour at the very edge of the square instead of being pushed
480
+ inside it. Only the gradients below are clipped to the radius. */}
481
+ <View
482
+ ref={ref}
483
+ style={{ height: boxHeight }}
484
+ className="w-full"
485
+ onLayout={(event: LayoutChangeEvent) => {
486
+ width.value = event.nativeEvent.layout.width;
487
+ }}
488
+ accessible
489
+ accessibilityRole="adjustable"
490
+ accessibilityLabel="Saturation and brightness"
491
+ accessibilityState={{ disabled: ctx.disabled }}
492
+ accessibilityValue={{
493
+ min: 0,
494
+ max: 100,
495
+ now: Math.round(announced.s * 100),
496
+ text: `${Math.round(announced.s * 100)}% saturation, ${Math.round(
497
+ announced.v * 100
498
+ )}% brightness`,
499
+ }}
500
+ accessibilityActions={[
501
+ { name: 'increment' },
502
+ { name: 'decrement' },
503
+ { name: 'brighter', label: 'Brighter' },
504
+ { name: 'darker', label: 'Darker' },
505
+ ]}
506
+ onAccessibilityAction={onAccessibilityAction}
507
+ >
508
+ <View style={StyleSheet.absoluteFill} className={slots.area({ className })}>
509
+ <Animated.View style={[StyleSheet.absoluteFill, hueStyle]} />
510
+ <LinearGradient
511
+ colors={['rgba(255,255,255,1)', 'rgba(255,255,255,0)']}
512
+ start={{ x: sign === 1 ? 0 : 1, y: 0.5 }}
513
+ end={{ x: sign === 1 ? 1 : 0, y: 0.5 }}
514
+ style={StyleSheet.absoluteFill}
515
+ />
516
+ <LinearGradient
517
+ colors={['rgba(0,0,0,0)', 'rgba(0,0,0,1)']}
518
+ start={{ x: 0.5, y: 0 }}
519
+ end={{ x: 0.5, y: 1 }}
520
+ style={StyleSheet.absoluteFill}
521
+ />
522
+ </View>
523
+
524
+ <Animated.View
525
+ pointerEvents="none"
526
+ style={[thumbStyle, { width: thumbSize, height: thumbSize }]}
527
+ className={slots.thumb({ className: thumbClassName })}
528
+ >
529
+ <Animated.View style={thumbFillStyle} className={slots.thumbFill()} />
530
+ </Animated.View>
531
+ </View>
532
+ </GestureDetector>
533
+ );
534
+ }
535
+ );
536
+
537
+ ColorPickerArea.displayName = 'ColorPicker.Area';
538
+
539
+ /* ------------------------------------------------------------------ *
540
+ * The two channel tracks share everything but what is drawn in them.
541
+ * ------------------------------------------------------------------ */
542
+
543
+ interface ChannelTrackProps {
544
+ /** What fills the track — a hue ramp, or a masked translucency ramp. */
545
+ children: ReactNode;
546
+ gesture: ReturnType<typeof Gesture.Race>;
547
+ thumbStyle: ReturnType<typeof useAnimatedStyle>;
548
+ thumbFillStyle: ReturnType<typeof useAnimatedStyle>;
549
+ onLayout: (event: LayoutChangeEvent) => void;
550
+ size: ColorPickerSize;
551
+ className?: string;
552
+ thumbClassName?: string;
553
+ label: string;
554
+ disabled: boolean;
555
+ accessibilityValue: { min: number; max: number; now: number; text: string };
556
+ onAccessibilityAction: (event: AccessibilityActionEvent) => void;
557
+ innerRef: React.ForwardedRef<View>;
558
+ }
559
+
560
+ function ChannelTrack({
561
+ children,
562
+ gesture,
563
+ thumbStyle,
564
+ thumbFillStyle,
565
+ onLayout,
566
+ size,
567
+ className,
568
+ thumbClassName,
569
+ label,
570
+ disabled,
571
+ accessibilityValue,
572
+ onAccessibilityAction,
573
+ innerRef,
574
+ }: ChannelTrackProps) {
575
+ const slots = colorPickerVariants({ size });
576
+ const trackHeight = TRACK_HEIGHT[size];
577
+ const thumbSize = TRACK_THUMB[size];
578
+
579
+ return (
580
+ <GestureDetector gesture={gesture}>
581
+ {/* The row is as tall as the thumb so an overhanging knob has somewhere
582
+ to be drawn whole; the track inside it is the shorter, clipped part. */}
583
+ <View
584
+ ref={innerRef}
585
+ style={{ height: thumbSize }}
586
+ className="w-full justify-center"
587
+ onLayout={onLayout}
588
+ accessible
589
+ accessibilityRole="adjustable"
590
+ accessibilityLabel={label}
591
+ accessibilityState={{ disabled }}
592
+ accessibilityValue={accessibilityValue}
593
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
594
+ onAccessibilityAction={onAccessibilityAction}
595
+ >
596
+ <View style={{ height: trackHeight }} className={slots.track({ className })}>
597
+ {children}
598
+ </View>
599
+ <Animated.View
600
+ pointerEvents="none"
601
+ style={[thumbStyle, { width: thumbSize, height: thumbSize }]}
602
+ className={slots.thumb({ className: thumbClassName })}
603
+ >
604
+ <Animated.View style={thumbFillStyle} className={slots.thumbFill()} />
605
+ </Animated.View>
606
+ </View>
607
+ </GestureDetector>
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Maps a touch anywhere on a track to a fraction of it, accounting for the
613
+ * thumb's own width: the thumb travels between the two edges rather than off
614
+ * them, so the usable run is the track minus one thumb.
615
+ */
616
+ function trackFraction(x: number, width: number, thumbSize: number, sign: number) {
617
+ 'worklet';
618
+ const along = sign === 1 ? x : width - x;
619
+ const travel = Math.max(width - thumbSize, 1);
620
+ return Math.min(Math.max((along - thumbSize / 2) / travel, 0), 1);
621
+ }
622
+
623
+ /* ------------------------------------------------------------------ *
624
+ * Hue.
625
+ * ------------------------------------------------------------------ */
626
+
627
+ export interface ColorPickerHueProps {
628
+ className?: string;
629
+ /** Extra classes for the draggable thumb. */
630
+ thumbClassName?: string;
631
+ }
632
+
633
+ const ColorPickerHue = forwardRef<View, ColorPickerHueProps>(
634
+ ({ className, thumbClassName }, ref) => {
635
+ const ctx = useColorPicker('ColorPicker.Hue');
636
+ const sign = useDirectionSign();
637
+ const thumbSize = TRACK_THUMB[ctx.size];
638
+ const width = useSharedValue(0);
639
+ const [announced, setAnnounced] = useState(() => Math.round(ctx.hue.value));
640
+
641
+ const settle = useCallback((h: number) => setAnnounced(Math.round(h)), []);
642
+
643
+ const apply = (x: number, commit: boolean) => {
644
+ 'worklet';
645
+ const h = trackFraction(x, width.value, thumbSize, sign) * 360;
646
+ ctx.hue.value = h;
647
+ runOnJS(ctx.emit)(
648
+ h,
649
+ ctx.saturation.value,
650
+ ctx.brightness.value,
651
+ ctx.opacity.value,
652
+ commit
653
+ );
654
+ if (commit) {
655
+ runOnJS(settle)(h);
656
+ if (ctx.haptics) runOnJS(selectionTick)();
657
+ }
658
+ };
659
+
660
+ /*
661
+ * A thin horizontal control inside a scroller: the pan waits for six points
662
+ * of sideways travel so a vertical flick starting on the track still
663
+ * scrolls the page, and the tap handles the case where there was no travel
664
+ * at all.
665
+ */
666
+ const pan = Gesture.Pan()
667
+ .enabled(!ctx.disabled)
668
+ .activeOffsetX([-6, 6])
669
+ .shouldCancelWhenOutside(false)
670
+ .onUpdate((event) => apply(event.x, false))
671
+ .onFinalize((event) => apply(event.x, true));
672
+
673
+ const tap = Gesture.Tap()
674
+ .enabled(!ctx.disabled)
675
+ .maxDuration(250)
676
+ .onEnd((event) => apply(event.x, true));
677
+
678
+ const gesture = Gesture.Race(pan, tap);
679
+
680
+ const thumbStyle = useAnimatedStyle(() => {
681
+ const travel = Math.max(width.value - thumbSize, 0);
682
+ return { transform: [{ translateX: (ctx.hue.value / 360) * travel * sign }] };
683
+ });
684
+
685
+ const thumbFillStyle = useAnimatedStyle(() => ({
686
+ backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),
687
+ }));
688
+
689
+ const nudge = (dir: 1 | -1) => {
690
+ const next = Math.min(Math.max(ctx.hue.value + dir * HUE_NUDGE, 0), 360);
691
+ ctx.hue.value = withTiming(next, TIMING);
692
+ settle(next);
693
+ ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);
694
+ };
695
+
696
+ return (
697
+ <ChannelTrack
698
+ innerRef={ref}
699
+ gesture={gesture}
700
+ thumbStyle={thumbStyle}
701
+ thumbFillStyle={thumbFillStyle}
702
+ onLayout={(event) => {
703
+ width.value = event.nativeEvent.layout.width;
704
+ }}
705
+ size={ctx.size}
706
+ className={className}
707
+ thumbClassName={thumbClassName}
708
+ label="Hue"
709
+ disabled={ctx.disabled}
710
+ accessibilityValue={{ min: 0, max: 360, now: announced, text: `${announced}°` }}
711
+ onAccessibilityAction={(event) => {
712
+ if (event.nativeEvent.actionName === 'increment') nudge(1);
713
+ else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
714
+ }}
715
+ >
716
+ <LinearGradient
717
+ colors={sign === 1 ? HUE_STOPS : HUE_STOPS_FLIPPED}
718
+ start={{ x: 0, y: 0.5 }}
719
+ end={{ x: 1, y: 0.5 }}
720
+ style={StyleSheet.absoluteFill}
721
+ />
722
+ </ChannelTrack>
723
+ );
724
+ }
725
+ );
726
+
727
+ ColorPickerHue.displayName = 'ColorPicker.Hue';
728
+
729
+ /* ------------------------------------------------------------------ *
730
+ * Alpha.
731
+ * ------------------------------------------------------------------ */
732
+
733
+ export interface ColorPickerAlphaProps {
734
+ className?: string;
735
+ /** Extra classes for the draggable thumb. */
736
+ thumbClassName?: string;
737
+ }
738
+
739
+ const ColorPickerAlpha = forwardRef<View, ColorPickerAlphaProps>(
740
+ ({ className, thumbClassName }, ref) => {
741
+ const ctx = useColorPicker('ColorPicker.Alpha');
742
+ const sign = useDirectionSign();
743
+ const thumbSize = TRACK_THUMB[ctx.size];
744
+ const width = useSharedValue(0);
745
+ const [announced, setAnnounced] = useState(() => Math.round(ctx.opacity.value * 100));
746
+
747
+ const settle = useCallback((a: number) => setAnnounced(Math.round(a * 100)), []);
748
+
749
+ const apply = (x: number, commit: boolean) => {
750
+ 'worklet';
751
+ const a = trackFraction(x, width.value, thumbSize, sign);
752
+ ctx.opacity.value = a;
753
+ runOnJS(ctx.emit)(
754
+ ctx.hue.value,
755
+ ctx.saturation.value,
756
+ ctx.brightness.value,
757
+ a,
758
+ commit
759
+ );
760
+ if (commit) {
761
+ runOnJS(settle)(a);
762
+ if (ctx.haptics) runOnJS(selectionTick)();
763
+ }
764
+ };
765
+
766
+ const pan = Gesture.Pan()
767
+ .enabled(!ctx.disabled)
768
+ .activeOffsetX([-6, 6])
769
+ .shouldCancelWhenOutside(false)
770
+ .onUpdate((event) => apply(event.x, false))
771
+ .onFinalize((event) => apply(event.x, true));
772
+
773
+ const tap = Gesture.Tap()
774
+ .enabled(!ctx.disabled)
775
+ .maxDuration(250)
776
+ .onEnd((event) => apply(event.x, true));
777
+
778
+ const gesture = Gesture.Race(pan, tap);
779
+
780
+ const thumbStyle = useAnimatedStyle(() => {
781
+ const travel = Math.max(width.value - thumbSize, 0);
782
+ return { transform: [{ translateX: ctx.opacity.value * travel * sign }] };
783
+ });
784
+
785
+ const solidStyle = useAnimatedStyle(() => ({
786
+ backgroundColor: hsvToCss(
787
+ ctx.hue.value,
788
+ ctx.saturation.value,
789
+ ctx.brightness.value,
790
+ 1
791
+ ),
792
+ }));
793
+
794
+ const thumbFillStyle = useAnimatedStyle(() => ({
795
+ backgroundColor: hsvToCss(
796
+ ctx.hue.value,
797
+ ctx.saturation.value,
798
+ ctx.brightness.value,
799
+ ctx.opacity.value
800
+ ),
801
+ }));
802
+
803
+ const nudge = (dir: 1 | -1) => {
804
+ const next = Math.min(Math.max(ctx.opacity.value + dir * NUDGE, 0), 1);
805
+ ctx.opacity.value = withTiming(next, TIMING);
806
+ settle(next);
807
+ ctx.emit(ctx.hue.value, ctx.saturation.value, ctx.brightness.value, next, true);
808
+ };
809
+
810
+ return (
811
+ <ChannelTrack
812
+ innerRef={ref}
813
+ gesture={gesture}
814
+ thumbStyle={thumbStyle}
815
+ thumbFillStyle={thumbFillStyle}
816
+ onLayout={(event) => {
817
+ width.value = event.nativeEvent.layout.width;
818
+ }}
819
+ size={ctx.size}
820
+ className={className}
821
+ thumbClassName={thumbClassName}
822
+ label="Opacity"
823
+ disabled={ctx.disabled}
824
+ accessibilityValue={{
825
+ min: 0,
826
+ max: 100,
827
+ now: announced,
828
+ text: `${announced}%`,
829
+ }}
830
+ onAccessibilityAction={(event) => {
831
+ if (event.nativeEvent.actionName === 'increment') nudge(1);
832
+ else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
833
+ }}
834
+ >
835
+ <Checkerboard />
836
+ {/*
837
+ * The ramp is a gradient used as a *mask* over a solid fill of the
838
+ * current colour, not a gradient between a transparent and an opaque
839
+ * copy of it. A gradient's colours are props, so the second form would
840
+ * have to be re-declared from React on every frame of a drag on the
841
+ * square above; the mask never changes, and only the fill under it does
842
+ * — which is a `backgroundColor` the UI thread can write itself.
843
+ */}
844
+ <MaskedView
845
+ style={StyleSheet.absoluteFill}
846
+ pointerEvents="none"
847
+ maskElement={
848
+ <LinearGradient
849
+ colors={['rgba(0,0,0,0)', 'rgba(0,0,0,1)']}
850
+ start={{ x: sign === 1 ? 0 : 1, y: 0.5 }}
851
+ end={{ x: sign === 1 ? 1 : 0, y: 0.5 }}
852
+ style={StyleSheet.absoluteFill}
853
+ />
854
+ }
855
+ >
856
+ <Animated.View style={[StyleSheet.absoluteFill, solidStyle]} />
857
+ </MaskedView>
858
+ </ChannelTrack>
859
+ );
860
+ }
861
+ );
862
+
863
+ ColorPickerAlpha.displayName = 'ColorPicker.Alpha';
864
+
865
+ /* ------------------------------------------------------------------ *
866
+ * Preview.
867
+ * ------------------------------------------------------------------ */
868
+
869
+ export interface ColorPickerPreviewProps {
870
+ className?: string;
871
+ /**
872
+ * Print the colour beside the swatch, in the picker's `format`. The string
873
+ * is built on the UI thread and only crosses to JavaScript when it differs
874
+ * from the last one, so a drag that is not changing the rounded value costs
875
+ * nothing.
876
+ */
877
+ showValue?: boolean;
878
+ /** Extra classes for the swatch. */
879
+ swatchClassName?: string;
880
+ /** Extra classes for the printed value. */
881
+ valueClassName?: string;
882
+ /** Anything to put after the value — a copy button, a label. */
883
+ children?: ReactNode;
884
+ }
885
+
886
+ const ColorPickerPreview = forwardRef<View, ColorPickerPreviewProps>(
887
+ ({ className, showValue = false, swatchClassName, valueClassName, children }, ref) => {
888
+ const ctx = useColorPicker('ColorPicker.Preview');
889
+ const slots = colorPickerVariants({ size: ctx.size });
890
+ const diameter = PREVIEW_SWATCH[ctx.size];
891
+
892
+ const [printed, setPrinted] = useState(() =>
893
+ formatColor(
894
+ {
895
+ h: ctx.hue.value,
896
+ s: ctx.saturation.value,
897
+ v: ctx.brightness.value,
898
+ a: ctx.opacity.value,
899
+ },
900
+ ctx.format
901
+ )
902
+ );
903
+
904
+ const format = ctx.format;
905
+ useAnimatedReaction(
906
+ () =>
907
+ formatColor(
908
+ {
909
+ h: ctx.hue.value,
910
+ s: ctx.saturation.value,
911
+ v: ctx.brightness.value,
912
+ a: ctx.opacity.value,
913
+ },
914
+ format
915
+ ),
916
+ (current, previous) => {
917
+ if (current !== previous) runOnJS(setPrinted)(current);
918
+ },
919
+ [format]
920
+ );
921
+
922
+ const fillStyle = useAnimatedStyle(() => ({
923
+ backgroundColor: hsvToCss(
924
+ ctx.hue.value,
925
+ ctx.saturation.value,
926
+ ctx.brightness.value,
927
+ ctx.opacity.value
928
+ ),
929
+ }));
930
+
931
+ return (
932
+ <View ref={ref} className={slots.preview({ className })}>
933
+ <View
934
+ style={{ width: diameter, height: diameter }}
935
+ className={slots.previewSwatch({ className: swatchClassName })}
936
+ >
937
+ <Checkerboard square={5} />
938
+ <Animated.View style={[StyleSheet.absoluteFill, fillStyle]} />
939
+ </View>
940
+ {showValue ? (
941
+ <Text className={slots.previewValue({ className: valueClassName })}>
942
+ {printed}
943
+ </Text>
944
+ ) : null}
945
+ {children}
946
+ </View>
947
+ );
948
+ }
949
+ );
950
+
951
+ ColorPickerPreview.displayName = 'ColorPicker.Preview';
952
+
953
+ /* ------------------------------------------------------------------ *
954
+ * Swatches.
955
+ * ------------------------------------------------------------------ */
956
+
957
+ export interface ColorPickerSwatchesProps {
958
+ className?: string;
959
+ /** The presets, in any format `ColorPicker` can read. */
960
+ colors: string[];
961
+ /** Diameter of one swatch in points. Defaults to the picker's size. */
962
+ swatchSize?: number;
963
+ /** Extra classes for one swatch. */
964
+ swatchClassName?: string;
965
+ }
966
+
967
+ const ColorPickerSwatches = forwardRef<View, ColorPickerSwatchesProps>(
968
+ ({ className, colors, swatchSize, swatchClassName }, ref) => {
969
+ const ctx = useColorPicker('ColorPicker.Swatches');
970
+ const slots = colorPickerVariants({ size: ctx.size });
971
+ const diameter = swatchSize ?? SWATCH[ctx.size];
972
+
973
+ // Unreadable strings are dropped here rather than rendered as black: a row
974
+ // of presets with a mystery black one in it is worse than a shorter row.
975
+ const parsed = useMemo(
976
+ () =>
977
+ colors
978
+ .map((raw) => ({ raw, color: parseColor(raw) }))
979
+ .filter((entry): entry is { raw: string; color: HsvaColor } => entry.color !== null),
980
+ [colors]
981
+ );
982
+
983
+ return (
984
+ <View ref={ref} className={slots.swatches({ className })}>
985
+ {parsed.map((entry, index) => (
986
+ <Swatch
987
+ key={`${entry.raw}-${index}`}
988
+ color={entry.color}
989
+ diameter={diameter}
990
+ className={swatchClassName}
991
+ ctx={ctx}
992
+ slots={slots}
993
+ />
994
+ ))}
995
+ </View>
996
+ );
997
+ }
998
+ );
999
+
1000
+ ColorPickerSwatches.displayName = 'ColorPicker.Swatches';
1001
+
1002
+ function Swatch({
1003
+ color,
1004
+ diameter,
1005
+ className,
1006
+ ctx,
1007
+ slots,
1008
+ }: {
1009
+ color: HsvaColor;
1010
+ diameter: number;
1011
+ className?: string;
1012
+ ctx: ColorPickerContextValue;
1013
+ slots: ReturnType<typeof colorPickerVariants>;
1014
+ }) {
1015
+ const hex = hsvToHex(color.h, color.s, color.v, color.a);
1016
+
1017
+ /*
1018
+ * Selected is an exact match on the written colour rather than a distance
1019
+ * between two sets of channels. A near-black has an arbitrary hue and a
1020
+ * meaningless saturation, so a per-channel comparison marks two visually
1021
+ * identical blacks as different and two different hues of black as the same.
1022
+ */
1023
+ const ringStyle = useAnimatedStyle(() => {
1024
+ const current = hsvToHex(
1025
+ ctx.hue.value,
1026
+ ctx.saturation.value,
1027
+ ctx.brightness.value,
1028
+ ctx.opacity.value
1029
+ );
1030
+ return { opacity: withTiming(current === hex ? 1 : 0, { duration: 120 }) };
1031
+ });
1032
+
1033
+ const onPress = () => {
1034
+ ctx.hue.value = color.h;
1035
+ ctx.saturation.value = color.s;
1036
+ ctx.brightness.value = color.v;
1037
+ ctx.opacity.value = color.a;
1038
+ ctx.emit(color.h, color.s, color.v, color.a, true);
1039
+ if (ctx.haptics) selectionTick();
1040
+ };
1041
+
1042
+ return (
1043
+ <Pressable
1044
+ onPress={onPress}
1045
+ disabled={ctx.disabled}
1046
+ accessibilityRole="button"
1047
+ accessibilityLabel={hex}
1048
+ style={{ width: diameter, height: diameter }}
1049
+ className={slots.swatch({ className })}
1050
+ >
1051
+ <View
1052
+ style={{ width: diameter, height: diameter }}
1053
+ className="absolute overflow-hidden rounded-full"
1054
+ >
1055
+ {color.a < 1 ? <Checkerboard square={4} /> : null}
1056
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: hex }]} />
1057
+ </View>
1058
+ {/* The ring sits outside the fill, so a swatch the same colour as the
1059
+ page still reads as selected. */}
1060
+ <Animated.View
1061
+ pointerEvents="none"
1062
+ style={[ringStyle, { width: diameter + 6, height: diameter + 6 }]}
1063
+ className={slots.swatchRing()}
1064
+ />
1065
+ </Pressable>
1066
+ );
1067
+ }
1068
+
1069
+ /* ------------------------------------------------------------------ *
1070
+ * Field — the strip above the controls: what is being picked, and what
1071
+ * it currently is.
1072
+ * ------------------------------------------------------------------ */
1073
+
1074
+ export interface ColorPickerFieldProps {
1075
+ className?: string;
1076
+ /** What the colour is for — "Accent", "Background", a layer name. */
1077
+ label?: string;
1078
+ /**
1079
+ * Print the current colour beside the swatch, in the picker's `format`.
1080
+ * On by default: the strip exists to say what the colour *is*, and a swatch
1081
+ * alone cannot be read out, copied down or typed into a design tool.
1082
+ */
1083
+ showValue?: boolean;
1084
+ /** Extra classes for the swatch. */
1085
+ swatchClassName?: string;
1086
+ /** Anything to put after the swatch — a copy button, a reset. */
1087
+ children?: ReactNode;
1088
+ }
1089
+
1090
+ /**
1091
+ * A header for the picker below it.
1092
+ *
1093
+ * The value it prints is built on the UI thread and only crosses to
1094
+ * JavaScript when the rounded string actually changes, the same as `Preview`.
1095
+ * A drag through a hundred frames of the same hex costs one render.
1096
+ */
1097
+ const ColorPickerField = forwardRef<View, ColorPickerFieldProps>(
1098
+ ({ className, label, showValue = true, swatchClassName, children }, ref) => {
1099
+ const ctx = useColorPicker('ColorPicker.Field');
1100
+ const slots = colorPickerVariants({ size: ctx.size });
1101
+ const diameter = FIELD_SWATCH[ctx.size];
1102
+
1103
+ const read = useCallback(
1104
+ () =>
1105
+ formatColor(
1106
+ {
1107
+ h: ctx.hue.value,
1108
+ s: ctx.saturation.value,
1109
+ v: ctx.brightness.value,
1110
+ a: ctx.opacity.value,
1111
+ },
1112
+ ctx.format
1113
+ ),
1114
+ // Shared values are stable objects; `format` is the only real dependency.
1115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1116
+ [ctx.format]
1117
+ );
1118
+
1119
+ const [printed, setPrinted] = useState(read);
1120
+
1121
+ const format = ctx.format;
1122
+ useAnimatedReaction(
1123
+ () =>
1124
+ formatColor(
1125
+ {
1126
+ h: ctx.hue.value,
1127
+ s: ctx.saturation.value,
1128
+ v: ctx.brightness.value,
1129
+ a: ctx.opacity.value,
1130
+ },
1131
+ format
1132
+ ),
1133
+ (current, previous) => {
1134
+ if (current !== previous) runOnJS(setPrinted)(current);
1135
+ },
1136
+ [format]
1137
+ );
1138
+
1139
+ const fillStyle = useAnimatedStyle(() => ({
1140
+ backgroundColor: hsvToCss(
1141
+ ctx.hue.value,
1142
+ ctx.saturation.value,
1143
+ ctx.brightness.value,
1144
+ ctx.opacity.value
1145
+ ),
1146
+ }));
1147
+
1148
+ return (
1149
+ <View
1150
+ ref={ref}
1151
+ className={slots.field({ className })}
1152
+ // One thing being read out, not three: a label, a value and a swatch
1153
+ // announced separately are three stops that each say a third of it.
1154
+ accessible
1155
+ accessibilityLabel={label ? `${label}, ${printed}` : printed}
1156
+ >
1157
+ {label ? <Text className={slots.fieldLabel()}>{label}</Text> : null}
1158
+ {showValue ? (
1159
+ <Text className={slots.fieldValue()}>{printed.toUpperCase()}</Text>
1160
+ ) : null}
1161
+ <View
1162
+ style={{ width: diameter, height: diameter }}
1163
+ className={slots.fieldSwatch({ className: swatchClassName })}
1164
+ >
1165
+ {/* Only behind a translucent colour — a checkerboard under an opaque
1166
+ swatch is a pattern nobody can see, drawn every frame. */}
1167
+ <Checkerboard square={4} />
1168
+ <Animated.View style={[StyleSheet.absoluteFill, fillStyle]} />
1169
+ </View>
1170
+ {children}
1171
+ </View>
1172
+ );
1173
+ }
1174
+ );
1175
+
1176
+ ColorPickerField.displayName = 'ColorPicker.Field';
1177
+
1178
+ /* ------------------------------------------------------------------ *
1179
+ * Channel — the readout that names the track under it.
1180
+ * ------------------------------------------------------------------ */
1181
+
1182
+ /** Which channel a readout is reading. */
1183
+ export type ColorPickerChannel = 'hue' | 'saturation' | 'brightness' | 'alpha';
1184
+
1185
+ /** What each channel is called, and how its number is written. */
1186
+ const CHANNEL_LABEL: Record<ColorPickerChannel, string> = {
1187
+ hue: 'Hue',
1188
+ saturation: 'Saturation',
1189
+ brightness: 'Brightness',
1190
+ alpha: 'Opacity',
1191
+ };
1192
+
1193
+ export interface ColorPickerChannelProps {
1194
+ className?: string;
1195
+ /** Which channel to read. */
1196
+ channel: ColorPickerChannel;
1197
+ /** Overrides the channel's own name. */
1198
+ label?: string;
1199
+ /**
1200
+ * Writes the number yourself. Receives degrees for `hue` and a percentage
1201
+ * for the other three, both already rounded.
1202
+ */
1203
+ format?: (value: number) => string;
1204
+ }
1205
+
1206
+ /**
1207
+ * The line above a track: the number on the leading edge, what it names on the
1208
+ * trailing one.
1209
+ *
1210
+ * The number is the one thing in this component that has to reach JavaScript
1211
+ * during a drag, because text cannot be written from a worklet. It is rounded
1212
+ * first and only sent when the rounded value changes, so a slow drag across a
1213
+ * hue track sends 360 updates rather than one per frame.
1214
+ */
1215
+ const ColorPickerChannelReadout = forwardRef<View, ColorPickerChannelProps>(
1216
+ ({ className, channel, label, format }, ref) => {
1217
+ const ctx = useColorPicker('ColorPicker.Channel');
1218
+ const slots = colorPickerVariants({ size: ctx.size });
1219
+
1220
+ const source =
1221
+ channel === 'hue'
1222
+ ? ctx.hue
1223
+ : channel === 'saturation'
1224
+ ? ctx.saturation
1225
+ : channel === 'brightness'
1226
+ ? ctx.brightness
1227
+ : ctx.opacity;
1228
+
1229
+ const [value, setValue] = useState(() =>
1230
+ Math.round(channel === 'hue' ? source.value : source.value * 100)
1231
+ );
1232
+
1233
+ useAnimatedReaction(
1234
+ () => Math.round(channel === 'hue' ? source.value : source.value * 100),
1235
+ (current, previous) => {
1236
+ if (current !== previous) runOnJS(setValue)(current);
1237
+ },
1238
+ [channel]
1239
+ );
1240
+
1241
+ const text = format
1242
+ ? format(value)
1243
+ : channel === 'hue'
1244
+ ? `${value}°`
1245
+ : `${value}%`;
1246
+
1247
+ return (
1248
+ <View ref={ref} className={slots.channel({ className })}>
1249
+ <Text className={slots.channelValue()}>{text}</Text>
1250
+ <Text className={slots.channelLabel()}>{label ?? CHANNEL_LABEL[channel]}</Text>
1251
+ </View>
1252
+ );
1253
+ }
1254
+ );
1255
+
1256
+ ColorPickerChannelReadout.displayName = 'ColorPicker.Channel';
1257
+
1258
+ /* ------------------------------------------------------------------ *
1259
+ * Brightness — the third channel, as a track.
1260
+ * ------------------------------------------------------------------ */
1261
+
1262
+ export interface ColorPickerBrightnessProps {
1263
+ className?: string;
1264
+ /** Extra classes for the draggable thumb. */
1265
+ thumbClassName?: string;
1266
+ }
1267
+
1268
+ /**
1269
+ * Black to the colour at full brightness.
1270
+ *
1271
+ * The square carries saturation and brightness together, so a picker built
1272
+ * around it never needs this. A wheel carries hue and saturation instead, and
1273
+ * leaves brightness with nowhere to live — this is where it goes.
1274
+ */
1275
+ const ColorPickerBrightness = forwardRef<View, ColorPickerBrightnessProps>(
1276
+ ({ className, thumbClassName }, ref) => {
1277
+ const ctx = useColorPicker('ColorPicker.Brightness');
1278
+ const sign = useDirectionSign();
1279
+ const thumbSize = TRACK_THUMB[ctx.size];
1280
+ const width = useSharedValue(0);
1281
+ const [announced, setAnnounced] = useState(() =>
1282
+ Math.round(ctx.brightness.value * 100)
1283
+ );
1284
+
1285
+ const settle = useCallback((v: number) => setAnnounced(Math.round(v * 100)), []);
1286
+
1287
+ /*
1288
+ * The ramp's far end is the current hue at full brightness, so it has to be
1289
+ * re-declared from React when the hue moves. Unlike the hue ramp — which is
1290
+ * every hue and therefore never changes — this one is only two colours, so
1291
+ * rebuilding it is a two-element array rather than a gradient recompile.
1292
+ */
1293
+ const [hueEnd, setHueEnd] = useState(() =>
1294
+ hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1)
1295
+ );
1296
+
1297
+ useAnimatedReaction(
1298
+ () => hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1),
1299
+ (current, previous) => {
1300
+ if (current !== previous) runOnJS(setHueEnd)(current);
1301
+ }
1302
+ );
1303
+
1304
+ const apply = (x: number, commit: boolean) => {
1305
+ 'worklet';
1306
+ const v = trackFraction(x, width.value, thumbSize, sign);
1307
+ ctx.brightness.value = v;
1308
+ runOnJS(ctx.emit)(ctx.hue.value, ctx.saturation.value, v, ctx.opacity.value, commit);
1309
+ if (commit) {
1310
+ runOnJS(settle)(v);
1311
+ if (ctx.haptics) runOnJS(selectionTick)();
1312
+ }
1313
+ };
1314
+
1315
+ const pan = Gesture.Pan()
1316
+ .enabled(!ctx.disabled)
1317
+ .activeOffsetX([-6, 6])
1318
+ .shouldCancelWhenOutside(false)
1319
+ .onUpdate((event) => apply(event.x, false))
1320
+ .onFinalize((event) => apply(event.x, true));
1321
+
1322
+ const tap = Gesture.Tap()
1323
+ .enabled(!ctx.disabled)
1324
+ .maxDuration(250)
1325
+ .onEnd((event) => apply(event.x, true));
1326
+
1327
+ const gesture = Gesture.Race(pan, tap);
1328
+
1329
+ const thumbStyle = useAnimatedStyle(() => {
1330
+ const travel = Math.max(width.value - thumbSize, 0);
1331
+ return { transform: [{ translateX: ctx.brightness.value * travel * sign }] };
1332
+ });
1333
+
1334
+ const thumbFillStyle = useAnimatedStyle(() => ({
1335
+ backgroundColor: hsvToCss(
1336
+ ctx.hue.value,
1337
+ ctx.saturation.value,
1338
+ ctx.brightness.value,
1339
+ 1
1340
+ ),
1341
+ }));
1342
+
1343
+ const nudge = (dir: 1 | -1) => {
1344
+ const next = Math.min(Math.max(ctx.brightness.value + dir * NUDGE, 0), 1);
1345
+ ctx.brightness.value = withTiming(next, TIMING);
1346
+ settle(next);
1347
+ ctx.emit(ctx.hue.value, ctx.saturation.value, next, ctx.opacity.value, true);
1348
+ };
1349
+
1350
+ return (
1351
+ <ChannelTrack
1352
+ innerRef={ref}
1353
+ gesture={gesture}
1354
+ thumbStyle={thumbStyle}
1355
+ thumbFillStyle={thumbFillStyle}
1356
+ onLayout={(event) => {
1357
+ width.value = event.nativeEvent.layout.width;
1358
+ }}
1359
+ size={ctx.size}
1360
+ className={className}
1361
+ thumbClassName={thumbClassName}
1362
+ label="Brightness"
1363
+ disabled={ctx.disabled}
1364
+ accessibilityValue={{
1365
+ min: 0,
1366
+ max: 100,
1367
+ now: announced,
1368
+ text: `${announced}%`,
1369
+ }}
1370
+ onAccessibilityAction={(event) => {
1371
+ if (event.nativeEvent.actionName === 'increment') nudge(1);
1372
+ else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
1373
+ }}
1374
+ >
1375
+ <LinearGradient
1376
+ colors={sign === 1 ? ['#000000', hueEnd] : [hueEnd, '#000000']}
1377
+ start={{ x: 0, y: 0.5 }}
1378
+ end={{ x: 1, y: 0.5 }}
1379
+ style={StyleSheet.absoluteFill}
1380
+ />
1381
+ </ChannelTrack>
1382
+ );
1383
+ }
1384
+ );
1385
+
1386
+ ColorPickerBrightness.displayName = 'ColorPicker.Brightness';
1387
+
1388
+ /* ------------------------------------------------------------------ *
1389
+ * Wheel — hue around, saturation out.
1390
+ * ------------------------------------------------------------------ */
1391
+
1392
+ /**
1393
+ * How many wedges the hue ring is cut into.
1394
+ *
1395
+ * There is no conic gradient here to draw a hue ring with, so it is
1396
+ * approximated by solid wedges. Three degrees each is under a pixel of colour
1397
+ * step at any size this is drawn at, and the wedges are given a hair of
1398
+ * overlap so the seams between them cannot show as lighter lines.
1399
+ */
1400
+ const WHEEL_WEDGES = 120;
1401
+ const WHEEL_OVERLAP = 0.6;
1402
+
1403
+ /** Radius of the drawing, in the wheel's own viewBox units. */
1404
+ const WHEEL_R = 50;
1405
+
1406
+ /** The hue ring, built once — it is the same wheel in every picker. */
1407
+ const WHEEL_PATHS = Array.from({ length: WHEEL_WEDGES }, (_, index) => {
1408
+ const step = 360 / WHEEL_WEDGES;
1409
+ const from = index * step;
1410
+ const to = from + step + WHEEL_OVERLAP;
1411
+ const rad = (deg: number) => (deg * Math.PI) / 180;
1412
+ const x1 = WHEEL_R + WHEEL_R * Math.cos(rad(from));
1413
+ const y1 = WHEEL_R + WHEEL_R * Math.sin(rad(from));
1414
+ const x2 = WHEEL_R + WHEEL_R * Math.cos(rad(to));
1415
+ const y2 = WHEEL_R + WHEEL_R * Math.sin(rad(to));
1416
+ return {
1417
+ d: `M ${WHEEL_R} ${WHEEL_R} L ${x1} ${y1} A ${WHEEL_R} ${WHEEL_R} 0 0 1 ${x2} ${y2} Z`,
1418
+ fill: hsvToCss(from, 1, 1, 1),
1419
+ };
1420
+ });
1421
+
1422
+ export interface ColorPickerWheelProps {
1423
+ className?: string;
1424
+ /** Diameter in points. Defaults to the picker's size. */
1425
+ size?: number;
1426
+ /** Extra classes for the draggable thumb. */
1427
+ thumbClassName?: string;
1428
+ }
1429
+
1430
+ /**
1431
+ * The square, bent into a circle: hue is the angle and saturation is the
1432
+ * distance from the middle.
1433
+ *
1434
+ * It reads the same three shared values as `ColorPicker.Area`, so the two are
1435
+ * interchangeable — a picker is a wheel instead of a square, not a wheel as
1436
+ * well as one. What it does not carry is brightness, which has no third
1437
+ * dimension to occupy here; pair it with `ColorPicker.Brightness`.
1438
+ *
1439
+ * The wheel is deliberately not mirrored under RTL. A track has a start and an
1440
+ * end and so has a direction to read it in; a wheel has neither, and flipping
1441
+ * which way round the spectrum runs would only make the same colour sit
1442
+ * somewhere else.
1443
+ */
1444
+ const ColorPickerWheel = forwardRef<View, ColorPickerWheelProps>(
1445
+ ({ className, size, thumbClassName }, ref) => {
1446
+ const ctx = useColorPicker('ColorPicker.Wheel');
1447
+ const slots = colorPickerVariants({ size: ctx.size });
1448
+ const gradientId = useId().replace(/[^a-zA-Z0-9]/g, '');
1449
+
1450
+ const diameter = size ?? WHEEL_SIZE[ctx.size];
1451
+ const thumbSize = AREA_THUMB[ctx.size];
1452
+ const radius = diameter / 2;
1453
+
1454
+ const [announced, setAnnounced] = useState(() => ({
1455
+ h: Math.round(ctx.hue.value),
1456
+ s: Math.round(ctx.saturation.value * 100),
1457
+ }));
1458
+
1459
+ const settle = useCallback((h: number, s: number) => {
1460
+ setAnnounced({ h: Math.round(h), s: Math.round(s * 100) });
1461
+ }, []);
1462
+
1463
+ const apply = (x: number, y: number, commit: boolean) => {
1464
+ 'worklet';
1465
+ const dx = x - radius;
1466
+ const dy = y - radius;
1467
+ // Clockwise from three o'clock, which is what `atan2` already gives in a
1468
+ // coordinate space whose y runs downwards — so the wedges above and the
1469
+ // touch here agree without either of them correcting for the other.
1470
+ const degrees = (Math.atan2(dy, dx) * 180) / Math.PI;
1471
+ const h = (degrees + 360) % 360;
1472
+ // Past the rim the colour is the one on the rim, rather than nothing:
1473
+ // a finger that overshoots the edge of a disc has still chosen a hue.
1474
+ const s = Math.min(Math.sqrt(dx * dx + dy * dy) / radius, 1);
1475
+
1476
+ ctx.hue.value = h;
1477
+ ctx.saturation.value = s;
1478
+ runOnJS(ctx.emit)(h, s, ctx.brightness.value, ctx.opacity.value, commit);
1479
+ if (commit) {
1480
+ runOnJS(settle)(h, s);
1481
+ if (ctx.haptics) runOnJS(selectionTick)();
1482
+ }
1483
+ };
1484
+
1485
+ const pan = Gesture.Pan()
1486
+ .enabled(!ctx.disabled)
1487
+ .minDistance(0)
1488
+ .shouldCancelWhenOutside(false)
1489
+ .onBegin((event) => apply(event.x, event.y, false))
1490
+ .onUpdate((event) => apply(event.x, event.y, false))
1491
+ .onFinalize((event) => apply(event.x, event.y, true));
1492
+
1493
+ const thumbStyle = useAnimatedStyle(() => {
1494
+ const angle = (ctx.hue.value * Math.PI) / 180;
1495
+ const r = ctx.saturation.value * radius;
1496
+ return {
1497
+ transform: [
1498
+ { translateX: radius + r * Math.cos(angle) - thumbSize / 2 },
1499
+ { translateY: radius + r * Math.sin(angle) - thumbSize / 2 },
1500
+ ],
1501
+ };
1502
+ });
1503
+
1504
+ const thumbFillStyle = useAnimatedStyle(() => ({
1505
+ backgroundColor: hsvToCss(
1506
+ ctx.hue.value,
1507
+ ctx.saturation.value,
1508
+ ctx.brightness.value,
1509
+ 1
1510
+ ),
1511
+ }));
1512
+
1513
+ // Brightness is not drawn into the wheel — it darkens the whole of it,
1514
+ // which is what brightness does.
1515
+ const dimStyle = useAnimatedStyle(() => ({
1516
+ opacity: 1 - ctx.brightness.value,
1517
+ }));
1518
+
1519
+ const nudge = (channel: 'hue' | 'saturation', dir: 1 | -1) => {
1520
+ if (channel === 'hue') {
1521
+ const next = (ctx.hue.value + dir * HUE_NUDGE + 360) % 360;
1522
+ ctx.hue.value = next;
1523
+ settle(next, ctx.saturation.value);
1524
+ ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);
1525
+ return;
1526
+ }
1527
+ const next = Math.min(Math.max(ctx.saturation.value + dir * NUDGE, 0), 1);
1528
+ ctx.saturation.value = withTiming(next, TIMING);
1529
+ settle(ctx.hue.value, next);
1530
+ ctx.emit(ctx.hue.value, next, ctx.brightness.value, ctx.opacity.value, true);
1531
+ };
1532
+
1533
+ const onAccessibilityAction = (event: AccessibilityActionEvent) => {
1534
+ const action = event.nativeEvent.actionName;
1535
+ if (action === 'increment') nudge('hue', 1);
1536
+ else if (action === 'decrement') nudge('hue', -1);
1537
+ else if (action === 'saturate') nudge('saturation', 1);
1538
+ else if (action === 'desaturate') nudge('saturation', -1);
1539
+ };
1540
+
1541
+ return (
1542
+ <GestureDetector gesture={pan}>
1543
+ {/* Square box, round drawing: the thumb travels to the rim and has to
1544
+ be drawn whole when it gets there, so the box is not clipped and
1545
+ only the wheel inside it is round. */}
1546
+ <View
1547
+ ref={ref}
1548
+ style={{ width: diameter, height: diameter }}
1549
+ className="self-center"
1550
+ accessible
1551
+ accessibilityRole="adjustable"
1552
+ accessibilityLabel="Hue and saturation"
1553
+ accessibilityState={{ disabled: ctx.disabled }}
1554
+ accessibilityValue={{
1555
+ min: 0,
1556
+ max: 360,
1557
+ now: announced.h,
1558
+ text: `${announced.h}°, ${announced.s}% saturation`,
1559
+ }}
1560
+ accessibilityActions={[
1561
+ { name: 'increment' },
1562
+ { name: 'decrement' },
1563
+ { name: 'saturate', label: 'More saturated' },
1564
+ { name: 'desaturate', label: 'Less saturated' },
1565
+ ]}
1566
+ onAccessibilityAction={onAccessibilityAction}
1567
+ >
1568
+ <View
1569
+ style={{ width: diameter, height: diameter }}
1570
+ className={slots.wheel({ className })}
1571
+ >
1572
+ <Svg width="100%" height="100%" viewBox="0 0 100 100">
1573
+ <Defs>
1574
+ <RadialGradient id={gradientId} cx="50%" cy="50%" r="50%">
1575
+ <Stop offset="0%" stopColor="#ffffff" stopOpacity={1} />
1576
+ <Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
1577
+ </RadialGradient>
1578
+ </Defs>
1579
+ {WHEEL_PATHS.map((wedge, index) => (
1580
+ <Path key={index} d={wedge.d} fill={wedge.fill} />
1581
+ ))}
1582
+ {/* White at the middle falling off to nothing at the rim — the
1583
+ same ramp the square runs across, wrapped around a centre. */}
1584
+ <Circle cx={WHEEL_R} cy={WHEEL_R} r={WHEEL_R} fill={`url(#${gradientId})`} />
1585
+ </Svg>
1586
+ <Animated.View
1587
+ pointerEvents="none"
1588
+ style={[StyleSheet.absoluteFill, dimStyle]}
1589
+ className="bg-black"
1590
+ />
1591
+ </View>
1592
+
1593
+ <Animated.View
1594
+ pointerEvents="none"
1595
+ style={[thumbStyle, { width: thumbSize, height: thumbSize }]}
1596
+ className={slots.thumb({ className: thumbClassName })}
1597
+ >
1598
+ <Animated.View style={thumbFillStyle} className={slots.thumbFill()} />
1599
+ </Animated.View>
1600
+ </View>
1601
+ </GestureDetector>
1602
+ );
1603
+ }
1604
+ );
1605
+
1606
+ ColorPickerWheel.displayName = 'ColorPicker.Wheel';
1607
+
1608
+ export const ColorPicker = Object.assign(ColorPickerRoot, {
1609
+ Field: ColorPickerField,
1610
+ Area: ColorPickerArea,
1611
+ Wheel: ColorPickerWheel,
1612
+ Channel: ColorPickerChannelReadout,
1613
+ Hue: ColorPickerHue,
1614
+ Brightness: ColorPickerBrightness,
1615
+ Alpha: ColorPickerAlpha,
1616
+ Preview: ColorPickerPreview,
1617
+ Swatches: ColorPickerSwatches,
1618
+ });