diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index d4271fb2238..cb4051de67d 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -41,7 +41,7 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; -export { PageScrollLockController } from './page-scroll-lock.js'; +export { PageScrollLockController } from './page-scroll-lock-controller/index.js'; export { PendingController, type PendingControllerHost, diff --git a/2nd-gen/packages/core/controllers/page-scroll-lock-controller/index.ts b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/index.ts new file mode 100644 index 00000000000..abe1d1e46c4 --- /dev/null +++ b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export { PageScrollLockController } from './src/page-scroll-lock.js'; diff --git a/2nd-gen/packages/core/controllers/page-scroll-lock-controller/page-scroll-lock-controller.mdx b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/page-scroll-lock-controller.mdx new file mode 100644 index 00000000000..7a7fa592966 --- /dev/null +++ b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/page-scroll-lock-controller.mdx @@ -0,0 +1,87 @@ +{/* Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */} + +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks'; + +import * as Stories from './stories/page-scroll-lock-controller.stories'; + + + + + +## Usage + +`PageScrollLockController` locks page scroll behind a blocking surface (a modal popover, dialog, or tray) by setting `overflow: hidden` on `document.documentElement`. A component's shadow stylesheet cannot reach ``, so this has to be done in JS. + +### What it does + +- **Reference-counted locking** — the lock is tracked at module scope with a shared count. The original `overflow` is captured once on the 0 → 1 transition and restored once on the 1 → 0 transition, so stacked blocking surfaces (a modal popover opened over another) don't clobber each other's saved state. +- **Idempotent per host** — each host holds at most one count; calling `lock()` or `unlock()` again while already in that state is a no-op. +- **Automatic release on disconnect** — `hostDisconnected()` releases the host's held lock, so a removed component never leaves the page permanently unscrollable. + +### Basic usage + +1. Construct the controller in the host's constructor. +2. Call `lock()` when the host becomes a blocking surface, and `unlock()` when it stops being one (for example, from `updated()` when an `open` property changes). + +```typescript +import { LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { PageScrollLockController } from '@adobe/spectrum-wc-core/controllers/page-scroll-lock-controller.js'; + +class SwcModalSurface extends LitElement { + @property({ type: Boolean, reflect: true }) open = false; + + private readonly scrollLock = new PageScrollLockController(this); + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (changes.has('open')) { + if (this.open) { + this.scrollLock.lock(); + } else { + this.scrollLock.unlock(); + } + } + } +} +``` + +## Behaviors + +### Lock and unlock + +Toggling the lock sets `overflow: hidden` on `document.documentElement`; toggling it off restores the original value. `PageScrollLockController` has no host interface requirement: any Lit `ReactiveElement` can construct one directly and call `lock()` / `unlock()`. + +### Stacked locks + +The lock is reference-counted at module scope. With multiple hosts locking and unlocking independently, for example stacked modal surfaces, the original `overflow` is captured once on the first lock and restored once the last lock releases. Lock both hosts below, then unlock one: the page stays locked until the second host also releases. + + + +## Accessibility + +### Features + +`PageScrollLockController` only toggles `overflow` on `document.documentElement`; it does not manage focus. Pair it with focus management (for example trapping focus within a modal dialog, or relying on the native Popover API's focus behavior) so keyboard and screen reader users cannot reach content behind the blocking surface either. + +### Best practices + +- Only lock page scroll for true modal surfaces (dialogs, modal popovers, trays that block interaction with the rest of the page). Non-modal surfaces like tooltips or hint popovers should not lock scroll. +- Call `lock()` / `unlock()` from a single lifecycle path (for example `updated()` keyed off an `open` property) rather than scattered call sites, so the host's lock state can't drift out of sync with its visible state. +- Rely on `hostDisconnected()` for cleanup; there is no need to call `unlock()` manually when a host is removed from the DOM. + +## API + +### Constructor + +`new PageScrollLockController(host)` — registers the controller on the host. + +### Members + +| Member | Description | +| ---------- | ------------------------------------------------------------------------------------------------------- | +| `lock()` | Locks page scroll on behalf of the host. Idempotent per host. | +| `unlock()` | Releases the host's lock if it holds one. Idempotent per host. Also called automatically on disconnect. | + + diff --git a/2nd-gen/packages/core/controllers/page-scroll-lock.ts b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/src/page-scroll-lock.ts similarity index 100% rename from 2nd-gen/packages/core/controllers/page-scroll-lock.ts rename to 2nd-gen/packages/core/controllers/page-scroll-lock-controller/src/page-scroll-lock.ts diff --git a/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/demo-hosts.ts new file mode 100644 index 00000000000..70238fdd566 --- /dev/null +++ b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/demo-hosts.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html, LitElement, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { PageScrollLockController } from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-scroll-lock-host': DemoScrollLockHost; + } +} + +/** + * @internal + * + * Storybook-only host pairing {@link PageScrollLockController} with a single + * toggle button and a tall filler area so the effect on the document's + * scrollbar is observable in the Canvas iframe. `PageScrollLockController` has + * no host interface requirement, so a plain `LitElement` is enough here. + */ +@customElement('demo-scroll-lock-host') +export class DemoScrollLockHost extends LitElement { + /** Whether this host currently holds a page scroll lock. */ + @property({ type: Boolean, reflect: true }) + public locked = false; + + private readonly _scrollLock = new PageScrollLockController(this); + + private readonly _onToggle = (): void => { + this.locked = !this.locked; + if (this.locked) { + this._scrollLock.lock(); + } else { + this._scrollLock.unlock(); + } + }; + + protected override render(): TemplateResult { + return html` + + `; + } +} + +/** Tall filler so the lock's effect on the scrollbar is visible. */ +export const scrollFiller: TemplateResult = html` +
+`; diff --git a/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/page-scroll-lock-controller.stories.ts b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/page-scroll-lock-controller.stories.ts new file mode 100644 index 00000000000..df9b3fe8321 --- /dev/null +++ b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/stories/page-scroll-lock-controller.stories.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import './demo-hosts.js'; + +import { scrollFiller } from './demo-hosts.js'; + +// ──────────────── +// METADATA +// ──────────────── + +/** + * `PageScrollLockController` is a Lit `ReactiveController` that locks page + * scroll behind a blocking surface (a modal popover, dialog, or tray) by + * setting `overflow: hidden` on `document.documentElement`. A component's + * shadow stylesheet cannot reach ``, so this is done in JS. + * + * The lock is reference-counted at module scope so stacked blocking surfaces + * (a modal opened over another) coordinate a single lock instead of clobbering + * one another's saved `overflow` value. + */ +const meta: Meta = { + title: 'Controllers/Page scroll lock controller', + component: 'demo-scroll-lock-host', + render: () => html` + + `, + parameters: { + docs: { + subtitle: + 'Reference-counted document scroll lock for modal blocking surfaces.', + }, + }, + tags: ['migrated', 'controller'], +}; + +export default meta; + +type Story = StoryObj; + +// ──────────────────── +// PLAYGROUND STORY +// ──────────────────── + +export const Playground: Story = { + render: () => html` + + ${scrollFiller} + `, + tags: ['dev'], +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], +}; + +// ────────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────────── + +export const StackedLocks: Story = { + render: () => html` + + + `, + tags: ['behaviors'], + parameters: { flexLayout: 'row-wrap' }, +}; +StackedLocks.storyName = 'Stacked locks'; diff --git a/2nd-gen/packages/core/controllers/test/page-scroll-lock.test.ts b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/test/page-scroll-lock-controller.test.ts similarity index 98% rename from 2nd-gen/packages/core/controllers/test/page-scroll-lock.test.ts rename to 2nd-gen/packages/core/controllers/page-scroll-lock-controller/test/page-scroll-lock-controller.test.ts index d449f5221fe..5e2835200eb 100644 --- a/2nd-gen/packages/core/controllers/test/page-scroll-lock.test.ts +++ b/2nd-gen/packages/core/controllers/page-scroll-lock-controller/test/page-scroll-lock-controller.test.ts @@ -18,7 +18,7 @@ import type { Meta, StoryObj as Story } from '@storybook/web-components'; import { PageScrollLockController } from '../index.js'; export default { - title: 'Controllers/Page scroll lock/Tests', + title: 'Controllers/Page scroll lock controller/Tests', tags: ['!autodocs', 'dev'], render: () => html`
diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index e6022934fcf..50f0319c485 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -163,6 +163,10 @@ "types": "./dist/controllers/live-selection-controller/index.d.ts", "import": "./dist/controllers/live-selection-controller/index.js" }, + "./controllers/page-scroll-lock-controller.js": { + "types": "./dist/controllers/page-scroll-lock-controller/index.d.ts", + "import": "./dist/controllers/page-scroll-lock-controller/index.js" + }, "./controllers/pending-controller": { "types": "./dist/controllers/pending-controller/index.d.ts", "import": "./dist/controllers/pending-controller/index.js" @@ -390,6 +394,9 @@ "controllers/live-selection-controller/index.js": [ "dist/controllers/live-selection-controller/index.d.ts" ], + "controllers/page-scroll-lock-controller.js": [ + "dist/controllers/page-scroll-lock-controller/index.d.ts" + ], "controllers/pending-controller": [ "dist/controllers/pending-controller/index.d.ts" ],