Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4145,6 +4145,16 @@ Type guard: true for a `NavSection` (a titled group of items), false for a plain
function isNavSection(item: NavItem | NavSection): item is NavSection
```

### layers

`import { layers } from "@codeatlasdev/ui"`

Reactive state of the layer stack. `layers.modalOpen` is true while a dialog, drawer or sheet is open; `layers.blocks(el)` is false for a trigger inside the topmost modal.

```ts
const layers: { readonly modalOpen: boolean; blocks(trigger?: Element | null): boolean; }
```

### letterSpacing

`import { letterSpacing } from "@codeatlasdev/ui"`
Expand Down
5 changes: 5 additions & 0 deletions .changeset/layers-store-modal-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": minor
---

New `layers` store: `layers.modalOpen` is reactive and true while a modal layer (Dialog, AlertDialog, Drawer, Sheet) is open; `layers.blocks(trigger)` is the same but false for a trigger inside the topmost modal. `Tooltip`, `HoverCard` and `NavigationMenuItem` no longer open on pointer movement while a modal is open (unless their trigger is inside it), so a hover panel can no longer appear under a dialog. Gate your own hover or edge UI with the store.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ Reading failures: locators fail with an ARIA snapshot; traces are in `__traces__

10a. **Cursor**: never write `cursor: pointer` for a clickable element. The reset (`@layer ca.reset`) gives `pointer` to `button`, `a[href]`, `label[for]`, `summary`, `[role=button|tab|option|menuitem*]` and `not-allowed` to `:disabled` / `[aria-disabled=true]`; a custom clickable tile is `<button>` or carries a role from that list. Override it with an unlayered app rule.

10b. **Hover UI under a modal**: never open a hover- or edge-triggered panel while a modal is open. `Tooltip`, `HoverCard` and `NavigationMenu` already check the layer stack; for your own, gate on `layers.modalOpen` (or `layers.blocks(trigger)`, false for a trigger inside the topmost modal) from `@codeatlasdev/ui`. Never fight it with `z-index`: it has no effect in the top layer.

10c. **Code surfaces** (an editor, a line highlight, a gutter, a custom `pre`): `line-height: var(--ca-code-line-height)` (an integer px; a fractional one makes a caret and an active-line highlight drift apart) and `font-family: var(--ca-font-mono)` on every layer, `code` included. `CodeSnippet`, `.ca-code` and `.ca-prose pre` already do.

11. **Theme**: a single `ThemeOptions` in `src/lib/theme.ts` (plain object, `import type` only: `vite.config`/`astro.config` load the file in Node, where the package root does not evaluate `.svelte`), passed to `caThemeHandle` + `caThemePlugin` (SvelteKit) or `caUI` (Astro). `defaultStyle` (the surface material, `solid` by default) is one field of it, not a separate setup. Override a token by declaring `--ca-*` on `:root` in the app, without `!important`. Local islands: `data-theme-scope="dark|light"`. Automatic setup: `bunx @codeatlasdev/ui init`.
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/lib/components/HoverCard/HoverCard.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import { createHoverIntent } from '../../primitives/index.js';
import { createHoverIntent, modalBlocks } from '../../primitives/index.js';
import { uid } from '../../utils/index.js';
import FloatingLayer from '../Popover/FloatingLayer.svelte';
import { tabIntoPopup } from '../Popover/trigger.js';
Expand Down Expand Up @@ -70,7 +70,7 @@
let armed = true;
let pointerFocus = false;
const onMove = (e: PointerEvent) => {
if (e.pointerType === 'touch' || !armed) return;
if (e.pointerType === 'touch' || !armed || modalBlocks(node)) return;
armed = false;
intent.enter();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { getContext, setContext } from 'svelte';
import { ChevronDown } from '@lucide/svelte';
import { createAttachmentKey } from 'svelte/attachments';
import { focusables } from '../../primitives/index.js';
import { focusables, modalBlocks } from '../../primitives/index.js';
import { uid } from '../../utils/index.js';
import FloatingLayer from '../Popover/FloatingLayer.svelte';
import { NAVMENU_CONTEXT, NAVMENU_PANEL_CONTEXT, type NavMenuContext } from './navmenu-context.js';
Expand Down Expand Up @@ -115,7 +115,7 @@
{onclick}
{onkeydown}
onpointermove={(e) => {
if (e.pointerType === 'touch' || !armed) return;
if (e.pointerType === 'touch' || !armed || modalBlocks(button)) return;
armed = false;
if (!open) hoverOpenedAt = performance.now();
nav.enter(id);
Expand Down
77 changes: 77 additions & 0 deletions packages/ui/src/lib/components/Overlays/ModalGate.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-svelte";
import { layers } from "../../stores/layers.svelte.js";
import Harness from "./ModalGate.test.svelte";

/**
* #550: a panel that opens under a modal (an edge peek in Atlas) is a bug class, so the kit's
* hover-opened UI consults the layer stack and stays shut while a modal is open, unless its
* trigger lives inside that modal. `layers.modalOpen` is the same signal for an app's own UI.
*/
async function openModal() {
await render(Harness);
await page.getByRole("button", { name: "Open modal" }).click();
await expect.element(page.getByRole("dialog")).toBeVisible();
}

/** The dialog is modal, so the page behind it is inert: Playwright cannot hover it. Fire the pointer ourselves. */
function moveOver(el: Element) {
el.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, pointerType: "mouse", clientX: 5, clientY: 5 })
);
}

describe("layers.modalOpen", () => {
it("follows the modal: false, true while open, false after Escape", async () => {
await render(Harness);
const out = page.getByTestId("modal-open");
await expect.element(out).toHaveTextContent("false");
await page.getByRole("button", { name: "Open modal" }).click();
await expect.element(out).toHaveTextContent("true");
expect(layers.modalOpen).toBe(true);
await userEvent.keyboard("{Escape}");
await expect.element(out).toHaveTextContent("false");
expect(layers.modalOpen).toBe(false);
});
});

describe("hover UI stays shut under a modal", () => {
it("Tooltip behind the modal does not open on pointer movement", async () => {
await openModal();
moveOver(page.getByText("Behind", { exact: true }).element());
await new Promise((r) => setTimeout(r, 150));
expect(
document.querySelector(".ca-tooltip-content[data-state=open], [role=tooltip]")
).toBeNull();
});

it("HoverCard behind the modal does not open", async () => {
await openModal();
moveOver(page.getByRole("link", { name: "@behind" }).element());
await new Promise((r) => setTimeout(r, 150));
expect(document.querySelector(".ca-hovercard-content")).toBeNull();
});

it("NavigationMenu behind the modal does not open its panel", async () => {
await openModal();
moveOver(page.getByRole("button", { name: "Behind products" }).element());
await new Promise((r) => setTimeout(r, 150));
expect(document.querySelector(".ca-navmenu-panel")).toBeNull();
});
});

describe("a trigger inside the modal still works", () => {
it("Tooltip inside the dialog opens", async () => {
await openModal();
await userEvent.hover(page.getByText("Inside", { exact: true }));
await expect.element(page.getByRole("tooltip")).toHaveTextContent("Inside tip");
});

it("submenu inside the dialog still opens on hover", async () => {
await openModal();
await page.getByRole("button", { name: "Inside menu" }).click();
await userEvent.hover(page.getByRole("menuitem", { name: "Inside sub" }));
await expect.element(page.getByRole("menuitem", { name: "Inside item" })).toBeVisible();
});
});
44 changes: 44 additions & 0 deletions packages/ui/src/lib/components/Overlays/ModalGate.test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import Button from "../Button/Button.svelte";
import Dialog from "../Dialog/Dialog.svelte";
import DropdownMenu from "../DropdownMenu/DropdownMenu.svelte";
import DropdownMenuItem from "../DropdownMenu/DropdownMenuItem.svelte";
import DropdownMenuSub from "../DropdownMenu/DropdownMenuSub.svelte";
import DropdownMenuSubTrigger from "../DropdownMenu/DropdownMenuSubTrigger.svelte";
import HoverCard from "../HoverCard/HoverCard.svelte";
import NavigationMenu from "../NavigationMenu/NavigationMenu.svelte";
import NavigationMenuItem from "../NavigationMenu/NavigationMenuItem.svelte";
import NavigationMenuLink from "../NavigationMenu/NavigationMenuLink.svelte";
import Tooltip from "../Tooltip/Tooltip.svelte";
import { layers } from "../../stores/layers.svelte.js";

