Skip to content

fix(overlay): Programmatic clicks on elements outside of the overlay now registers #5670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 19, 2025
9 changes: 9 additions & 0 deletions .changeset/fluffy-ads-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@spectrum-web-components/overlay': minor
---

Added `allow-outside-click` property to `<sp-overlay>` with deprecation notice. This property allows clicks outside the overlay to close it, but is not recommended for accessibility reasons and will be removed in a future version.

This property is being added as deprecated to support the fallback for `showModal()` which was removed as part of performance optimization. We will no longer support outside clicks for modal overlays as they violate accessibility guidelines.

**Breaking Change**: The property defaults to `false` and shows deprecation warnings when used. Consider using explicit close buttons or modal/page overlay types instead for better accessibility.
5 changes: 5 additions & 0 deletions .changeset/solid-points-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@spectrum-web-components/overlay': minor
---

**Fixed** : external click registration behavior in the `sp-overlay` component. Programmatic clicks on elements outside of modal overlays now properly register and close the overlay, while user-initiated clicks are prevented from doing so.
38 changes: 28 additions & 10 deletions packages/overlay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ Some Overlays will always be passed focus (e.g. modal or page Overlays). When th

The `trigger` option accepts an `HTMLElement` or a `VirtualTrigger` from which to position the Overlay.

- You can import the `VirtualTrigger` class from the overlay package to create a virtual trigger that can be used to position an Overlay. This is useful when you want to position an Overlay relative to a point on the screen that is not an element in the DOM, like the mouse cursor.
- You can import the `VirtualTrigger` class from the overlay package to create a virtual trigger that can be used to position an Overlay. This is useful when you want to position an Overlay relative to a point on the screen that is not an element in the DOM, like the mouse cursor.

The `type` of an Overlay outlines a number of things about the interaction model within which it works:

Expand Down Expand Up @@ -408,8 +408,8 @@ The `overlay` value in this case will hold a reference to the actual `<sp-overla

"Fully" in this context means that all CSS transitions that have dispatched `transitionrun` events on the direct children of the `<sp-overlay>` element have successfully dispatched their `transitionend` or `transitioncancel` event. Keep in mind the following:

- `transition*` events bubble; this means that while transition events on light DOM content of those direct children will be heard, those events will not be taken into account
- `transition*` events are not composed; this means that transition events on shadow DOM content of the direct children will not propagate to a level in the DOM where they can be heard
- `transition*` events bubble; this means that while transition events on light DOM content of those direct children will be heard, those events will not be taken into account
- `transition*` events are not composed; this means that transition events on shadow DOM content of the direct children will not propagate to a level in the DOM where they can be heard

This means that in both cases, if the transition is meant to be a part of the opening or closing of the overlay in question you will need to redispatch the `transitionrun`, `transitionend`, and `transitioncancel` events from that transition from the closest direct child of the `<sp-overlay>`.

Expand Down Expand Up @@ -532,6 +532,7 @@ This means that in both cases, if the transition is meant to be a part of the op
.triggerElement=${HTMLElement}
.triggerInteraction=${'click' | 'longpress' | 'hover'}
type=${'auto' | 'hint' | 'manual' | 'modal' | 'page'}
?allow-outside-click=${boolean}
></sp-overlay>
```

Expand Down Expand Up @@ -573,6 +574,23 @@ Common in `modal`/`page` overlays for full-screen content</sp-table-cell>
</sp-table-body>
</sp-table>

##### Deprecated Properties

> **⚠️ Deprecation Notice**: The `allow-outside-click` property is deprecated and will be removed in a future version.

The `allow-outside-click` property allows clicks outside the overlay to close it. **We do not recommend using this property for accessibility reasons** as it can cause unexpected behavior and accessibility issues. When set to `true`, it configures the focus trap to allow outside clicks, which may interfere with proper focus management and user expectations.

```html
<!-- @deprecated Not recommended for accessibility reasons -->
<sp-overlay trigger="trigger@click" allow-outside-click="true">
<sp-popover>
<p>This overlay can be closed by clicking outside</p>
</sp-popover>
</sp-overlay>
```

**Alternative approaches**: Instead of using `allow-outside-click`, consider implementing explicit close buttons or using the `type="modal"` or `type="page"` overlay types which provide better accessibility and user experience.

Comment on lines +577 to +593
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the wording on this. Thanks for including it!

#### Styling

`<sp-overlay>` element will use the `<dialog>` element or `popover` attribute to project your content onto the top-layer of the browser, without being moved in the DOM tree. That means that you can style your overlay content with whatever techniques you are already leveraging to style the content that doesn't get overlaid. This means standard CSS selectors, CSS Custom Properties, and CSS Parts applied in your parent context will always apply to your overlaid content.
Expand Down Expand Up @@ -772,9 +790,9 @@ When nesting multiple overlays, it is important to ensure that the nested overla

The overlay manages focus based on its type:

- For `modal` and `page` types, focus is always trapped within the overlay
- For `auto` and `manual` types, focus behavior is controlled by the `receives-focus` attribute
- For `hint` type, focus remains on the trigger element
- For `modal` and `page` types, focus is always trapped within the overlay
- For `auto` and `manual` types, focus behavior is controlled by the `receives-focus` attribute
- For `hint` type, focus remains on the trigger element

