Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/slideouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>` 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 `<body>` 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.

Expand Down
110 changes: 110 additions & 0 deletions docs/z-layers.md
Original file line number Diff line number Diff line change
@@ -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 `<body>`.** 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 `<body>`, 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 `<dialog>` too, but open it non-modally — those stay
z-indexed and are on the ladder. Lion writes the value inline on its wrapping `<dialog>`, 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.
2 changes: 1 addition & 1 deletion packages/craftcms-garnish/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
8 changes: 7 additions & 1 deletion packages/craftcms-garnish/src/drag/drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>` 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;
Expand Down Expand Up @@ -110,7 +116,7 @@ export class Drag<S extends DragSettings = DragSettings> extends BaseDrag<S> {
helperOpacity: 1,
moveHelperToCursor: false,
helper: null,
helperBaseZindex: 1000,
helperBaseZindex: 3000,
helperLagBase: 3,
helperLagIncrementDividend: 1.5,
helperSpacingX: 5,
Expand Down
6 changes: 3 additions & 3 deletions packages/craftcms-garnish/tests/drag-drop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions packages/craftcms-ui/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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[] = [];

Expand Down
2 changes: 1 addition & 1 deletion packages/craftcms-ui/src/components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/craftcms-ui/src/components/pane/pane.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/craftcms-ui/src/components/popover/popover.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -73,6 +74,11 @@ export default class CraftPopover extends OverlayMixin(LitElement) {
_defineOverlayConfig() {
return {
...withDropdownConfig(),
// Lion renders the overlay into a non-modal `<dialog>` appended to
// `<body>` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/craftcms-ui/src/components/tabs/tabs.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
3 changes: 2 additions & 1 deletion packages/craftcms-ui/src/components/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions packages/craftcms-ui/src/constants/z-layers.ts
Original file line number Diff line number Diff line change
@@ -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 `<dialog>`) 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()}`;
}
1 change: 1 addition & 0 deletions packages/craftcms-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/craftcms-ui/src/styles/cp.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading