diff --git a/.changeset/disabled-field-colors.md b/.changeset/disabled-field-colors.md new file mode 100644 index 0000000..e3192c8 --- /dev/null +++ b/.changeset/disabled-field-colors.md @@ -0,0 +1,5 @@ +--- +"datocms-react-ui": patch +--- + +Disabled `TextInput`, `TextareaInput` and `SelectInput` fields now match native DatoCMS fields. They use the new `--color--disabled-field--*` tokens, which fixes the low-contrast value text in dark mode. An empty disabled field shows a dimmed placeholder, and a disabled text field no longer reacts to hover. `SelectInput` also maps react-select's palette slot for slot like the CMS does, so menus no longer fall back to light-only greys in the slots the CMS maps. On older DatoCMS versions that don't send the new tokens, fields fall back to the previous `--color--disabled--*` colors. diff --git a/packages/react-ui/__tests__/fieldColors.test.ts b/packages/react-ui/__tests__/fieldColors.test.ts new file mode 100644 index 0000000..4f8072d --- /dev/null +++ b/packages/react-ui/__tests__/fieldColors.test.ts @@ -0,0 +1,211 @@ +// The repo's @types/node predates the `node:` specifiers +// biome-ignore lint/style/useNodejsImportProtocol: see above +import { readFileSync } from 'fs'; +// biome-ignore lint/style/useNodejsImportProtocol: see above +import { fileURLToPath } from 'url'; +import postcss, { type Rule } from 'postcss'; +import postcssNested from 'postcss-nested'; +import type { CSSObjectWithLabel, Theme } from 'react-select'; +import { buildStyles, themeConfig } from '../src/SelectInput/theme'; + +const disabledFieldSurface = + 'var(--color--disabled-field--surface, var(--color--disabled--surface))'; +const disabledFieldInk = + 'var(--color--disabled-field--ink, var(--color--disabled--ink))'; +const disabledFieldInkPlaceholder = + 'var(--color--disabled-field--ink-placeholder, var(--color--ink-placeholder))'; + +/** react-select's own default theme, as passed to a `ThemeConfig` function. */ +const reactSelectDefaultTheme: Theme = { + borderRadius: 4, + colors: { + primary: '#2684FF', + primary75: '#4C9AFF', + primary50: '#B2D4FF', + primary25: '#DEEBFF', + danger: '#DE350B', + dangerLight: '#FFBDAD', + neutral0: 'hsl(0, 0%, 100%)', + neutral5: 'hsl(0, 0%, 95%)', + neutral10: 'hsl(0, 0%, 90%)', + neutral20: 'hsl(0, 0%, 80%)', + neutral30: 'hsl(0, 0%, 70%)', + neutral40: 'hsl(0, 0%, 60%)', + neutral50: 'hsl(0, 0%, 50%)', + neutral60: 'hsl(0, 0%, 40%)', + neutral70: 'hsl(0, 0%, 30%)', + neutral80: 'hsl(0, 0%, 20%)', + neutral90: 'hsl(0, 0%, 10%)', + }, + spacing: { baseUnit: 4, controlHeight: 38, menuGutter: 8 }, +}; + +// The style functions only read the state flags below; the rest of +// react-select's state object is irrelevant to the colors under test. +const callStyle = (key: string, state: Record, styles: any) => + styles[key]({} as CSSObjectWithLabel, state) as Record; + +describe('SelectInput themeConfig', () => { + const { colors, borderRadius } = themeConfig(reactSelectDefaultTheme); + + it('maps the palette slots the CMS maps, to the same tokens', () => { + expect(borderRadius).toBe(0); + expect(colors).toMatchObject({ + neutral0: 'var(--color--surface-raised)', + neutral10: disabledFieldSurface, + neutral20: 'var(--color--border)', + neutral30: 'var(--color--border-hover)', + neutral40: 'var(--color--ink-disabled)', + neutral80: 'var(--color--ink)', + primary: 'var(--color--selected--surface)', + primary25: 'var(--color--surface-raised-hover)', + }); + }); +}); + +describe('SelectInput styles', () => { + const permutations = [false, true].flatMap((isDisabled) => + [false, true].flatMap((error) => + [false, true].map((isFocused) => ({ isDisabled, error, isFocused })), + ), + ); + + it.each(permutations)( + 'control: disabled=$isDisabled error=$error focused=$isFocused', + ({ isDisabled, error, isFocused }) => { + const control = callStyle( + 'control', + { isFocused, isDisabled }, + buildStyles(isDisabled, error), + ); + + expect(control.backgroundColor).toBe( + isDisabled ? disabledFieldSurface : 'var(--color--surface-raised)', + ); + expect(control.borderColor).toBe( + error + ? 'var(--color--danger-soft--border)' + : isFocused + ? 'var(--color--focus--border)' + : 'var(--color--border)', + ); + expect(control.boxShadow).toBe( + isFocused + ? `0 0 0 4px var(--color--${ + error ? 'danger-soft' : 'focus' + }--outline)` + : undefined, + ); + expect(control.minHeight).toBe(40); + }, + ); + + it.each([false, true])('placeholder: disabled=%s', (isDisabled) => { + const placeholder = callStyle('placeholder', { isDisabled }, buildStyles()); + + expect(placeholder.color).toBe( + isDisabled + ? disabledFieldInkPlaceholder + : 'var(--color--ink-placeholder)', + ); + }); + + it.each([false, true])('singleValue: disabled=%s', (isDisabled) => { + const singleValue = callStyle('singleValue', { isDisabled }, buildStyles()); + + expect(singleValue.color).toBe( + isDisabled ? disabledFieldInk : 'var(--color--ink)', + ); + }); + + it.each([ + { isSelected: false, isFocused: false, expected: undefined }, + { + isSelected: false, + isFocused: true, + expected: 'var(--color--surface-raised-hover)', + }, + { + isSelected: true, + isFocused: false, + expected: 'var(--color--selected--surface)', + }, + { + isSelected: true, + isFocused: true, + expected: 'var(--color--selected--surface)', + }, + ])( + 'option: selected=$isSelected focused=$isFocused', + ({ isSelected, isFocused, expected }) => { + const option = callStyle( + 'option', + { isSelected, isFocused }, + buildStyles(), + ); + + expect(option.backgroundColor).toBe(expected); + expect(option.color).toBe('var(--color--ink)'); + }, + ); +}); + +/** Flattens a CSS module's nesting and returns its declarations by selector. */ +const declarationsBySelector = (component: string) => { + const source = readFileSync( + fileURLToPath( + new URL(`../src/${component}/styles.module.css`, import.meta.url).href, + ), + 'utf8', + ); + const root = postcss([postcssNested]).process(source, { + from: undefined, + }).root; + const result: Record> = {}; + + root.walkRules((rule: Rule) => { + result[rule.selector] ??= {}; + rule.walkDecls((decl) => { + result[rule.selector][decl.prop] = decl.value.replace(/\s+/g, ' '); + }); + }); + + return result; +}; + +/** Collapses `var( a, b )` whitespace the formatter adds across lines. */ +const normalize = (value: string) => + value.replace(/\(\s+/g, '(').replace(/\s+\)/g, ')'); + +describe.each(['TextInput', 'TextareaInput'])('%s disabled colors', (name) => { + const rules = declarationsBySelector(name); + const disabled = rules[`.${name}--disabled`]; + + it('uses the disabled-field surface and ink, with fallbacks', () => { + expect(normalize(disabled.background)).toBe(disabledFieldSurface); + expect(normalize(disabled.color)).toBe(disabledFieldInk); + expect(disabled['border-color']).toBe('var(--color--border)'); + }); + + it('dims the placeholder of an empty disabled field', () => { + expect(normalize(rules[`.${name}--disabled::placeholder`].color)).toBe( + disabledFieldInkPlaceholder, + ); + }); + + it('keeps the border static on hover, like the CMS', () => { + expect(rules[`.${name}--disabled:hover`]['border-color']).toBe( + 'var(--color--border)', + ); + }); + + it('declares disabled overrides after the base rules, so they win the tie', () => { + const selectors = Object.keys(rules); + + for (const pseudo of ['::placeholder', ':hover']) { + expect(selectors.indexOf(`.${name}--disabled${pseudo}`)).toBeGreaterThan( + selectors.indexOf(`.${name}${pseudo}`), + ); + } + }); +}); diff --git a/packages/react-ui/src/Canvas/index.tsx b/packages/react-ui/src/Canvas/index.tsx index 3849afe..8928de0 100644 --- a/packages/react-ui/src/Canvas/index.tsx +++ b/packages/react-ui/src/Canvas/index.tsx @@ -207,7 +207,7 @@ export type CanvasProps = { * collapsible={{ isOpen, onToggle: () => setOpen((v) => !v) }} * > *

