Skip to content

fix(react-ui): disabled fields use the disabled-field tokens, like the CMS - #58

Open
roger-datocms wants to merge 2 commits into
masterfrom
fix/react-ui-disabled-field-tokens
Open

roger-datocms wants to merge 2 commits into
masterfrom
fix/react-ui-disabled-field-tokens

Conversation

@roger-datocms

@roger-datocms roger-datocms commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

(Claude here)

Problem

Disabled fields in plugins don't look like native disabled fields in the CMS (Basecamp card).

CMS commit f555baf23 moved disabled and read-only text fields to new --color--disabled-field--* tokens: surface, ink and ink-placeholder. The CMS already sends them to plugins through ctx.cssDesignTokens, but datocms-react-ui still used --color--disabled--*. As a result:

  • Dark mode: a disabled field's value is dark grey on mid grey, about 1.9:1 contrast.
  • Empty disabled fields: the placeholder isn't dimmed, so an empty field looks like a filled one.
  • SelectInput: the option hover color differs from the CMS. Several react-select palette slots fall back to react-select's hardcoded light-only greys.
  • Hover: a disabled text field changes its border on hover. Native ones don't.

Fix

  • TextInput / TextareaInput, disabled: the surface and ink use disabled-field--surface and disabled-field--ink. The placeholder uses disabled-field--ink-placeholder. The border stays static on hover.
  • SelectInput, disabled: the control background, value and placeholder use the same three tokens.
  • SelectInput theme: it maps react-select's palette slot for slot like the CMS's SelectInput/useThemeProps.ts, adding neutral0, neutral10, neutral40 and neutral80. Option hover is now surface-raised-hover, and primary is now selected--surface. Like the CMS, it leaves neutral60 (focused indicator) and primary50 (pressed option) on react-select's defaults.
  • Fallbacks: each new token falls back to the old one, e.g. 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.
  • Testability: the select theme and styles move to SelectInput/theme.ts, a pure module, so unit tests can cover them.
  • Docs: Canvas's token docs get a "Context: disabled-field" section.

Includes a patch changeset for datocms-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).

1-in-app-dark

In the CMS, light mode:

2-in-app-light

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.

3-fields-default-theme
Cover sheet, all 16 themes: field states 4-fields-all-themes
Cover sheet, all 16 themes: open menus with a selected and a hovered option 5-menus-all-themes

Testing

Unit tests (__tests__/fieldColors.test.ts, 25 new tests):

  • every select control permutation (disabled × error × focused)
  • placeholder, value and option states
  • the theme slot mapping
  • the parsed CSS of both text components, including that the disabled overrides come after the base rules

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:

Check Before After
Worst disabled-value contrast, dark 1.86:1 6.69:1 (same as CMS)
Menu option colors vs CMS 32/32 differ 0/32 differ
Disabled text field border on hover changes static (same as CMS)
Host without the new tokens — falls back to the previous colors

In the CMS (Playwright, headless): I ran this on the plugins-sdk-testing project, with both builds installed as plugins on one record. The live CMS sends all three disabled-field tokens. The plugin fields now match the native ones, except for the disabled select border below.

npm run build and npm test pass.

Open questions (CMS side)

These come in addition to the three on the card:

  • Disabled select border: a disabled CMS select draws its border in the surface color, because react-select's neutral10 is mapped to disabled-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?
  • Error state: CMS selects in invalid fields still get no red border, while SDK selects do. This is open question 2 on the card. The cover sheet shows it in the select · error column.

🤖 Generated with Claude Code

roger-datocms and others added 2 commits October 1, 2026 10:43
…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>
@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/datocms-react-ui@6d078f3
npm i https://pkg.pr.new/datocms-plugin-sdk@6d078f3

commit: 6d078f3

@roger-datocms roger-datocms self-assigned this Oct 1, 2026
@roger-datocms roger-datocms added the bug Something isn't working label Oct 1, 2026
Comment on lines +3 to +11
// 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))';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.

Comment on lines +19 to +41
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)',
},
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.

Comment on lines +129 to +135
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)',
}),

@roger-datocms roger-datocms Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.)

Comment on lines +15 to +16
const useStyles = (isDisabled?: boolean, error?: boolean) =>
useMemo(() => buildStyles(isDisabled, error), [isDisabled, error]);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.

Comment on lines 37 to 52
.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));
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.

Comment on lines +202 to +210
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}`),
);
}
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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.

@roger-datocms
roger-datocms marked this pull request as ready for review October 1, 2026 19:20
@roger-datocms
roger-datocms requested a review from sistrall October 1, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant