diff --git a/.nx/version-plans/version-plan-1783541829799.md b/.nx/version-plans/version-plan-1783541829799.md new file mode 100644 index 00000000000..a4717424084 --- /dev/null +++ b/.nx/version-plans/version-plan-1783541829799.md @@ -0,0 +1,34 @@ +--- +eslint-plugin-gamut: major +gamut-styles: minor +gamut: major +gamut-kit: major +--- + +Add a semantic z-index scale (`zIndices`) and migrate Gamut off ad-hoc z-index values. + +**gamut-styles** + +- Add the `zIndices` token scale (exported object + theme scale with `--zIndices-*` CSS variables): + `underlay (-100)`, `base (0)`, `foreground (100)`, `portal (200)`, `widget (300)`, `appBar (400)`, + `flyout (500)`, `modal (600)`, `popover (700)`, `toaster (800)`, `tooltip (900)`. +- It is a _soft_ scale: the `zIndex` prop stays numeric, so tokens are used as `zIndex={zIndices.modal}` + and raw-number escape hatches / arithmetic (`zIndices.foreground - 2`) still work. +- `elements.headerZ` now aliases `zIndices.appBar` (was `15`). Prefer `zIndices.appBar` going forward. + +**gamut** + +- ⚠️ Behavior change — component stacking now uses the scale, which retunes several z-index values: + `Overlay`/`Modal`/`Dialog` → `modal (600)`, `Flyout` → `flyout (500)`, `Toaster` → `toaster (800)` + (fixes toasts previously stacking atPortal`default →`portal (200)`, sticky +`TableHeader`→`foreground (100)`, and many in-flow values → `underlay`/`base`/`foreground`. +- `SelectDropdown`'s options menu now portals to `document.body` (a true popover at `zIndices.popover`), + so it renders above sticky headers and modal content instead of being clipped. +- No public API is removed or retyped; the `zIndex` prop still accepts numbers. + +**eslint-plugin-gamut** + +- Add rule `gamut/no-raw-z-index`, enabled as `error` in the recommended config. It flags raw numeric + z-index literals in style objects and JSX props and steers to `zIndices` tokens; token references and + arithmetic are allowed, and deliberate escape hatches use an inline `eslint-disable`. Major because + the new error-level recommended rule can fail existing lint in consumers until raw z-index is migrated. 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..75da9221e7b --- /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: zIndices.modal };`, + `;`, + // Arithmetic on a token is allowed (e.g. Tip's shadow). + `const styles = { zIndex: zIndices.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..3a0ca1e55eb --- /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 `zIndices` scale.', + recommended: 'error', + }, + messages: { + noRawZIndex: + 'Semantic tokens from the `zIndices` scale (e.g. `zIndices.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..c04ce5490db 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": 400, }, "modes": { "dark": { @@ -151,6 +151,19 @@ exports[`themes admin - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 100, + "modal": 600, + "popover": 700, + "portal": 200, + "toaster": 800, + "tooltip": 900, + "underlay": -100, + "widget": 300, + }, }, "_variables": { "mode": { @@ -257,7 +270,18 @@ exports[`themes admin - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 100, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -100, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -513,6 +537,19 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -595,7 +632,7 @@ exports[`themes core - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -667,6 +704,19 @@ exports[`themes core - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 100, + "modal": 600, + "popover": 700, + "portal": 200, + "toaster": 800, + "tooltip": 900, + "underlay": -100, + "widget": 300, + }, }, "_variables": { "mode": { @@ -773,7 +823,18 @@ exports[`themes core - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 100, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -100, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1029,6 +1090,19 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -1114,7 +1188,7 @@ exports[`themes lxStudio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -1186,6 +1260,19 @@ exports[`themes lxStudio - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 100, + "modal": 600, + "popover": 700, + "portal": 200, + "toaster": 800, + "tooltip": 900, + "underlay": -100, + "widget": 300, + }, }, "_variables": { "mode": { @@ -1295,7 +1382,18 @@ exports[`themes lxStudio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 100, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -100, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1556,6 +1654,19 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -1654,7 +1765,7 @@ exports[`themes percipio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -1726,6 +1837,19 @@ exports[`themes percipio - theme shape 1`] = ` "text-secondary": "rgba(34, 35, 37, 0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 100, + "modal": 600, + "popover": 700, + "portal": 200, + "toaster": 800, + "tooltip": 900, + "underlay": -100, + "widget": 300, + }, }, "_variables": { "mode": { @@ -1848,7 +1972,18 @@ exports[`themes percipio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 100, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -100, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2120,6 +2255,19 @@ sans-serif", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -2222,7 +2370,7 @@ exports[`themes platform - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -2346,6 +2494,19 @@ exports[`themes platform - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 100, + "modal": 600, + "popover": 700, + "portal": 200, + "toaster": 800, + "tooltip": 900, + "underlay": -100, + "widget": 300, + }, }, "_variables": { "mode": { @@ -2498,7 +2659,18 @@ exports[`themes platform - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 100, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -100, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2852,5 +3024,18 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; diff --git a/packages/gamut-styles/src/themes/core.ts b/packages/gamut-styles/src/themes/core.ts index 3a6da844330..eb4457cdbea 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, + zIndices, } from '../variables'; /** @@ -28,6 +29,7 @@ export const coreTheme = createTheme({ fontWeight, spacing, elements, + zIndices, }) .addColors(corePalette) .addColorModes('light', { @@ -141,6 +143,7 @@ export const coreTheme = createTheme({ 2: `2px solid ${colors['border-primary']}`, })) .createScaleVariables('elements') + .createScaleVariables('zIndices') .addName('core') .build(); diff --git a/packages/gamut-styles/src/variables/elements.ts b/packages/gamut-styles/src/variables/elements.ts index 25bc743bd83..387199e0f28 100644 --- a/packages/gamut-styles/src/variables/elements.ts +++ b/packages/gamut-styles/src/variables/elements.ts @@ -1,9 +1,12 @@ +import { zIndices } from './zIndices'; + 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 `zIndices` + * scale so consumers still reading `elements.headerZ` stay in sync with the scale. + * Prefer `zIndex="appBar"` in new code. */ - headerZ: 15, + headerZ: zIndices.appBar, } as const; diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts index d4f074462ef..06ad877133d 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 './zIndices'; diff --git a/packages/gamut-styles/src/variables/zIndices.ts b/packages/gamut-styles/src/variables/zIndices.ts new file mode 100644 index 00000000000..873d947a8ac --- /dev/null +++ b/packages/gamut-styles/src/variables/zIndices.ts @@ -0,0 +1,43 @@ +/** + * Semantic z-index scale. A single scale covers both in-flow layers (low values) and + * portal layers (≥100). Every z-index in Gamut should reference a token here rather than a + * magic number. + * + * The `zIndex` prop is intentionally left numeric/unscaled, and this object is numeric, so + * tokens are used as `zIndex={zIndices.modal}`. That preserves the escape hatch (a raw + * in-between number, e.g. `zIndex={605}`) and arithmetic on tokens (e.g. `zIndices.foreground - 2`). + * + * Portal-band values are spaced by 100 so in-between escape-hatch numbers are available. + * `portal` (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 zIndices = { + /** 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`). Set to 100 so it + * clears common ad-hoc low z-index values. + */ + foreground: 100, + /** Portal floor / `BodyPortal` default — un-tokenized portal content lands here. */ + portal: 200, + /** Persistent floating page furniture at rest (e.g. an AI chat launcher, help bubble). */ + widget: 300, + /** Global app header / nav bar. Aliased by the legacy `elements.headerZ` constant. */ + appBar: 400, + /** Portaled side panel (the `Flyout` component = `Drawer` inside `Overlay`). */ + flyout: 500, + /** `Overlay`, `Modal`, and `Dialog` (they share one portal primitive). */ + modal: 600, + /** Portal-mode `Popover` and the portaled `SelectDropdown` menu — above modal. */ + popover: 700, + /** Toasts / notifications — visible above modals. */ + toaster: 800, + /** Floating tooltips (`FloatingTip`) — highest, so they are never clipped. */ + tooltip: 900, +} as const; diff --git a/packages/gamut-styles/src/variance/config.ts b/packages/gamut-styles/src/variance/config.ts index ad2b6e3fc23..1cacdaef3f1 100644 --- a/packages/gamut-styles/src/variance/config.ts +++ b/packages/gamut-styles/src/variance/config.ts @@ -328,6 +328,9 @@ export const positioning = { resolveProperty: getPropertyMode, transform: transformSize, }, + // Intentionally unscaled: scaled variance props are token-only and reject raw numbers, + // which would break the numeric escape hatch and Tip's z-index arithmetic. Use the numeric + // `zIndices` token object instead (e.g. zIndex={zIndices.modal}). zIndex: { property: 'zIndex' }, opacity: { property: 'opacity' }, } as const; diff --git a/packages/gamut/src/Anchor/index.tsx b/packages/gamut/src/Anchor/index.tsx index 287ff0e6d37..b65906656eb 100644 --- a/packages/gamut/src/Anchor/index.tsx +++ b/packages/gamut/src/Anchor/index.tsx @@ -1,4 +1,9 @@ -import { styledOptions, system, variant } from '@codecademy/gamut-styles'; +import { + styledOptions, + system, + variant, + zIndices, +} from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { ComponentProps, forwardRef, HTMLProps, RefObject } from 'react'; @@ -21,7 +26,7 @@ const outlineFocusVisible = { border: 2, borderColor: 'primary', opacity: 0, - zIndex: 0, + zIndex: zIndices.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..bf1c9718c98 100644 --- a/packages/gamut/src/BarChart/layout/GridLines.tsx +++ b/packages/gamut/src/BarChart/layout/GridLines.tsx @@ -1,4 +1,4 @@ -import { css } from '@codecademy/gamut-styles'; +import { css, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import React, { useMemo } from 'react'; @@ -12,7 +12,7 @@ const GridLineWrapper = styled(Box)( inset: 0, pointerEvents: 'none', position: 'absolute', - zIndex: 0, + zIndex: zIndices.base, }) ); diff --git a/packages/gamut/src/BodyPortal/index.tsx b/packages/gamut/src/BodyPortal/index.tsx index 5da951b486b..34309f27739 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, + zIndices, +} from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useState } from 'react'; import * as React from 'react'; @@ -19,16 +24,17 @@ const PortalWrapper = styled 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 + * Stacking layer for the portaled content. Pass a `zIndices` token + * (e.g. `zIndices.modal`) or a raw number as an escape hatch. Defaults to + * `zIndices.portal` — the floor of the portal band — so un-tokenized portal content + * stays above local page content instead of silently landing at a low value. */ zIndex?: number; } export const BodyPortal: React.FC> = ({ children, - zIndex = 1, + zIndex = zIndices.portal, }) => { 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..0d53c0a39a9 100644 --- a/packages/gamut/src/Box/props.ts +++ b/packages/gamut/src/Box/props.ts @@ -1,4 +1,4 @@ -import { system } from '@codecademy/gamut-styles'; +import { system, zIndices } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import { WithChildrenProp } from '../utils'; @@ -24,7 +24,7 @@ export const sharedStates = system.states({ }, context: { position: 'relative', - zIndex: 1, + zIndex: zIndices.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..534aa43b7de 100644 --- a/packages/gamut/src/Button/shared/styles.ts +++ b/packages/gamut/src/Button/shared/styles.ts @@ -4,6 +4,7 @@ import { styledOptions, system, transitionConcat, + zIndices, } from '@codecademy/gamut-styles'; import { CSSObject, ThemeProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -67,7 +68,7 @@ export const buttonStyles = system.css({ border: 2, inset: -5, opacity: 0, - zIndex: 0, + zIndex: zIndices.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..811fb5fb99a 100644 --- a/packages/gamut/src/DataList/EmptyRows.tsx +++ b/packages/gamut/src/DataList/EmptyRows.tsx @@ -1,3 +1,5 @@ +import { zIndices } from '@codecademy/gamut-styles'; + import { FlexBox } from '../Box'; import { FillButton } from '../Button'; import { Text } from '../Typography'; @@ -15,7 +17,7 @@ export const EmptyRows = () => { position="sticky" top="calc(50% - 66px)" width="320px" - zIndex={1} + zIndex={zIndices.foreground} > diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx index 425348fa6dd..29e2b477461 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -1,4 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; +import { zIndices } from '@codecademy/gamut-styles'; import * as React from 'react'; import { Box } from '../../../Box'; @@ -12,7 +13,7 @@ export const CalendarWrapper: React.FC = ({ children }) => ( border={1} borderRadius="sm" position="relative" - zIndex={1} + zIndex={zIndices.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..6a992bb7969 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -1,4 +1,9 @@ -import { css, states, transitionConcat } from '@codecademy/gamut-styles'; +import { + css, + states, + transitionConcat, + zIndices, +} from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -119,7 +124,7 @@ export const DateCell = styled.td( borderRadius: 'lg', border: 2, opacity: 0, - zIndex: 0, + zIndex: zIndices.base, }, '&:focus-visible::before': { opacity: 1, diff --git a/packages/gamut/src/Flyout/index.tsx b/packages/gamut/src/Flyout/index.tsx index 8bd38fc0ccf..adfee0e0c4c 100644 --- a/packages/gamut/src/Flyout/index.tsx +++ b/packages/gamut/src/Flyout/index.tsx @@ -1,5 +1,5 @@ import { MiniDeleteIcon } from '@codecademy/gamut-icons'; -import { Background, Colors } from '@codecademy/gamut-styles'; +import { Background, Colors, zIndices } from '@codecademy/gamut-styles'; import * as React from 'react'; import { FlexBox } from '../Box'; @@ -52,6 +52,7 @@ export const Flyout: React.FC = ({ escapeCloses isOpen={expanded} shroud + zIndex={zIndices.flyout} onRequestClose={onClose} > diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index f31839eaa46..603b626cbfe 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -276,6 +276,9 @@ export const SelectDropdown: React.FC = ({ isOptionDisabled={(option) => option.disabled} isSearchable={isSearchable} menuAlignment={menuAlignment} + menuPortalTarget={ + typeof document !== 'undefined' ? document.body : undefined + } name={name} options={selectOptions} placeholder={placeholder} diff --git a/packages/gamut/src/Form/SelectDropdown/styles.ts b/packages/gamut/src/Form/SelectDropdown/styles.ts index ba9178c113c..281e1e87f8c 100644 --- a/packages/gamut/src/Form/SelectDropdown/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/styles.ts @@ -3,6 +3,7 @@ import { states, theme as GamutTheme, variant, + zIndices, } 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 = { @@ -164,6 +164,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 ?? zIndices.popover, + }), menuList: (provided, state: BaseSelectComponentProps) => { const sizeInteger = state.selectProps.size === 'small' ? 2 : 3; const maxHeight = `${ diff --git a/packages/gamut/src/List/TableHeader.tsx b/packages/gamut/src/List/TableHeader.tsx index 60810da9c91..63add861255 100644 --- a/packages/gamut/src/List/TableHeader.tsx +++ b/packages/gamut/src/List/TableHeader.tsx @@ -1,3 +1,4 @@ +import { zIndices } from '@codecademy/gamut-styles'; import { ComponentProps, forwardRef } from 'react'; import { Box } from '../Box'; @@ -12,7 +13,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: zIndices.foreground, fontFamily: 'accent', pb: { _: 0, c_base: 8, c_sm: 0 }, }), @@ -468,7 +469,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, '&:after': { content: '""', @@ -482,7 +483,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, // p: 0 removes the browser's default padding of 1px p: 0, @@ -490,7 +491,7 @@ export const StickyHeaderColWrapper = styled.th( flexShrink: 0, position: 'sticky', left: 0, - zIndex: 1, + zIndex: zIndices.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 f8eea4af20d..0a36aed2f91 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -2,6 +2,7 @@ import { styledOptions, system, transitionConcat, + zIndices, } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -88,7 +89,7 @@ const interactiveVariants = system.variant({ alignItems: 'center', cursor: 'pointer', width: 1, - zIndex: 1, + zIndex: zIndices.foreground, px: 24, py: 12, position: 'relative', @@ -108,7 +109,7 @@ const interactiveVariants = system.variant({ border: 2, borderColor: 'primary', opacity: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, [MenuItemSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1, @@ -224,7 +225,7 @@ const StyledListLink = styled('a', styledOptions<'a'>())( export const ListLink = forwardRef< HTMLAnchorElement, ComponentProps ->(({ zIndex = 1, ...rest }, ref) => ( +>(({ zIndex = zIndices.foreground, ...rest }, ref) => ( )); diff --git a/packages/gamut/src/Overlay/index.tsx b/packages/gamut/src/Overlay/index.tsx index 9ddda4854d0..e4512961341 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, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useCallback } from 'react'; import * as React from 'react'; @@ -34,8 +34,9 @@ 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 `zIndices` token or a raw number (escape + * hatch). Defaults to `zIndices.modal`; a portaled side panel should pass + * `zIndices.flyout` to sit below modals. */ zIndex?: number; }; @@ -61,7 +62,7 @@ export const Overlay: React.FC = ({ onRequestClose, isOpen, allowScroll = false, - zIndex = 3, + zIndex = zIndices.modal, }) => { const handleOutsideClick = useCallback(() => { if (clickOutsideCloses) { diff --git a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx index c3d6bcd8b15..3bf50b9e0be 100644 --- a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx +++ b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx @@ -1,5 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; -import { styledOptions, system } from '@codecademy/gamut-styles'; +import { styledOptions, system, zIndices } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { ComponentProps, forwardRef } from 'react'; @@ -13,7 +13,7 @@ const PatternBackdropBody = styled('div', styledOptions)< >( system.css({ position: 'relative', - zIndex: 1, + zIndex: zIndices.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..59fcf3c5545 100644 --- a/packages/gamut/src/Popover/styles/base.ts +++ b/packages/gamut/src/Popover/styles/base.ts @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { toolTipBodyCss } from '../../Tip/shared/styles/styles'; @@ -31,7 +31,7 @@ export const popoverStates = states({ export const raisedDivVariants = variant({ base: { - zIndex: 1, + zIndex: zIndices.foreground, }, defaultVariant: 'primary', variants: { diff --git a/packages/gamut/src/Popover/styles/variants.ts b/packages/gamut/src/Popover/styles/variants.ts index 1ebf78d59e7..ba22712decf 100644 --- a/packages/gamut/src/Popover/styles/variants.ts +++ b/packages/gamut/src/Popover/styles/variants.ts @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { createVariantsFromAlignments } from '../../Tip/shared/styles/createVariantsUtils'; import { tooltipArrowHeight } from '../../Tip/shared/styles/styles'; @@ -47,7 +47,7 @@ const beakVariantStyles = createVariantsFromAlignments( export const beakVariants = variant({ base: { background: 'transparent', - zIndex: 1, + zIndex: zIndices.foreground, position: 'fixed', }, prop: 'beak', diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx index c93f1965bc8..0259a1e9272 100644 --- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx @@ -1,4 +1,9 @@ -import { elementDir, system, useElementDir } from '@codecademy/gamut-styles'; +import { + elementDir, + system, + useElementDir, + zIndices, +} from '@codecademy/gamut-styles'; import { variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -282,5 +287,5 @@ export const PopoverContainer: React.FC = ({ if (inline) return content; - return {content}; + return {content}; }; diff --git a/packages/gamut/src/Tabs/TabButton.tsx b/packages/gamut/src/Tabs/TabButton.tsx index 0cbaf3f2582..fc150c6560a 100644 --- a/packages/gamut/src/Tabs/TabButton.tsx +++ b/packages/gamut/src/Tabs/TabButton.tsx @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -29,7 +29,7 @@ const focusVisibleStyles = { borderColor: 'primary', position: 'absolute', inset: 0, - zIndex: 0, + zIndex: zIndices.base, borderRadiusTop: 'md', borderRadiusBottom: 'none', }, @@ -48,7 +48,7 @@ export const tabVariants = variant({ color: 'text', font: 'inherit', cursor: 'pointer', - zIndex: 1, + zIndex: zIndices.foreground, [TabSelectors.DISABLED]: { opacity: 0.25, cursor: 'not-allowed', diff --git a/packages/gamut/src/Tabs/Tabs.tsx b/packages/gamut/src/Tabs/Tabs.tsx index 3194df06756..13332160986 100644 --- a/packages/gamut/src/Tabs/Tabs.tsx +++ b/packages/gamut/src/Tabs/Tabs.tsx @@ -1,4 +1,4 @@ -import { Background } from '@codecademy/gamut-styles'; +import { Background, zIndices } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; import * as React from 'react'; @@ -25,10 +25,10 @@ export const Tabs: React.FC = (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..e445e6c8686 100644 --- a/packages/gamut/src/Tabs/styles.tsx +++ b/packages/gamut/src/Tabs/styles.tsx @@ -1,4 +1,4 @@ -import { system, variant } from '@codecademy/gamut-styles'; +import { system, variant, zIndices } from '@codecademy/gamut-styles'; export const tabContainerVariants = variant({ base: { @@ -15,7 +15,7 @@ export const tabContainerVariants = variant({ bg: 'text', position: 'absolute', bottom: 0, - zIndex: 0, + zIndex: zIndices.base, width: '100%', }, }, diff --git a/packages/gamut/src/Tip/PreviewTip/elements.tsx b/packages/gamut/src/Tip/PreviewTip/elements.tsx index 6f42308c3c4..21cde41ef91 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, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useMemo } from 'react'; @@ -147,7 +147,7 @@ export const PreviewTipShadow: React.FC = ({ return ( diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 871cfaac1ac..b59608e1459 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -1,3 +1,5 @@ +import { zIndices } from '@codecademy/gamut-styles'; + import { InfoTipContainer } from '../InfoTip/styles'; import { PreviewTipContents, PreviewTipShadow } from '../PreviewTip/elements'; import { ToolTipContainer } from '../ToolTip/elements'; @@ -55,7 +57,7 @@ export const InlineTip: React.FC = ({ const tipBody = ( = ({ colorMode = 'light', }) => { return ( - + diff --git a/packages/gamut/src/Typography/Text.tsx b/packages/gamut/src/Typography/Text.tsx index 173661b8632..a05bf9d53d0 100644 --- a/packages/gamut/src/Typography/Text.tsx +++ b/packages/gamut/src/Typography/Text.tsx @@ -3,6 +3,7 @@ import { styledOptions, system, variant, + zIndices, } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -76,7 +77,7 @@ const textStates = states({ fontWeight: 'bold', minWidth: '0.4rem', position: 'relative', - zIndex: 1, + zIndex: zIndices.foreground, // the text is more legible against the background color with text smoothing MozOsxFontSmoothing: 'grayscale', WebkitFontSmoothing: 'antialiased', @@ -90,7 +91,7 @@ const textStates = states({ position: 'absolute', top: '50%', width: 'calc(100% + 0.4rem)', - zIndex: -1, + zIndex: zIndices.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..d597f7f639f --- /dev/null +++ b/packages/styleguide/src/lib/Foundations/ZIndex.mdx @@ -0,0 +1,75 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import { ComponentHeader, LinkTo, 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', +}; + + + + + +Gamut exposes one semantic z-index scale, `zIndices`, 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 { zIndices } from '@codecademy/gamut-styles'; + +; +``` + +## The scale + +Values run in two bands. **In-flow** tokens (below `0`–`100`) order elements relative to their +siblings inside a normal stacking context. **Portal** tokens (`200`+) order elements that render +into a portal at `document.body` and compete at the page root. Portal values are spaced by 100. + + + +- **`underlay` → `foreground`** are in-flow: a decorative layer behind content, a neutral ground + that establishes a stacking context, and content raised in front (including sticky headers). +- **`portal` → `tooltip`** are the portal layers, ordered so the things that must never be clipped + (tooltips, toasts, popovers) sit above dialogs, which sit above side panels and the app bar. +- `portal` is the floor of the portal band and the default for `BodyPortal`, so portaled content + that omits a specific token still lands above page content. + +## Why it's a _soft_ scale + +Unlike hard token scales such as borderRadii or spacing — +where the prop is bound to the scale and **only** accepts token values — the `zIndex` prop is left +**numeric**. `zIndices` is just a numeric object you reference by name; the prop still accepts a raw +number. This is deliberate: + +- **Escape hatch.** Stacking occasionally needs a one-off value between two layers (e.g. a confirm + dialog nested inside a modal). You can pass a raw in-between number without leaving the system: + + ```tsx + ; // just above modal + ; // deliberate one-off + ``` + +- **Arithmetic.** Some components derive a relative value from a base (e.g. a tooltip's shadow sits + a step behind its body: `zIndices.foreground - 2`). A token-only string prop can't express that. + +Binding `zIndex` to the scale as a hard token would break both of the above (variance scaled props +reject raw numbers), so it stays soft. To keep it from sliding back into magic numbers, the +`gamut/no-raw-z-index` lint rule flags bare numeric z-index values and steers you to a token; a +genuine escape hatch uses an inline `eslint-disable` with a short justification. + +## Guidance + +- **Reach for a token first.** `zIndices.modal`, `zIndices.foreground`, etc. — the name documents + intent far better than a number. +- **In-flow vs. portal.** Use the in-flow tokens for stacking within a component; only portaled + content belongs on the `portal`+ band. +- **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; `tooltip` 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..a74f1e51a82 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, + zIndices as zIndicesTokens, } 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,91 @@ 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' }, + ], + toaster: [{ label: 'Toaster', id: 'Molecules/Toasts/Toaster' }], + tooltip: [{ label: 'ToolTip', id: 'Molecules/Tips/ToolTip' }], +}; + +// Tokens with no single component example get a short note instead of links. +const zIndexNotes: Record = { + portal: 'BodyPortal default', + widget: 'Floating launchers (none in Gamut yet)', + appBar: 'App header / nav (app-owned)', +}; + +export const zIndices = { + // Object insertion order runs low → high (underlay … tooltip). + rows: Object.entries(zIndicesTokens).map(([id, value]) => ({ + id, + value, + })), + columns: [ + { ...PROP_COLUMN, name: 'Token' }, + { + ...PATH_COLUMN, + render: ({ id }: any) => zIndices.{id}, + }, + VALUE_COLUMN, + { + key: 'band', + name: 'Band', + size: 'lg', + render: ({ value }: any) => ( + {value >= zIndicesTokens.portal ? 'portal' : 'in-flow'} + ), + }, + { + 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 = () => (