- * The flat, low-contrast pair applied to non-interactive controls: disabled buttons, disabled selects and disabled toggles. + * The flat, low-contrast pair applied to non-interactive controls: disabled buttons, switches and checkboxes. *

* * {(isOpen, setOpen) => ( *
setOpen((v) => !v) }} + * > + *

+ * Disabled and read-only text fields and selects. The value must stay readable, and an empty field's placeholder must still read as "empty". Older DatoCMS versions don't send these tokens, so give each one a fallback: disabled--surface and disabled--ink for the surface and ink, and ink-placeholder for the placeholder, e.g. var(--color--disabled-field--surface, var(--color--disabled--surface)). + *

+ * + *
+ * )} + * + * + * + * {(isOpen, setOpen) => ( + *
setOpen((v) => !v) }} * > diff --git a/packages/react-ui/src/SelectInput/index.tsx b/packages/react-ui/src/SelectInput/index.tsx index 069f69c..ad393ed 100644 --- a/packages/react-ui/src/SelectInput/index.tsx +++ b/packages/react-ui/src/SelectInput/index.tsx @@ -2,8 +2,6 @@ import React, { useMemo } from 'react'; import RawSelect, { type Props as RawSelectProps, type GroupBase, - type StylesConfig, - type ThemeConfig, } from 'react-select'; import RawAsyncSelect, { type AsyncProps } from 'react-select/async'; import RawAsyncCreatableSelect, { @@ -12,139 +10,10 @@ import RawAsyncCreatableSelect, { import RawCreatableSelect, { type CreatableProps, } from 'react-select/creatable'; +import { buildStyles, themeConfig } from './theme'; -const themeConfig: ThemeConfig = (existing) => ({ - ...existing, - borderRadius: 0, - colors: { - ...existing.colors, - primary25: 'var(--color--surface-hover)', - // disabled - neutral10: 'var(--color--border)', - // normal - neutral20: 'var(--color--border)', - // focused - primary: 'var(--color--focus--border)', - // hover - neutral30: 'var(--color--border-hover)', - }, -}); - -const useStyles = (isDisabled?: boolean, error?: boolean) => { - return useMemo(() => { - return { - placeholder: (provided) => ({ - ...provided, - color: 'var(--color--ink-placeholder)', - }), - container: (provided) => { - return { - ...provided, - fontSize: 'inherit', - }; - }, - - control: (provided, { isFocused }) => { - let result = provided; - - result = { - ...result, - minHeight: 40, - }; - - if (isFocused) { - return { - ...result, - borderColor: error - ? 'var(--color--danger-soft--border)' - : 'var(--color--focus--border)', - backgroundColor: isDisabled - ? 'var(--color--disabled--surface)' - : 'var(--color--surface-raised)', - boxShadow: `0 0 0 4px ${ - error - ? 'var(--color--danger-soft--outline)' - : 'var(--color--focus--outline)' - }`, - '&:hover': { - borderColor: error - ? 'var(--color--danger-soft--border)' - : 'var(--color--focus--border)', - }, - }; - } - - return { - ...result, - borderColor: error - ? 'var(--color--danger-soft--border)' - : 'var(--color--border)', - backgroundColor: isDisabled - ? 'var(--color--disabled--surface)' - : 'var(--color--surface-raised)', - '&:hover': { - borderColor: error - ? 'var(--color--danger-soft--border)' - : 'var(--color--border-hover)', - }, - }; - }, - multiValueRemove: (provided) => ({ - ...provided, - cursor: 'pointer', - color: 'var(--color--primary-soft--ink)', - ':hover': { - backgroundColor: 'var(--color--primary-soft--surface-hover)', - color: 'var(--color--primary-soft--ink)', - }, - }), - menu: (provided) => { - return { - ...provided, - zIndex: 1000, - minWidth: 250, - backgroundColor: 'var(--color--surface-raised)', - boxShadow: 'var(--shadow--floating)', - }; - }, - singleValue: (provided) => ({ - ...provided, - color: 'var(--color--ink)', - }), - input: (provided) => ({ - ...provided, - color: 'var(--color--ink)', - boxShadow: 'none', - 'input:focus': { - boxShadow: 'none', - }, - }), - option: (provided, { isFocused, isSelected }) => ({ - ...provided, - backgroundColor: isSelected - ? 'var(--color--selected--surface)' - : isFocused - ? 'var(--color--surface-hover)' - : undefined, - color: 'var(--color--ink)', - }), - multiValue: (provided) => { - return { - ...provided, - zIndex: 100, - backgroundColor: 'var(--color--primary-soft--surface)', - userSelect: 'none', - }; - }, - multiValueLabel: (provided) => ({ - ...provided, - fontSize: 'inherit', - padding: 3, - color: 'var(--color--primary-soft--ink)', - }), - }; - }, [isDisabled, error]); -}; +const useStyles = (isDisabled?: boolean, error?: boolean) => + useMemo(() => buildStyles(isDisabled, error), [isDisabled, error]); type ErrorProp = { error?: boolean }; diff --git a/packages/react-ui/src/SelectInput/theme.ts b/packages/react-ui/src/SelectInput/theme.ts new file mode 100644 index 0000000..a075b37 --- /dev/null +++ b/packages/react-ui/src/SelectInput/theme.ts @@ -0,0 +1,167 @@ +import type { StylesConfig, Theme } from 'react-select'; + +// Hosts older than the `disabled-field` context don't send its tokens, and +// the SDK defines no colors of its own, so each one falls back to the token +// disabled fields used before. +const disabledFieldSurface = + 'var(--color--disabled-field--surface, var(--color--disabled--surface))'; +const disabledFieldInk = + 'var(--color--disabled-field--ink, var(--color--disabled--ink))'; +const disabledFieldInkPlaceholder = + 'var(--color--disabled-field--ink-placeholder, var(--color--ink-placeholder))'; + +/** + * Maps react-select's palette slots to DatoCMS tokens, slot for slot like the + * CMS's own `SelectInput/useThemeProps.ts`. Like the CMS, it leaves + * `neutral60` (focused indicator) and `primary50` (pressed option) on + * react-select's defaults. + */ +export const themeConfig = (existing: Theme): Theme => ({ + ...existing, + borderRadius: 0, + colors: { + ...existing.colors, + // menu & option background + neutral0: 'var(--color--surface-raised)', + // disabled background + neutral10: disabledFieldSurface, + // default border + neutral20: 'var(--color--border)', + // hover border + neutral30: 'var(--color--border-hover)', + // disabled indicator/text + neutral40: 'var(--color--ink-disabled)', + // value text + neutral80: 'var(--color--ink)', + // selected option background + primary: 'var(--color--selected--surface)', + // option hover background + primary25: 'var(--color--surface-raised-hover)', + }, +}); + +/** + * Builds the react-select `styles` for a SelectInput. + * + * @param isDisabled - Whether the control is disabled + * @param error - Whether the field is in an error state + */ +export const buildStyles = ( + isDisabled?: boolean, + error?: boolean, +): StylesConfig => ({ + placeholder: (provided, state) => ({ + ...provided, + color: state.isDisabled + ? disabledFieldInkPlaceholder + : 'var(--color--ink-placeholder)', + }), + container: (provided) => { + return { + ...provided, + fontSize: 'inherit', + }; + }, + + control: (provided, { isFocused }) => { + let result = provided; + + result = { + ...result, + minHeight: 40, + }; + + if (isFocused) { + return { + ...result, + borderColor: error + ? 'var(--color--danger-soft--border)' + : 'var(--color--focus--border)', + backgroundColor: isDisabled + ? disabledFieldSurface + : 'var(--color--surface-raised)', + boxShadow: `0 0 0 4px ${ + error + ? 'var(--color--danger-soft--outline)' + : 'var(--color--focus--outline)' + }`, + '&:hover': { + borderColor: error + ? 'var(--color--danger-soft--border)' + : 'var(--color--focus--border)', + }, + }; + } + + return { + ...result, + borderColor: error + ? 'var(--color--danger-soft--border)' + : 'var(--color--border)', + backgroundColor: isDisabled + ? disabledFieldSurface + : 'var(--color--surface-raised)', + '&:hover': { + borderColor: error + ? 'var(--color--danger-soft--border)' + : 'var(--color--border-hover)', + }, + }; + }, + multiValueRemove: (provided) => ({ + ...provided, + cursor: 'pointer', + color: 'var(--color--primary-soft--ink)', + ':hover': { + backgroundColor: 'var(--color--primary-soft--surface-hover)', + color: 'var(--color--primary-soft--ink)', + }, + }), + menu: (provided) => { + return { + ...provided, + zIndex: 1000, + minWidth: 250, + backgroundColor: 'var(--color--surface-raised)', + boxShadow: 'var(--shadow--floating)', + }; + }, + singleValue: (provided, state) => ({ + ...provided, + // the disabled control sits on `disabled-field--surface`, so its text must + // use the paired context ink: the standalone `ink-disabled` used by + // react-select's neutral40 slot can coincide with that surface + color: state.isDisabled ? disabledFieldInk : 'var(--color--ink)', + }), + input: (provided) => ({ + ...provided, + color: 'var(--color--ink)', + boxShadow: 'none', + 'input:focus': { + boxShadow: 'none', + }, + }), + option: (provided, { isFocused, isSelected }) => ({ + ...provided, + backgroundColor: isSelected + ? 'var(--color--selected--surface)' + : isFocused + ? 'var(--color--surface-raised-hover)' + : undefined, + color: 'var(--color--ink)', + }), + multiValue: (provided) => { + return { + ...provided, + zIndex: 100, + backgroundColor: 'var(--color--primary-soft--surface)', + userSelect: 'none', + }; + }, + multiValueLabel: (provided) => ({ + ...provided, + fontSize: 'inherit', + padding: 3, + color: 'var(--color--primary-soft--ink)', + }), +}); diff --git a/packages/react-ui/src/TextInput/styles.module.css b/packages/react-ui/src/TextInput/styles.module.css index 4c90a2b..c2171f9 100644 --- a/packages/react-ui/src/TextInput/styles.module.css +++ b/packages/react-ui/src/TextInput/styles.module.css @@ -35,9 +35,19 @@ } .TextInput--disabled { - color: var(--color--disabled--ink); + /* `disabled-field--*` fall back to the pre-context tokens on older hosts */ + color: var(--color--disabled-field--ink, var(--color--disabled--ink)); border-color: var(--color--border); - background: var(--color--disabled--surface); + background: var(--color--disabled-field--surface, var(--color--disabled--surface)); + + /* like the CMS, a disabled field doesn't react to hover */ + &:hover { + border-color: var(--color--border); + } + + &::placeholder { + color: var(--color--disabled-field--ink-placeholder, var(--color--ink-placeholder)); + } } .TextInput--error { diff --git a/packages/react-ui/src/TextareaInput/styles.module.css b/packages/react-ui/src/TextareaInput/styles.module.css index ca66fe0..59009a3 100644 --- a/packages/react-ui/src/TextareaInput/styles.module.css +++ b/packages/react-ui/src/TextareaInput/styles.module.css @@ -35,9 +35,19 @@ } .TextareaInput--disabled { - color: var(--color--disabled--ink); + /* `disabled-field--*` fall back to the pre-context tokens on older hosts */ + color: var(--color--disabled-field--ink, var(--color--disabled--ink)); border-color: var(--color--border); - background: var(--color--disabled--surface); + background: var(--color--disabled-field--surface, var(--color--disabled--surface)); + + /* like the CMS, a disabled field doesn't react to hover */ + &:hover { + border-color: var(--color--border); + } + + &::placeholder { + color: var(--color--disabled-field--ink-placeholder, var(--color--ink-placeholder)); + } } .TextareaInput--error {