Skip to content
Open
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
156 changes: 156 additions & 0 deletions addon/-private/popover.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
Turns an element into an anchored, non-modal popover using the browser's
Popover API (for top-layer rendering) and CSS anchor positioning (for
placement relative to an anchor element).

The element must carry a `popover="manual"` attribute and the `docs-popover`
class (see `addon/styles/components/_docs-popover.scss`). Placement is
driven by CSS via the `data-docs-placement` attribute set here.

Browsers that support the Popover API but not CSS anchor positioning
(Safari 17/18, Firefox < 147) fall back to positioning the popover with
inline `top`/`left`/`right` styles computed from the anchor's bounding box.

@param {HTMLElement} element the popover element
@param {Object} options
@param {string|Element} options.anchor the element (or selector) the popover is positioned against
@param {string|Element} [options.trigger=options.anchor] element (or selector) whose clicks never count as "outside"
@param {'bottom-start'|'bottom-end'} [options.placement='bottom-start'] which edges to align
@param {Function} [options.onClose] called when the user clicks outside or presses Escape
@return {Function} a teardown function
@private
*/
export function openAnchoredPopover(
element,
{ anchor, trigger = anchor, placement = 'bottom-start', onClose } = {},
) {
let anchorElement = resolve(anchor);
let triggerElement = resolve(trigger) || anchorElement;
let teardowns = [];

element.dataset.docsPlacement = placement;

if (anchorElement) {
if (supportsAnchorPositioning()) {
teardowns.push(linkAnchor(element, anchorElement));
} else {
teardowns.push(positionManually(element, anchorElement, placement));
}
}

if (typeof element.showPopover === 'function') {
element.showPopover();
teardowns.push(() => {
if (element.isConnected && element.matches(':popover-open')) {
element.hidePopover();
}
});
}

if (onClose) {
let onPointerDown = (event) => {
let { target } = event;
let isInside =
element.contains(target) ||
anchorElement?.contains(target) ||
triggerElement?.contains(target);

if (!isInside) {
onClose();
}
};
let onKeyDown = (event) => {
if (event.key === 'Escape') {
onClose();
}
};

document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
teardowns.push(() => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
});
}

return () => {
teardowns.reverse().forEach((teardown) => teardown());
teardowns = [];
};
}

function resolve(elementOrSelector) {
if (typeof elementOrSelector === 'string') {
return document.querySelector(elementOrSelector);
}
return elementOrSelector || null;
}

export function supportsAnchorPositioning() {
return (
typeof CSS !== 'undefined' &&
CSS.supports?.('position-anchor', '--docs-popover') &&
CSS.supports?.('position-area', 'bottom')
);
}

let anchorCounter = 0;

// Associates the popover with its anchor through CSS anchor positioning. The
// actual placement (`position-area`) lives in the stylesheet.
function linkAnchor(element, anchorElement) {
let name = anchorElement.style.anchorName;
let ownsName = false;

if (!name || name === 'none') {
name = `--docs-popover-anchor-${anchorCounter++}`;
anchorElement.style.anchorName = name;
ownsName = true;
}

element.style.positionAnchor = name;

return () => {
element.style.positionAnchor = '';
if (ownsName) {
anchorElement.style.anchorName = '';
}
};
}

// Fallback for browsers without CSS anchor positioning: mirror the anchor's
// bounding box into inline styles, and keep them fresh on scroll and resize.
function positionManually(element, anchorElement, placement) {
let viewportGap = 8;

let update = () => {
let anchorRect = anchorElement.getBoundingClientRect();
let { innerWidth, innerHeight } = window;

element.style.top = `${anchorRect.bottom}px`;
element.style.bottom = 'auto';
element.style.maxHeight = `${Math.max(0, innerHeight - anchorRect.bottom - viewportGap)}px`;

if (placement === 'bottom-end') {
let right = Math.max(0, innerWidth - anchorRect.right);
element.style.right = `${Math.min(right, Math.max(0, innerWidth - element.offsetWidth))}px`;
element.style.left = 'auto';
} else {
let left = Math.max(0, anchorRect.left);
element.style.left = `${Math.min(left, Math.max(0, innerWidth - element.offsetWidth))}px`;
element.style.right = 'auto';
}
};

update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);

return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
['top', 'bottom', 'left', 'right', 'maxHeight'].forEach((property) => {
element.style[property] = '';
});
};
}
2 changes: 1 addition & 1 deletion addon/components/docs-header/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

<DocsHeader::SearchBox @query={{this.query}} @onInput={{set this "query"}} />

<DocsHeader::Link @onClick={{toggle "isShowingVersionSelector" this}}>
<DocsHeader::Link @onClick={{toggle "isShowingVersionSelector" this}} data-docs-version-selector-trigger>
<span data-test-id="current-version" data-version-selector class="docs-flex docs-items-center">

