@djangocfg/widget-visual 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/package.json +106 -0
  4. package/src/design/ColorPalette/ColorPalette.tsx +129 -0
  5. package/src/design/ColorPalette/README.md +34 -0
  6. package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
  7. package/src/design/ColorPalette/components/Swatch.tsx +102 -0
  8. package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
  9. package/src/design/ColorPalette/index.ts +12 -0
  10. package/src/design/ColorPalette/lazy.tsx +21 -0
  11. package/src/design/ColorPalette/types.ts +63 -0
  12. package/src/design/ColorPalette/utils.ts +114 -0
  13. package/src/design/ColorPicker/ColorPicker.tsx +257 -0
  14. package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
  15. package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
  16. package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
  17. package/src/design/ColorPicker/context/index.ts +2 -0
  18. package/src/design/ColorPicker/index.ts +15 -0
  19. package/src/design/ColorPicker/lazy.tsx +24 -0
  20. package/src/design/ColorPicker/lib/color-utils.ts +323 -0
  21. package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
  22. package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
  23. package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
  24. package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
  25. package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
  26. package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
  27. package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
  28. package/src/design/ColorPicker/parts/index.ts +7 -0
  29. package/src/design/ColorPicker/types.ts +77 -0
  30. package/src/gallery/components/Gallery.tsx +182 -0
  31. package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
  32. package/src/gallery/components/compact/index.ts +1 -0
  33. package/src/gallery/components/index.ts +21 -0
  34. package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
  35. package/src/gallery/components/lightbox/index.ts +1 -0
  36. package/src/gallery/components/media/GalleryImage.tsx +105 -0
  37. package/src/gallery/components/media/GalleryMedia.tsx +70 -0
  38. package/src/gallery/components/media/GalleryVideo.tsx +241 -0
  39. package/src/gallery/components/media/index.ts +3 -0
  40. package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
  41. package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
  42. package/src/gallery/components/preview/index.ts +2 -0
  43. package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
  44. package/src/gallery/components/shared/index.ts +1 -0
  45. package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
  46. package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
  47. package/src/gallery/components/thumbnails/index.ts +2 -0
  48. package/src/gallery/gallery.stories.tsx +182 -0
  49. package/src/gallery/hooks/index.ts +23 -0
  50. package/src/gallery/hooks/useGallery.ts +138 -0
  51. package/src/gallery/hooks/useImageDimensions.ts +224 -0
  52. package/src/gallery/hooks/usePinchZoom.ts +239 -0
  53. package/src/gallery/hooks/usePreloadImages.ts +119 -0
  54. package/src/gallery/hooks/useSwipe.ts +87 -0
  55. package/src/gallery/hooks/useVirtualList.ts +129 -0
  56. package/src/gallery/hooks/useZoom.ts +321 -0
  57. package/src/gallery/index.ts +66 -0
  58. package/src/gallery/thumbnails.stories.tsx +127 -0
  59. package/src/gallery/types.ts +185 -0
  60. package/src/gallery/utils/imageAnalysis.ts +52 -0
  61. package/src/gallery/utils/index.ts +16 -0
  62. package/src/gallery/utils/normalizeUrl.ts +31 -0
  63. package/src/index.ts +18 -0
  64. package/src/lottie/LottiePlayer.client.tsx +319 -0
  65. package/src/lottie/index.tsx +66 -0
  66. package/src/lottie/lazy.tsx +65 -0
  67. package/src/lottie/lottie-player.stories.tsx +173 -0
  68. package/src/lottie/types.ts +138 -0
  69. package/src/lottie/useLottie.ts +187 -0
  70. package/src/lottie/usePrefersReducedMotion.ts +46 -0
  71. package/src/marquee/Marquee.tsx +637 -0
  72. package/src/marquee/index.ts +7 -0
  73. package/src/marquee/lazy.tsx +14 -0
  74. package/src/marquee/marquee.stories.tsx +118 -0
  75. package/src/marquee/types.ts +40 -0
  76. package/src/qrcode/QRCode.tsx +468 -0
  77. package/src/qrcode/index.ts +10 -0
  78. package/src/qrcode/lazy.tsx +19 -0
  79. package/src/qrcode/qr-code.stories.tsx +102 -0
  80. package/src/qrcode/types.ts +57 -0
