diff --git a/docs/slideouts.md b/docs/slideouts.md index d39b666ea01..3b29c76652e 100644 --- a/docs/slideouts.md +++ b/docs/slideouts.md @@ -461,8 +461,9 @@ Two things to know if you touch this area: - **Don't reuse legacy class names.** The legacy stylesheet owns `.slideout-shade` and hides it with `:not(.visible) { display: none }`. The shared shade is `.cp-slideout-shade` for that reason, and - it sits at `z-index: 99` — one below both kinds of panel, so it stays underneath them regardless - of the order they were appended to `` in. + it sits at `--c-z-slideout-shade` — one rung below `--c-z-slideout`, which both kinds of panel + use, so it stays underneath them regardless of the order they were appended to `` in. See + [z-layers.md](z-layers.md). - The legacy payload's key order is pinned by `tests/Feature/Http/Responses/CpScreenSlideoutTest.php`. If that test goes red, the jQuery slideout stack is broken. diff --git a/docs/z-layers.md b/docs/z-layers.md new file mode 100644 index 00000000000..8518baf9378 --- /dev/null +++ b/docs/z-layers.md @@ -0,0 +1,110 @@ +# Z-layers + +Every surface in the CP that escapes normal flow picks a **rung** from a shared ladder instead of +inventing a number. The ladder is declared once, in two mirrored files: + +| File | For | +| --- | --- | +| [`packages/craftcms-ui/src/styles/shared/z-layers.css`](../packages/craftcms-ui/src/styles/shared/z-layers.css) | CSS (`var(--c-z-modal)`). The source of truth. | +| [`packages/craftcms-ui/src/constants/z-layers.ts`](../packages/craftcms-ui/src/constants/z-layers.ts) | JS (`ZLayer.Modal`), for overlay configs and inline styles. | + +`src/styles/z-layers.test.ts` asserts the two agree, so a rung added or moved in one has to be added +or moved in the other. + +The stylesheet is imported by `@craftcms/ui/styles/cp.css`, which `resources/css/cp.css` pulls in, +which the CP's only layout (`resources/views/app.blade.php`) always loads. The tokens are therefore +available on every CP page, including inside shadow roots — custom properties inherit through the +shadow boundary. + +## The ladder + +### Local — within a component's own stacking context + +| Token | `ZLayer` | Value | Use for | +| --- | --- | --- | --- | +| `--c-z-behind` | `Behind` | `-1` | Decorative fill painted behind its own content | +| `--c-z-base` | `Base` | `0` | Explicitly on the baseline — mostly to reset a lift | +| `--c-z-raised` | `Raised` | `1` | Lifted above sibling content (a check overlay, a focus ring) | +| `--c-z-floating` | `Floating` | `2` | Above a sibling that's already raised | +| `--c-z-sticky` | `Sticky` | `10` | Sticky headers/footers/toolbars inside a scroll container | + +### Page-level — competing with the rest of the CP + +| Token | `ZLayer` | Value | Use for | +| --- | --- | --- | --- | +| `--c-z-page-header` | `PageHeader` | `2000` | Sticky page and editor headers | +| `--c-z-nav` | `Nav` | `2100` | Persistent CP chrome — the global sidebar | +| `--c-z-drag` | `Drag` | `3000` | Drag helpers and drop indicators | +| `--c-z-slideout-shade` | `SlideoutShade` | `4000` | The shade behind a slideout | +| `--c-z-slideout` | `Slideout` | `4100` | Slideout panels and their container | +| `--c-z-modal-shade` | `ModalShade` | `5000` | The shade behind a modal | +| `--c-z-modal` | `Modal` | `5100` | Modal panels | +| `--c-z-overlay` | `Overlay` | `6000` | Menus, comboboxes, selects, popovers, HUDs | +| `--c-z-notification` | `Notification` | `7000` | Toasts and notifications | +| `--c-z-tooltip` | `Tooltip` | `8000` | Tooltips | +| `--c-z-debug` | `Debug` | `9000` | Dev-only chrome (the debug toolbar) | + +Rungs are spaced by 1000 (100 within a shade/panel pair) so a new layer can be slotted between two +existing ones without renumbering anything. + +## Rules + +**Pick local unless the thing is attached to ``.** A page-level rung only wins if no ancestor +has created a stacking context, so using one from inside a component works right up until somebody +adds a `transform`, an `opacity`, or a `filter` above it. If the surface renders in place, it's +local; if it's teleported, portalled, or appended to ``, it's page-level. + +**Anchored overlays sit above modals on purpose.** `--c-z-overlay` is above `--c-z-modal` because an +overlay is opened *from* a surface and has to paint above whichever surface opened it — a menu inside +a modal is ordinary, and there's no reliable way for the menu to know what it was opened from. +`--c-z-tooltip` is highest for the same reason: anything at all can have a tooltip. + +**Don't add a rung for one component.** Reach for `--c-z-raised` / `--c-z-floating` first. A new rung +is warranted only when a surface genuinely has to be ordered against other page-level surfaces. + +## What the ladder doesn't cover + +### The top layer + +`craft-dialog` is a Lion modal dialog, which opens with `HTMLDialogElement.showModal()`. Top-layer +content paints above every z-indexed element regardless of the number, so **no rung will ever cover +it**, and ordering *between* top-layer elements is order-of-entry rather than z-index. + +Lion's non-modal overlays (`craft-popover` and everything built on it, `craft-tooltip`, +`craft-select-rich`, `craft-combobox`) use a `` too, but open it non-modally — those stay +z-indexed and are on the ladder. Lion writes the value inline on its wrapping ``, so it can't +be styled from a stylesheet; each component passes it through `_defineOverlayConfig()`: + +```ts +override _defineOverlayConfig() { + return {...super._defineOverlayConfig(), zIndex: ZLayer.Overlay}; +} +``` + +A new Lion-based overlay that forgets this inherits Lion's default of `9999`, which lands above every +rung — visibly wrong only in that it out-stacks tooltips. + +### The legacy CP bundle + +`packages/craftcms-legacy/cp/src/css` still uses raw numbers, topping out at `1001` (`.prompt`, the +login screen). That's why the page-level band starts at `2000`: every rung clears legacy without +legacy having to be renumbered first, and the two stacks can share a page — which they do on any +`CpScreenResponse` screen, where the Inertia shell wraps PHP-rendered inner HTML. + +Roughly, legacy occupies: + +| Legacy value | What | Ladder equivalent | +| --- | --- | --- | +| `99` | `.craft-tooltip` | `--c-z-tooltip` | +| `100`/`101` | Garnish HUDs and modals, `#notifications`, datepicker/timepicker, selectize dropdowns, live preview | `--c-z-overlay`, `--c-z-notification`, `--c-z-modal` | +| `1000` | `.progressbar` | `--c-z-page-header` | +| `1001` | `.prompt`, login | `--c-z-modal` | +| `1000000` | chart tooltips | `--c-z-tooltip` | + +Migrating those is deliberately **not** part of the ladder's introduction: the legacy CSS is a +prebuilt webpack bundle whose ordering is load-bearing for surfaces that no longer have tests, and +its numbers are internally consistent with each other. Port a legacy surface onto the ladder when you +port the surface itself. + +`@craftcms/garnish` is standalone and can't import `@craftcms/ui`, so `Drag`'s `helperBaseZindex` +default repeats `--c-z-drag`'s value (`3000`) rather than referencing it. diff --git a/packages/craftcms-garnish/docs/api-reference.md b/packages/craftcms-garnish/docs/api-reference.md index dabd134f392..7d0c197651a 100644 --- a/packages/craftcms-garnish/docs/api-reference.md +++ b/packages/craftcms-garnish/docs/api-reference.md @@ -398,7 +398,7 @@ also `new Drag(settings)` (param shift when the first arg is a plain object). | `helperOpacity` | `1` | Helper opacity (`1` → no override). | | `moveHelperToCursor` | `false` | Put the helper's top-left at the cursor instead of the grab offset. | | `helper` | `null` | Helper wrapper: `(helper, index) => wrapped`, an element/markup to wrap into, or `null` (bare clone). | -| `helperBaseZindex` | `1000` | Base z-index for helpers. | +| `helperBaseZindex` | `3000` | Base z-index for helpers — the `--c-z-drag` rung of the CP's stacking ladder. | | `helperLagBase` | `3` | Base follow-lag divisor. | | `helperLagIncrementDividend` | `1.5` | Per-helper lag increment dividend. | | `helperSpacingX` / `helperSpacingY` | `5` / `5` | Per-index helper offset (px). | diff --git a/packages/craftcms-garnish/src/drag/drag.ts b/packages/craftcms-garnish/src/drag/drag.ts index c2e84a09a0f..122b8e6595d 100644 --- a/packages/craftcms-garnish/src/drag/drag.ts +++ b/packages/craftcms-garnish/src/drag/drag.ts @@ -79,6 +79,12 @@ export interface DragSettings extends BaseDragSettings { * element/markup the clone is appended into. `null` → use the bare clone. */ helper: DragHelper; + /** + * Base z-index for drag helpers, which are appended to `` and follow + * the pointer over the CP's chrome. Defaults to the `--c-z-drag` rung of the + * CP's stacking ladder (see `docs/z-layers.md`) — Garnish is standalone and + * can't import `@craftcms/ui`, so the number is repeated here. + */ helperBaseZindex: number; helperLagBase: number; helperLagIncrementDividend: number; @@ -110,7 +116,7 @@ export class Drag extends BaseDrag { helperOpacity: 1, moveHelperToCursor: false, helper: null, - helperBaseZindex: 1000, + helperBaseZindex: 3000, helperLagBase: 3, helperLagIncrementDividend: 1.5, helperSpacingX: 5, diff --git a/packages/craftcms-garnish/tests/drag-drop.test.ts b/packages/craftcms-garnish/tests/drag-drop.test.ts index 811b3b1b111..b867473ffd5 100644 --- a/packages/craftcms-garnish/tests/drag-drop.test.ts +++ b/packages/craftcms-garnish/tests/drag-drop.test.ts @@ -108,7 +108,7 @@ describe('Drag settings + defaults', () => { expect(d.settings!.minMouseDist).toBe(7); // Drag default survives. expect(d.settings!.hideDraggee).toBe(true); - expect(d.settings!.helperBaseZindex).toBe(1000); + expect(d.settings!.helperBaseZindex).toBe(3000); // BaseDrag default survives. expect(d.settings!.ignoreHandleSelector).toBe( 'input, textarea, button, select, .btn' @@ -332,8 +332,8 @@ describe('Drag._createHelper', () => { expect(helper.style.boxSizing).toBe('border-box'); expect(helper.style.display).toBe('block'); expect(helper.style.pointerEvents).toBe('none'); - // zIndex = base(1000) + draggeeLength(1) - index(0) = 1001 - expect(helper.style.zIndex).toBe('1001'); + // zIndex = base(3000) + draggeeLength(1) - index(0) = 3001 + expect(helper.style.zIndex).toBe('3001'); // real=true target: mouseX - mouseOffsetX = 90, mouseY - mouseOffsetY = 45 expect(helper.style.left).toBe('90px'); expect(helper.style.top).toBe('45px'); diff --git a/packages/craftcms-ui/src/components/combobox/combobox.ts b/packages/craftcms-ui/src/components/combobox/combobox.ts index 762b1c9ddc7..20b35d1b19a 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.ts @@ -1,4 +1,5 @@ import {LionCombobox} from '@lion/ui/combobox.js'; +import {ZLayer} from '@src/constants/z-layers.js'; import {html, nothing, render} from 'lit'; import {property} from 'lit/decorators.js'; import styles from './combobox.styles.js'; @@ -64,6 +65,12 @@ export default class CraftCombobox extends LionCombobox { return [...super.styles, styles]; } + /** Puts the listbox on the CP's stacking ladder instead of Lion's 9999. */ + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + override _defineOverlayConfig() { + return {...super._defineOverlayConfig(), zIndex: ZLayer.Overlay}; + } + /** Options to render. Groups are supported via `type: 'optgroup'`. */ @property({type: Array}) options: ComboboxItem[] = []; diff --git a/packages/craftcms-ui/src/components/field/field.styles.ts b/packages/craftcms-ui/src/components/field/field.styles.ts index 75337546924..f81000b16b1 100644 --- a/packages/craftcms-ui/src/components/field/field.styles.ts +++ b/packages/craftcms-ui/src/components/field/field.styles.ts @@ -36,7 +36,7 @@ export default css` width: 2px; height: 100%; cursor: help; - z-index: 1; + z-index: var(--c-z-raised, 1); border-radius: 1px; } diff --git a/packages/craftcms-ui/src/components/pane/pane.styles.ts b/packages/craftcms-ui/src/components/pane/pane.styles.ts index d1ac843d89e..c9df59a314c 100644 --- a/packages/craftcms-ui/src/components/pane/pane.styles.ts +++ b/packages/craftcms-ui/src/components/pane/pane.styles.ts @@ -148,7 +148,7 @@ export default css` padding-block: var(--_pane-spacing) 0; position: sticky; inset-block-start: 0; - z-index: 10; + z-index: var(--c-z-sticky, 10); background-color: var(--_pane-background); } @@ -179,7 +179,7 @@ export default css` padding-block: calc(var(--_pane-spacing) / 2); position: sticky; inset-block-end: 0; - z-index: 10; + z-index: var(--c-z-sticky, 10); } .cp-pane__spacer { diff --git a/packages/craftcms-ui/src/components/popover/popover.ts b/packages/craftcms-ui/src/components/popover/popover.ts index 22958422809..0e08eb5599e 100644 --- a/packages/craftcms-ui/src/components/popover/popover.ts +++ b/packages/craftcms-ui/src/components/popover/popover.ts @@ -1,6 +1,7 @@ import {html, LitElement, type PropertyValues} from 'lit'; import {property} from 'lit/decorators.js'; import {OverlayMixin, withDropdownConfig} from '@lion/ui/overlays.js'; +import {ZLayer} from '@src/constants/z-layers.js'; import type {VirtualElement} from '@popperjs/core'; import {wireOverlayLifecycleEvents} from '@src/utilities/overlay-events.js'; import {viewportEscapingModifiers} from '@src/utilities/overlay-position.js'; @@ -73,6 +74,11 @@ export default class CraftPopover extends OverlayMixin(LitElement) { _defineOverlayConfig() { return { ...withDropdownConfig(), + // Lion renders the overlay into a non-modal `` appended to + // `` and writes this inline, defaulting to 9999 — high enough to + // clear anything, which is how it ended up above the CP's modals by + // accident. Put it on the ladder instead. + zIndex: ZLayer.Overlay, inheritsReferenceWidth: this.matchInvokerWidth ? 'min' : 'none', popperConfig: { // Position relative to the viewport so the overlay escapes any diff --git a/packages/craftcms-ui/src/components/select-rich/select-rich.ts b/packages/craftcms-ui/src/components/select-rich/select-rich.ts index 94256263707..03f3c3832c0 100644 --- a/packages/craftcms-ui/src/components/select-rich/select-rich.ts +++ b/packages/craftcms-ui/src/components/select-rich/select-rich.ts @@ -1,4 +1,5 @@ import {LionSelectRich} from '@lion/ui/select-rich.js'; +import {ZLayer} from '@src/constants/z-layers.js'; import {html} from 'lit'; import {property} from 'lit/decorators.js'; import styles from './select-rich.styles.js'; @@ -35,6 +36,12 @@ export default class CraftSelectRich extends LionSelectRich { }; } + /** Puts the listbox on the CP's stacking ladder instead of Lion's 9999. */ + // @ts-ignore Lion's OverlayMixin is typed via JSDoc. + override _defineOverlayConfig() { + return {...super._defineOverlayConfig(), zIndex: ZLayer.Overlay}; + } + /** Renders the invoker at a smaller size. */ @property({reflect: true, type: Boolean}) small = false; diff --git a/packages/craftcms-ui/src/components/slide-picker/slide-picker.styles.ts b/packages/craftcms-ui/src/components/slide-picker/slide-picker.styles.ts index 9e3b34ae0ff..9fc36c4c9cb 100644 --- a/packages/craftcms-ui/src/components/slide-picker/slide-picker.styles.ts +++ b/packages/craftcms-ui/src/components/slide-picker/slide-picker.styles.ts @@ -54,7 +54,7 @@ export default css` outline: var(--c-focus-outline-width) solid var(--c-color-focus-outline); outline-offset: var(--c-focus-outline-offset); position: relative; - z-index: 1; + z-index: var(--c-z-raised, 1); } :host([read-only]) .slide-picker__segment { diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts index 88f3ef8d924..9ebafbc046d 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts @@ -24,7 +24,7 @@ export default css` margin-inline-start: calc(-4 / 16 * 1rem); margin-block-start: 4px; inset-inline-start: 50%; - z-index: 1; + z-index: var(--c-z-raised, 1); width: 0; height: 0; border-inline-start: calc(5 / 16 * 1rem) solid transparent; @@ -43,7 +43,7 @@ export default css` } .overlay { - z-index: 2; + z-index: var(--c-z-floating, 2); position: absolute; inset-block: 0 1px; inset-inline: 0; diff --git a/packages/craftcms-ui/src/components/tabs/tabs.styles.ts b/packages/craftcms-ui/src/components/tabs/tabs.styles.ts index 793409a4cf6..076b93a258b 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.styles.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.styles.ts @@ -76,7 +76,7 @@ export default css` /* Above the tabs, so the invoker's own click-target pseudo-element can't be covered by the tab beside it. */ position: relative; - z-index: 1; + z-index: var(--c-z-raised, 1); } .tabs__overflow[hidden] { diff --git a/packages/craftcms-ui/src/components/tooltip/tooltip.ts b/packages/craftcms-ui/src/components/tooltip/tooltip.ts index 6ce447ac593..7b966bf1e71 100644 --- a/packages/craftcms-ui/src/components/tooltip/tooltip.ts +++ b/packages/craftcms-ui/src/components/tooltip/tooltip.ts @@ -4,6 +4,7 @@ import {LionTooltip} from '@lion/ui/tooltip.js'; import {withTooltipConfig} from '@lion/ui/overlays.js'; import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; import {viewportEscapingModifiers} from '../../utilities/overlay-position.js'; +import {ZLayer} from '../../constants/z-layers.js'; /** * craft-tooltip shows contextual text for an external trigger element @@ -114,7 +115,7 @@ export default class CraftTooltip extends LionTooltip { // @ts-ignore Lion's OverlayMixin is typed via JSDoc. override _defineOverlayConfig() { - const config = {...super._defineOverlayConfig()}; + const config = {...super._defineOverlayConfig(), zIndex: ZLayer.Tooltip}; if (this.#isClickTriggered || this.#isManual) { // Disable Lion's hover/focus interaction. diff --git a/packages/craftcms-ui/src/constants/z-layers.ts b/packages/craftcms-ui/src/constants/z-layers.ts new file mode 100644 index 00000000000..e23a9c437f3 --- /dev/null +++ b/packages/craftcms-ui/src/constants/z-layers.ts @@ -0,0 +1,45 @@ +/** + * The CP's stacking ladder, as numbers. + * + * A mirror of `styles/shared/z-layers.css`, which is the documented source of + * truth — read that file for what each rung means and why the page-level band + * starts at 2000. This copy exists for the places that can't reach a custom + * property: Lion overlay configs (which write `z-index` inline onto the + * wrapping ``) and JS that sets `style.zIndex` directly. + * + * `z-layers.test.ts` parses the CSS and asserts the two agree, so a rung added + * or moved in one file has to be added or moved in the other. + */ +export const ZLayer = { + /* Local: within a component's own stacking context. */ + Behind: -1, + Base: 0, + Raised: 1, + Floating: 2, + Sticky: 10, + + /* Page-level: competing with the rest of the CP. */ + PageHeader: 2000, + Nav: 2100, + Drag: 3000, + SlideoutShade: 4000, + Slideout: 4100, + ModalShade: 5000, + Modal: 5100, + Overlay: 6000, + Notification: 7000, + Tooltip: 8000, + Debug: 9000, +} as const; + +export type ZLayerKey = keyof typeof ZLayer; +export type ZLayerValue = (typeof ZLayer)[ZLayerKey]; + +/** + * The custom property each rung is published as, e.g. `PageHeader` → + * `--c-z-page-header`. Used by the sync test, and by anything that would rather + * hand CSS a `var()` than a hard number. + */ +export function zLayerProperty(layer: ZLayerKey): string { + return `--c-z-${layer.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()}`; +} diff --git a/packages/craftcms-ui/src/index.ts b/packages/craftcms-ui/src/index.ts index 3229b000048..369729dec9a 100644 --- a/packages/craftcms-ui/src/index.ts +++ b/packages/craftcms-ui/src/index.ts @@ -146,3 +146,4 @@ export {default as visuallyHiddenStyles} from './styles/visually-hidden.styles.j export * from './constants/variants'; export * from './constants/appearances'; export * from './constants/colors'; +export * from './constants/z-layers'; diff --git a/packages/craftcms-ui/src/styles/cp.css b/packages/craftcms-ui/src/styles/cp.css index fd41f9584b3..6e5b1a76002 100644 --- a/packages/craftcms-ui/src/styles/cp.css +++ b/packages/craftcms-ui/src/styles/cp.css @@ -3,6 +3,7 @@ @import './shared/preflight.css' layer(preflight); @import './shared/variables.css' layer(theme); @import './shared/tokens.css' layer(theme); +@import './shared/z-layers.css' layer(theme); @import './shared/colorable.css' layer(theme); @import './shared/base.css' layer(base); diff --git a/packages/craftcms-ui/src/styles/shared/z-layers.css b/packages/craftcms-ui/src/styles/shared/z-layers.css new file mode 100644 index 00000000000..bcd737c0a9e --- /dev/null +++ b/packages/craftcms-ui/src/styles/shared/z-layers.css @@ -0,0 +1,94 @@ +/** + * Z-layers — the CP's stacking ladder. + * + * Every surface that escapes normal flow picks a rung here instead of inventing + * a number. Mirrored one-for-one by `constants/z-layers.ts` for the places that + * need the value in JS (Lion overlay configs, inline styles); a unit test keeps + * the two in sync, so change both together. + * + * Two bands, and the distinction matters: + * + * - **Local** (`behind`…`sticky`) is for stacking *inside* a component's own + * stacking context — a check overlay on a thumbnail, a sticky footer inside a + * scroll pane. These are deliberately tiny and are safe to use anywhere, + * because they only ever compete with their own siblings. + * + * - **Page-level** (`page-header` and up) is for surfaces that compete with the + * rest of the CP. Reaching for one of these from inside a component is almost + * always the wrong call: it only wins if no ancestor has created a stacking + * context, so it works until someone adds a `transform` or an `opacity` above + * it. Use these on things attached to `` (or to the CP shell). + * + * Two things live outside the ladder entirely and can't be ordered against it: + * + * - **The top layer.** `craft-dialog` is a Lion modal dialog, which opens via + * `HTMLDialogElement.showModal()`. Top-layer content paints above every + * z-indexed element no matter how large the number, so no rung here will ever + * cover it. Order between top-layer elements is order-of-entry, not z-index. + * - **The legacy CP bundle** (`packages/craftcms-legacy/cp/src/css`), which + * still uses raw numbers topping out at 1001. That's why the page-level band + * starts at 2000 rather than at 1 — every rung clears legacy without legacy + * having to be renumbered first. See `docs/z-layers.md`. + * + * Rungs are spaced by 1000 (100 within a pair) so a new layer can be slotted + * between two existing ones without a renumber. + */ +:root, +:host { + /* --- Local: within a component's own stacking context --- */ + + /** Decorative fill painted behind its own content. */ + --c-z-behind: -1; + + /** Explicitly on the flow's baseline — mostly for resetting a lift. */ + --c-z-base: 0; + + /** Lifted above sibling content (a check overlay, a focus ring). */ + --c-z-raised: 1; + + /** Above a sibling that's already `--c-z-raised`. */ + --c-z-floating: 2; + + /** Sticky headers/footers/toolbars pinned inside a scroll container. */ + --c-z-sticky: 10; + + /* --- Page-level: competing with the rest of the CP --- */ + + /** Sticky page and editor headers, above the content they scroll over. */ + --c-z-page-header: 2000; + + /** Persistent CP chrome — the global sidebar. Above page headers, because a + collapsed rail's labels overflow across them. */ + --c-z-nav: 2100; + + /** Drag helpers and drop indicators, which follow the pointer over chrome. */ + --c-z-drag: 3000; + + /** The shade behind a slideout. */ + --c-z-slideout-shade: 4000; + + /** Slideout panels and their container. */ + --c-z-slideout: 4100; + + /** The shade behind a modal. */ + --c-z-modal-shade: 5000; + + /** Modal panels. */ + --c-z-modal: 5100; + + /** Anchored overlays — menus, comboboxes, selects, popovers, HUDs. Above + modals on purpose: an overlay is opened *from* a surface, so it has to + paint above whichever surface opened it, and a menu inside a modal is + ordinary. */ + --c-z-overlay: 6000; + + /** Toasts and notifications, which have to stay readable over an overlay. */ + --c-z-notification: 7000; + + /** Tooltips. Above everything else that's addressable, since anything at all + can have one. */ + --c-z-tooltip: 8000; + + /** Dev-only chrome (the debug toolbar). Nothing ships above this. */ + --c-z-debug: 9000; +} diff --git a/packages/craftcms-ui/src/styles/z-layers.test.ts b/packages/craftcms-ui/src/styles/z-layers.test.ts new file mode 100644 index 00000000000..9a5fe867fbf --- /dev/null +++ b/packages/craftcms-ui/src/styles/z-layers.test.ts @@ -0,0 +1,55 @@ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +import {describe, expect, it} from 'vite-plus/test'; + +import { + ZLayer, + zLayerProperty, + type ZLayerKey, +} from '@src/constants/z-layers.js'; + +// Read rather than import: Vitest stubs CSS imports (`test.css` defaults to +// off), so `?raw` would hand back an empty string. `cwd` is the package root. +const css = readFileSync( + join(process.cwd(), 'src/styles/shared/z-layers.css'), + 'utf8' +); + +/** Every `--c-z-*: ;` declaration in the stylesheet, as a name → value map. */ +const declared = new Map( + [...css.matchAll(/(--c-z-[a-z-]+):\s*(-?\d+);/g)].map(([, name, value]) => [ + name, + Number(value), + ]) +); + +const layers = Object.keys(ZLayer) as ZLayerKey[]; + +/** + * `z-layers.css` is the documented source of truth and `constants/z-layers.ts` + * is its mirror for JS. Nothing generates one from the other, so these assert + * they haven't drifted. + */ +describe('z-layers', () => { + it.each(layers)('publishes %s as a custom property', (layer) => { + expect(declared.get(zLayerProperty(layer))).toBe(ZLayer[layer]); + }); + + it('declares no custom property the constant map is missing', () => { + const expected = layers.map(zLayerProperty).sort(); + expect([...declared.keys()].sort()).toEqual(expected); + }); + + it('orders the rungs the same way it lists them', () => { + const values = layers.map((layer) => ZLayer[layer] as number); + expect(values).toEqual([...values].sort((a, b) => a - b)); + }); + + it('clears the legacy CP bundle, which tops out at 1001', () => { + const pageLevel = layers.slice(layers.indexOf('PageHeader')); + for (const layer of pageLevel) { + expect(ZLayer[layer]).toBeGreaterThan(1001); + } + }); +}); diff --git a/resources/css/cp.css b/resources/css/cp.css index b4fff33991a..e1782f06603 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -146,7 +146,7 @@ Remove once we have a stable way to create a `` via twig */ .cp-checkbox-select__item craft-reorder-button { position: relative; - z-index: 1; + z-index: var(--c-z-raised); } /** diff --git a/resources/css/fld.css b/resources/css/fld.css index 523f031ef2f..e9bcc307993 100644 --- a/resources/css/fld.css +++ b/resources/css/fld.css @@ -412,7 +412,7 @@ body:not(.dragging) .fld-element { padding-inline: var(--padding); box-shadow: var(--c-pane-shadow); background-color: var(--c-color-neutral-fill-quiet); - z-index: 3; + z-index: var(--c-z-floating); } .fld-element-settings-footer > .ee-site-select { diff --git a/resources/css/global-sidebar.css b/resources/css/global-sidebar.css index b17f31745b0..3889072a7d6 100644 --- a/resources/css/global-sidebar.css +++ b/resources/css/global-sidebar.css @@ -13,7 +13,10 @@ border-inline-end: 1px solid var(--border-hairline); width: var(--global-sidebar-width); isolation: isolate; - z-index: 1; + /* Local, not `--c-z-nav`: this is the sidebar as rendered inside the legacy + CP shell, whose modals and HUDs still sit at 100/101. The Inertia shell's + sidebar is `CpSidebar.vue`, which is on the ladder. */ + z-index: var(--c-z-raised); @media only screen and (width <= 1999px) { --is-always-visible: false; diff --git a/resources/css/slideout.css b/resources/css/slideout.css index e89062c3079..c36db4d02e7 100644 --- a/resources/css/slideout.css +++ b/resources/css/slideout.css @@ -7,7 +7,7 @@ the shade, the positioned container, the panel itself, and the generic sidebar, preview thumb, image actions) are not included here. */ .slideout-shade { - z-index: 100; + z-index: var(--c-z-slideout-shade); position: fixed; inset-block-start: 0; inset-inline-start: 0; @@ -31,7 +31,7 @@ sidebar, preview thumb, image actions) are not included here. } .slideout-container { - z-index: 100; + z-index: var(--c-z-slideout); box-sizing: border-box; position: fixed; inset-block-start: 0; @@ -55,7 +55,7 @@ body.has-debug-toolbar .slideout-container { } .slideout { - z-index: 100; + z-index: var(--c-z-slideout); box-sizing: border-box; position: absolute; background-color: var(--c-modal-fill); @@ -153,7 +153,8 @@ body.has-debug-toolbar .slideout-container { padding-inline: var(--c-spacing-md); border-block-start: var(--c-color-neutral-border-quiet); background: var(--c-modal-fill); - z-index: 3; + /* Above the scrolled body it overlaps, inside the slideout's own context. */ + z-index: var(--c-z-floating); } .slideout__footer > .so-notice { diff --git a/resources/js/common/components/CpSidebar.vue b/resources/js/common/components/CpSidebar.vue index c195339ded6..7f794c244af 100644 --- a/resources/js/common/components/CpSidebar.vue +++ b/resources/js/common/components/CpSidebar.vue @@ -81,11 +81,11 @@