diff --git a/.eslintrc.js b/.eslintrc.js index 0a1552dc81a..c584514557e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { 'gamut/prefer-themed': 'error', 'gamut/no-css-standalone': 'error', 'gamut/no-inline-style': 'error', + 'gamut/no-raw-z-index': 'error', 'gamut/import-paths': 'error', 'import/no-extraneous-dependencies': 'off', }, diff --git a/.nx/version-plans/version-plan-1784663569372.md b/.nx/version-plans/version-plan-1784663569372.md new file mode 100644 index 00000000000..2667e08834d --- /dev/null +++ b/.nx/version-plans/version-plan-1784663569372.md @@ -0,0 +1,8 @@ +--- +eslint-plugin-gamut: minor +gamut-styles: major +variance: minor +gamut: major +--- + +Adding new zIndex scale to Gamut. Inclues a new eslint rule for avoiding raw z-index values. Allows variance scales to include raw values if need be. diff --git a/packages/eslint-plugin-gamut/src/index.tsx b/packages/eslint-plugin-gamut/src/index.tsx index 9459f37a2a0..f4346fbc28d 100644 --- a/packages/eslint-plugin-gamut/src/index.tsx +++ b/packages/eslint-plugin-gamut/src/index.tsx @@ -2,6 +2,7 @@ import gamutImportPaths from './gamut-import-paths'; import noCssStandalone from './no-css-standalone'; import noInlineStyle from './no-inline-style'; import noKbdElement from './no-kbd-element'; +import noRawZIndex from './no-raw-z-index'; import preferThemed from './prefer-themed'; import recommended from './recommended'; @@ -10,6 +11,7 @@ const rules = { 'no-css-standalone': noCssStandalone, 'no-inline-style': noInlineStyle, 'no-kbd-element': noKbdElement, + 'no-raw-z-index': noRawZIndex, 'prefer-themed': preferThemed, }; diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts new file mode 100644 index 00000000000..4a0180cf5fb --- /dev/null +++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts @@ -0,0 +1,55 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +import rule from './no-raw-z-index'; + +const ruleTester = new ESLintUtils.RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('no-raw-z-index', rule, { + valid: [ + // Semantic tokens are the expected usage. + `const styles = { zIndex: zIndexes.modal };`, + `;`, + // Arithmetic on a token is allowed (e.g. Tip's shadow). + `const styles = { zIndex: zIndexes.foreground - 2 };`, + `;`, + // Variables / non-literal expressions are not flagged. + `;`, + `const styles = { zIndex };`, + // Unrelated properties. + `const styles = { padding: 0 };`, + `;`, + ], + invalid: [ + { + code: `const styles = { zIndex: 1 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { zIndex: 0 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { zIndex: -1 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { 'z-index': 100 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `;`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `;`, + errors: [{ messageId: 'noRawZIndex' }], + }, + ], +}); diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts new file mode 100644 index 00000000000..77da92ae299 --- /dev/null +++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts @@ -0,0 +1,62 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +import { createRule } from './createRule'; + +/** + * True for a numeric literal, including a negated one like `-1`. + */ +const isNumericLiteral = (node: TSESTree.Node | null | undefined): boolean => { + if (!node) return false; + if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'number') { + return true; + } + return ( + node.type === AST_NODE_TYPES.UnaryExpression && + (node.operator === '-' || node.operator === '+') && + isNumericLiteral(node.argument) + ); +}; + +const isZIndexKey = (key: TSESTree.Node): boolean => + (key.type === AST_NODE_TYPES.Identifier && key.name === 'zIndex') || + (key.type === AST_NODE_TYPES.Literal && key.value === 'zIndex') || + (key.type === AST_NODE_TYPES.Literal && key.value === 'z-index'); + +export default createRule({ + create(context) { + return { + // Style objects: `{ zIndex: 1 }` / `{ 'z-index': 1 }` + Property(node) { + if (isZIndexKey(node.key) && isNumericLiteral(node.value)) { + context.report({ messageId: 'noRawZIndex', node: node.value }); + } + }, + // JSX props: `` + JSXAttribute(node) { + if ( + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === 'zIndex' && + node.value?.type === AST_NODE_TYPES.JSXExpressionContainer && + isNumericLiteral(node.value.expression as TSESTree.Node) + ) { + context.report({ messageId: 'noRawZIndex', node: node.value }); + } + }, + }; + }, + defaultOptions: [], + meta: { + docs: { + description: + 'Discourage raw numeric z-index values which can lead to z-index stacking issues and encourage usage of semantic tokens from the `zIndexes` scale.', + recommended: 'error', + }, + messages: { + noRawZIndex: + 'Semantic tokens from the `zIndexes` scale (e.g. `zIndexes.modal`) are recommendedinstead of a raw z-index number. For a deliberate in-between value, disable this rule inline with a justifying comment.', + }, + type: 'suggestion', + schema: [], + }, + name: 'no-raw-z-index', +}); diff --git a/packages/eslint-plugin-gamut/src/recommended.ts b/packages/eslint-plugin-gamut/src/recommended.ts index 970bb7700ff..a1d71b635c9 100644 --- a/packages/eslint-plugin-gamut/src/recommended.ts +++ b/packages/eslint-plugin-gamut/src/recommended.ts @@ -2,6 +2,7 @@ export default { rules: { 'gamut/no-css-standalone': 'error', 'gamut/no-inline-style': 'error', + 'gamut/no-raw-z-index': 'error', 'gamut/prefer-themed': 'off', 'gamut/gamut-import-paths': 'error', }, diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap index 2be2c419212..66fe5e546ad 100644 --- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap +++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap @@ -79,7 +79,7 @@ exports[`themes admin - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 300, }, "modes": { "dark": { @@ -151,6 +151,17 @@ exports[`themes admin - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndexes": { + "appBar": 300, + "base": 0, + "floating": 200, + "flyout": 400, + "foreground": 100, + "modal": 500, + "popover": 600, + "topmost": 700, + "underlay": -100, + }, }, "_variables": { "mode": { @@ -257,7 +268,16 @@ exports[`themes admin - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 300, + "--zIndexes-appBar": 300, + "--zIndexes-base": 0, + "--zIndexes-floating": 200, + "--zIndexes-flyout": 400, + "--zIndexes-foreground": 100, + "--zIndexes-modal": 500, + "--zIndexes-popover": 600, + "--zIndexes-topmost": 700, + "--zIndexes-underlay": -100, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -513,6 +533,17 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndexes": { + "appBar": "var(--zIndexes-appBar)", + "base": "var(--zIndexes-base)", + "floating": "var(--zIndexes-floating)", + "flyout": "var(--zIndexes-flyout)", + "foreground": "var(--zIndexes-foreground)", + "modal": "var(--zIndexes-modal)", + "popover": "var(--zIndexes-popover)", + "topmost": "var(--zIndexes-topmost)", + "underlay": "var(--zIndexes-underlay)", + }, } `; @@ -595,7 +626,7 @@ exports[`themes core - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 300, }, "modes": { "dark": { @@ -667,6 +698,17 @@ exports[`themes core - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndexes": { + "appBar": 300, + "base": 0, + "floating": 200, + "flyout": 400, + "foreground": 100, + "modal": 500, + "popover": 600, + "topmost": 700, + "underlay": -100, + }, }, "_variables": { "mode": { @@ -773,7 +815,16 @@ exports[`themes core - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 300, + "--zIndexes-appBar": 300, + "--zIndexes-base": 0, + "--zIndexes-floating": 200, + "--zIndexes-flyout": 400, + "--zIndexes-foreground": 100, + "--zIndexes-modal": 500, + "--zIndexes-popover": 600, + "--zIndexes-topmost": 700, + "--zIndexes-underlay": -100, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1029,6 +1080,17 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndexes": { + "appBar": "var(--zIndexes-appBar)", + "base": "var(--zIndexes-base)", + "floating": "var(--zIndexes-floating)", + "flyout": "var(--zIndexes-flyout)", + "foreground": "var(--zIndexes-foreground)", + "modal": "var(--zIndexes-modal)", + "popover": "var(--zIndexes-popover)", + "topmost": "var(--zIndexes-topmost)", + "underlay": "var(--zIndexes-underlay)", + }, } `; @@ -1114,7 +1176,7 @@ exports[`themes lxStudio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 300, }, "modes": { "dark": { @@ -1186,6 +1248,17 @@ exports[`themes lxStudio - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndexes": { + "appBar": 300, + "base": 0, + "floating": 200, + "flyout": 400, + "foreground": 100, + "modal": 500, + "popover": 600, + "topmost": 700, + "underlay": -100, + }, }, "_variables": { "mode": { @@ -1295,7 +1368,16 @@ exports[`themes lxStudio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 300, + "--zIndexes-appBar": 300, + "--zIndexes-base": 0, + "--zIndexes-floating": 200, + "--zIndexes-flyout": 400, + "--zIndexes-foreground": 100, + "--zIndexes-modal": 500, + "--zIndexes-popover": 600, + "--zIndexes-topmost": 700, + "--zIndexes-underlay": -100, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1556,6 +1638,17 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndexes": { + "appBar": "var(--zIndexes-appBar)", + "base": "var(--zIndexes-base)", + "floating": "var(--zIndexes-floating)", + "flyout": "var(--zIndexes-flyout)", + "foreground": "var(--zIndexes-foreground)", + "modal": "var(--zIndexes-modal)", + "popover": "var(--zIndexes-popover)", + "topmost": "var(--zIndexes-topmost)", + "underlay": "var(--zIndexes-underlay)", + }, } `; @@ -1654,7 +1747,7 @@ exports[`themes percipio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 300, }, "modes": { "dark": { @@ -1726,6 +1819,17 @@ exports[`themes percipio - theme shape 1`] = ` "text-secondary": "rgba(34, 35, 37, 0.75)", }, }, + "zIndexes": { + "appBar": 300, + "base": 0, + "floating": 200, + "flyout": 400, + "foreground": 100, + "modal": 500, + "popover": 600, + "topmost": 700, + "underlay": -100, + }, }, "_variables": { "mode": { @@ -1848,7 +1952,16 @@ exports[`themes percipio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 300, + "--zIndexes-appBar": 300, + "--zIndexes-base": 0, + "--zIndexes-floating": 200, + "--zIndexes-flyout": 400, + "--zIndexes-foreground": 100, + "--zIndexes-modal": 500, + "--zIndexes-popover": 600, + "--zIndexes-topmost": 700, + "--zIndexes-underlay": -100, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2120,6 +2233,17 @@ sans-serif", "8": "0.5rem", "96": "6rem", }, + "zIndexes": { + "appBar": "var(--zIndexes-appBar)", + "base": "var(--zIndexes-base)", + "floating": "var(--zIndexes-floating)", + "flyout": "var(--zIndexes-flyout)", + "foreground": "var(--zIndexes-foreground)", + "modal": "var(--zIndexes-modal)", + "popover": "var(--zIndexes-popover)", + "topmost": "var(--zIndexes-topmost)", + "underlay": "var(--zIndexes-underlay)", + }, } `; @@ -2222,7 +2346,7 @@ exports[`themes platform - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 300, }, "modes": { "dark": { @@ -2346,6 +2470,17 @@ exports[`themes platform - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndexes": { + "appBar": 300, + "base": 0, + "floating": 200, + "flyout": 400, + "foreground": 100, + "modal": 500, + "popover": 600, + "topmost": 700, + "underlay": -100, + }, }, "_variables": { "mode": { @@ -2498,7 +2633,16 @@ exports[`themes platform - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 300, + "--zIndexes-appBar": 300, + "--zIndexes-base": 0, + "--zIndexes-floating": 200, + "--zIndexes-flyout": 400, + "--zIndexes-foreground": 100, + "--zIndexes-modal": 500, + "--zIndexes-popover": 600, + "--zIndexes-topmost": 700, + "--zIndexes-underlay": -100, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2852,5 +2996,16 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndexes": { + "appBar": "var(--zIndexes-appBar)", + "base": "var(--zIndexes-base)", + "floating": "var(--zIndexes-floating)", + "flyout": "var(--zIndexes-flyout)", + "foreground": "var(--zIndexes-foreground)", + "modal": "var(--zIndexes-modal)", + "popover": "var(--zIndexes-popover)", + "topmost": "var(--zIndexes-topmost)", + "underlay": "var(--zIndexes-underlay)", + }, } `; diff --git a/packages/gamut-styles/src/themes/core.ts b/packages/gamut-styles/src/themes/core.ts index 3a6da844330..e41a089abea 100644 --- a/packages/gamut-styles/src/themes/core.ts +++ b/packages/gamut-styles/src/themes/core.ts @@ -11,6 +11,7 @@ import { lineHeight, mediaQueries, spacing, + zIndexes, } from '../variables'; /** @@ -28,6 +29,7 @@ export const coreTheme = createTheme({ fontWeight, spacing, elements, + zIndexes, }) .addColors(corePalette) .addColorModes('light', { @@ -141,6 +143,7 @@ export const coreTheme = createTheme({ 2: `2px solid ${colors['border-primary']}`, })) .createScaleVariables('elements') + .createScaleVariables('zIndexes') .addName('core') .build(); diff --git a/packages/gamut-styles/src/variables/elements.ts b/packages/gamut-styles/src/variables/elements.ts index 25bc743bd83..c9e39f7a21e 100644 --- a/packages/gamut-styles/src/variables/elements.ts +++ b/packages/gamut-styles/src/variables/elements.ts @@ -1,9 +1,12 @@ +import { zIndexes } from './zIndexes'; + export const elements = { headerHeight: { base: '4rem', md: '5rem' }, /** - * Semi-arbitrary z-index for global page headers. - * @remarks PLEASE talk to web platform before adding new z-index constants! + * z-index for global page headers. Aliases the `appBar` token from the `zIndexes` + * scale so consumers still reading `elements.headerZ` stay in sync with the scale. + * Prefer `zIndex="appBar"` in new code. */ - headerZ: 15, + headerZ: zIndexes.appBar, } as const; diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts index d4f074462ef..5fd8de5cd3e 100644 --- a/packages/gamut-styles/src/variables/index.ts +++ b/packages/gamut-styles/src/variables/index.ts @@ -5,3 +5,4 @@ export * from './responsive'; export * from './spacing'; export * from './timing'; export * from './typography'; +export * from './zIndexes'; diff --git a/packages/gamut-styles/src/variables/zIndexes.ts b/packages/gamut-styles/src/variables/zIndexes.ts new file mode 100644 index 00000000000..69bcc1e6d00 --- /dev/null +++ b/packages/gamut-styles/src/variables/zIndexes.ts @@ -0,0 +1,55 @@ +import { Globals } from 'csstype'; + +/** + * Semantic z-index scale. Every z-index in Gamut should reference a token here rather than a + * magic number. + * + * The `zIndex` system prop accepts a token name directly (e.g. `zIndex="modal"`), this object's + * numeric values (e.g. `zIndex={zIndexes.modal}`), a raw in-between number as an escape hatch + * (e.g. `zIndex={550}`), or arithmetic on a token (e.g. `zIndexes.foreground - 2`). + * + * Values are spaced by 100 so in-between escape-hatch numbers are available. `floating` (200) is + * the floor of the portal band and the default for `BodyPortal`. + * + * @remarks PLEASE talk to web platform before adding new z-index tokens. + */ +export const zIndexes = { + /** Decorative layer behind content (underlines, backdrops, shadows). */ + underlay: -100, + /** Ground layer — establishes a local stacking context without lifting above siblings. */ + base: 0, + /** + * The raised in-flow layer: an element in front of what sits/scrolls behind it, but below + * all portal overlays. Covers content lifted above an `underlay` (e.g. text over its + * underline) and sticky content headers (e.g. a sticky table `thead`). + */ + foreground: 100, + /** + * Portal floor: the default for `BodyPortal`, and the layer for persistent floating page + * furniture at rest (e.g. an AI chat launcher, help bubble). Above page content, below the + * app nav and all overlays. + */ + floating: 200, + /** Global app header / nav bar. Aliased by the legacy `elements.headerZ` constant. */ + appBar: 300, + /** Portaled side panel (the `Flyout` component = `Drawer` inside `Overlay`). */ + flyout: 400, + /** `Overlay`, `Modal`, and `Dialog` (they share one portal primitive). */ + modal: 500, + /** Portal-mode `Popover` and the portaled `SelectDropdown` menu — above modal. */ + popover: 600, + /** + * Top-most transient overlays that must never be clipped: floating tooltips (`FloatingTip`) + * and toasts / notifications (`Toaster`). Highest layer — nothing in Gamut sits above it. + */ + topmost: 700, +} as const; + +/** + * A `zIndexes` token name (e.g. `'foreground'`), a raw number as an escape hatch + * (e.g. `550`, or `zIndexes.foreground + 1`), or a CSS global (`'initial'`, `'inherit'`, …). + * Use this for component `zIndex` props that forward to a `Box`-like `zIndex` system prop or + * `BodyPortal`. The scale still resolves token names to `var(--zIndexes-*)` at runtime; this + * type just also permits the numeric/global escape hatches the scale type alone would reject. + */ +export type ZIndexType = keyof typeof zIndexes | number | Globals; diff --git a/packages/gamut-styles/src/variance/config.ts b/packages/gamut-styles/src/variance/config.ts index ad2b6e3fc23..7dff05a366f 100644 --- a/packages/gamut-styles/src/variance/config.ts +++ b/packages/gamut-styles/src/variance/config.ts @@ -328,7 +328,10 @@ export const positioning = { resolveProperty: getPropertyMode, transform: transformSize, }, - zIndex: { property: 'zIndex' }, + // `scale: 'zIndexes'` resolves token names (e.g. `zIndex="modal"`) to `var(--zIndexes-*)`. + // `allowRawValue` keeps the numeric/global escape hatch (e.g. `zIndex={550}`, + // `zIndex={zIndexes.foreground + 1}`) that scaled props otherwise reject. + zIndex: { property: 'zIndex', scale: 'zIndexes', allowRawValue: true }, opacity: { property: 'opacity' }, } as const; diff --git a/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md b/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md index d926714257c..2b440fe5c77 100644 --- a/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-datalist/SKILL.md @@ -221,7 +221,7 @@ DataList shows a default empty state when `rows` is empty. Override with `emptyM height="inherit" position="absolute" width="inherit" - zIndex={1} + zIndex="foreground" > `). When a finding maps to a skill, note it in the report so the developer knows where to get remediation guidance. -Run Check 0 first, then Checks 1–5, then print a single consolidated report using the format at the end of this file. +Run Check 0 first, then Checks 1–6, then print a single consolidated report using the format at the end of this file. -Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-typography`](../gamut-typography/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md) +Remediation skills: [`gamut-theming`](../gamut-theming/SKILL.md) · [`gamut-color-mode`](../gamut-color-mode/SKILL.md) · [`gamut-system-props`](../gamut-system-props/SKILL.md) · [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) · [`gamut-typography`](../gamut-typography/SKILL.md) · [`gamut-zindex`](../gamut-zindex/SKILL.md) · [`gamut-testing`](../gamut-testing/SKILL.md) --- @@ -330,6 +330,45 @@ Skill reference for remediation: [`gamut-testing`](../gamut-testing/SKILL.md) --- +## Check 6 — Raw z-index values + +Gamut coordinates stacking order through one semantic scale, `zIndexes`, from `@codecademy/gamut-styles`: `underlay` (-100), `base` (0), `foreground` (100), `floating` (200), `appBar` (300), `flyout` (400), `modal` (500), `popover` (600), `topmost` (700). A raw numeric z-index bypasses this scale and is what the `gamut/no-raw-z-index` eslint rule (`error` level) exists to catch — this check finds the same violations by grep so they show up even in a repo that hasn't wired the rule into its eslint config yet. + +Discovery: Grep source files (`.ts`, `.tsx`, `.js`, `.jsx`) for a raw numeric literal (optionally negative) in a `zIndex` JSX prop or a `zIndex`/`'z-index'` style-object key. Skip `node_modules`, `dist`, `.next`, `build`, `.turbo`. + +- JSX prop: `zIndex=\{-?[0-9]+\}` +- Style object key: `\bzIndex:\s*-?[0-9]+\b` and `['"]z-index['"]:\s*-?[0-9]+` + +Exclude a match when: + +- The line (or the line above it) has `eslint-disable-next-line gamut/no-raw-z-index` / `eslint-disable-line gamut/no-raw-z-index` with a justifying comment — the rule allows this as a deliberate escape hatch. Report as `ℹ note`, not a violation. +- The value is arithmetic on a token, e.g. `zIndex={zIndexes.foreground - 2}` — the regexes above only match when a number immediately follows `{`/`:`, so a leading token identifier already excludes these; discard any accidental match where the captured "number" is preceded by an identifier or `.`. +- A variable is being passed (`zIndex={zIndex}`, `zIndex: props.zIndex`) — not a literal, not flagged (same as the eslint rule). + +### Workflow (each match) + +1. Record the raw number and whether it's a JSX prop or style-object key. +2. Suggest the nearest scale token: + + | Raw value | Suggested token | + | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `-1` | `zIndexes.underlay` | + | `0` | `zIndexes.base` | + | `1`–`3` | `zIndexes.foreground` (common legacy in-flow/sticky value) | + | other | Nearest token by magnitude (e.g. `12` → `zIndexes.foreground` or `zIndexes.appBar - 288`, depending on stacking intent) — flag `⚠ needs manual review` since intent isn't inferable from the number alone | + +3. For an "other" value with no obvious nearest token, still report the match but mark it for manual review rather than guessing a token — the developer who wrote the number knows what it needed to sit above/below. + +Severity: ✗ error for every raw literal match (mirrors the eslint rule's `error` level) except lines exempted by an inline disable comment (→ ℹ note). + +Reporting: `file:line zIndex={} → suggest: zIndexes.` (JSX) or `file:line zIndex: → suggest: zIndexes.` (style object). For unmapped "other" values: `file:line zIndex={} → ⚠ needs manual review — no obvious token`. + +Also check whether the project depends on `@codecademy/gamut-styles` at a version that exports `zIndexes` (see Check 1) — if not, note that upgrading is required before remediation. + +Skill reference: [`gamut-zindex`](../gamut-zindex/SKILL.md) — full scale reference, `ZIndexType`, and `gamut/no-raw-z-index` rule details. + +--- + ## Output format ``` @@ -380,6 +419,12 @@ Hardcoded colors [→ ga ⚠ src/Nav.tsx:8 '#BADA55' → semantic: (n/a) | palette: — | note: no Gamut token ✗ Non-Gamut CSS vars --darkNeutralColor (8 uses), --whiteColor (5 uses) → --color-text, --color-background +Raw z-index [→ gamut-zindex] + ✗ src/HeroBanner.tsx:9 zIndex={3} → suggest: zIndexes.foreground + ✗ src/Nav.tsx:14 zIndex: 12 → ⚠ needs manual review — no obvious token + ℹ src/Vendor.tsx:31 zIndex={9999} (eslint-disable-next-line gamut/no-raw-z-index — justified) + (or: ✓ none found) + Test setup [→ gamut-testing] ✓ @codecademy/gamut-tests used in 12 test files ✗ jest.mock(@codecademy/gamut) 2 occurrences — remove; prefer setupRtl (or harness + setupRtl) diff --git a/packages/gamut/agent-tools/skills/gamut-system-props/SKILL.md b/packages/gamut/agent-tools/skills/gamut-system-props/SKILL.md index 5682afa7abe..bb5de4a4e42 100644 --- a/packages/gamut/agent-tools/skills/gamut-system-props/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-system-props/SKILL.md @@ -144,7 +144,7 @@ const Overlay = styled.div(variance.compose(system.layout, system.positioning)); ; ``` -Key props: `position`, `inset`, `top`, `right`, `bottom`, `left`, `zIndex`, `opacity` +Key props: `position`, `inset`, `top`, `right`, `bottom`, `left`, `zIndex`, `opacity`. For choosing a `zIndex` value, see [`gamut-zindex`](../gamut-zindex/SKILL.md) (the semantic `zIndexes` scale and the `gamut/no-raw-z-index` lint rule). ### `system.shadow` diff --git a/packages/gamut/agent-tools/skills/gamut-zindex/SKILL.md b/packages/gamut/agent-tools/skills/gamut-zindex/SKILL.md new file mode 100644 index 00000000000..bae3a703e5c --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-zindex/SKILL.md @@ -0,0 +1,134 @@ +--- +name: gamut-zindex +description: Use this skill when setting a `zIndex` on a Gamut component or styled component, choosing a stacking layer for an overlay/portal/sticky element, typing a `zIndex` prop with `ZIndexType`, or fixing a raw numeric z-index flagged by `gamut/no-raw-z-index` — not for other system props (see gamut-system-props) or general css()/variant()/states() authoring (see gamut-style-utilities). +--- + +# Gamut Z-Index + +Source: `@codecademy/gamut-styles` — `packages/gamut-styles/src/variables/zIndexes.ts` (scale + `ZIndexType`), `packages/gamut-styles/src/variance/config.ts` (`zIndex` system prop config). Lint rule: `packages/eslint-plugin-gamut/src/no-raw-z-index.ts`. + +See also: [`gamut-system-props`](../gamut-system-props/SKILL.md) (`system.positioning`, the rest of the `zIndex` prop group). [`gamut-style-utilities`](../gamut-style-utilities/SKILL.md) (`css()`, `variant()`, `states()`). Storybook: [Foundations / Z-Index](https://gamut.codecademy.com/?path=/docs-foundations-z-index--page), [Meta / ESLint rules](https://gamut.codecademy.com/?path=/docs-meta-eslint-rules--page). + +## Overview + +Gamut coordinates stacking order through one semantic scale, `zIndexes`, exported from `@codecademy/gamut-styles`. Every `zIndex` in Gamut should reference a token from this scale rather than a magic number — a repo-wide eslint rule, `gamut/no-raw-z-index`, enforces it. + +```tsx +import { zIndexes } from '@codecademy/gamut-styles'; + + // token name — preferred + // equivalent numeric value +``` + +## The scale + +Tokens are spaced by 100, low to high, leaving room for in-between escape-hatch numbers: + +| Token | Value | Use for | +| ------------ | ----- | ------------------------------------------------------------------------------------------------------- | +| `underlay` | -100 | Decorative layer behind content (underlines, backdrops, shadows) | +| `base` | 0 | Ground layer — local stacking context without lifting above siblings | +| `foreground` | 100 | Raised in-flow layer above `underlay`/siblings, below all portal overlays; also sticky headers | +| `floating` | 200 | Portal floor — `BodyPortal` default; persistent floating page furniture (AI chat launcher, help bubble) | +| `appBar` | 300 | Global app header/nav bar (aliased by the legacy `elements.headerZ` constant) | +| `flyout` | 400 | Portaled side panel (`Flyout` = `Drawer` inside `Overlay`) | +| `modal` | 500 | `Overlay`, `Modal`, `Dialog` (share one portal primitive) | +| `popover` | 600 | Portal-mode `Popover` and the portaled `SelectDropdown` menu — above modal | +| `topmost` | 700 | Top-most transient overlays: floating tooltips, toasts/notifications — nothing in Gamut sits above this | + +**Talk to web platform before adding a new token to the scale.** Third-party widgets (injected marketing/chat scripts) set their own z-index and are outside Gamut's control — `topmost` is the ceiling for anything Gamut owns. + +## Usage + +### `zIndex` system prop + +Available on `Box`/`FlexBox`/`GridBox` and any styled component composing `system.positioning`. Accepts, in order of preference: + +1. A token name: `zIndex="popover"` +2. The scale's numeric value: `zIndex={zIndexes.popover}` +3. Arithmetic on a token, for a deliberate offset within a layer: `zIndex={zIndexes.foreground - 2}` +4. A raw number as an escape hatch for a genuine one-off: `zIndex={550}` — leave a comment justifying it + +```tsx + + … + +``` + +### In `css()` / `variant()` / `states()` + +Token names resolve the same way inside these — including in nested pseudo-selector objects — because they share the same scale-aware property config as the `zIndex` prop: + +```tsx +import { css, variant } from '@codecademy/gamut-styles'; + +const styles = css({ + position: 'absolute', + zIndex: 'popover', + '&::before': { + content: '""', + zIndex: 'underlay', + }, +}); + +const cardVariants = variant({ + base: { zIndex: 'base' }, + variants: { raised: { zIndex: 'foreground' } }, +}); +``` + +### Typing a component's `zIndex` prop + +Use `ZIndexType` (a token name, a raw number, or a CSS global like `'inherit'`) instead of `number` so consumers can pass a token: + +```tsx +import { ZIndexType } from '@codecademy/gamut-styles'; + +export interface OverlayProps { + zIndex?: ZIndexType; +} +``` + +### Portal / overlay component defaults + +Several Gamut components already default their `zIndex` prop to a scale token — override only for a deliberate custom stacking order: + +| Component | Default | Notes | +| ------------------------------- | ------------ | ---------------------------------------------------------- | +| `BodyPortal` | `"floating"` | Base portal primitive several others build on | +| `Overlay` (→ `Modal`, `Dialog`) | `"modal"` | | +| `Flyout` | `"flyout"` | Passes `zIndex="flyout"` to its internal `Overlay` | +| `PopoverContainer`, `Popover` | `"popover"` | Portals via `` | +| `SelectDropdown` menu | `"popover"` | `react-select`'s `menuPortal`, portaled to `document.body` | +| `Toaster` | `"topmost"` | | + +## The `gamut/no-raw-z-index` eslint rule + +**Level:** `error` (repo-wide, via root `.eslintrc.js`) + +Flags a raw numeric literal in a `zIndex` JSX prop or a `zIndex`/`'z-index'` style-object key. Token names and arithmetic on a token are allowed; a variable is never flagged (the rule can't know what it resolves to). + +```tsx +// ❌ Flagged +; +const styles = css({ zIndex: 100 }); + +// ✅ OK +; +const styles = css({ zIndex: 'popover' }); +; // deliberate offset, arithmetic on a token +``` + +For a genuine one-off that doesn't map to a token, disable the rule inline with a comment justifying the choice: + +```tsx +// eslint-disable-next-line gamut/no-raw-z-index -- must sit one layer below the legacy FCN nav (12) + +``` + +## Common mistakes to avoid + +- Don't hardcode a number when a token already names the intent (`zIndex={500}` → `zIndex="modal"`). +- Don't reference `elements.headerZ` in new code — it's a legacy alias for `zIndexes.appBar`; use the token directly. +- Don't type a new `zIndex` prop as `number` — use `ZIndexType` so callers can pass a token name. +- Don't add a new token to the scale without checking with web platform first; reach for the escape hatch (a raw number, or arithmetic on a token) for a one-off instead. diff --git a/packages/gamut/src/Anchor/index.tsx b/packages/gamut/src/Anchor/index.tsx index 287ff0e6d37..7bb43b900a9 100644 --- a/packages/gamut/src/Anchor/index.tsx +++ b/packages/gamut/src/Anchor/index.tsx @@ -21,7 +21,7 @@ const outlineFocusVisible = { border: 2, borderColor: 'primary', opacity: 0, - zIndex: 0, + zIndex: 'base', }, [ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: { diff --git a/packages/gamut/src/BarChart/layout/GridLines.tsx b/packages/gamut/src/BarChart/layout/GridLines.tsx index 14db738f132..154a639a703 100644 --- a/packages/gamut/src/BarChart/layout/GridLines.tsx +++ b/packages/gamut/src/BarChart/layout/GridLines.tsx @@ -12,7 +12,7 @@ const GridLineWrapper = styled(Box)( inset: 0, pointerEvents: 'none', position: 'absolute', - zIndex: 0, + zIndex: 'base', }) ); diff --git a/packages/gamut/src/BodyPortal/__tests__/BodyPortal.test.tsx b/packages/gamut/src/BodyPortal/__tests__/BodyPortal.test.tsx new file mode 100644 index 00000000000..0961f2e8c54 --- /dev/null +++ b/packages/gamut/src/BodyPortal/__tests__/BodyPortal.test.tsx @@ -0,0 +1,50 @@ +import { setupRtl } from '@codecademy/gamut-tests'; + +import { BodyPortal, BodyPortalProps } from '..'; + +const BodyPortalTest = (props?: Partial) => ( +
+ +
Howdy!
+
+
+); + +const renderView = setupRtl(BodyPortalTest, {}); + +describe('BodyPortal', () => { + it('renders children outside its mounting container', () => { + const { view } = renderView(); + + const content = view.getByTestId('portal-content'); + expect(content.closest('main')).toBe(null); + expect(document.body).toContainElement(content); + }); + + it('applies the floating z-index token by default', () => { + const { view } = renderView(); + + const content = view.getByTestId('portal-content'); + expect(content.parentElement).toHaveStyle({ + zIndex: 'var(--zIndexes-floating)', + }); + }); + + it('resolves a custom z-index token to its CSS variable', () => { + const { view } = renderView({ zIndex: 'modal' }); + + const content = view.getByTestId('portal-content'); + expect(content.parentElement).toHaveStyle({ + zIndex: 'var(--zIndexes-modal)', + }); + }); + + it('passes through a raw numeric z-index escape hatch', () => { + const { view } = renderView({ zIndex: 550 }); + + const content = view.getByTestId('portal-content'); + expect(content.parentElement).toHaveStyle({ + zIndex: 550, + }); + }); +}); diff --git a/packages/gamut/src/BodyPortal/index.tsx b/packages/gamut/src/BodyPortal/index.tsx index 5da951b486b..267d6f35fa9 100644 --- a/packages/gamut/src/BodyPortal/index.tsx +++ b/packages/gamut/src/BodyPortal/index.tsx @@ -1,4 +1,9 @@ -import { ColorMode, system, useCurrentMode } from '@codecademy/gamut-styles'; +import { + ColorMode, + system, + useCurrentMode, + ZIndexType, +} from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useState } from 'react'; import * as React from 'react'; @@ -17,18 +22,13 @@ const PortalWrapper = styled ) .withComponent(ColorMode); -interface BodyPortalProps { - /** - * TEMPORARY: a stopgap solution to avoid zIndex conflicts - - * will be reworked with: GM-624 - * previously, zIndex was set to 1 in the CSS function - */ - zIndex?: number; +export interface BodyPortalProps { + zIndex?: ZIndexType; } export const BodyPortal: React.FC> = ({ children, - zIndex = 1, + zIndex = 'floating', }) => { const [ready, setReady] = useState(false); const mode = useCurrentMode(); diff --git a/packages/gamut/src/Box/props.ts b/packages/gamut/src/Box/props.ts index 536ff575392..390fcef7f45 100644 --- a/packages/gamut/src/Box/props.ts +++ b/packages/gamut/src/Box/props.ts @@ -24,7 +24,7 @@ export const sharedStates = system.states({ }, context: { position: 'relative', - zIndex: 1, + zIndex: 'foreground', }, 'no-select': { WebkitTouchCallout: 'none', diff --git a/packages/gamut/src/Button/shared/styles.ts b/packages/gamut/src/Button/shared/styles.ts index 9bcf0cbed8f..02b608cf9ac 100644 --- a/packages/gamut/src/Button/shared/styles.ts +++ b/packages/gamut/src/Button/shared/styles.ts @@ -67,7 +67,7 @@ export const buttonStyles = system.css({ border: 2, inset: -5, opacity: 0, - zIndex: 0, + zIndex: 'base', }, [ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1, diff --git a/packages/gamut/src/DataList/EmptyRows.tsx b/packages/gamut/src/DataList/EmptyRows.tsx index 3684664b57e..7203fa90718 100644 --- a/packages/gamut/src/DataList/EmptyRows.tsx +++ b/packages/gamut/src/DataList/EmptyRows.tsx @@ -15,7 +15,7 @@ export const EmptyRows = () => { position="sticky" top="calc(50% - 66px)" width="320px" - zIndex={1} + zIndex="foreground" > diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx index 425348fa6dd..ac6bde63f72 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -12,7 +12,7 @@ export const CalendarWrapper: React.FC = ({ children }) => ( border={1} borderRadius="sm" position="relative" - zIndex={1} + zIndex="foreground" > {children}
diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx index b5a7354e8d7..ca91002442b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -119,7 +119,7 @@ export const DateCell = styled.td( borderRadius: 'lg', border: 2, opacity: 0, - zIndex: 0, + zIndex: 'base', }, '&:focus-visible::before': { opacity: 1, diff --git a/packages/gamut/src/Flyout/index.tsx b/packages/gamut/src/Flyout/index.tsx index 2c55d7b2952..3110b0807cd 100644 --- a/packages/gamut/src/Flyout/index.tsx +++ b/packages/gamut/src/Flyout/index.tsx @@ -52,6 +52,7 @@ export const Flyout: React.FC = ({ escapeCloses isOpen={expanded} shroud + zIndex="flyout" onRequestClose={onClose} > diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index 56a7c5c7d35..4569a035752 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -173,6 +173,9 @@ export const SelectDropdown: React.FC = ({ isSearchable={isSearchable} isValidNewOption={isValidNewOption} menuAlignment={menuAlignment} + menuPortalTarget={ + typeof document !== 'undefined' ? document.body : undefined + } name={name} noOptionsMessage={noOptionsMessage} options={selectOptions} diff --git a/packages/gamut/src/Form/SelectDropdown/core/styles.ts b/packages/gamut/src/Form/SelectDropdown/core/styles.ts index 605a73b7e8c..0ebd3033d56 100644 --- a/packages/gamut/src/Form/SelectDropdown/core/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/core/styles.ts @@ -3,6 +3,7 @@ import { states, theme as GamutTheme, variant, + ZIndexType, } from '@codecademy/gamut-styles'; import { StylesConfig } from 'react-select'; @@ -25,7 +26,6 @@ import { BaseSelectComponentProps } from '../types/styles'; const selectDropdownStyles = css({ ...formBaseFieldStylesObject, display: 'flex', - zIndex: 3, }); const selectFocusStyles = { @@ -61,7 +61,7 @@ const dropdownBorderStates = states({ error: { borderColorTop: 'feedback-error' }, }); -const dropdownBorderStyles = (zIndex = 2) => +const dropdownBorderStyles = (zIndex: ZIndexType = 'popover') => css({ ...formBaseComponentStyles, border: 1, @@ -91,7 +91,7 @@ const placeholderColor = css({ export const getMemoizedStyles = ( theme: typeof GamutTheme, - zIndex?: number + zIndex?: ZIndexType ): StylesConfig => { return { clearIndicator: (provided) => ({ @@ -166,6 +166,12 @@ export const getMemoizedStyles = ( : {}), }; }, + menuPortal: (provided) => ({ + ...provided, + // The menu is portaled to the body, so it stacks at the page root as a popover — + // above sticky headers and modal content. A raw `zIndex` prop overrides as an escape hatch. + zIndex: zIndex ?? 'popover', + }), menuList: (provided, state: BaseSelectComponentProps) => { const sizeInteger = state.selectProps.size === 'small' ? 2 : 3; const maxHeight = `${ diff --git a/packages/gamut/src/Form/SelectDropdown/types/styles.ts b/packages/gamut/src/Form/SelectDropdown/types/styles.ts index 59d3abb84d2..ceba5946654 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/styles.ts @@ -1,3 +1,4 @@ +import { ZIndexType } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import { conditionalBorderStates } from '../core/styles'; @@ -24,7 +25,7 @@ export interface SharedProps extends InternalInputsProps, SelectDropdownSizes { */ menuAlignment?: 'left' | 'right'; /** Z-index for the dropdown menu */ - zIndex?: number; + zIndex?: ZIndexType; } /** diff --git a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx index b0e97bfab4f..cf0499d2a66 100644 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx @@ -1124,4 +1124,44 @@ describe('SelectDropdown', () => { }); }); }); + + describe('menu portal', () => { + // The menu portals to `document.body` (rather than rendering inline) so it can't be + // clipped by an `overflow: hidden` ancestor. This changed the DOM location of every + // menu's options in every consuming app, so it's covered explicitly here. + const getPortalNode = (view: ReturnType['view']) => { + const listbox = view.getByRole('listbox'); + const portal = listbox.parentElement?.parentElement; + if (!portal) throw new Error('Expected portal node to exist'); + return portal; + }; + + it('renders the options menu in a portal appended to document.body', async () => { + const { view } = renderView(); + + await openDropdown(view); + + const listbox = view.getByRole('listbox'); + expect(view.container).not.toContainElement(listbox); + expect(document.body).toContainElement(listbox); + }); + + it('applies the popover z-index token to the portaled menu by default', async () => { + const { view } = renderView(); + + await openDropdown(view); + + expect(getPortalNode(view)).toHaveStyle({ + zIndex: 'popover', + }); + }); + + it('applies a raw zIndex override to the portaled menu when provided', async () => { + const { view } = renderView({ zIndex: 12345 }); + + await openDropdown(view); + + expect(getPortalNode(view)).toHaveStyle({ zIndex: 12345 }); + }); + }); }); diff --git a/packages/gamut/src/List/TableHeader.tsx b/packages/gamut/src/List/TableHeader.tsx index 60810da9c91..6d470b0c957 100644 --- a/packages/gamut/src/List/TableHeader.tsx +++ b/packages/gamut/src/List/TableHeader.tsx @@ -12,7 +12,13 @@ export const TableHeader = forwardRef( ({ children, ...rest }, ref) => { const { spacing, scrollable, variant } = useListContext(); return ( - + ( flexDirection: { _: 'row', c_base: 'column', c_sm: 'row' }, top: 0, bg: 'background-current', - zIndex: 2, + zIndex: 'foreground', fontFamily: 'accent', pb: { _: 0, c_base: 8, c_sm: 0 }, }), @@ -468,7 +468,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: 'underlay', }, '&:after': { content: '""', @@ -482,7 +482,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: 'underlay', }, // p: 0 removes the browser's default padding of 1px p: 0, @@ -490,7 +490,7 @@ export const StickyHeaderColWrapper = styled.th( flexShrink: 0, position: 'sticky', left: 0, - zIndex: 1, + zIndex: 'foreground', bg: { _: 'inherit', c_base: 'transparent', c_sm: 'inherit' }, '&:not(:first-of-type)': { left: { _: 16, c_base: 0, c_sm: 16 }, diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index 47aa6820117..a5cc78bcd86 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -88,7 +88,7 @@ const interactiveVariants = system.variant({ alignItems: 'center', cursor: 'pointer', width: 1, - zIndex: 1, + zIndex: 'foreground', px: 24, py: 12, position: 'relative', @@ -108,7 +108,7 @@ const interactiveVariants = system.variant({ border: 2, borderColor: 'primary', opacity: 0, - zIndex: -1, + zIndex: 'underlay', }, [MenuItemSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1, @@ -224,7 +224,7 @@ const StyledListLink = styled('a', styledOptions<'a'>())( export const ListLink = forwardRef< HTMLAnchorElement, ComponentProps ->(({ zIndex = 1, ...rest }, ref) => ( +>(({ zIndex = 'foreground', ...rest }, ref) => ( )); diff --git a/packages/gamut/src/Overlay/index.tsx b/packages/gamut/src/Overlay/index.tsx index 9ddda4854d0..ad3bd6a1e53 100644 --- a/packages/gamut/src/Overlay/index.tsx +++ b/packages/gamut/src/Overlay/index.tsx @@ -1,4 +1,4 @@ -import { states } from '@codecademy/gamut-styles'; +import { states, ZIndexType } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useCallback } from 'react'; import * as React from 'react'; @@ -34,10 +34,11 @@ export type OverlayProps = { /** Whether the overlay allows scroll */ allowScroll?: boolean; /** - * z-index for the Overlay. Defaults to 3 to appear above common UI elements - * like headers . Can be overridden when needed for custom stacking orders. + * Stacking layer for the Overlay. Pass a `zIndexes` token or a raw number (escape + * hatch). Defaults to `"modal"`; a portaled side panel should pass + * `"flyout"` to sit below modals. */ - zIndex?: number; + zIndex?: ZIndexType; }; const OverlayContainer = styled(FlexBox)( @@ -61,7 +62,7 @@ export const Overlay: React.FC = ({ onRequestClose, isOpen, allowScroll = false, - zIndex = 3, + zIndex = 'modal', }) => { const handleOutsideClick = useCallback(() => { if (clickOutsideCloses) { diff --git a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx index c3d6bcd8b15..f431b8171ae 100644 --- a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx +++ b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx @@ -13,7 +13,7 @@ const PatternBackdropBody = styled('div', styledOptions)< >( system.css({ position: 'relative', - zIndex: 1, + zIndex: 'foreground', bg: 'background', border: 1, maxWidth: 1, @@ -29,7 +29,12 @@ type PatternBackdropProps = ComponentProps; */ export const PatternBackdrop = forwardRef( ({ children, ...rest }, ref) => ( - + {isOpen && ( - + ) : ( - + ); export type PopoverContainerProps = Pick; diff --git a/packages/gamut/src/Popover/styles/base.ts b/packages/gamut/src/Popover/styles/base.ts index d3aae9f9c2c..9f4eabb16ee 100644 --- a/packages/gamut/src/Popover/styles/base.ts +++ b/packages/gamut/src/Popover/styles/base.ts @@ -31,7 +31,7 @@ export const popoverStates = states({ export const raisedDivVariants = variant({ base: { - zIndex: 1, + zIndex: 'foreground', }, defaultVariant: 'primary', variants: { diff --git a/packages/gamut/src/Popover/styles/variants.ts b/packages/gamut/src/Popover/styles/variants.ts index 1ebf78d59e7..f8001fe58d1 100644 --- a/packages/gamut/src/Popover/styles/variants.ts +++ b/packages/gamut/src/Popover/styles/variants.ts @@ -47,7 +47,7 @@ const beakVariantStyles = createVariantsFromAlignments( export const beakVariants = variant({ base: { background: 'transparent', - zIndex: 1, + zIndex: 'foreground', position: 'fixed', }, prop: 'beak', diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx index c93f1965bc8..128582075a6 100644 --- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx @@ -282,5 +282,5 @@ export const PopoverContainer: React.FC = ({ if (inline) return content; - return {content}; + return {content}; }; diff --git a/packages/gamut/src/ProgressBar/index.tsx b/packages/gamut/src/ProgressBar/index.tsx index 202b4022c6a..7d514e66f01 100644 --- a/packages/gamut/src/ProgressBar/index.tsx +++ b/packages/gamut/src/ProgressBar/index.tsx @@ -182,7 +182,7 @@ export const ProgressBar: React.FC = ({ variant={variant} > {`Progress: ${percent}%`} - {Pattern && } + {Pattern && } = (props) => { {/* currently only supporting dark mode for the LE variant. */} {props.variant === 'block' ? ( - + ) : ( - + )} ); diff --git a/packages/gamut/src/Tabs/styles.tsx b/packages/gamut/src/Tabs/styles.tsx index 68d2579f9fb..b6b955fa910 100644 --- a/packages/gamut/src/Tabs/styles.tsx +++ b/packages/gamut/src/Tabs/styles.tsx @@ -15,7 +15,7 @@ export const tabContainerVariants = variant({ bg: 'text', position: 'absolute', bottom: 0, - zIndex: 0, + zIndex: 'base', width: '100%', }, }, diff --git a/packages/gamut/src/Tip/PreviewTip/elements.tsx b/packages/gamut/src/Tip/PreviewTip/elements.tsx index 6f42308c3c4..34bad35c6fb 100644 --- a/packages/gamut/src/Tip/PreviewTip/elements.tsx +++ b/packages/gamut/src/Tip/PreviewTip/elements.tsx @@ -1,5 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; -import { css, variant } from '@codecademy/gamut-styles'; +import { css, variant, zIndexes } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useMemo } from 'react'; @@ -143,11 +143,19 @@ export const PreviewTipShadow: React.FC = ({ zIndex, }) => { const shadowAlignment = getShadowAlignment(alignment); + // The shadow sits two layers below the tip. Resolve a token name to its numeric value so + // we can offset it; a raw number is used directly; anything else (a CSS global) falls back. + const numericZIndex = + typeof zIndex === 'number' + ? zIndex + : typeof zIndex === 'string' && zIndex in zIndexes + ? zIndexes[zIndex as keyof typeof zIndexes] + : undefined; return ( diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 51107b70c59..9f3cc669df8 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -81,7 +81,7 @@ export const InlineTip: React.FC = ({ const tipBody = ( ; - zIndex?: number; + zIndex?: ZIndexType; } & React.PropsWithChildren; diff --git a/packages/gamut/src/Toaster/index.tsx b/packages/gamut/src/Toaster/index.tsx index 809d08aade6..0cbaac7fdd9 100644 --- a/packages/gamut/src/Toaster/index.tsx +++ b/packages/gamut/src/Toaster/index.tsx @@ -26,7 +26,7 @@ export const Toaster: React.FC = ({ colorMode = 'light', }) => { return ( - + diff --git a/packages/gamut/src/Typography/Text.tsx b/packages/gamut/src/Typography/Text.tsx index 173661b8632..d8bdfcfbcd8 100644 --- a/packages/gamut/src/Typography/Text.tsx +++ b/packages/gamut/src/Typography/Text.tsx @@ -76,7 +76,7 @@ const textStates = states({ fontWeight: 'bold', minWidth: '0.4rem', position: 'relative', - zIndex: 1, + zIndex: 'foreground', // the text is more legible against the background color with text smoothing MozOsxFontSmoothing: 'grayscale', WebkitFontSmoothing: 'antialiased', @@ -90,7 +90,7 @@ const textStates = states({ position: 'absolute', top: '50%', width: 'calc(100% + 0.4rem)', - zIndex: -1, + zIndex: 'underlay', }, }, screenreader: { diff --git a/packages/styleguide/src/lib/Foundations/ZIndex.mdx b/packages/styleguide/src/lib/Foundations/ZIndex.mdx new file mode 100644 index 00000000000..03e1911c96f --- /dev/null +++ b/packages/styleguide/src/lib/Foundations/ZIndex.mdx @@ -0,0 +1,52 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import { ComponentHeader, TokenTable } from '~styleguide/blocks'; + +import * as TABLES from './shared/elements'; + +export const parameters = { + title: 'Z-Index', + subtitle: + 'A single semantic scale for coordinating stacking order across Gamut.', + status: 'static', +}; + + + + + +z-index should only be used when necessary to override the default stacking order. When possible, use the default stacking order and avoid using z-index. + +To standardize z-index values across the application, Gamut exposes one semantic z-index scale, `zIndexes`, so stacking order is coordinated by name +instead of by scattered magic numbers. Import it from `@codecademy/gamut-styles` and pass a token to +the `zIndex` prop: + +```tsx +import { zIndexes } from '@codecademy/gamut-styles'; + +; +``` + +## The scale + + + +## Custom z-index values + +The `zIndex` prop is left **numeric**. `zIndexes` is just a numeric object you reference by name; the prop still accepts a raw number. This is deliberate in situations where you need to set a z-index that is not part of the scale. + +```tsx +; // just above modal +; // deliberate one-off +``` + +These situations should be rare, but when they do arise, you can use the scale as a reference point. + +## Guidance + +- **Reach for a token first.** `zIndexes.modal`, `zIndexes.foreground`, etc. — the name documents + intent far better than a number. +- **Escape hatch sparingly.** A raw number is fine for a genuine one-off, but prefer + `token ± n` so the relationship to the scale stays visible, and leave a comment. +- **Third-party widgets** (e.g. injected marketing/chat scripts) set their own z-index and are out + of Gamut's control; `topmost` is the highest Gamut layer. diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index 1ec160156af..a77a5957cb2 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -6,6 +6,7 @@ import { lxStudioColors, theme, trueColors, + zIndexes as zIndexesTokens, } from '@codecademy/gamut-styles'; // eslint-disable-next-line gamut/import-paths import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config'; @@ -438,6 +439,84 @@ export const borderRadii = { ], }; +// Representative components that use each token, linked to their Storybook stories. +// Story ids are the folder path under `src/lib` (Storybook auto-title). +const zIndexExamples: Record = { + underlay: [ + { label: 'Text', id: 'Typography/Text' }, + { label: 'Menu', id: 'Molecules/Menu' }, + ], + base: [ + { label: 'Tabs', id: 'Molecules/Tabs' }, + { label: 'Anchor', id: 'Typography/Anchor' }, + ], + foreground: [ + { label: 'Text', id: 'Typography/Text' }, + { label: 'Tabs', id: 'Molecules/Tabs' }, + { label: 'DataList', id: 'Organisms/Lists & Tables/DataList' }, + ], + flyout: [{ label: 'Flyout', id: 'Molecules/Flyout' }], + modal: [ + { label: 'Modal', id: 'Molecules/Modals/Modal' }, + { label: 'Dialog', id: 'Molecules/Modals/Dialog' }, + ], + popover: [ + { label: 'Popover', id: 'Molecules/Popover' }, + { label: 'SelectDropdown', id: 'Atoms/FormInputs/SelectDropdown' }, + ], + topmost: [ + { label: 'ToolTip', id: 'Molecules/Tips/ToolTip' }, + { label: 'Toaster', id: 'Molecules/Toasts/Toaster' }, + ], +}; + +// Tokens with no single component example get a short note instead of links. +const zIndexNotes: Record = { + floating: 'BodyPortal default; floating launchers (e.g. AI chat button)', + appBar: 'App header / nav (app-owned)', +}; + +export const zIndexes = { + // Object insertion order runs low → high (underlay … tooltip). + rows: Object.entries(zIndexesTokens).map(([id, value]) => ({ + id, + value, + })), + columns: [ + { ...PROP_COLUMN, name: 'Token' }, + { + ...PATH_COLUMN, + render: ({ id }: any) => zIndexes.{id}, + }, + VALUE_COLUMN, + { + key: 'usedBy', + name: 'Used by', + size: 'lg', + render: ({ id }: any) => { + const examples = zIndexExamples[id]; + if (!examples?.length) { + return {zIndexNotes[id] ?? '—'}; + } + return ( + <> + {examples.flatMap((example, i) => { + const link = ( + + {example.label} + + ); + return i === 0 + ? [link] + : [, , link]; + })} + + ); + }, + }, + ], +}; + export const LightModeTable = () => ( diff --git a/packages/styleguide/src/lib/Meta/ESLint rules.mdx b/packages/styleguide/src/lib/Meta/ESLint rules.mdx index fcc82dc92dd..c7201b4ffdf 100644 --- a/packages/styleguide/src/lib/Meta/ESLint rules.mdx +++ b/packages/styleguide/src/lib/Meta/ESLint rules.mdx @@ -176,6 +176,57 @@ import { theme } from '@codecademy/gamut-styles'; --- +### `gamut/no-raw-z-index` + +**Level:** `error` + +Discourages raw numeric `z-index` values in favor of semantic tokens from the `zIndexes` scale exported by `gamut-styles`. This applies to both JSX `zIndex` props (e.g. ``) and style object properties (`zIndex` / `'z-index'` in `css()` or styled-component style objects). + +**Why?** Magic z-index numbers make stacking order hard to reason about and prone to regressions as new overlapping UI gets added. The `zIndexes` scale (`underlay`, `base`, `foreground`, `floating`, `appBar`, `flyout`, `modal`, `popover`, `topmost`) gives every layer a named, semantic role so stacking intent stays legible and consistent across the app. + +#### ❌ Incorrect + +```tsx +// Raw number in a JSX prop +; + +// Raw number in a style object +const styles = css({ + position: 'absolute', + zIndex: 100, +}); +``` + +#### ✅ Correct + +```tsx +import { zIndexes } from '@codecademy/gamut-styles'; + +// Use a semantic token + + +// Token arithmetic is allowed for a deliberate offset within a layer + + +const styles = css({ + position: 'absolute', + zIndex: zIndexes.popover, +}); +``` + +#### Disabling the rule + +For a deliberate in-between value that doesn't map to an existing token, disable the rule inline with a comment justifying the choice: + +```tsx +// eslint-disable-next-line gamut/no-raw-z-index -- must sit one layer below the legacy FCN nav (12) + +``` + + + +--- + ### `gamut/prefer-themed` **Level:** `off` (by default) @@ -222,6 +273,7 @@ module.exports = { 'gamut/no-inline-style': 'error', 'gamut/no-css-standalone': 'error', 'gamut/import-paths': 'error', + 'gamut/no-raw-z-index': 'error', 'gamut/prefer-themed': 'off', }, }; diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index bd0e2c435ed..80404fd33eb 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -10,6 +10,7 @@ import { Text, } from '@codecademy/gamut'; import { SparkleIcon } from '@codecademy/gamut-icons'; +import { zIndexes } from '@codecademy/gamut-styles'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; @@ -292,18 +293,18 @@ export const InfoTipInsideModal: Story = { export const ZIndex: Story = { args: { info: 'I am inline, cool', - zIndex: 5, + zIndex: zIndexes.foreground, }, render: (args) => ( - + I will not be behind the infotip, sad + unreadable - + I will be behind the infotip, nice + great diff --git a/packages/variance/src/types/config.ts b/packages/variance/src/types/config.ts index c4dc0be88eb..b56c841b569 100644 --- a/packages/variance/src/types/config.ts +++ b/packages/variance/src/types/config.ts @@ -35,6 +35,13 @@ export interface Prop extends BaseProperty { props?: AbstractProps ) => string | number | CSSObject; resolveProperty?: (useLogicalProperties: boolean) => PropertyMode; + /** + * Keep the raw CSS-value escape hatch (arbitrary numbers, globals) available on a + * scale-backed prop, alongside the scale's token names. Without this a scale-backed prop + * is token-only. Runtime is unaffected — the parser already falls back to the raw value + * for anything that isn't a scale token; this only widens the accepted type. + */ + allowRawValue?: boolean; } export interface AbstractPropTransformer extends Prop { @@ -60,19 +67,30 @@ export type PropertyValues< // Uses 'physical' for directional properties (both physical/logical have same value types) type BasePropertyKey

= P extends DirectionalProperty ? P['physical'] : P; +// When a scale-backed prop opts into `allowRawValue`, also permit the full raw CSS value +// type (the same type an unscaled prop gets), which includes arbitrary numbers and globals. +type RawValueEscapeHatch = Config extends { + allowRawValue: true; +} + ? PropertyValues, true> + : never; + export type ScaleValue = Config['scale'] extends keyof Theme ? | keyof Theme[Config['scale']] | PropertyValues> + | RawValueEscapeHatch : Config['scale'] extends MapScale ? | keyof Config['scale'] | PropertyValues> + | RawValueEscapeHatch : Config['scale'] extends ArrayScale ? | Config['scale'][number] | PropertyValues> + | RawValueEscapeHatch : PropertyValues, true>; export type Scale = ResponsiveProp<