Skip to content

Commit 7f44d48

Browse files
stipsancursoragent
andauthored
feat(color-input): vendor a typed fork of react-color (#1364)
* feat(color-input): vendor a typed fork of react-color Add a minimal, fully-typed fork of the parts of react-color@2.19.3 used by the color input (the CustomPicker HOC plus the common Saturation, Hue, Alpha, Checkboard and EditableInput building blocks) under src/react-color/. Class components are kept as classes this round; converting them to function components is deferred to a follow-up PR. reactcss and prop-types are dropped, the raw <style> gradients in Hue/Saturation move to styled-components, and each file credits its upstream source via TSDoc. Adds a LICENSE crediting react-color (Case Sandberg, MIT) alongside Sanity.io. * refactor(color-input): use the vendored react-color fork and drop the dependency Re-point ColorInput, ColorPickerFields, ColorList and types to ./react-color, remove the @ts-expect-error on isValidHex and the renderers={{} as ...} cast, and drop the react-color/@types/react-color dependencies in favor of the local fork (adding lodash-es for the saturation throttle). Public API and runtime behavior are unchanged. * fix(color-input): make checkboard render() a private helper knip flagged the render() export as unused; it is only called internally by get(), so drop the export. * fix(color-input): address valid copilot review feedback - EditableInput: default nullish value to '' before stringifying so the input never shows 'UNDEFINED'. - Saturation: use the standard cursor 'pointer' instead of the legacy, non-standard 'hand' value. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 301fe1f commit 7f44d48

22 files changed

Lines changed: 1160 additions & 85 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sanity/color-input': patch
3+
---
4+
5+
Inline a small, typed fork of the `react-color` building blocks used by the color input and drop the `react-color` and `@types/react-color` dependencies
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright (c) 2015 Case Sandberg (react-color, https://github.com/casesandberg/react-color)
4+
Copyright (c) 2026 Sanity.io
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.

plugins/@sanity/color-input/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Which will render accordingly:
7272
### Color list
7373

7474
To add list of predefined selectable color swatches for the user to choose from use `colorList`.
75-
This uses the `react-color` under the hood https://casesandberg.github.io/react-color/#api-color
75+
The color picker is a small fork of [`react-color`](https://github.com/casesandberg/react-color), so `colorList` accepts any of its supported [color formats](https://casesandberg.github.io/react-color/#api-color).
7676

7777
```js
7878
// ...fields...

plugins/@sanity/color-input/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,16 @@
4848
"dependencies": {
4949
"@sanity/icons": "catalog:",
5050
"@sanity/ui": "catalog:",
51-
"react-color": "^2.19.3",
51+
"lodash-es": "catalog:",
5252
"tinycolor2": "^1.6.0"
5353
},
5454
"devDependencies": {
5555
"@repo/package.config": "workspace:*",
5656
"@repo/tsconfig": "workspace:*",
5757
"@sanity/pkg-utils": "catalog:",
58+
"@types/lodash-es": "catalog:",
5859
"@types/node": "catalog:",
5960
"@types/react": "catalog:",
60-
"@types/react-color": "^2.17.12",
6161
"@types/tinycolor2": "^1.4.6",
6262
"babel-plugin-react-compiler": "catalog:",
6363
"babel-plugin-styled-components": "catalog:",

plugins/@sanity/color-input/src/ColorInput.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import {AddIcon, TrashIcon} from '@sanity/icons'
22
import {Box, Button, Card, Flex, Inline, Stack, Text} from '@sanity/ui'
33
import {startTransition, useOptimistic, useRef} from 'react'
4-
import {type Color, CustomPicker} from 'react-color'
5-
import {Alpha, Checkboard, Hue, Saturation} from 'react-color/lib/components/common'
6-
import type {CustomPickerInjectedProps} from 'react-color/lib/components/common/ColorWrap'
74
import {type ObjectInputProps, set, setIfMissing, unset} from 'sanity'
85
import {styled} from 'styled-components'
96

107
import {ColorList} from './ColorList'
118
import {ColorPickerFields} from './ColorPickerFields'
9+
import {
10+
Alpha,
11+
Checkboard,
12+
type Color,
13+
CustomPicker,
14+
type CustomPickerInjectedProps,
15+
Hue,
16+
Saturation,
17+
} from './react-color'
1218
import type {ColorSchemaType, ColorValue} from './types'
1319

1420
const ColorBox = styled(Box)`
@@ -88,13 +94,7 @@ const ColorPickerInner = (props: ColorPickerProps) => {
8894
overflow="hidden"
8995
style={{position: 'relative', minWidth: '4em', background: '#fff'}}
9096
>
91-
<Checkboard
92-
size={8}
93-
white="transparent"
94-
grey="rgba(0,0,0,.08)"
95-
// oxlint-disable-next-line no-unsafe-type-assertion
96-
renderers={{} as {canvas: unknown}}
97-
/>
97+
<Checkboard size={8} white="transparent" grey="rgba(0,0,0,.08)" />
9898
<ColorBox
9999
style={{
100100
backgroundColor: `rgba(${rgb?.r},${rgb?.g},${rgb?.b},${rgb?.a})`,

plugins/@sanity/color-input/src/ColorList.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {Flex} from '@sanity/ui'
2-
import type {Color, ColorChangeHandler} from 'react-color'
32
import {styled} from 'styled-components'
43
import tinycolor from 'tinycolor2'
54

5+
import type {Color, ColorChangeHandler} from './react-color'
6+
67
const ColorListWrap = styled(Flex)`
78
gap: 0.25em;
89
`

plugins/@sanity/color-input/src/ColorPickerFields.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import {Box, Flex, useTheme} from '@sanity/ui'
22
import {useCallback, useMemo} from 'react'
3-
import type {Color, ColorChangeHandler, HSLColor, RGBColor} from 'react-color'
4-
import {EditableInput} from 'react-color/lib/components/common'
5-
import type {EditableInputStyles} from 'react-color/lib/components/common/EditableInput'
6-
// @ts-expect-error missing export
7-
import {isValidHex} from 'react-color/lib/helpers/color'
3+
4+
import {
5+
type Color,
6+
type ColorChangeHandler,
7+
EditableInput,
8+
type EditableInputStyles,
9+
type HSLColor,
10+
isValidHex,
11+
type RGBColor,
12+
} from './react-color'
813

914
interface ColorPickerFieldsProps {
1015
rgb?: RGBColor
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Alpha (transparency) slider.
3+
*
4+
* Forked from
5+
* {@link https://github.com/casesandberg/react-color/blob/v2.19.3/src/components/common/Alpha.js | react-color's Alpha}
6+
* (MIT, Copyright (c) 2015 Case Sandberg). See the plugin LICENSE.
7+
*
8+
* @remarks
9+
* Kept as a class component for this round (function-component conversion is a
10+
* follow-up PR). `reactcss` has been removed in favor of plain inline styles,
11+
* and the unused custom `pointer` slot dropped.
12+
*/
13+
import {Component} from 'react'
14+
import type {CSSProperties, ReactElement} from 'react'
15+
16+
import {Checkboard} from './Checkboard'
17+
import * as alpha from './helpers/alpha'
18+
import type {
19+
AlphaColorResult,
20+
CheckboardRenderers,
21+
ColorChangeHandler,
22+
HSLColor,
23+
PickerEvent,
24+
RGBColor,
25+
} from './types'
26+
27+
export interface AlphaProps {
28+
rgb: RGBColor
29+
hsl: HSLColor
30+
a?: number | undefined
31+
direction?: 'horizontal' | 'vertical' | undefined
32+
radius?: string | undefined
33+
shadow?: string | undefined
34+
renderers?: CheckboardRenderers | undefined
35+
onChange?: ColorChangeHandler<AlphaColorResult> | undefined
36+
}
37+
38+
export class Alpha extends Component<AlphaProps> {
39+
private container: HTMLDivElement | null = null
40+
41+
override componentWillUnmount(): void {
42+
this.unbindEventListeners()
43+
}
44+
45+
private readonly setContainerRef = (node: HTMLDivElement | null): void => {
46+
this.container = node
47+
}
48+
49+
private readonly handleChange = (event: PickerEvent): void => {
50+
if (!this.container) {
51+
return
52+
}
53+
const change = alpha.calculateChange(
54+
event,
55+
this.props.hsl,
56+
this.props.direction,
57+
this.props.a,
58+
this.container,
59+
)
60+
if (change && typeof this.props.onChange === 'function') {
61+
this.props.onChange(change)
62+
}
63+
}
64+
65+
private readonly handleMouseDown = (event: React.MouseEvent<HTMLDivElement>): void => {
66+
this.handleChange(event.nativeEvent)
67+
window.addEventListener('mousemove', this.handleChange)
68+
window.addEventListener('mouseup', this.handleMouseUp)
69+
}
70+
71+
private readonly handleMouseUp = (): void => {
72+
this.unbindEventListeners()
73+
}
74+
75+
private readonly unbindEventListeners = (): void => {
76+
window.removeEventListener('mousemove', this.handleChange)
77+
window.removeEventListener('mouseup', this.handleMouseUp)
78+
}
79+
80+
override render(): ReactElement {
81+
const {rgb, direction, radius, shadow, renderers} = this.props
82+
const alphaValue = rgb.a ?? 1
83+
const gradient =
84+
direction === 'vertical'
85+
? `linear-gradient(to bottom, rgba(${rgb.r},${rgb.g},${rgb.b}, 0) 0%, rgba(${rgb.r},${rgb.g},${rgb.b}, 1) 100%)`
86+
: `linear-gradient(to right, rgba(${rgb.r},${rgb.g},${rgb.b}, 0) 0%, rgba(${rgb.r},${rgb.g},${rgb.b}, 1) 100%)`
87+
const pointerStyle: CSSProperties =
88+
direction === 'vertical'
89+
? {position: 'absolute', left: 0, top: `${alphaValue * 100}%`}
90+
: {position: 'absolute', left: `${alphaValue * 100}%`}
91+
92+
return (
93+
<div style={{position: 'absolute', inset: 0, borderRadius: radius}}>
94+
<div style={{position: 'absolute', inset: 0, overflow: 'hidden', borderRadius: radius}}>
95+
<Checkboard renderers={renderers} />
96+
</div>
97+
<div
98+
style={{
99+
position: 'absolute',
100+
inset: 0,
101+
background: gradient,
102+
boxShadow: shadow,
103+
borderRadius: radius,
104+
}}
105+
/>
106+
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the slider surface is dragged via pointer coordinates, which have no keyboard equivalent */}
107+
<div
108+
style={{position: 'relative', height: '100%', margin: '0 3px'}}
109+
ref={this.setContainerRef}
110+
onMouseDown={this.handleMouseDown}
111+
onTouchMove={this.handleChange}
112+
onTouchStart={this.handleChange}
113+
>
114+
<div style={pointerStyle}>
115+
<div
116+
style={{
117+
width: '4px',
118+
borderRadius: '1px',
119+
height: '8px',
120+
boxShadow: '0 0 2px rgba(0, 0, 0, .6)',
121+
background: '#fff',
122+
marginTop: '1px',
123+
transform: 'translateX(-2px)',
124+
}}
125+
/>
126+
</div>
127+
</div>
128+
</div>
129+
)
130+
}
131+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Checkerboard transparency backdrop.
3+
*
4+
* Forked from
5+
* {@link https://github.com/casesandberg/react-color/blob/v2.19.3/src/components/common/Checkboard.js | react-color's Checkboard}
6+
* (MIT, Copyright (c) 2015 Case Sandberg). See the plugin LICENSE. The upstream
7+
* `children` cloning branch is dropped as it is unused here.
8+
*/
9+
import type {ReactElement} from 'react'
10+
11+
import * as checkboard from './helpers/checkboard'
12+
import type {CheckboardRenderers, RenderersProps} from './types'
13+
14+
const EMPTY_RENDERERS: CheckboardRenderers = {}
15+
16+
export interface CheckboardProps extends RenderersProps {
17+
white?: string | undefined
18+
grey?: string | undefined
19+
size?: number | undefined
20+
borderRadius?: string | undefined
21+
boxShadow?: string | undefined
22+
}
23+
24+
export function Checkboard({
25+
white = 'transparent',
26+
grey = 'rgba(0,0,0,.08)',
27+
size = 8,
28+
renderers = EMPTY_RENDERERS,
29+
borderRadius,
30+
boxShadow,
31+
}: CheckboardProps): ReactElement {
32+
const background = checkboard.get(white, grey, size, renderers.canvas)
33+
return (
34+
<div
35+
style={{
36+
borderRadius,
37+
boxShadow,
38+
position: 'absolute',
39+
inset: 0,
40+
background: background ? `url(${background}) center left` : undefined,
41+
}}
42+
/>
43+
)
44+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* `CustomPicker` higher-order component: manages color state and injects the
3+
* normalized color (`hsl`/`hsv`/`rgb`/`hex`) plus an `onChange` handler into the
4+
* wrapped picker.
5+
*
6+
* Forked from
7+
* {@link https://github.com/casesandberg/react-color/blob/v2.19.3/src/components/common/ColorWrap.js | react-color's ColorWrap}
8+
* (MIT, Copyright (c) 2015 Case Sandberg). See the plugin LICENSE.
9+
*
10+
* @remarks
11+
* Kept as a class component for this round (function-component conversion is a
12+
* follow-up PR). `prop-types` and the unused `lodash` `debounce` of
13+
* `onChangeComplete` have been removed.
14+
*/
15+
import {PureComponent} from 'react'
16+
import type {ComponentType, ReactElement} from 'react'
17+
18+
import {simpleCheckForValidColor, toState} from './helpers/color'
19+
import type {Color, ColorState, CustomPickerInjectedProps, CustomPickerProps} from './types'
20+
21+
type Diff<T, U> = Pick<T, Exclude<keyof T, keyof U>>
22+
23+
const DEFAULT_COLOR: Color = {h: 250, s: 0.5, l: 0.2, a: 1}
24+
25+
export function CustomPicker<A extends object>(
26+
Picker: ComponentType<A & CustomPickerInjectedProps>,
27+
): ComponentType<Diff<A, CustomPickerProps> & CustomPickerProps> {
28+
type OuterProps = Diff<A, CustomPickerProps> & CustomPickerProps
29+
30+
class ColorPicker extends PureComponent<OuterProps, ColorState> {
31+
static getDerivedStateFromProps(nextProps: CustomPickerProps, state: ColorState): ColorState {
32+
return {...toState(nextProps.color ?? DEFAULT_COLOR, state.oldHue)}
33+
}
34+
35+
constructor(props: OuterProps) {
36+
super(props)
37+
this.state = {...toState(props.color ?? DEFAULT_COLOR, 0)}
38+
}
39+
40+
private readonly handleChange = (data: Color): void => {
41+
if (!simpleCheckForValidColor(data)) {
42+
return
43+
}
44+
const incomingHue = typeof data === 'string' ? undefined : 'h' in data ? data.h : undefined
45+
const colors = toState(data, incomingHue || this.state.oldHue)
46+
this.setState(colors)
47+
this.props.onChange?.(colors)
48+
}
49+
50+
override render(): ReactElement {
51+
const injected: CustomPickerInjectedProps = {
52+
hsl: this.state.hsl,
53+
hsv: this.state.hsv,
54+
rgb: this.state.rgb,
55+
hex: this.state.hex,
56+
oldHue: this.state.oldHue,
57+
source: this.state.source,
58+
onChange: this.handleChange,
59+
}
60+
// The merged props satisfy the wrapped picker's contract at runtime, but the
61+
// generic `Diff` plumbing can't be expressed to the type-checker.
62+
// oxlint-disable-next-line no-unsafe-type-assertion
63+
const pickerProps = {...this.props, ...injected} as A & CustomPickerInjectedProps
64+
return <Picker {...pickerProps} />
65+
}
66+
}
67+
68+
return ColorPicker
69+
}

0 commit comments

Comments
 (0)