@@ -0,0 +1,114 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ import type { ColorFormat } from './types';
4
+
5
+ /**
6
+ * Parse a `#rrggbb` / `#rgb` hex string into `{ r, g, b }` (0–255).
7
+ * Returns `null` for malformed input.
8
+ */
9
+ function parseHex(hex: string): { r: number; g: number; b: number } | null {
10
+ const cleaned = hex.trim().replace(/^#/, '');
11
+ if (cleaned.length === 3) {
12
+ const [c0, c1, c2] = cleaned;
13
+ if (c0 === undefined || c1 === undefined || c2 === undefined) return null;
14
+ const r = parseInt(c0 + c0, 16);
15
+ const g = parseInt(c1 + c1, 16);
16
+ const b = parseInt(c2 + c2, 16);
17
+ if ([r, g, b].some(Number.isNaN)) return null;
18
+ return { r, g, b };
19
+ }
20
+ if (cleaned.length === 6) {
21
+ const r = parseInt(cleaned.slice(0, 2), 16);
22
+ const g = parseInt(cleaned.slice(2, 4), 16);
23
+ const b = parseInt(cleaned.slice(4, 6), 16);
24
+ if ([r, g, b].some(Number.isNaN)) return null;
25
+ return { r, g, b };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function rgbToHsl(
31
+ r: number,
32
+ g: number,
33
+ b: number,
34
+ ): { h: number; s: number; l: number } {
35
+ const rn = r / 255;
36
+ const gn = g / 255;
37
+ const bn = b / 255;
38
+ const max = Math.max(rn, gn, bn);
39
+ const min = Math.min(rn, gn, bn);
40
+ const l = (max + min) / 2;
41
+ let h = 0;
42
+ let s = 0;
43
+ if (max !== min) {
44
+ const d = max - min;
45
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
46
+ switch (max) {
47
+ case rn:
48
+ h = (gn - bn) / d + (gn < bn ? 6 : 0);
49
+ break;
50
+ case gn:
51
+ h = (bn - rn) / d + 2;
52
+ break;
53
+ case bn:
54
+ h = (rn - gn) / d + 4;
55
+ break;
56
+ }
57
+ h /= 6;
58
+ }
59
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
60
+ }
61
+
62
+ /**
63
+ * Format a hex color into the requested {@link ColorFormat}. Falls
64
+ * back to the original string when the hex is unparseable.
65
+ */
66
+ export function formatColor(hex: string, format: ColorFormat): string {
67
+ if (format === 'hex') return hex.toLowerCase();
68
+ const rgb = parseHex(hex);
69
+ if (!rgb) return hex;
70
+ if (format === 'rgb') return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
71
+ const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
72
+ return `hsl(${h}, ${s}%, ${l}%)`;
73
+ }
74
+
75
+ /**
76
+ * WCAG relative luminance — the same formula `ui-core`'s preset-contrast gate
77
+ * uses (`scripts/check-preset-contrast.mjs`), so a swatch and a theme token
78
+ * are judged by one standard.
79
+ *
80
+ * Note the gamma step: sRGB channels are gamma-encoded, and skipping the
81
+ * linearisation is what makes a naive brightness number disagree with what an
82
+ * eye sees.
83
+ */
84
+ function relativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
85
+ const linear = [r, g, b].map((channel) => {
86
+ const c = channel / 255;
87
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
88
+ });
89
+ return (linear[0] ?? 0) * 0.2126 + (linear[1] ?? 0) * 0.7152 + (linear[2] ?? 0) * 0.0722;
90
+ }
91
+
92
+ /**
93
+ * Return `'light'` when black text reads better against the colour than white
94
+ * does, otherwise `'dark'`.
95
+ *
96
+ * **This compares the two actual contrast ratios rather than testing a
97
+ * brightness threshold.** It used to be Rec. 601 luminance against a
98
+ * hard-coded `> 0.6`, which put pure green (`0.587`) on the wrong side: the
99
+ * swatch got white text at **1.37:1**, where black would have given 15.30:1.
100
+ * AA wants 4.5:1, so that was not a near miss — and it took the whole
101
+ * bright-green band and the mid greys with it (`#999999`: 2.85:1 white vs
102
+ * 7.37:1 black).
103
+ *
104
+ * A threshold is a guess at where the crossover sits. The crossover is
105
+ * computable, so compute it.
106
+ */
107
+ export function getReadableContrast(hex: string): 'light' | 'dark' {
108
+ const rgb = parseHex(hex);
109
+ if (!rgb) return 'dark';
110
+ const luminance = relativeLuminance(rgb);
111
+ const contrastWithBlack = (luminance + 0.05) / 0.05;
112
+ const contrastWithWhite = 1.05 / (luminance + 0.05);
113
+ return contrastWithBlack >= contrastWithWhite ? 'light' : 'dark';
114
+ }
@@ -0,0 +1,257 @@
1
+ 'use client';
2
+
3
+ import { Slot as SlotPrimitive } from '@radix-ui/react-slot';
4
+ import { useDirection as useDirectionPrimitive } from '@radix-ui/react-direction';
5
+ import * as React from 'react';
6
+ import { useComposedRefs } from '@djangocfg/ui-core/lib';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+ import {
9
+ Popover,
10
+ PopoverContent,
11
+ PopoverTrigger,
12
+ } from '@djangocfg/ui-core/components';
13
+ import { VisuallyHiddenInput } from '@djangocfg/ui-core/components';
14
+ import { Button } from '@djangocfg/ui-core/components';
15
+ import { ColorPickerStoreProvider, useStoreContext, useStore } from './context/ColorPickerStore';
16
+ import { ColorPickerProvider, useColorPickerContext } from './context/ColorPickerContext';
17
+ import { hexToRgb, rgbToHsv, rgbToHex } from './lib/color-utils';
18
+ import type { ColorPickerProps, DivProps, StoreState, ColorValue } from './types';
19
+
20
+ const ROOT_NAME = 'ColorPicker';
21
+ const TRIGGER_NAME = 'ColorPickerTrigger';
22
+ const CONTENT_NAME = 'ColorPickerContent';
23
+
24
+ function useLazyRef<T>(init: () => T): React.RefObject<T> {
25
+ const ref = React.useRef<T | null>(null);
26
+ if (ref.current === null) {
27
+ ref.current = init();
28
+ }
29
+ return ref as React.RefObject<T>;
30
+ }
31
+
32
+ function useAsRef<T>(value: T) {
33
+ const ref = React.useRef(value);
34
+ React.useEffect(() => {
35
+ ref.current = value;
36
+ });
37
+ return ref;
38
+ }
39
+
40
+ export function ColorPicker(props: ColorPickerProps) {
41
+ const {
42
+ value: valueProp,
43
+ defaultValue = '#000000',
44
+ onValueChange,
45
+ format: formatProp,
46
+ defaultFormat = 'hex',
47
+ onFormatChange,
48
+ defaultOpen,
49
+ open: openProp,
50
+ onOpenChange,
51
+ name,
52
+ disabled,
53
+ inline,
54
+ readOnly,
55
+ required,
56
+ ...rootProps
57
+ } = props;
58
+
59
+ const colorString = valueProp ?? defaultValue;
60
+ const color = hexToRgb(colorString);
61
+
62
+ const initialState = React.useMemo(() => ({
63
+ color,
64
+ hsv: rgbToHsv(color),
65
+ open: openProp ?? defaultOpen ?? false,
66
+ format: formatProp ?? defaultFormat,
67
+ }), []);
68
+
69
+ return (
70
+ <ColorPickerStoreProvider
71
+ initialColor={initialState.color}
72
+ initialHsv={initialState.hsv}
73
+ initialOpen={initialState.open}
74
+ initialFormat={initialState.format}
75
+ onValueChange={onValueChange}
76
+ onOpenChange={onOpenChange}
77
+ onFormatChange={onFormatChange}
78
+ >
79
+ <ColorPickerImpl
80
+ {...rootProps}
81
+ value={valueProp}
82
+ defaultOpen={defaultOpen}
83
+ open={openProp}
84
+ name={name}
85
+ disabled={disabled}
86
+ inline={inline}
87
+ readOnly={readOnly}
88
+ required={required}
89
+ />
90
+ </ColorPickerStoreProvider>
91
+ );
92
+ }
93
+
94
+ ColorPicker.displayName = ROOT_NAME;
95
+
96
+ interface ColorPickerImplProps extends Omit<ColorPickerProps, 'defaultValue' | 'onValueChange' | 'onOpenChange' | 'format' | 'defaultFormat' | 'onFormatChange'> {}
97
+
98
+ function ColorPickerImpl(props: ColorPickerImplProps) {
99
+ const {
100
+ value: valueProp,
101
+ dir: dirProp,
102
+ defaultOpen,
103
+ open: openProp,
104
+ name,
105
+ ref,
106
+ asChild,
107
+ disabled,
108
+ inline,
109
+ modal,
110
+ readOnly,
111
+ required,
112
+ ...rootProps
113
+ } = props;
114
+
115
+ const store = useStoreContext('ColorPickerImpl');
116
+
117
+ const dir = useDirectionPrimitive(dirProp);
118
+
119
+ const [formTrigger, setFormTrigger] = React.useState<HTMLDivElement | null>(null);
120
+ const composedRef = useComposedRefs(ref, (node: HTMLDivElement | null) => setFormTrigger(node));
121
+ const isFormControl = formTrigger ? !!formTrigger.closest('form') : true;
122
+
123
+ React.useLayoutEffect(() => {
124
+ if (valueProp !== undefined) {
125
+ const currentState = store.getState();
126
+ const color = hexToRgb(valueProp, currentState.color.a);
127
+ const hsv = rgbToHsv(color);
128
+ store.setColor(color);
129
+ store.setHsv(hsv);
130
+ }
131
+ }, [valueProp, store]);
132
+
133
+ React.useLayoutEffect(() => {
134
+ if (openProp !== undefined) {
135
+ store.setOpen(openProp);
136
+ }
137
+ }, [openProp, store]);
138
+
139
+ const contextValue = React.useMemo(() => ({
140
+ dir,
141
+ disabled,
142
+ inline,
143
+ readOnly,
144
+ required,
145
+ }), [dir, disabled, inline, readOnly, required]);
146
+
147
+ const value = useStore((state) => rgbToHex(state.color));
148
+ const open = useStore((state) => state.open);
149
+
150
+ const RootPrimitive = asChild ? SlotPrimitive : 'div';
151
+
152
+ if (inline) {
153
+ return (
154
+ <ColorPickerProvider value={contextValue}>
155
+ <RootPrimitive {...rootProps} ref={composedRef} />
156
+ {isFormControl && (
157
+ <VisuallyHiddenInput
158
+ type="hidden"
159
+ control={formTrigger}
160
+ name={name}
161
+ value={value}
162
+ disabled={disabled}
163
+ readOnly={readOnly}
164
+ required={required}
165
+ />
166
+ )}
167
+ </ColorPickerProvider>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <ColorPickerProvider value={contextValue}>
173
+ <Popover
174
+ defaultOpen={defaultOpen}
175
+ open={open}
176
+ onOpenChange={store.setOpen}
177
+ modal={modal}
178
+ >
179
+ <RootPrimitive {...rootProps} ref={composedRef} />
180
+ {isFormControl && (
181
+ <VisuallyHiddenInput
182
+ type="hidden"
183
+ control={formTrigger}
184
+ name={name}
185
+ value={value}
186
+ disabled={disabled}
187
+ readOnly={readOnly}
188
+ required={required}
189
+ />
190
+ )}
191
+ </Popover>
192
+ </ColorPickerProvider>
193
+ );
194
+ }
195
+
196
+ ColorPickerImpl.displayName = 'ColorPickerImpl';
197
+
198
+ export function ColorPickerTrigger(props: React.ComponentProps<typeof PopoverTrigger> & { asChild?: boolean }) {
199
+ const { asChild, disabled, ...triggerProps } = props;
200
+
201
+ const context = useColorPickerContext(TRIGGER_NAME);
202
+
203
+ const isDisabled = disabled || context.disabled;
204
+
205
+ const TriggerPrimitive = asChild ? SlotPrimitive : Button;
206
+
207
+ if (context.inline) {
208
+ return (
209
+ <TriggerPrimitive
210
+ data-slot="color-picker-trigger"
211
+ disabled={isDisabled}
212
+ {...triggerProps}
213
+ />
214
+ );
215
+ }
216
+
217
+ return (
218
+ <PopoverTrigger asChild disabled={isDisabled}>
219
+ <TriggerPrimitive data-slot="color-picker-trigger" {...triggerProps} />
220
+ </PopoverTrigger>
221
+ );
222
+ }
223
+
224
+ ColorPickerTrigger.displayName = TRIGGER_NAME;
225
+
226
+ export function ColorPickerContent(props: React.ComponentProps<typeof PopoverContent> & { asChild?: boolean }) {
227
+ const { asChild, className, children, ...popoverContentProps } = props;
228
+
229
+ const context = useColorPickerContext(CONTENT_NAME);
230
+
231
+ if (context.inline) {
232
+ const ContentPrimitive = asChild ? SlotPrimitive : 'div';
233
+
234
+ return (
235
+ <ContentPrimitive
236
+ data-slot="color-picker-content"
237
+ {...popoverContentProps}
238
+ className={cn('flex w-[340px] flex-col gap-4 p-4', className)}
239
+ >
240
+ {children}
241
+ </ContentPrimitive>
242
+ );
243
+ }
244
+
245
+ return (
246
+ <PopoverContent
247
+ data-slot="color-picker-content"
248
+ asChild={asChild}
249
+ {...popoverContentProps}
250
+ className={cn('flex w-[340px] flex-col gap-4 p-4', className)}
251
+ >
252
+ {children}
253
+ </PopoverContent>
254
+ );
255
+ }
256
+
257
+ ColorPickerContent.displayName = CONTENT_NAME;
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ ColorPicker,
5
+ ColorPickerTrigger,
6
+ ColorPickerContent,
7
+ ColorPickerArea,
8
+ ColorPickerHueSlider,
9
+ ColorPickerAlphaSlider,
10
+ ColorPickerSwatch,
11
+ ColorPickerFormatSelect,
12
+ ColorPickerInput,
13
+ } from '.';
14
+
15
+ const meta = {
16
+ title: 'Widgets/Visual/ColorPicker',
17
+ component: ColorPicker,
18
+ tags: ['autodocs'],
19
+ parameters: {
20
+ layout: 'centered',
21
+ docs: {
22
+ description: {
23
+ component:
24
+ 'Color picker with popover or inline layout. Supports HEX / RGBA / HSV formats. ' +
25
+ 'Trigger is the swatch + value preview; the popover (or inline panel) hosts the ' +
26
+ 'gradient area, hue/alpha sliders, format select and text input.',
27
+ },
28
+ },
29
+ },
30
+ } satisfies Meta<typeof ColorPicker>;
31
+
32
+ export default meta;
33
+ type Story = StoryObj<typeof meta>;
34
+
35
+ interface HostArgs {
36
+ defaultValue?: string;
37
+ inline?: boolean;
38
+ disabled?: boolean;
39
+ defaultOpen?: boolean;
40
+ }
41
+
42
+ function ColorPickerHost({
43
+ defaultValue = '#3b82f6',
44
+ inline,
45
+ disabled,
46
+ defaultOpen,
47
+ }: HostArgs) {
48
+ const [value, setValue] = useState(defaultValue);
49
+ return (
50
+ <ColorPicker
51
+ value={value}
52
+ onValueChange={setValue}
53
+ inline={inline}
54
+ disabled={disabled}
55
+ defaultOpen={defaultOpen}
56
+ >
57
+ <ColorPickerTrigger asChild>
58
+ <button
59
+ type="button"
60
+ className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1.5 text-sm shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
61
+ disabled={disabled}
62
+ >
63
+ <ColorPickerSwatch />
64
+ <span className="font-mono">{value.toUpperCase()}</span>
65
+ </button>
66
+ </ColorPickerTrigger>
67
+ <ColorPickerContent className="w-[300px]">
68
+ <ColorPickerArea />
69
+ <ColorPickerHueSlider />
70
+ <ColorPickerAlphaSlider />
71
+ <div className="flex items-center gap-2">
72
+ <ColorPickerSwatch />
73
+ <ColorPickerFormatSelect />
74
+ </div>
75
+ <ColorPickerInput />
76
+ </ColorPickerContent>
77
+ </ColorPicker>
78
+ );
79
+ }
80
+
81
+ /** Popover trigger — popover opened by default so the docs page shows the full picker. */
82
+ export const Default: Story = {
83
+ render: () => <ColorPickerHost defaultOpen />,
84
+ };
85
+
86
+ /** Trigger only — click the swatch to open the popover. */
87
+ export const PopoverClosed: Story = {
88
+ render: () => <ColorPickerHost />,
89
+ };
90
+
91
+ /** Inline panel without a popover — full editor mounted in place. */
92
+ export const Inline: Story = {
93
+ render: () => (
94
+ <div className="w-[300px] rounded-lg border border-border bg-card p-4">
95
+ <ColorPicker value="#3b82f6" inline>
96
+ <ColorPickerContent className="w-full p-0">
97
+ <ColorPickerArea />
98
+ <ColorPickerHueSlider />
99
+ <ColorPickerAlphaSlider />
100
+ <div className="flex items-center gap-2">
101
+ <ColorPickerSwatch />
102
+ <ColorPickerFormatSelect />
103
+ </div>
104
+ <ColorPickerInput />
105
+ </ColorPickerContent>
106
+ </ColorPicker>
107
+ </div>
108
+ ),
109
+ };
110
+
111
+ export const Disabled: Story = {
112
+ render: () => <ColorPickerHost disabled defaultValue="#ef4444" />,
113
+ };
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { ColorPickerContextValue } from '../types';
5
+
6
+ const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
7
+
8
+ export function useColorPickerContext(consumerName: string) {
9
+ const context = React.useContext(ColorPickerContext);
10
+ if (!context) {
11
+ throw new Error(`\`${consumerName}\` must be used within ColorPicker`);
12
+ }
13
+ return context;
14
+ }
15
+
16
+ export function ColorPickerProvider({
17
+ children,
18
+ value,
19
+ }: {
20
+ children: React.ReactNode;
21
+ value: ColorPickerContextValue;
22
+ }) {
23
+ return (
24
+ <ColorPickerContext.Provider value={value}>
25
+ {children}
26
+ </ColorPickerContext.Provider>
27
+ );
28
+ }
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { StoreState, Store, ColorValue, HSVColorValue, ColorFormat } from '../types';
5
+ import { rgbToHex, rgbToHsl, rgbToHsv, hsvToRgb as hsvToRgbFn } from '../lib/color-utils';
6
+
7
+ const StoreContext = React.createContext<Store | null>(null);
8
+
9
+ export function useStoreContext(consumerName: string) {
10
+ const context = React.useContext(StoreContext);
11
+ if (!context) {
12
+ throw new Error(`\`${consumerName}\` must be used within ColorPicker`);
13
+ }
14
+ return context;
15
+ }
16
+
17
+ export function useStore<U>(selector: (state: StoreState) => U): U {
18
+ const store = useStoreContext('useStore');
19
+
20
+ const getSnapshot = React.useCallback(
21
+ () => selector(store.getState()),
22
+ [store, selector],
23
+ );
24
+
25
+ return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
26
+ }
27
+
28
+ function useLazyRef<T>(init: () => T): React.RefObject<T> {
29
+ const ref = React.useRef<T | null>(null);
30
+ if (ref.current === null) {
31
+ ref.current = init();
32
+ }
33
+ return ref as React.RefObject<T>;
34
+ }
35
+
36
+ interface ColorPickerStoreProviderProps {
37
+ children: React.ReactNode;
38
+ initialColor: ColorValue;
39
+ initialHsv: HSVColorValue;
40
+ initialOpen: boolean;
41
+ initialFormat: ColorFormat;
42
+ onValueChange?: (value: string) => void;
43
+ onOpenChange?: (open: boolean) => void;
44
+ onFormatChange?: (format: ColorFormat) => void;
45
+ }
46
+
47
+ export function ColorPickerStoreProvider(props: ColorPickerStoreProviderProps) {
48
+ const {
49
+ children,
50
+ initialColor,
51
+ initialHsv,
52
+ initialOpen,
53
+ initialFormat,
54
+ onValueChange,
55
+ onOpenChange,
56
+ onFormatChange,
57
+ } = props;
58
+
59
+ const listenersRef = useLazyRef(() => new Set<() => void>());
60
+ const stateRef = useLazyRef<StoreState>(() => ({
61
+ color: initialColor,
62
+ hsv: initialHsv,
63
+ open: initialOpen,
64
+ format: initialFormat,
65
+ }));
66
+
67
+ const callbacksRef = React.useRef({ onValueChange, onOpenChange, onFormatChange });
68
+ React.useEffect(() => {
69
+ callbacksRef.current = { onValueChange, onOpenChange, onFormatChange };
70
+ });
71
+
72
+ const store = React.useMemo<Store>(() => {
73
+ return {
74
+ subscribe: (cb) => {
75
+ listenersRef.current.add(cb);
76
+ return () => listenersRef.current.delete(cb);
77
+ },
78
+ getState: () => stateRef.current,
79
+ setColor: (value: ColorValue) => {
80
+ if (Object.is(stateRef.current.color, value)) return;
81
+
82
+ const prevState = { ...stateRef.current };
83
+ stateRef.current.color = value;
84
+
85
+ const cb = callbacksRef.current.onValueChange;
86
+ if (cb) {
87
+ const colorString = colorToString(value, prevState.format);
88
+ cb(colorString);
89
+ }
90
+
91
+ store.notify();
92
+ },
93
+ setHsv: (value: HSVColorValue) => {
94
+ if (Object.is(stateRef.current.hsv, value)) return;
95
+
96
+ const prevState = { ...stateRef.current };
97
+ stateRef.current.hsv = value;
98
+
99
+ const cb = callbacksRef.current.onValueChange;
100
+ if (cb) {
101
+ const colorValue = hsvToRgb(value);
102
+ const colorString = colorToString(colorValue, prevState.format);
103
+ cb(colorString);
104
+ }
105
+
106
+ store.notify();
107
+ },
108
+ setOpen: (value: boolean) => {
109
+ if (Object.is(stateRef.current.open, value)) return;
110
+
111
+ stateRef.current.open = value;
112
+
113
+ const cb = callbacksRef.current.onOpenChange;
114
+ if (cb) cb(value);
115
+
116
+ store.notify();
117
+ },
118
+ setFormat: (value: ColorFormat) => {
119
+ if (Object.is(stateRef.current.format, value)) return;
120
+
121
+ stateRef.current.format = value;
122
+
123
+ const cb = callbacksRef.current.onFormatChange;
124
+ if (cb) cb(value);
125
+
126
+ store.notify();
127
+ },
128
+ notify: () => {
129
+ for (const cb of listenersRef.current) {
130
+ cb();
131
+ }
132
+ },
133
+ };
134
+ }, [listenersRef, stateRef]);
135
+
136
+ return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
137
+ }
138
+
139
+ function colorToString(color: ColorValue, format: ColorFormat): string {
140
+ switch (format) {
141
+ case 'hex':
142
+ return rgbToHex(color);
143
+ case 'rgb':
144
+ return color.a < 1
145
+ ? `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`
146
+ : `rgb(${color.r}, ${color.g}, ${color.b})`;
147
+ case 'hsl': {
148
+ const hsl = rgbToHsl(color);
149
+ return color.a < 1
150
+ ? `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${color.a})`
151
+ : `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
152
+ }
153
+ case 'hsb': {
154
+ const hsv = rgbToHsv(color);
155
+ return color.a < 1
156
+ ? `hsba(${hsv.h}, ${hsv.s}%, ${hsv.v}%, ${color.a})`
157
+ : `hsb(${hsv.h}, ${hsv.s}%, ${hsv.v}%)`;
158
+ }
159
+ default:
160
+ return rgbToHex(color);
161
+ }
162
+ }
163
+
164
+ function hsvToRgb(hsv: HSVColorValue): ColorValue {
165
+ return hsvToRgbFn(hsv);
166
+ }
@@ -0,0 +1,2 @@
1
+ export { ColorPickerStoreProvider, useStore, useStoreContext } from './ColorPickerStore';
2
+ export { ColorPickerProvider, useColorPickerContext } from './ColorPickerContext';
@@ -0,0 +1,15 @@
1
+ export {
2
+ ColorPicker,
3
+ ColorPickerTrigger,
4
+ ColorPickerContent,
5
+ } from './ColorPicker';
6
+ export {
7
+ ColorPickerArea,
8
+ ColorPickerHueSlider,
9
+ ColorPickerAlphaSlider,
10
+ ColorPickerSwatch,
11
+ ColorPickerEyeDropper,
12
+ ColorPickerFormatSelect,
13
+ ColorPickerInput,
14
+ } from './parts';
15
+ export type { ColorPickerProps, ColorPickerInputProps } from './types';