Skip to content

Commit 28c1ed1

Browse files
authored
Merge pull request #518 from components-ai/contrast
Constrain to accessible contrasts when generating colors
2 parents c42b762 + 285032d commit 28c1ed1

File tree

14 files changed

+165
-26
lines changed

14 files changed

+165
-26
lines changed

.changeset/grumpy-apples-agree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@compai/css-gui": patch
3+
---
4+
5+
Constrain to accessible contrasts when generating colors

packages/gui/src/components/Editor/Controls.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface ControlProps extends InputProps {
5252
showRemove?: boolean
5353
}
5454
const Control = ({ field, showRemove = false, ...props }: ControlProps) => {
55-
const { getField, setField, removeField } = useEditor()
55+
const { getField, getParentField, setField, removeField } = useEditor()
5656
const { removeDynamicProperty } = useDynamicControls()
5757
const fieldset = useFieldset()
5858
const property = getPropertyFromField(field)
@@ -99,6 +99,8 @@ const Control = ({ field, showRemove = false, ...props }: ControlProps) => {
9999
setField(fullField, newValue)
100100
}}
101101
onRemove={showRemove ? handleRemoveProperty : undefined}
102+
ruleset={getParentField(fullField)}
103+
property={property}
102104
/>
103105
)
104106
}
@@ -196,8 +198,12 @@ export const Editor = ({
196198
function regenerateAll(): any {
197199
return mapValues(allStyles, (value, property) => {
198200
return (
199-
properties[property].regenerate?.({ theme, previousValue: value }) ??
200-
value
201+
properties[property].regenerate?.({
202+
theme,
203+
previousValue: value,
204+
ruleset: allStyles,
205+
property,
206+
}) ?? value
201207
)
202208
})
203209
}

packages/gui/src/components/inputs/SchemaInput.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ interface Props<T> {
88
schema: DataTypeSchema<T>
99
label: string
1010
value: T
11+
ruleset?: any
12+
property?: string
1113
onChange(value: T): void
1214
onRemove?(): void
1315
onDrag?(): void
@@ -26,12 +28,22 @@ export function SchemaInput<T>({
2628
label,
2729
value,
2830
onChange,
31+
ruleset,
32+
property,
2933
...props
3034
}: Props<T>) {
3135
const Input = schema.input
3236
const InlineInput = schema.inlineInput
3337

34-
const content = Input && <Input label="" value={value} onChange={onChange} />
38+
const content = Input && (
39+
<Input
40+
label=""
41+
value={value}
42+
onChange={onChange}
43+
ruleset={ruleset}
44+
property={property}
45+
/>
46+
)
3547
const { hasBlockInput = () => !!content } = schema
3648
return (
3749
<Collapsible.Root defaultOpen>
@@ -41,6 +53,8 @@ export function SchemaInput<T>({
4153
label={label}
4254
value={value}
4355
onChange={onChange}
56+
ruleset={ruleset}
57+
property={property}
4458
>
4559
{hasBlockInput(value) && (
4660
<Collapsible.Trigger asChild>
@@ -58,7 +72,13 @@ export function SchemaInput<T>({
5872
</Collapsible.Trigger>
5973
)}
6074
{InlineInput && (
61-
<InlineInput label="" value={value} onChange={onChange} />
75+
<InlineInput
76+
label=""
77+
value={value}
78+
onChange={onChange}
79+
ruleset={ruleset}
80+
property={property}
81+
/>
6282
)}
6383
</InputHeader>
6484
{content && <Collapsible.Content>{content}</Collapsible.Content>}

packages/gui/src/components/inputs/SelectInput.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ interface Props<T extends string> {
77
onRemove?: () => void
88
value: T
99
options: readonly T[]
10+
ruleset?: any
11+
property?: string
1012
}
1113
// A select input with a label
1214
export function SelectInput<T extends string>(props: Props<T>) {

packages/gui/src/components/primitives/ColorPicker/Field.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface Props {
2020
* the regenerate button. If not provided, uses the default random color generator.
2121
*/
2222
onRegenerate?(theme?: Theme): Color
23+
ruleset?: any
24+
property?: string
2325
}
2426

2527
/**
@@ -32,6 +34,8 @@ export default function ColorPicker({
3234
title,
3335
onRemove,
3436
onRegenerate,
37+
ruleset,
38+
property,
3539
}: Props) {
3640
return (
3741
<div>
@@ -68,7 +72,13 @@ export default function ColorPicker({
6872
return
6973
} else {
7074
// Otherwise, regenerate a random color based on theme
71-
const path = randomColor(theme) ?? randomHexColor()
75+
const path =
76+
randomColor({
77+
theme,
78+
ruleset,
79+
previousValue: value,
80+
property,
81+
}) ?? randomHexColor()
7282
const color = themeGet({
7383
theme,
7484
path,

packages/gui/src/components/providers/EditorContext.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ThemeProvider as ThemeUIProvider } from 'theme-ui'
22
import { get, unset } from 'lodash-es'
33
import { createContext, ReactNode, useContext } from 'react'
44
import { KeyArg, Recipe, EditorData } from './types'
5-
import { applyRecipe } from './util'
5+
import { applyRecipe, parentPath } from './util'
66
import { ThemeProvider, useTheme } from './ThemeContext'
77
import { EditorConfigProvider, EditorConfig } from './EditorConfigContext'
88
import { theme as uiTheme } from '../ui/theme'
@@ -14,6 +14,7 @@ export interface EditorContextValue<V> extends EditorData<V> {
1414
theme?: Theme
1515
setValue(value: Recipe<V>): void
1616
getField<T = any>(key: KeyArg): T
17+
getParentField<T = any>(key: KeyArg): T
1718
setField<T>(key: KeyArg, value: Recipe<T>): void
1819
setFields<T>(fields: Record<string, Recipe<T>>, removeFields?: KeyArg[]): void
1920
removeField(key: KeyArg): void
@@ -29,6 +30,14 @@ export function useEditor() {
2930
return field ? (get(value, field) as T) : value
3031
}
3132

33+
function getParentField<T = any>(field: KeyArg | undefined) {
34+
if (!field || !Array.isArray(field)) {
35+
return value
36+
}
37+
38+
return get(value, parentPath(field))
39+
}
40+
3241
function getFields<T = any>(fields: KeyArg[] | undefined) {
3342
const fieldsValue = fields?.reduce((acc: any, curr: KeyArg) => {
3443
const fieldValue = get(value, curr) as T
@@ -83,6 +92,7 @@ export function useEditor() {
8392
return {
8493
...context,
8594
getField,
95+
getParentField,
8696
getFields,
8797
setField,
8898
setFields,

packages/gui/src/components/providers/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export function joinPath(path1: KeyArg, path2: KeyArg): KeyPath {
66
return [...toPath(path1), ...toPath(path2)]
77
}
88

9+
export function parentPath(path: KeyPath): KeyPath {
10+
const parentPath = [...path]
11+
parentPath.pop()
12+
return parentPath
13+
}
14+
915
// lib/util's set and get functions don't handle the case
1016
// of setting a nested path when parent paths don't exist,
1117
// so we make our own functions for now

packages/gui/src/components/schemas/color.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { get } from 'theme-ui'
21
import { themeGet } from '../../lib'
32
import { randomColor, randomHexColor } from '../../lib/color'
4-
import { stringifyUnit } from '../../lib/stringify'
53
import { Color } from '../../types/css'
64
import { ColorInput } from '../inputs/ColorInput'
75
import PalettePopover, {
@@ -22,8 +20,10 @@ function rawColor({
2220
inlineInput: ColorInput,
2321
stringify: (value) => value,
2422
defaultValue,
25-
regenerate: ({ theme }) => {
26-
const path = randomColor(theme) || randomHexColor()
23+
regenerate: (...args) => {
24+
const path = randomColor(...args) || randomHexColor()
25+
const theme = args?.[0]?.theme
26+
2727
return themeGet({
2828
theme,
2929
path,
@@ -55,8 +55,8 @@ const themeColor: DataTypeSchema<ThemeColor> = {
5555
})
5656
},
5757
defaultValue: { type: 'theme', path: 'primary' },
58-
regenerate: ({ theme }) => {
59-
const path = randomColor(theme) ?? ''
58+
regenerate: (...args) => {
59+
const path = randomColor(...args) ?? ''
6060
const color: ThemeColor = { type: 'theme', path }
6161
return color
6262
},

packages/gui/src/components/schemas/options.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,11 @@ export function optionsSchema<T extends Record<string, any>>({
3434
)
3535
}
3636
function regenerate({
37-
theme,
3837
previousValue,
38+
...options
3939
}: RegenOptions<Unionize<T>>): Unionize<T> {
4040
const type = getType(previousValue)
41-
const newValue = variants[type].regenerate?.({
42-
theme,
43-
previousValue,
44-
})
41+
const newValue = variants[type].regenerate?.({ previousValue, ...options })
4542
return newValue ?? previousValue
4643
}
4744

packages/gui/src/components/schemas/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface DataTypeSchema<T> {
3434
export interface RegenOptions<T> {
3535
previousValue: T
3636
theme?: Theme
37+
ruleset?: any
38+
property?: string
3739
}
3840

3941
export type SchemaVariants<T> = { [V in keyof T]: DataTypeSchema<T[V]> }

packages/gui/src/components/ui/InputHeader.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export function InputHeader({
3232
onDragEnd,
3333
regenerate,
3434
reorder,
35+
ruleset,
36+
property,
3537
}: Props) {
3638
const theme = useTheme()
3739
return (
@@ -61,7 +63,9 @@ export function InputHeader({
6163
title="regenerate"
6264
sx={{ transition: 'opacity 150ms' }}
6365
onClick={() => {
64-
onChange(regenerate({ theme, previousValue: value }))
66+
onChange(
67+
regenerate({ theme, previousValue: value, ruleset, property })
68+
)
6569
}}
6670
>
6771
<RefreshCw size={12} />

packages/gui/src/lib/color.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,88 @@
1-
import { isBoolean, isNumber, isObject, isString, sample } from 'lodash-es'
2-
import { Theme } from '../types/theme'
1+
import {
2+
cloneDeep,
3+
isBoolean,
4+
isNumber,
5+
isObject,
6+
isString,
7+
sample,
8+
} from 'lodash-es'
9+
import getContrast from 'get-contrast'
10+
import { ThemeColor } from '../components/primitives/ColorPicker/PalettePicker'
11+
import { RegenOptions } from '../components/schemas/types'
12+
import { Color } from '../types/css'
13+
import { themeGet } from './theme'
314

4-
export function randomColor(theme?: Theme) {
5-
if (theme && theme.colors) {
6-
const path = sample(Object.keys(flatten(theme.colors)))
15+
const CONTRAST_THRESHOLD = 4.5
16+
const CONTRAST_PROPERTY_MAP: Record<string, string> = {
17+
color: 'backgroundColor',
18+
backgroundColor: 'color',
19+
}
20+
21+
const getColorToContrastWith = (
22+
property: string,
23+
ruleset: Record<string, any>,
24+
theme?: any
25+
) => {
26+
const valueOrPath = ruleset[CONTRAST_PROPERTY_MAP[property]]
27+
return themeGet({
28+
theme,
29+
path: valueOrPath.path || valueOrPath,
30+
property: 'color',
31+
})
32+
}
33+
34+
const hasContrastToCheck = (
35+
property?: string,
36+
ruleset?: Record<string, any>,
37+
theme?: any
38+
) => {
39+
if (!property || !ruleset) {
40+
return false
41+
}
42+
43+
return !!getColorToContrastWith(property, ruleset, theme)
44+
}
45+
46+
export function randomColor({
47+
theme,
48+
ruleset,
49+
property,
50+
}: RegenOptions<Color | ThemeColor>) {
51+
if (!theme?.colors) {
52+
return randomHexColor()
53+
}
54+
55+
const allColors = cloneDeep(theme.colors)
56+
// @ts-ignore
57+
delete allColors.modes
758

8-
return path
59+
const colors = flatten(allColors)
60+
61+
if (!hasContrastToCheck(property, ruleset, theme)) {
62+
return sample(Object.keys(colors))
963
}
1064

11-
return randomHexColor()
65+
const colorToContrastWith = getColorToContrastWith(property!, ruleset, theme)
66+
const colorsWithContrast = Object.entries(colors).reduce(
67+
(acc: string[], curr) => {
68+
const [path, value] = curr
69+
70+
try {
71+
if (
72+
getContrast.ratio(value, colorToContrastWith) >= CONTRAST_THRESHOLD
73+
) {
74+
return [...acc, path]
75+
}
76+
} catch (e) {}
77+
78+
return acc
79+
},
80+
[]
81+
)
82+
83+
return colorsWithContrast.length
84+
? sample(colorsWithContrast)
85+
: sample(Object.keys(colors))
1286
}
1387

1488
export function randomHexColor() {

packages/gui/src/types/editor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export interface EditorProps<T, K = never> {
22
value: T | K
33
onChange(newValue: T | K): void
44
onRemove?(): void
5+
ruleset?: any
6+
property?: string
57
}
68

79
export type EditorPropsWithLabel<T, K = never> = EditorProps<T, K> & {

packages/gui/src/types/modules.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
declare module 'culori'
22
declare module 'escape-html'
3+
declare module 'get-contrast'

0 commit comments

Comments
 (0)