Example of proper focus management:

Expand Down Expand Up @@ -840,10 +858,10 @@ Example of proper focus management:

#### Screen reader considerations

- Use `aria-haspopup` on trigger elements to indicate the type of overlay
- Provide descriptive labels using `aria-label` or `aria-labelledby`
- Use proper heading structure within overlays
- Ensure error messages are announced using `aria-live`
- Use `aria-haspopup` on trigger elements to indicate the type of overlay
- Provide descriptive labels using `aria-label` or `aria-labelledby`
- Use proper heading structure within overlays
- Ensure error messages are announced using `aria-live`

Example of a tooltip with proper screen reader support:

Expand Down
32 changes: 31 additions & 1 deletion packages/overlay/src/Overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ if (!browserSupportsPopover) {
* @attr {string} receives-focus - How focus should be handled ('true'|'false'|'auto')
* @attr {boolean} delayed - Whether the overlay should wait for a warm-up period before opening
* @attr {boolean} open - Whether the overlay is currently open
* @attr {boolean} allow-outside-click - @deprecated Whether clicks outside the overlay should close it (not recommended for accessibility)
*/
export class Overlay extends ComputedOverlayBase {
static override styles = [styles];
Expand Down Expand Up @@ -289,6 +290,18 @@ export class Overlay extends ComputedOverlayBase {
@property({ attribute: 'receives-focus' })
override receivesFocus: 'true' | 'false' | 'auto' = 'auto';

/**
* @deprecated This property will be removed in a future version.
* We do not recommend using this property for accessibility reasons.
* It allows clicks outside the overlay to close it, which can cause
* unexpected behavior and accessibility issues.
*
* @type {boolean}
* @default false
*/
Comment on lines +293 to +301
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this.

@property({ type: Boolean, attribute: 'allow-outside-click' })
allowOutsideClick = false;

/**
* A reference to the slot element within the overlay.
*
Expand Down Expand Up @@ -495,7 +508,6 @@ export class Overlay extends ComputedOverlayBase {
*
* This method handles the necessary steps to open the popover, including managing delays,
* ensuring the popover is in the DOM, making transitions, and applying focus.
*
* @protected
* @override
* @returns {Promise<void>} A promise that resolves when the popover has been fully opened.
Expand Down Expand Up @@ -552,6 +564,7 @@ export class Overlay extends ComputedOverlayBase {
},
// disable escape key capture to close the overlay, the focus-trap library captures it otherwise
escapeDeactivates: false,
allowOutsideClick: this.allowOutsideClick,
});

if (this.type === 'modal' || this.type === 'page') {
Expand Down Expand Up @@ -972,6 +985,23 @@ export class Overlay extends ComputedOverlayBase {
);
}

// Warn about deprecated allowOutsideClick property
if (changes.has('allowOutsideClick') && this.allowOutsideClick) {
if (window.__swc?.DEBUG) {
window.__swc.warn(
this,
`The "allow-outside-click" attribute on <${this.localName}> has been deprecated and will be removed in a future release. We do not recommend using this attribute for accessibility reasons. It allows clicks outside the overlay to close it, which can cause unexpected behavior and accessibility issues.`,
'https://opensource.adobe.com/spectrum-web-components/components/overlay/',
{ level: 'deprecation' }
);
} else {
// Fallback for testing environments or when SWC is not available
console.warn(
`[${this.localName}] The "allow-outside-click" attribute has been deprecated and will be removed in a future release. We do not recommend using this attribute for accessibility reasons. It allows clicks outside the overlay to close it, which can cause unexpected behavior and accessibility issues.`
);
}
}

// Manage the open state if the 'open' property has changed.
if (changes.has('open') && (this.hasUpdated || this.open)) {
this.manageOpen(changes.get('open'));
Expand Down
35 changes: 35 additions & 0 deletions packages/overlay/test/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1195,3 +1195,38 @@ describe('Overlay should correctly trap focus', () => {
expect(document.activeElement).to.equal(input);
});
});

describe('Overlay - Deprecated Properties', () => {
it('should support allowOutsideClick property with deprecation warning', async () => {
const consoleSpy = spy(console, 'warn');

const el = await fixture<HTMLDivElement>(html`
<div>
<sp-button id="trigger">Open Overlay</sp-button>
<sp-overlay
trigger="trigger@click"
type="auto"
?allow-outside-click=${true}
>
<sp-popover dialog>
<p>Overlay content</p>
</sp-popover>
</sp-overlay>
</div>
`);

const overlay = el.querySelector('sp-overlay') as Overlay;
await elementUpdated(overlay);

// Verify the property is set correctly
expect(overlay.allowOutsideClick).to.be.true;
expect(overlay.hasAttribute('allow-outside-click')).to.be.true;

// Verify the deprecation warning is shown (either via SWC or console.warn fallback)
expect(consoleSpy.calledOnce).to.be.true;
expect(consoleSpy.firstCall.args[0]).to.include('allow-outside-click');
expect(consoleSpy.firstCall.args[0]).to.include('deprecated');

consoleSpy.restore();
});
});
Loading