let open = $state(false);
</script>

<Button onclick={() => (open = true)}>Open modal</Button>
<output data-testid="modal-open">{layers.modalOpen}</output>

<!-- Page behind the modal: hover UI that must stay shut while the modal is open. -->
<Tooltip content="Behind tip" delay={0}>Behind</Tooltip>
<HoverCard openDelay={0} closeDelay={0}>
{#snippet triggerChild(props)}<a href="#u" {...props}>@behind</a>{/snippet}
<strong>Behind card</strong>
</HoverCard>
<NavigationMenu aria-label="Behind nav" openDelay={0} closeDelay={0}>
<NavigationMenuItem label="Behind products">
<NavigationMenuLink href="#a">Behind link</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenu>

<Dialog bind:open title="Settings">
<Tooltip content="Inside tip" delay={0}>Inside</Tooltip>
<DropdownMenu>
{#snippet trigger()}Inside menu{/snippet}
<DropdownMenuSub>
{#snippet trigger()}
<DropdownMenuSubTrigger>Inside sub</DropdownMenuSubTrigger>
{/snippet}
<DropdownMenuItem>Inside item</DropdownMenuItem>
</DropdownMenuSub>
</DropdownMenu>
</Dialog>
3 changes: 2 additions & 1 deletion packages/ui/src/lib/components/Tooltip/Tooltip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import { modalBlocks } from '../../primitives/index.js';
import { uid } from '../../utils/index.js';
import FloatingLayer from '../Popover/FloatingLayer.svelte';

Expand Down Expand Up @@ -55,7 +56,7 @@
// cursor fires enter without the user doing anything. Touch never opens a hover tooltip.
let armed = true;
const onMove = (e: PointerEvent) => {
if (e.pointerType === 'touch' || !armed) return;
if (e.pointerType === 'touch' || !armed || modalBlocks(node)) return;
armed = false;
openIntent();
};
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export type { LayerChange, LayerInfo, LayerKind } from "./primitives/layer.js";
export { onLayerChange } from "./primitives/layer.js";
export type { ColorScheme, SurfaceStyle } from "./stores/color-scheme.svelte.js";
export { colorScheme } from "./stores/color-scheme.svelte.js";
export { layers } from "./stores/layers.svelte.js";
export { reducedMotion, reducedTransparency } from "./stores/reduced-motion.svelte.js";
export type { Breakpoint } from "./stores/responsive.svelte.js";
export { responsive } from "./stores/responsive.svelte.js";
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
type LayerKind,
type LayerOptions,
type LayerPositioning,
modalBlocks,
onLayerChange,
openLayer,
} from "./layer.js";
Expand Down
17 changes: 17 additions & 0 deletions packages/ui/src/lib/primitives/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ interface Entry {
*/
const stack: Entry[] = [];
let listening = false;
/** Set by `layers.svelte.ts`: how the reactive store learns the stack changed. */
let onStack: (() => void) | undefined;
export function watchStack(fn: () => void) {
onStack = fn;
}

/**
* True while a modal layer (not one already closing) is open and `el` is not inside the topmost one:
* hover-opened UI (tooltips, hover cards, menus, edge peeks) must stay shut then, because the page
* behind a modal is inert and anything it opened would paint above or under the dialog the user is
* working in. A trigger that lives inside that modal is allowed.
*/
export function modalBlocks(el?: Element | null): boolean {
const host = topModal();
return host !== undefined && !(el && host.contains(el));
}

const isClosing = (e: Entry) => e.opts.closing?.() === true;

Expand Down Expand Up @@ -175,6 +191,7 @@ function announce() {
modal: stack.some((l) => l.kind === "modal"),
};
document.dispatchEvent(new CustomEvent<LayerChange>("layerchange", { detail }));
onStack?.();
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/lib/stores/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type { ColorScheme } from "./color-scheme.svelte.js";
export { colorScheme } from "./color-scheme.svelte.js";

export { layers } from "./layers.svelte.js";

export { reducedMotion, reducedTransparency } from "./reduced-motion.svelte.js";
export type { Breakpoint } from "./responsive.svelte.js";
export { responsive } from "./responsive.svelte.js";
Expand Down
30 changes: 30 additions & 0 deletions packages/ui/src/lib/stores/layers.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { modalBlocks, watchStack } from "../primitives/layer.js";

// ─── Layers store: is a modal open? ──────────────────────────────────────────
// Reactive view of the layer stack (`openLayer`: dialog, drawer, menu, popover, tooltip). Gate any
// hover- or edge-triggered UI of your own with it: a panel that opens under a modal is the bug.

function createLayersStore() {
let version = $state(0);
// The stack changes inside other components' effects (an attachment opens the layer): a write to
// `$state` there would run in the middle of their update. Bump it after, in a microtask.
watchStack(() => queueMicrotask(() => version++));
return {
/** A modal layer (dialog, drawer, sheet) is open, so the page behind it is inert. */
get modalOpen() {
void version; // subscribe: re-read when the layer stack changes
return modalBlocks();
},
/**
* Whether hover/edge UI anchored at `trigger` must stay shut: a modal is open and `trigger` is
* not inside the topmost one. Without `trigger` it equals `modalOpen`.
*/
blocks(trigger?: Element | null) {
void version; // subscribe: re-read when the layer stack changes
return modalBlocks(trigger);
},
};
}

/** Reactive state of the layer stack. `layers.modalOpen` is true while a dialog, drawer or sheet is open; `layers.blocks(el)` is false for a trigger inside the topmost modal. */
export const layers = createLayersStore();
15 changes: 15 additions & 0 deletions site/src/content/docs/feedback/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ Escape and outside presses go to the topmost layer only, so a menu inside a dial

An open modal makes the rest of the page inert (no pointer, no Tab, no screen reader), and that includes top-layer popovers that live outside it. So a menu, select list, popover or tooltip is mounted inside the topmost open modal while it is open and goes back to where it was declared when it closes. This also holds when the modal opens after the popover (a shortcut opening a dialog over an open menu): the popover moves into the new modal and is shown again above it, and when a modal closes first its popovers are handed to the next modal or put back. Do not give a layer a `z-index` to fight this: it has no effect in the top layer.

Hover-opened UI never opens under a modal. `Tooltip`, `HoverCard` and `NavigationMenuItem` ignore pointer movement while a modal layer is open, unless the trigger itself lives inside the topmost modal (a tooltip on a dialog button still works). One already open when the modal opens is adopted by it, as above. A menu submenu needs no gate: its trigger is inside a menu, and a menu open under a modal is adopted into it. Gate your own hover or edge UI (an edge peek, a rail flyout, a custom hover panel) with the `layers` store instead of a z-index:

```svelte
<script lang="ts">
import { layers } from "@codeatlasdev/ui";
let peek = $state(false);
// Never open under a dialog; close if one opens meanwhile.
$effect(() => { if (layers.modalOpen) peek = false; });
</script>

<div class="edge" onpointerenter={() => { if (!layers.modalOpen) peek = true; }}></div>
```

`layers.modalOpen` is reactive and true while a modal layer (not one already closing) is open. `layers.blocks(trigger)` is the same but false for a trigger inside the topmost modal, so a panel that lives in a dialog keeps working.

A host that draws outside the DOM (a native view over a webview) can follow the stack with `onLayerChange`. It fires on every open and close with the open layers, bottom to top, and their rectangles, and returns the function that unsubscribes:

```ts
Expand Down
1 change: 1 addition & 0 deletions site/src/content/docs/getting-started/choosing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Every overlay below is a native `<dialog>` or a `popover` element in the browser
| A menu on right-click, long-press or Shift+F10 over a region | [`ContextMenu`](/docs/navigation/context-menu/) | The `menu` snippet takes the `DropdownMenu` items. Never the only way to reach an action. |
| A rich preview of a link or person on hover and focus (touch: the link's own action) | [`HoverCard`](/docs/navigation/hover-card/) | `openDelay`, `closeDelay`, Tab reaches links inside. Tap-to-open content is `Popover`. |
| A label on hover or focus | [`Tooltip`](/docs/display/tooltip/) | Text only. Never put a control or the only copy of a fact in it. |
| Your own hover or edge UI that must not open under a dialog | [`layers`](/docs/feedback/dialog/) store | `layers.modalOpen`, `layers.blocks(trigger)`. Kit hover UI already obeys it. |
| A full-screen mobile drill-down (chat thread, detail) | [`MobilePane`](/docs/layout/mobile-pane/) | Fixed, safe-area aware, back button. Replaces a hand-made fixed panel. |
| Photos or videos at full size | [`Lightbox`](/docs/feedback/lightbox/) | `src` and `alt` for one image, `items` for a gallery. `alt` is required. |
| Jump to a route or run a command from the keyboard | [`CommandPalette`](/docs/navigation/command-palette/) | Opens with Cmd+K or Ctrl+K. Takes your `modules` and `onNavigate`. |
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/getting-started/workarounds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ Imports are from `@codeatlasdev/ui` unless the last column names a subpath.
| `justify-content: flex-start` and `margin-left: auto` on `.ca-btn__trailing` | `<Button align="between" fullWidth>` | [Button](/docs/inputs/button/) |
| `100vh` or `100dvh` | `height: var(--ca-vh)` | [Mobile](/docs/getting-started/mobile/#safe-areas-and-viewport-height) |
| `cursor: pointer` on a clickable `div role="button"`, `summary` or `label for`, or `cursor: not-allowed` on a disabled one | Nothing: the reset layer (`@layer ca.reset`) already sets `pointer` on `button`, `a[href]`, `label[for]`, `summary` and `[role=button\|tab\|option\|menuitem*]`, and `not-allowed` on `:disabled` and `[aria-disabled=true]`. An unlayered app rule such as `.tile { cursor: grab }` overrides it with no `!important` | [Theming](/docs/getting-started/theming/#cascade-and-overriding-tokens) |
||||||| parent of 17966ab8 (feat: code surfaces get an integer px line-height token (#551))
| `line-height: 1.7` (or any unitless value) on a code font, or a highlight layer whose line height does not equal the text layer's, so a caret and an active-line band drift apart | `line-height: var(--ca-code-line-height)` (integer px) and `font-family: var(--ca-font-mono)` on every layer of the code surface | [CodeSnippet](/docs/display/code-snippet/#code-surfaces-integer-line-height-one-font-stack) |
| An edge peek, rail flyout or hover panel that opens while a dialog is open, or a `document.querySelector('dialog[open]')` check before opening it | `layers.modalOpen` (reactive) or `layers.blocks(trigger)`. `Tooltip`, `HoverCard` and `NavigationMenu` already stay shut under a modal | [Dialog](/docs/feedback/dialog/) |
| A `min-height: 44px` touch override | Nothing: controls grow to 44px under `(pointer: coarse)` | [Mobile](/docs/getting-started/mobile/#native-feel) |
| `padding-bottom: env(safe-area-inset-bottom)` on fixed UI | `var(--ca-safe-bottom)` and the other `--ca-safe-*` tokens | [Mobile](/docs/getting-started/mobile/#safe-areas-and-viewport-height) |
| A keyboard-padding hack for a form or composer | `use:keyboardAware` | [Mobile](/docs/getting-started/mobile/#the-on-screen-keyboard), `@codeatlasdev/ui/actions` |
Expand Down