Repository navigation
fix(react-ui): disabled fields use the disabled-field tokens, like the CMS - #58
roger-datocms wants to merge 2 commits into
Conversation
…e CMS TextInput, TextareaInput and SelectInput now use the CMS's `--color--disabled-field--*` tokens when disabled, with fallbacks to `--color--disabled--*` for hosts that don't send them. This fixes the ~1.9:1 value contrast in dark mode and dims the placeholder of an empty disabled field. Disabled text fields no longer change border on hover. SelectInput's theme now maps every react-select palette slot the way the CMS's useThemeProps does, so option hover uses surface-raised-hover and nothing falls back to react-select's light-only greys. The theme and styles move to SelectInput/theme.ts so unit tests can cover them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Map react-select's `primary` slot to `selected--surface`, like the CMS, so pressing the selected option no longer flashes the focus color. - Say "slot for slot like the CMS" instead of "every slot": like the CMS, `neutral60` and `primary50` keep react-select's defaults. - Drop a theme test that could never fail. - Correct the Canvas docs: the placeholder falls back to `ink-placeholder`, not to the disabled context. - Write each fallback `var()` chain on one line so the built CSS doesn't keep line breaks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit: |
| // 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))'; |
There was a problem hiding this comment.
(Claude here) These three chains carry the whole fix. Current CMS versions send the disabled-field--* tokens through ctx.cssDesignTokens. Older hosts don't, so each chain falls back to the token disabled fields used before. The SDK defines no colors of its own, so it can't hardcode a default.
| 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)', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
(Claude here) The palette now matches the CMS's SelectInput/useThemeProps.ts slot for slot. Before, neutral0, neutral40 and neutral80 fell back to react-select's hardcoded light-only greys, which looked wrong in dark mode. primary moved from the focus border to selected--surface, like the CMS. That only shows while you press the selected option: the focused control's border comes from buildStyles below. Like the CMS, neutral60 and primary50 keep react-select's defaults.
| 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)', | ||
| }), |
There was a problem hiding this comment.
(Claude here) A disabled select value used to keep the full --color--ink, so it looked enabled in both schemes. It now uses the ink paired with the disabled surface, the same as the CMS. (The 1.9:1 → 6.7:1 dark-mode contrast fix is in the text fields, in the CSS modules.)
| const useStyles = (isDisabled?: boolean, error?: boolean) => | ||
| useMemo(() => buildStyles(isDisabled, error), [isDisabled, error]); |
There was a problem hiding this comment.
(Claude here) The theme and styles moved to theme.ts unchanged apart from the token fixes. As plain functions there, the unit tests can call them directly without rendering react-select. All four wrappers (plain, Async, Creatable, AsyncCreatable) still share them.
| .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)); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
(Claude here) This mirrors the CMS's [disabled][disabled] rule in styles/_base.css. The new &:hover keeps the border static, like native disabled fields. Before, .TextInput:hover changed it. The nested rules tie with the base :hover and ::placeholder rules on specificity, so they win only because they come later in the file. TextareaInput has the same block.
| 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}`), | ||
| ); | ||
| } | ||
| }); |
There was a problem hiding this comment.
(Claude here) This test guards the cascade from the comment above. If someone moves the disabled block above the base :hover or ::placeholder rules, the token tests still pass but the styling breaks. This order check catches that.
(Claude here)
Problem
Disabled fields in plugins don't look like native disabled fields in the CMS (Basecamp card).
CMS commit
f555baf23moved disabled and read-only text fields to new--color--disabled-field--*tokens:surface,inkandink-placeholder. The CMS already sends them to plugins throughctx.cssDesignTokens, butdatocms-react-uistill used--color--disabled--*. As a result:Fix
TextInput/TextareaInput, disabled: the surface and ink usedisabled-field--surfaceanddisabled-field--ink. The placeholder usesdisabled-field--ink-placeholder. The border stays static on hover.SelectInput, disabled: the control background, value and placeholder use the same three tokens.SelectInputtheme: it maps react-select's palette slot for slot like the CMS'sSelectInput/useThemeProps.ts, addingneutral0,neutral10,neutral40andneutral80. Option hover is nowsurface-raised-hover, andprimaryis nowselected--surface. Like the CMS, it leavesneutral60(focused indicator) andprimary50(pressed option) on react-select's defaults.var(--color--disabled-field--surface, var(--color--disabled--surface)). The SDK defines no colors of its own, and older hosts don't send the new tokens.SelectInput/theme.ts, a pure module, so unit tests can cover them.Canvas's token docs get a "Context: disabled-field" section.Includes a
patchchangeset fordatocms-react-ui.Screenshots
On the cover sheets, magenta boxes surround the fields whose measured colors differ between before and after. Each box spans both rows (or both menus) and lists what changed, with before → after hex values; fields outside a box look the same before and after.
In the CMS, dark mode. At the top are native fields that the plugin disabled with
ctx.disableField. Below them is the published react-ui ("before", red heading), then this branch ("after", green heading).In the CMS, light mode:
Cover sheet, default theme (hue 270). Each scheme has rows for CMS native, before, after, and after on an old host without the new tokens.
Cover sheet, all 16 themes: field states
Cover sheet, all 16 themes: open menus with a selected and a hovered option
Testing
Unit tests (
__tests__/fieldColors.test.ts, 25 new tests):Against the old CSS, 3 of the new tests fail.
Cover sheet (Playwright): it renders every field state in 16 themes × light/dark. The themes are 13 monochromatic hues plus 3 grandfathered custom palettes. Each theme's tokens come from the CMS's own ramp builders and semantic-token CSS. The CMS reference row uses the CMS's own input CSS and
useThemeProps. The harness measured 1,274 cells:In the CMS (Playwright, headless): I ran this on the
plugins-sdk-testingproject, with both builds installed as plugins on one record. The live CMS sends all threedisabled-fieldtokens. The plugin fields now match the native ones, except for the disabled select border below.npm run buildandnpm testpass.Open questions (CMS side)
These come in addition to the three on the card:
neutral10is mapped todisabled-field--surface. So the border disappears. Disabled CMS text fields, and the SDK select, keep--color--border. I kept the SDK consistent with the text fields. Should the CMS select keep its border too?select · errorcolumn.🤖 Generated with Claude Code