{{#if (eq this.currentVersion.key this.config.latestVersionName)}}
Expand Down
20 changes: 11 additions & 9 deletions addon/components/docs-header/search-results/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
...attributes
>
{{#if (and this.trimmedQuery this.search.isIdle)}}
<DocsModalDialog
@tetherTarget="[data-search-box]"
@attachment="top left"
@clickOutsideToClose={{true}}
@onClose={{@onClose}}
@targetAttachment="bottom left"
@constraints={{array (hash to="window" attachment="together" pin=true)}}
<div
popover="manual"
class="docs-popover"
data-test-docs-search-results
{{docs-popover
anchor="[data-search-box]"
placement="bottom-start"
onClose=@onClose
}}
>
<ul
class="docs-w-76 docs-bg-white docs-shadow-md"
Expand All @@ -34,6 +36,6 @@
</li>
{{/each}}
</ul>
</DocsModalDialog>
</div>
{{/if}}
</div>
</div>
22 changes: 12 additions & 10 deletions addon/components/docs-header/version-selector/index.hbs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<DocsModalDialog
@tetherTarget="[data-version-selector]"
@attachment="top right"
@clickOutsideToClose={{true}}
@onClose={{@onClose}}
@targetAttachment="bottom right"
<div
popover="manual"
class="docs-popover docs-version-selector"
data-test-docs-version-selector
{{docs-popover
anchor="[data-version-selector]"
trigger="[data-docs-version-selector-trigger]"
placement="bottom-end"
onClose=@onClose
}}
...attributes
>
<ul
class="docs-mt-2 docs-bg-white docs-shadow-md docs-text-xs docs-rounded docs-z-10"
>
<ul class="docs-bg-white docs-shadow-md docs-text-xs docs-rounded">
{{#each this.sortedVersions as |version index|}}
<li data-test-id="version">
<a
Expand Down Expand Up @@ -69,4 +71,4 @@
</li>
{{/each}}
</ul>
</DocsModalDialog>
</div>
27 changes: 15 additions & 12 deletions addon/components/docs-keyboard-shortcuts/index.hbs
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
{{#if this.isShowingKeyboardShortcuts}}
<DocsModalDialog
@animatable={{true}}
@overlay={{true}}
@wrapperClass="docs-fullscreen-modal docs-keyboard-shortcuts-modal modal-fade"
@clickOutsideToClose={{true}}
@onClose={{this.toggleKeyboardShortcuts}}
<dialog
class="docs-fullscreen-modal docs-keyboard-shortcuts-modal"
aria-labelledby="docs-keyboard-shortcuts-title"
closedby="any"
tabindex="-1"
data-test-docs-keyboard-shortcuts-dialog
{{docs-dialog onClose=this.hideKeyboardShortcuts}}
...attributes
>
<div class="docs-fullscreen-modal__header">
<h2 class="docs-fullscreen-modal__title">
<h2 class="docs-fullscreen-modal__title" id="docs-keyboard-shortcuts-title">
Keyboard shortcuts
</h2>
<a
href="#"
<button
type="button"
class="docs-fullscreen-modal__close-button"
{{on "click" this.toggleKeyboardShortcuts}}
aria-label="Close"
data-test-docs-keyboard-shortcuts-close
{{on "click" this.hideKeyboardShortcuts}}
>
&times;
</a>
</button>
</div>

<div class="docs-fullscreen-modal__body">
Expand Down Expand Up @@ -184,5 +187,5 @@
</tbody>
</table>
</div>
</DocsModalDialog>
</dialog>
{{/if}}
7 changes: 6 additions & 1 deletion addon/components/docs-keyboard-shortcuts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ export default class DocsKeyboardShortcutsComponent extends Component {
}
}

// The native <dialog> already closes itself on Escape (and fires `close`,
// which the {{docs-dialog}} modifier routes here). Keeping the key binding
// makes the behaviour explicit and lets it be exercised with synthetic key
// events in tests.
@onKey('Escape', { event: 'keyup' })
@action
hideKeyboardShortcuts() {
if (!formElementHasFocus() && this.isShowingKeyboardShortcuts) {
if (this.isShowingKeyboardShortcuts) {
this.isShowingKeyboardShortcuts = false;
}
}
Expand Down
16 changes: 0 additions & 16 deletions addon/components/docs-modal-dialog.js

This file was deleted.

60 changes: 60 additions & 0 deletions addon/modifiers/docs-dialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Modifier from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';

/**
Opens a native `<dialog>` as a modal for as long as it is rendered.

```hbs
<dialog closedby="any" tabindex="-1" {{docs-dialog onClose=this.hide}}>
...
</dialog>
```

On insert the dialog is shown with `showModal()` and focused, so the page
behind it is inert and screen readers announce it. `onClose` is called when
the dialog closes natively (Escape, `closedby="any"`, a form with
`method="dialog"`, or `dialog.close()`) and when the backdrop is clicked in
browsers that do not support `closedby`; the caller is expected to stop
rendering the dialog in response.

@class DocsDialogModifier
@private
*/
export default class DocsDialogModifier extends Modifier {
_removeListeners = null;

constructor(owner, args) {
super(owner, args);
registerDestructor(this, () => this._removeListeners?.());
}

modify(dialog, positional, { onClose }) {
if (!dialog.open) {
dialog.showModal();
dialog.focus();
}

this._removeListeners?.();
this._removeListeners = null;

if (!onClose) {
return;
}

let handleClose = () => onClose();
// A click whose target is the <dialog> element itself landed on its
// ::backdrop, i.e. outside the dialog box.
let handleClick = (event) => {
if (event.target === dialog) {
onClose();
}
};

dialog.addEventListener('close', handleClose);
dialog.addEventListener('click', handleClick);
this._removeListeners = () => {
dialog.removeEventListener('close', handleClose);
dialog.removeEventListener('click', handleClick);
};
}
}
Loading
Loading