Problem
Throughout Codex, we have followed a pattern when writing styles for cases where there are different styles for different variants, e.g.:
- enabled/disabled
- default/error status
- different types where there is a default (button action, button weight, message type, etc.)
- different layouts (e.g. block/inline, framed/quiet)
There are two components to this pattern:
- We do not write styles for the default state and then override them with more specific selectors, e.g. setting styles for enabled buttons with the .cdx-button selector and then overriding those styles for .cdx-button:disabled. Instead, we set enabled styles on .cdx-button:enabled and disabled styles on .cdx-button:disabled. The original intent of this pattern was to make styles more readable by clearly separating mutually exclusive styles, and to ease maintainability by avoiding unexpected specificity or cascade issues.
- When there is a "default" state defined by a CSS class (i.e. not a native attribute), we do not actually use that class to set the default styles. Instead, we use a :not() selector, or a series of :not() selectors. We do this to make it easier to use CSS-only components: users do not have to include the default class, e.g. cdx-button--weight-normal or cdx-message--block. If we did require users to do this, they may forget and they will not get most of the styles needed for a given component.
This pattern works well in terms of code readability and maintainability, and easing the burden on CSS-only component users. However, there are 2 issues:
- The mutually exclusive pattern was new to some of us working on Codex—some developers are used to writing default styles on the root selector and then overriding those styles with a more specific selector.
- The :not() selectors are more specific than a simple class selector, especially when we end up with chains of them, e.g. &:not( .cdx-message--warning ):not( .cdx-message--error ):not( .cdx-message--success ) in place of &.cdx-message--notice.
The second issue has already caused bugs, where a style was unexpectedly overridden by one within a block of chained :not() selectors. It also caused great difficulty when updating the Tabs styles, which are already complex and now contain a significant amount of unwanted specificity.
Solution
The DST engineers discussed this and decided that:
- We will continue to use the mutually exclusive styles pattern for things that are binary (enabled/disabled, block/inline)
- We will not use that pattern for things with multiple values (button action and weight, message type). Instead, we will write default styles on the root selector and override them with more specific selectors.
:not() selector inventory
| Component | Purpose | Proposed action |
|---|---|---|
| Accordion | :focus but not :focus-visible | Keep as is; in the future evaluate whether/how we want to use :focus-visible |
| Button | Icons in non-icon-only fake buttons | Keep as is |
| Button | Not applying :focus styles to :active | Keep as is |
| Button | Non-quiet buttons | Remove :not(), make these styles default and make quiet buttons override them |
| Button | Normal enabled buttons (not primary, not quiet, not disabled) | Replace primary/quiet :not() with default+override ; keep disabled :not() |
| Checkbox | Non-indeterminate checked state | Keep as is |
| Checkbox | Not applying :focus styles to :active or :hover | Keep as is (maybe consider reordering styles?) |
| Field | Not disabled | Keep as is |
| Label | Not disabled | Keep as is |
| Label | Not visually hidden | Keep as is |
| Menu | Not applying :last-of-type styles to an only child | Keep as is |
| Message | Inline as default (not block) | Keep as is |
| Message | Notice as default (not warning, error or success) | Consider replacing :not() with default + override (may be difficult) or requiring explicit class |
| ProgressBar | Block as default (not inline) | Keep as is |
| ProgressBar | Not disabled | Keep as is |
| Tabs | Non-disabled tab | Keep as is |
| Tabs | Quiet as default (not framed) | Keep as is |
| TextArea | Not applying readonly styling when disabled | Keep as is |
| ToggleSwitch | Non-empty | Keep as is |
| ToggleSwitch | Not applying :focus styles to :active | Keep as is |
| TypeaheadSearch | Not expanded as default | Keep as is |
| TypeaheadSearch | Not auto-expanding by default | Keep as is |
Acceptance criteria
- All instances where :not() selectors are used for non-binary states are identified and updated to set the default styles on the root selector and override those styles with more specific selectors
- Testing is done to ensure there are no visual regressions caused by these changes