@kolkrabbi/kol-component 0.8.0 → 0.10.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.
- package/package.json +2 -2
- package/src/atoms/Button.jsx +5 -1
- package/src/atoms/FullscreenOverlay.jsx +11 -9
- package/src/atoms/SearchInput.jsx +66 -1
- package/src/hooks/colorMath.js +214 -0
- package/src/hooks/useEyedropper.js +124 -0
- package/src/index.js +9 -0
- package/src/molecules/Accordion.jsx +4 -2
- package/src/molecules/Dropdown.jsx +6 -2
- package/src/molecules/MenuItem.jsx +1 -1
- package/src/molecules/PaletteHarmonyWheel.jsx +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@floating-ui/react": "^0.27.19",
|
|
25
25
|
"embla-carousel-react": "^8.6.0",
|
|
26
|
-
"@kolkrabbi/kol-icons": "0.
|
|
26
|
+
"@kolkrabbi/kol-icons": "0.6.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -10,7 +10,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
10
10
|
*
|
|
11
11
|
* @param {Object} props
|
|
12
12
|
* @param {ReactNode} props.children - Button content
|
|
13
|
-
* @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'control'} props.variant - Visual variant. `control` is an alias for `ghost` (legacy call-sites).
|
|
13
|
+
* @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'danger'|'grey'|'control'} props.variant - Visual variant. `danger` is the destructive treatment (--ui-error fill); `control` is an alias for `ghost` (legacy call-sites).
|
|
14
14
|
* @param {'sm'|'md'|'lg'} props.size - Button size (default: 'md')
|
|
15
15
|
* @param {string} props.iconLeft - Icon name to display on the left
|
|
16
16
|
* @param {string} props.iconRight - Icon name to display on the right
|
|
@@ -71,6 +71,10 @@ const Button = ({
|
|
|
71
71
|
? 'kol-btn-outline'
|
|
72
72
|
: resolvedVariant === 'ghost'
|
|
73
73
|
? 'kol-btn-ghost'
|
|
74
|
+
: resolvedVariant === 'danger'
|
|
75
|
+
? 'kol-btn-danger'
|
|
76
|
+
: resolvedVariant === 'grey'
|
|
77
|
+
? 'kol-btn-grey'
|
|
74
78
|
: 'kol-btn-secondary'
|
|
75
79
|
|
|
76
80
|
// Add size class — pairs the padding rule with its mono type class.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
2
|
|
|
3
|
-
export default function FullscreenOverlay({ open, onClose, children }) {
|
|
3
|
+
export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
|
|
4
4
|
const sheetRef = useRef(null)
|
|
5
5
|
|
|
6
6
|
useEffect(() => {
|
|
@@ -24,14 +24,16 @@ export default function FullscreenOverlay({ open, onClose, children }) {
|
|
|
24
24
|
return (
|
|
25
25
|
<div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
|
|
26
26
|
<div ref={sheetRef} className="kol-overlay-sheet">
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
{closeButton && (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className="kol-overlay-close"
|
|
31
|
+
onClick={onClose}
|
|
32
|
+
aria-label="Close"
|
|
33
|
+
>
|
|
34
|
+
×
|
|
35
|
+
</button>
|
|
36
|
+
)}
|
|
35
37
|
{children}
|
|
36
38
|
</div>
|
|
37
39
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
1
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -7,6 +8,14 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
7
8
|
* icon, then two mutually exclusive trailing affordances: a caller-authored
|
|
8
9
|
* shortcut kbd chip while the value is empty, a clear × while it isn't.
|
|
9
10
|
*
|
|
11
|
+
* `expanding` is the third body plan (after the shell and `bare`): a round
|
|
12
|
+
* icon-button pill that animates open into an inline field — the /work
|
|
13
|
+
* navbar pattern, extracted from kol-content's WorkViewToggle (2026-07-15).
|
|
14
|
+
* `open` is controllable so a parent can choreograph siblings around it
|
|
15
|
+
* (collapse a toggle, reveal a close button); uncontrolled works too.
|
|
16
|
+
* Escape reports `onOpenChange(false)` — clearing on close stays the
|
|
17
|
+
* parent's call.
|
|
18
|
+
*
|
|
10
19
|
* Always controlled (`value` defaults to ''). Controlled + no onChange =
|
|
11
20
|
* deliberate display-only → readOnly, same convention as Input. Extra props
|
|
12
21
|
* (onKeyDown, autoFocus, role, aria-*) spread onto the inner <input> —
|
|
@@ -23,13 +32,19 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
23
32
|
* @param {string} shortcutHint caller-authored kbd chip (e.g. "⌘K"); shown while the value is empty
|
|
24
33
|
* @param {Function} onFocus (event) => void — input focus
|
|
25
34
|
* @param {string} size 'sm' | 'md' — kol-control size + matched mono type class
|
|
26
|
-
* @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare)
|
|
35
|
+
* @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare/expanding)
|
|
27
36
|
* @param {boolean} bare borderless inline field for overlay panels (full width, no shell chrome; keeps icon/clear/chip slots)
|
|
37
|
+
* @param {boolean} expanding round icon-button pill that animates open into an inline field
|
|
38
|
+
* @param {boolean} open (expanding) controlled open state; omit for internal state
|
|
39
|
+
* @param {Function} onOpenChange (expanding) (bool) => void — trigger click / Escape
|
|
40
|
+
* @param {number} expandedWidth (expanding) open pill width in px (default 280)
|
|
41
|
+
* @param {string} triggerLabel (expanding) aria-label on the icon trigger (default 'Open search')
|
|
28
42
|
* @param {string} className extra classes on the shell
|
|
29
43
|
*/
|
|
30
44
|
|
|
31
45
|
const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
|
|
32
46
|
const ICON_SIZE = { sm: 14, md: 14 }
|
|
47
|
+
const CUBIC_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
|
|
33
48
|
|
|
34
49
|
export default function SearchInput({
|
|
35
50
|
value = '',
|
|
@@ -41,9 +56,59 @@ export default function SearchInput({
|
|
|
41
56
|
size = 'md',
|
|
42
57
|
variant = 'filled',
|
|
43
58
|
bare = false,
|
|
59
|
+
expanding = false,
|
|
60
|
+
open,
|
|
61
|
+
onOpenChange,
|
|
62
|
+
expandedWidth = 280,
|
|
63
|
+
triggerLabel = 'Open search',
|
|
44
64
|
className = '',
|
|
45
65
|
...inputProps
|
|
46
66
|
}) {
|
|
67
|
+
/* expanding body plan — hooks stay unconditional (expanding never flips at runtime) */
|
|
68
|
+
const [internalOpen, setInternalOpen] = useState(false)
|
|
69
|
+
const inputRef = useRef(null)
|
|
70
|
+
const isOpen = open !== undefined ? open : internalOpen
|
|
71
|
+
const setOpen = (next) => {
|
|
72
|
+
if (open === undefined) setInternalOpen(next)
|
|
73
|
+
onOpenChange?.(next)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (expanding && isOpen) inputRef.current?.focus()
|
|
78
|
+
}, [expanding, isOpen])
|
|
79
|
+
|
|
80
|
+
if (expanding) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={`flex items-center bg-fg-04 rounded-full h-9 ${className}`.trim()}
|
|
84
|
+
style={{ width: isOpen ? expandedWidth : 36, transition: `width 600ms ${CUBIC_EASE}` }}
|
|
85
|
+
>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={`flex items-center justify-center w-9 h-9 rounded-full text-auto flex-shrink-0 border border-transparent ${isOpen ? '' : 'transition-colors hover:border-fg-12'}`}
|
|
89
|
+
onClick={() => !isOpen && setOpen(true)}
|
|
90
|
+
aria-label={triggerLabel}
|
|
91
|
+
aria-expanded={isOpen}
|
|
92
|
+
>
|
|
93
|
+
<Icon name="search" size={16} className="text-fg-80" />
|
|
94
|
+
</button>
|
|
95
|
+
{isOpen && (
|
|
96
|
+
<input
|
|
97
|
+
ref={inputRef}
|
|
98
|
+
type="search"
|
|
99
|
+
value={value ?? ''}
|
|
100
|
+
onChange={onChange}
|
|
101
|
+
readOnly={!onChange || undefined}
|
|
102
|
+
placeholder={placeholder}
|
|
103
|
+
spellCheck={false}
|
|
104
|
+
className="bg-transparent outline-none kol-helper-14 flex-1 text-fg-80 caret-current pr-4 min-w-0 appearance-none [&::-webkit-search-cancel-button]:hidden"
|
|
105
|
+
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
|
|
106
|
+
{...inputProps}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
47
112
|
const shellCls = [
|
|
48
113
|
bare
|
|
49
114
|
? 'flex w-full gap-2.5 px-4 py-3'
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* colorMath — HSL/hex conversion + harmony generation for the KOL color
|
|
3
|
+
* pickers (SpectrumControls, PaletteHarmonyWheel).
|
|
4
|
+
*
|
|
5
|
+
* Plain functions, not a hook — it lives in src/hooks/ because that is the
|
|
6
|
+
* taxonomy's only non-component folder (same rationale as cssVar.js).
|
|
7
|
+
*
|
|
8
|
+
* Two harmony paths:
|
|
9
|
+
* - Deterministic — `harmonyColors(hue, harmony, {saturation, lightness})`
|
|
10
|
+
* / `generateHarmony(baseHex, harmony)`: role-offset hues off a base,
|
|
11
|
+
* no randomness, so dragging the harmony wheel is smooth + repeatable.
|
|
12
|
+
* - Seeded/jittered — `seedHarmony(baseHex, mode)`: slot 0 = the base,
|
|
13
|
+
* slots 1–5 derived via a mode with small random jitter, so consecutive
|
|
14
|
+
* "randomize" clicks produce related-but-different variants around a seed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* ── HSL / hex conversion ─────────────────────────────────────────────── */
|
|
18
|
+
|
|
19
|
+
/** '#RRGGBB' → { h: 0–360, s: 0–100, l: 0–100 }. */
|
|
20
|
+
export function hexToHsl(hex) {
|
|
21
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255
|
|
22
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255
|
|
23
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255
|
|
24
|
+
const max = Math.max(r, g, b)
|
|
25
|
+
const min = Math.min(r, g, b)
|
|
26
|
+
let h = 0, s = 0
|
|
27
|
+
const l = (max + min) / 2
|
|
28
|
+
if (max !== min) {
|
|
29
|
+
const d = max - min
|
|
30
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
31
|
+
switch (max) {
|
|
32
|
+
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
|
33
|
+
case g: h = (b - r) / d + 2; break
|
|
34
|
+
case b: h = (r - g) / d + 4; break
|
|
35
|
+
}
|
|
36
|
+
h *= 60
|
|
37
|
+
}
|
|
38
|
+
return { h, s: s * 100, l: l * 100 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** (h: 0–360, s: 0–100, l: 0–100) → uppercase '#RRGGBB'. */
|
|
42
|
+
export function hslToHex(h, s, l) {
|
|
43
|
+
h = normHue(h)
|
|
44
|
+
s = Math.max(0, Math.min(100, s)) / 100
|
|
45
|
+
l = Math.max(0, Math.min(100, l)) / 100
|
|
46
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
47
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
48
|
+
const m = l - c / 2
|
|
49
|
+
let r = 0, g = 0, b = 0
|
|
50
|
+
if (h < 60) { r = c; g = x; b = 0 }
|
|
51
|
+
else if (h < 120) { r = x; g = c; b = 0 }
|
|
52
|
+
else if (h < 180) { r = 0; g = c; b = x }
|
|
53
|
+
else if (h < 240) { r = 0; g = x; b = c }
|
|
54
|
+
else if (h < 300) { r = x; g = 0; b = c }
|
|
55
|
+
else { r = c; g = 0; b = x }
|
|
56
|
+
const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
|
|
57
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** RGB components (0–255) → uppercase '#RRGGBB'. Shared with the eyedropper. */
|
|
61
|
+
export function rgbToHex(r, g, b) {
|
|
62
|
+
return '#' + [r, g, b]
|
|
63
|
+
.map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
|
|
64
|
+
.join('')
|
|
65
|
+
.toUpperCase()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wrap any hue into [0, 360). */
|
|
69
|
+
export const normHue = (h) => ((h % 360) + 360) % 360
|
|
70
|
+
|
|
71
|
+
/* ── Harmony schemes ──────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hue offsets for the five palette roles (Primary / Secondary / Light /
|
|
75
|
+
* Dark / Accent) relative to the base hue. The wheel draws a satellite
|
|
76
|
+
* marker per unique offset; the generators emit one color per role.
|
|
77
|
+
*/
|
|
78
|
+
export const HARMONIES = [
|
|
79
|
+
{ id: 'analogous', label: 'Analogous', roleOffsets: [0, -30, -15, 15, 30] },
|
|
80
|
+
{ id: 'complementary', label: 'Complementary', roleOffsets: [0, 0, 0, 180, 180] },
|
|
81
|
+
{ id: 'split', label: 'Split complementary', roleOffsets: [0, 150, 210, 0, 150] },
|
|
82
|
+
{ id: 'triadic', label: 'Triadic', roleOffsets: [0, 120, 240, 0, 120] },
|
|
83
|
+
{ id: 'tetradic', label: 'Tetradic', roleOffsets: [0, 90, 180, 270, 0] },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
/** Resolve a harmony id string OR a harmony object to the object. */
|
|
87
|
+
export const harmonyById = (harmony) => {
|
|
88
|
+
if (harmony && typeof harmony === 'object') return harmony
|
|
89
|
+
return HARMONIES.find((h) => h.id === harmony) ?? HARMONIES[0]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deterministic role colors for a harmony, driven by a hue + fixed S/L.
|
|
94
|
+
* Preserves saturation/lightness across roles (only hue rotates), so the
|
|
95
|
+
* output tracks the wheel's satellite markers exactly.
|
|
96
|
+
*
|
|
97
|
+
* @param {number} hue base hue, 0–360
|
|
98
|
+
* @param {string|object} harmony harmony id or object
|
|
99
|
+
* @param {{saturation?: number, lightness?: number}} opts base S/L (0–100)
|
|
100
|
+
* @returns {string[]} one '#RRGGBB' per role offset
|
|
101
|
+
*/
|
|
102
|
+
export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 } = {}) {
|
|
103
|
+
const { roleOffsets } = harmonyById(harmony)
|
|
104
|
+
return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deterministic role colors derived from a base hex (S/L taken from the hex).
|
|
109
|
+
* Convenience wrapper over `harmonyColors` for callers holding a color, not
|
|
110
|
+
* a hue.
|
|
111
|
+
*/
|
|
112
|
+
export function generateHarmony(baseHex, harmony) {
|
|
113
|
+
const { h, s, l } = hexToHsl(baseHex)
|
|
114
|
+
return harmonyColors(h, harmony, { saturation: s, lightness: l })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── Seeded (jittered) modes ──────────────────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
const jitter = (range) => (Math.random() - 0.5) * 2 * range
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Seed-based generators — slot 0 = the base, slots 1–5 derived via the mode.
|
|
123
|
+
* Each call jitters parameters so repeated "randomize" produces visibly
|
|
124
|
+
* different but harmonically-related variants around the same seed.
|
|
125
|
+
*/
|
|
126
|
+
const SEED_MODES = {
|
|
127
|
+
random: (base) => {
|
|
128
|
+
const { h, s, l } = hexToHsl(base)
|
|
129
|
+
return [
|
|
130
|
+
base,
|
|
131
|
+
hslToHex(normHue(h + Math.random() * 360), s, l),
|
|
132
|
+
hslToHex(normHue(h + Math.random() * 360), s, l),
|
|
133
|
+
hslToHex(h, s, Math.max(10, l - 25 + jitter(10))),
|
|
134
|
+
hslToHex(h, s, Math.min(90, l + 25 + jitter(10))),
|
|
135
|
+
hslToHex(h, s * (0.4 + Math.random() * 0.4), l),
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
monochromatic: (base) => {
|
|
140
|
+
const { h, s, l } = hexToHsl(base)
|
|
141
|
+
const j = 8
|
|
142
|
+
return [
|
|
143
|
+
base,
|
|
144
|
+
hslToHex(h, s, Math.max(10, l - 30 + jitter(j))),
|
|
145
|
+
hslToHex(h, s, Math.max(20, l - 15 + jitter(j))),
|
|
146
|
+
hslToHex(h, s, Math.min(85, l + 15 + jitter(j))),
|
|
147
|
+
hslToHex(h, s, Math.min(95, l + 30 + jitter(j))),
|
|
148
|
+
hslToHex(h, s, Math.min(98, l + 45 + jitter(j))),
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
analogous: (base) => {
|
|
153
|
+
const { h, s, l } = hexToHsl(base)
|
|
154
|
+
return [
|
|
155
|
+
base,
|
|
156
|
+
hslToHex(normHue(h - 30 + jitter(8)), s, l),
|
|
157
|
+
hslToHex(normHue(h - 15 + jitter(8)), s, l),
|
|
158
|
+
hslToHex(normHue(h + 15 + jitter(8)), s, l),
|
|
159
|
+
hslToHex(normHue(h + 30 + jitter(8)), s, l),
|
|
160
|
+
hslToHex(normHue(h + 45 + jitter(8)), s, l),
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
complementary: (base) => {
|
|
165
|
+
const { h, s, l } = hexToHsl(base)
|
|
166
|
+
return [
|
|
167
|
+
base,
|
|
168
|
+
hslToHex(h, s, Math.max(15, l - 25 + jitter(8))),
|
|
169
|
+
hslToHex(h, s, Math.min(85, l + 25 + jitter(8))),
|
|
170
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, l),
|
|
171
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 15 + jitter(8))),
|
|
172
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.min(85, l + 15 + jitter(8))),
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
triadic: (base) => {
|
|
177
|
+
const { h, s, l } = hexToHsl(base)
|
|
178
|
+
return [
|
|
179
|
+
base,
|
|
180
|
+
hslToHex(normHue(h + 120 + jitter(10)), s, l),
|
|
181
|
+
hslToHex(normHue(h + 240 + jitter(10)), s, l),
|
|
182
|
+
hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
|
|
183
|
+
hslToHex(normHue(h + 120 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
|
|
184
|
+
hslToHex(normHue(h + 240 + jitter(10)), s, Math.min(85, l + 20 + jitter(8))),
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
doubleComplementary: (base) => {
|
|
189
|
+
const { h, s, l } = hexToHsl(base)
|
|
190
|
+
return [
|
|
191
|
+
base,
|
|
192
|
+
hslToHex(normHue(h + 90 + jitter(10)), s, l),
|
|
193
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, l),
|
|
194
|
+
hslToHex(normHue(h + 270 + jitter(10)), s, l),
|
|
195
|
+
hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
|
|
196
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The available seed-mode ids (for a consumer's mode selector). */
|
|
202
|
+
export const SEED_MODE_IDS = Object.keys(SEED_MODES)
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate a jittered 6-color palette from a seed hex.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} baseHex the seed color, occupies slot 0
|
|
208
|
+
* @param {string} mode one of SEED_MODE_IDS (falls back to 'random')
|
|
209
|
+
* @returns {string[]} six '#RRGGBB' values
|
|
210
|
+
*/
|
|
211
|
+
export function seedHarmony(baseHex, mode = 'random') {
|
|
212
|
+
const gen = SEED_MODES[mode] ?? SEED_MODES.random
|
|
213
|
+
return gen(baseHex)
|
|
214
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEyedropper — cross-browser color sampling.
|
|
3
|
+
*
|
|
4
|
+
* Primary path is the native `window.EyeDropper` API (Chromium): one call,
|
|
5
|
+
* samples any pixel on screen, returns an sRGB hex. Where it is unavailable
|
|
6
|
+
* (Firefox, Safari) the hook falls back to an injected `fallback` sampler —
|
|
7
|
+
* e.g. `pickFromCanvasElement`, which samples a pixel from a <canvas> the
|
|
8
|
+
* consumer renders. The DS stays decoupled: it never reaches into an app's
|
|
9
|
+
* canvas/scene — the consumer supplies the fallback.
|
|
10
|
+
*
|
|
11
|
+
* Pairs with the EyedropPick affordance in SwatchControls: gate the button on
|
|
12
|
+
* `supported`, wire the button's `onPick` to `pick`.
|
|
13
|
+
*
|
|
14
|
+
* const { supported, pick } = useEyedropper({ fallback })
|
|
15
|
+
* // supported → render the eyedropper button
|
|
16
|
+
* const hex = await pick() // '#RRGGBB' or null (cancelled / unavailable)
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
19
|
+
import { rgbToHex } from './colorMath.js'
|
|
20
|
+
|
|
21
|
+
/* Native support probe — read at call time so it is correct even before the
|
|
22
|
+
* post-mount state (below) has settled. */
|
|
23
|
+
function hasNativeEyeDropper() {
|
|
24
|
+
return typeof window !== 'undefined' && 'EyeDropper' in window
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generic canvas-region sampler — the reusable core of a canvas eyedropper.
|
|
29
|
+
* Sets a crosshair cursor on `canvas`, awaits one pointer press on it, reads
|
|
30
|
+
* the pixel under the cursor via getImageData, and resolves the hex. Escape,
|
|
31
|
+
* a press outside the canvas bounds, or an aborted `signal` resolve to null.
|
|
32
|
+
*
|
|
33
|
+
* Backing-store aware: scales client coords by canvas.width/height ÷ CSS box,
|
|
34
|
+
* so a HiDPI or CSS-scaled canvas still samples the right pixel.
|
|
35
|
+
*
|
|
36
|
+
* @param {HTMLCanvasElement} canvas the canvas to sample from
|
|
37
|
+
* @param {{signal?: AbortSignal}} [opts]
|
|
38
|
+
* @returns {Promise<string|null>} uppercase '#RRGGBB' or null
|
|
39
|
+
*/
|
|
40
|
+
export async function pickFromCanvasElement(canvas, { signal } = {}) {
|
|
41
|
+
if (!canvas || typeof canvas.getContext !== 'function') return null
|
|
42
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
43
|
+
if (!ctx) return null
|
|
44
|
+
if (signal?.aborted) return null
|
|
45
|
+
|
|
46
|
+
const prevCursor = canvas.style.cursor
|
|
47
|
+
canvas.style.cursor = 'crosshair'
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
canvas.removeEventListener('pointerdown', onDown, true)
|
|
52
|
+
document.removeEventListener('keydown', onKey, true)
|
|
53
|
+
signal?.removeEventListener('abort', onAbort)
|
|
54
|
+
canvas.style.cursor = prevCursor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const onDown = (e) => {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
e.stopPropagation()
|
|
60
|
+
const rect = canvas.getBoundingClientRect()
|
|
61
|
+
const sx = rect.width ? canvas.width / rect.width : 1
|
|
62
|
+
const sy = rect.height ? canvas.height / rect.height : 1
|
|
63
|
+
const vx = Math.floor((e.clientX - rect.left) * sx)
|
|
64
|
+
const vy = Math.floor((e.clientY - rect.top) * sy)
|
|
65
|
+
if (vx < 0 || vy < 0 || vx >= canvas.width || vy >= canvas.height) {
|
|
66
|
+
cleanup(); resolve(null); return
|
|
67
|
+
}
|
|
68
|
+
let data
|
|
69
|
+
try { data = ctx.getImageData(vx, vy, 1, 1).data }
|
|
70
|
+
catch { cleanup(); resolve(null); return } /* tainted canvas */
|
|
71
|
+
cleanup()
|
|
72
|
+
resolve(rgbToHex(data[0], data[1], data[2]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const onKey = (e) => {
|
|
76
|
+
if (e.key === 'Escape') { e.preventDefault(); cleanup(); resolve(null) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onAbort = () => { cleanup(); resolve(null) }
|
|
80
|
+
|
|
81
|
+
/* Capture phase so we beat the consumer's own canvas handlers. */
|
|
82
|
+
canvas.addEventListener('pointerdown', onDown, true)
|
|
83
|
+
document.addEventListener('keydown', onKey, true)
|
|
84
|
+
signal?.addEventListener('abort', onAbort)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {{fallback?: (opts?: {signal?: AbortSignal}) => Promise<string|null>}} [options]
|
|
90
|
+
* fallback — sampler used when the native EyeDropper API is absent. Return
|
|
91
|
+
* a hex or null. Without a fallback, unsupported browsers report
|
|
92
|
+
* `supported: false` and `pick` resolves null.
|
|
93
|
+
* @returns {{supported: boolean, nativeSupported: boolean,
|
|
94
|
+
* pick: (opts?: {signal?: AbortSignal}) => Promise<string|null>}}
|
|
95
|
+
*/
|
|
96
|
+
export function useEyedropper({ fallback } = {}) {
|
|
97
|
+
/* Resolved after mount so SSR / first paint stays stable (starts false,
|
|
98
|
+
* flips true only where the native API exists). */
|
|
99
|
+
const [nativeSupported, setNativeSupported] = useState(false)
|
|
100
|
+
useEffect(() => { setNativeSupported(hasNativeEyeDropper()) }, [])
|
|
101
|
+
|
|
102
|
+
const fallbackRef = useRef(fallback)
|
|
103
|
+
fallbackRef.current = fallback
|
|
104
|
+
|
|
105
|
+
const supported = nativeSupported || !!fallback
|
|
106
|
+
|
|
107
|
+
const pick = useCallback(async ({ signal } = {}) => {
|
|
108
|
+
if (hasNativeEyeDropper()) {
|
|
109
|
+
try {
|
|
110
|
+
const res = await new window.EyeDropper().open(signal ? { signal } : undefined)
|
|
111
|
+
return res?.sRGBHex ? res.sRGBHex.toUpperCase() : null
|
|
112
|
+
} catch {
|
|
113
|
+
return null /* user cancelled (Escape) rejects the promise */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (fallbackRef.current) return (await fallbackRef.current({ signal })) ?? null
|
|
117
|
+
return null
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
return useMemo(
|
|
121
|
+
() => ({ supported, nativeSupported, pick }),
|
|
122
|
+
[supported, nativeSupported, pick],
|
|
123
|
+
)
|
|
124
|
+
}
|
package/src/index.js
CHANGED
|
@@ -76,6 +76,7 @@ export { default as MediaRow } from './molecules/MediaRow.jsx'
|
|
|
76
76
|
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
77
77
|
export { MenuPopover } from './molecules/MenuPopover.jsx'
|
|
78
78
|
export { ModalProvider, useModal } from './molecules/Modal.jsx'
|
|
79
|
+
export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
|
|
79
80
|
export { default as PropertyInput } from './molecules/PropertyInput.jsx'
|
|
80
81
|
export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
|
|
81
82
|
export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
|
|
@@ -123,4 +124,12 @@ export { default as useReveal } from './hooks/useReveal.js'
|
|
|
123
124
|
export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
124
125
|
export { default as useTilt } from './hooks/useTilt.js'
|
|
125
126
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
127
|
+
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
126
128
|
export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
|
|
129
|
+
|
|
130
|
+
// color math (support module — HSL/hex conversion + harmony generation)
|
|
131
|
+
export {
|
|
132
|
+
hexToHsl, hslToHex, rgbToHex, normHue,
|
|
133
|
+
HARMONIES, harmonyById, harmonyColors, generateHarmony,
|
|
134
|
+
SEED_MODE_IDS, seedHarmony,
|
|
135
|
+
} from './hooks/colorMath.js'
|
|
@@ -38,8 +38,10 @@ export function AccordionPanel({
|
|
|
38
38
|
onClick={toggle}
|
|
39
39
|
aria-expanded={open}
|
|
40
40
|
>
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
{/* no text-transform (casing law 2026-07-15): titles render as authored —
|
|
42
|
+
* kol-helper-12 already carries the tracked-caps letterspacing */}
|
|
43
|
+
<span className="kol-accordion-title kol-helper-12">{title}</span>
|
|
44
|
+
{meta && <span className="kol-accordion-meta kol-helper-12 text-fg-48 ml-auto mr-3">{meta}</span>}
|
|
43
45
|
<span className="kol-accordion-chevron kol-helper-16 text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
|
|
44
46
|
</button>
|
|
45
47
|
{open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
|
|
@@ -13,6 +13,8 @@ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
|
|
|
13
13
|
*
|
|
14
14
|
* variant="primary" (default) — filled trigger; open panel continues the
|
|
15
15
|
* same fill (one piece: no border, no gap, hairline divider inside)
|
|
16
|
+
* variant="grey" — oq-12 filled trigger (opaque per the fill
|
|
17
|
+
* law); panel continues it
|
|
16
18
|
* variant="outline" — bordered trigger; open panel carries the
|
|
17
19
|
* same border, trigger's bottom edge acts as the divider
|
|
18
20
|
*
|
|
@@ -110,7 +112,9 @@ const Dropdown = ({
|
|
|
110
112
|
|
|
111
113
|
const triggerCls = [
|
|
112
114
|
'kol-btn',
|
|
113
|
-
|
|
115
|
+
/* grey is dropdown-only chrome (2026-07-15) — no kol-btn-* class so it
|
|
116
|
+
* never leaks into Button's variant set */
|
|
117
|
+
resolvedVariant === 'grey' ? 'kol-dd-trigger--grey' : `kol-btn-${resolvedVariant}`,
|
|
114
118
|
`kol-btn-${resolvedSize}`,
|
|
115
119
|
SIZE_TYPE[resolvedSize],
|
|
116
120
|
'kol-dd-trigger',
|
|
@@ -154,7 +158,7 @@ const Dropdown = ({
|
|
|
154
158
|
focus={false}
|
|
155
159
|
className={`kol-dd-panel kol-dd-panel--${resolvedVariant}`}
|
|
156
160
|
>
|
|
157
|
-
{resolvedVariant === 'primary' && <div className="kol-dd-div" />}
|
|
161
|
+
{(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
|
|
158
162
|
|
|
159
163
|
<div className="flex max-h-[300px] flex-col items-stretch overflow-y-auto" role="listbox">
|
|
160
164
|
{options.map((option) => {
|
|
@@ -95,7 +95,7 @@ export function MenuDropdownItem({ onClick, disabled, prefix, iconLeft, shortcut
|
|
|
95
95
|
onClick={onClick}
|
|
96
96
|
disabled={disabled}
|
|
97
97
|
role="menuitem"
|
|
98
|
-
className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
98
|
+
className="w-full kol-helper-12 px-3 h-8 shrink-0 inline-flex items-center gap-2 text-body hover:text-emphasis disabled:opacity-40 disabled:cursor-not-allowed text-left"
|
|
99
99
|
>
|
|
100
100
|
{prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
|
|
101
101
|
{iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMath.js'
|
|
3
|
+
|
|
4
|
+
/* taxonomy-ok: a pure-canvas hue + harmony picker in the SpectrumControls
|
|
5
|
+
* color-picker family. It nests no KOL component — its only import is the
|
|
6
|
+
* colorMath support module in hooks/ — but belongs beside its siblings
|
|
7
|
+
* SpectrumControls / SwatchControls, not down in atoms. */
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* PaletteHarmonyWheel — a hue ring with a draggable base-hue handle and
|
|
11
|
+
* satellite markers at the active harmony's scheme hues. Dragging (or the
|
|
12
|
+
* arrow keys) rotates the base hue; every change emits the full set of
|
|
13
|
+
* harmony colors so the caller never re-derives them.
|
|
14
|
+
*
|
|
15
|
+
* The scheme table is DATA-INJECTED (`harmonies`, defaulting to the shared
|
|
16
|
+
* HARMONIES); saturation/lightness of the generated colors are injected too,
|
|
17
|
+
* so this stays a pure hue picker — the caller owns S/L.
|
|
18
|
+
*
|
|
19
|
+
* Controlled on `hue`; `onChange({ hue, colors })` fires on press, drag, and
|
|
20
|
+
* arrow keys. `colors` is one hex per role offset of the active harmony
|
|
21
|
+
* (see colorMath.harmonyColors), matching the satellite markers 1:1.
|
|
22
|
+
*
|
|
23
|
+
* The ring hues, marker outlines and handle halo are literal color math
|
|
24
|
+
* (hsl / #FFFFFF / rgba) on purpose — a spectrum is not themeable, and the
|
|
25
|
+
* markers sit on fully-saturated ring hues, not on the surface (same
|
|
26
|
+
* rationale as SpectrumControls). No token belongs here.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} size px, square (default 248)
|
|
29
|
+
* @param {number} hue controlled base hue, 0–360
|
|
30
|
+
* @param {string|object} harmony active harmony id or object (default 'analogous')
|
|
31
|
+
* @param {number} saturation base saturation for emitted colors, 0–100 (default 100)
|
|
32
|
+
* @param {number} lightness base lightness for emitted colors, 0–100 (default 50)
|
|
33
|
+
* @param {Array} harmonies injectable scheme table (default HARMONIES)
|
|
34
|
+
* @param {Function} onChange ({ hue, colors }) => void
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/* Marker outline — white for contrast against the fully-saturated ring hues
|
|
38
|
+
* (theme-independent: the wheel's colours, not the surface, sit behind it). */
|
|
39
|
+
const MARKER_STROKE = '#FFFFFF'
|
|
40
|
+
|
|
41
|
+
export default function PaletteHarmonyWheel({
|
|
42
|
+
size = 248,
|
|
43
|
+
hue,
|
|
44
|
+
harmony = 'analogous',
|
|
45
|
+
saturation = 100,
|
|
46
|
+
lightness = 50,
|
|
47
|
+
harmonies = HARMONIES,
|
|
48
|
+
onChange,
|
|
49
|
+
}) {
|
|
50
|
+
const canvasRef = useRef(null)
|
|
51
|
+
const draggingRef = useRef(false)
|
|
52
|
+
const emitRef = useRef(null)
|
|
53
|
+
|
|
54
|
+
const active = harmonyById(
|
|
55
|
+
typeof harmony === 'string'
|
|
56
|
+
? (harmonies.find((h) => h.id === harmony) ?? harmonies[0])
|
|
57
|
+
: harmony,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/* Emit next hue + its harmony colors. Held in a ref so the pointer/key
|
|
61
|
+
* handlers stay stable while always seeing the latest props. */
|
|
62
|
+
emitRef.current = (nextHue) => {
|
|
63
|
+
const h = normHue(nextHue)
|
|
64
|
+
onChange?.({ hue: h, colors: harmonyColors(h, active, { saturation, lightness }) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const outerR = size / 2 - 8
|
|
68
|
+
const innerR = outerR - 22
|
|
69
|
+
const midR = (outerR + innerR) / 2
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const canvas = canvasRef.current
|
|
73
|
+
if (!canvas) return
|
|
74
|
+
const dpr = window.devicePixelRatio || 1
|
|
75
|
+
canvas.width = size * dpr
|
|
76
|
+
canvas.height = size * dpr
|
|
77
|
+
const ctx = canvas.getContext('2d')
|
|
78
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
79
|
+
ctx.clearRect(0, 0, size, size)
|
|
80
|
+
const cx = size / 2
|
|
81
|
+
const cy = size / 2
|
|
82
|
+
|
|
83
|
+
/* Angle mapping: hue 0 at 12 o'clock, clockwise. */
|
|
84
|
+
const angleFor = (h) => ((h - 90) * Math.PI) / 180
|
|
85
|
+
|
|
86
|
+
/* Hue ring — 360 thin annulus wedges (1.5° overlap hides seams). */
|
|
87
|
+
for (let a = 0; a < 360; a++) {
|
|
88
|
+
const a0 = angleFor(a)
|
|
89
|
+
const a1 = angleFor(a + 1.5)
|
|
90
|
+
ctx.beginPath()
|
|
91
|
+
ctx.arc(cx, cy, outerR, a0, a1)
|
|
92
|
+
ctx.arc(cx, cy, innerR, a1, a0, true)
|
|
93
|
+
ctx.closePath()
|
|
94
|
+
ctx.fillStyle = `hsl(${a}, 100%, 50%)`
|
|
95
|
+
ctx.fill()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const baseHue = normHue(hue)
|
|
99
|
+
const offsets = [...new Set((active?.roleOffsets ?? [0]).map(normHue))]
|
|
100
|
+
|
|
101
|
+
/* Spokes + satellite markers at the scheme hues. */
|
|
102
|
+
for (const off of offsets) {
|
|
103
|
+
const h = normHue(baseHue + off)
|
|
104
|
+
const a = angleFor(h)
|
|
105
|
+
ctx.beginPath()
|
|
106
|
+
ctx.moveTo(cx, cy)
|
|
107
|
+
ctx.lineTo(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR)
|
|
108
|
+
ctx.strokeStyle = 'rgba(128, 128, 128, 0.4)'
|
|
109
|
+
ctx.lineWidth = 1
|
|
110
|
+
ctx.stroke()
|
|
111
|
+
if (off === 0) continue /* base hue gets the big handle below */
|
|
112
|
+
const x = cx + Math.cos(a) * midR
|
|
113
|
+
const y = cy + Math.sin(a) * midR
|
|
114
|
+
ctx.beginPath()
|
|
115
|
+
ctx.arc(x, y, 5, 0, Math.PI * 2)
|
|
116
|
+
ctx.fillStyle = `hsl(${h}, 100%, 50%)`
|
|
117
|
+
ctx.fill()
|
|
118
|
+
ctx.strokeStyle = MARKER_STROKE
|
|
119
|
+
ctx.lineWidth = 1.5
|
|
120
|
+
ctx.stroke()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Base-hue handle. */
|
|
124
|
+
const ba = angleFor(baseHue)
|
|
125
|
+
const bx = cx + Math.cos(ba) * midR
|
|
126
|
+
const by = cy + Math.sin(ba) * midR
|
|
127
|
+
ctx.beginPath()
|
|
128
|
+
ctx.arc(bx, by, 9, 0, Math.PI * 2)
|
|
129
|
+
ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
|
|
130
|
+
ctx.fill()
|
|
131
|
+
ctx.strokeStyle = MARKER_STROKE
|
|
132
|
+
ctx.lineWidth = 2
|
|
133
|
+
ctx.stroke()
|
|
134
|
+
ctx.beginPath()
|
|
135
|
+
ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
|
|
136
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
|
|
137
|
+
ctx.lineWidth = 1
|
|
138
|
+
ctx.stroke()
|
|
139
|
+
}, [size, hue, active, outerR, innerR, midR])
|
|
140
|
+
|
|
141
|
+
const pointFromEvent = (e) => {
|
|
142
|
+
const rect = canvasRef.current.getBoundingClientRect()
|
|
143
|
+
const dx = e.clientX - rect.left - rect.width / 2
|
|
144
|
+
const dy = e.clientY - rect.top - rect.height / 2
|
|
145
|
+
return { dist: Math.hypot(dx, dy), hue: normHue((Math.atan2(dy, dx) * 180) / Math.PI + 90) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const onPointerDown = (e) => {
|
|
149
|
+
const { dist, hue: h } = pointFromEvent(e)
|
|
150
|
+
if (dist < innerR - 14 || dist > outerR + 10) return
|
|
151
|
+
draggingRef.current = true
|
|
152
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
153
|
+
emitRef.current?.(h)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const onPointerMove = (e) => {
|
|
157
|
+
if (!draggingRef.current) return
|
|
158
|
+
emitRef.current?.(pointFromEvent(e).hue)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const endDrag = () => { draggingRef.current = false }
|
|
162
|
+
|
|
163
|
+
const onKeyDown = (e) => {
|
|
164
|
+
const step = e.shiftKey ? 15 : 3
|
|
165
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
emitRef.current?.(normHue(hue + step))
|
|
168
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
emitRef.current?.(normHue(hue - step))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<canvas
|
|
176
|
+
ref={canvasRef}
|
|
177
|
+
role="slider"
|
|
178
|
+
aria-label="Base hue"
|
|
179
|
+
aria-valuemin={0}
|
|
180
|
+
aria-valuemax={359}
|
|
181
|
+
aria-valuenow={Math.round(normHue(hue))}
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
className="cursor-pointer touch-none"
|
|
184
|
+
style={{ width: size, height: size }}
|
|
185
|
+
onPointerDown={onPointerDown}
|
|
186
|
+
onPointerMove={onPointerMove}
|
|
187
|
+
onPointerUp={endDrag}
|
|
188
|
+
onPointerCancel={endDrag}
|
|
189
|
+
onKeyDown={onKeyDown}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|