@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- 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,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';
|