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
2 changes: 1 addition & 1 deletion 2nd-gen/packages/core/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';

<Meta of={Stories} />

<DocsHeader />

## 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 `<html>`, 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.

<Canvas of={Stories.StackedLocks} />

## 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. |

<DocsFooter />
Original file line number Diff line number Diff line change
@@ -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`
<button type="button" @click=${this._onToggle}>
${this.locked ? 'Unlock page scroll' : 'Lock page scroll'}
</button>
`;
}
}

/** Tall filler so the lock's effect on the scrollbar is visible. */
export const scrollFiller: TemplateResult = html`
<div style="block-size: 1200px;"></div>
`;
Original file line number Diff line number Diff line change
@@ -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 `<html>`, 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`
<demo-scroll-lock-host></demo-scroll-lock-host>
`,
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`
<demo-scroll-lock-host></demo-scroll-lock-host>
${scrollFiller}
`,
tags: ['dev'],
};

// ──────────────────────────
// OVERVIEW STORY
// ──────────────────────────

export const Overview: Story = {
tags: ['overview'],
};

// ──────────────────────────────
// BEHAVIORS STORIES
// ──────────────────────────────

export const StackedLocks: Story = {
render: () => html`
<demo-scroll-lock-host></demo-scroll-lock-host>
<demo-scroll-lock-host></demo-scroll-lock-host>
`,
tags: ['behaviors'],
parameters: { flexLayout: 'row-wrap' },
};
StackedLocks.storyName = 'Stacked locks';
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<div></div>
Expand Down
7 changes: 7 additions & 0 deletions 2nd-gen/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
],
Expand Down
Loading