diff --git a/elements/rh-dialog/demo/rh-dialog.html b/elements/rh-dialog/demo/rh-dialog.html index 54dc315a9d..c4d5fd47db 100644 --- a/elements/rh-dialog/demo/rh-dialog.html +++ b/elements/rh-dialog/demo/rh-dialog.html @@ -6,7 +6,7 @@

Modal dialog with a header

aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

- + Learn more diff --git a/elements/rh-dialog/docs/40-accessibility.md b/elements/rh-dialog/docs/40-accessibility.md index 5b023d234e..655b5de9c6 100644 --- a/elements/rh-dialog/docs/40-accessibility.md +++ b/elements/rh-dialog/docs/40-accessibility.md @@ -63,9 +63,25 @@ Only the close button and any interactive elements are selectable. ### Backdrop -A dialog will not close by users clicking or tapping the backdrop or outside of the container. +A dialog will close by users clicking or tapping the backdrop or outside of the container. +## Accessible labels + +Each dialog needs an accessible name. If a dialog has a heading tag in the `header` or default slot, this component will automatically apply an appropriate ID to the heading tag and an `aria-labelledby` attribute to the ``. + +Users can optionally provide an `accessible-label` attribute which overrides the built-in `aria-labelledby` functionality: + +```html + + ... + +``` + +The `accessible-label` attribute assigns an `aria-label` to the `` element. + +If neither an `accessible-label` nor any headings exist, the `aria-label` on the `` will default to the text of the dialog's trigger. + ## Additional guidelines diff --git a/elements/rh-dialog/rh-dialog.css b/elements/rh-dialog/rh-dialog.css index f12ce32234..8448f39bb3 100644 --- a/elements/rh-dialog/rh-dialog.css +++ b/elements/rh-dialog/rh-dialog.css @@ -1,56 +1,60 @@ :host { + --rh-dialog-backdrop-background-color: rgba(3, 3, 3, 0.62); + --rh-dialog-overlay-background-color: transparent; + display: block; position: relative; - - --_spacer-align-top: var(--rh-space-md, 8px); - --_height-offset: min(var(--_spacer-align-top), var(--rh-space-3xl, 48px)); } [hidden] { display: none !important; } -section { - display: flex; - position: fixed; - height: 100%; - width: 100%; - top: 0; - left: 0; - align-items: center; - justify-content: center; - z-index: 500; -} - -#container { - position: relative; - max-height: inherit; +.visually-hidden { + block-size: 1px; + border: 0; + clip: rect(0, 0, 0, 0); + inline-size: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; } +/* NOTE: @deprecated, use `::backdrop` instead. Remove for RHDS v3. */ [part='overlay'] { + background-color: + var(--rh-dialog-overlay-background-color, + var(--rh-dialog-backdrop-background-color)); + block-size: 100%; + inset-block-start: 0; + inset-inline-start: 0; position: fixed; - height: 100%; - width: 100%; - top: 0; - left: 0; - background-color: rgba(3, 3, 3, 0.62); + inline-size: 100%; +} + +::backdrop { + background-color: var(--rh-dialog-backdrop-background-color); } [part='dialog'] { - position: relative; - margin: 0 auto; - width: var(--_box-width, calc(100% - var(--rh-space-2xl, 32px))); - max-height: var(--_box-max-height, calc(100% - var(--rh-space-3xl, 48px))); - box-shadow: - 0 1rem 2rem 0 rgba(3, 3, 3, 0.16), - 0 0 0.5rem 0 rgba(3, 3, 3, 0.1); - padding: var(--rh-space-xl, 24px); - margin-inline: var(--rh-space-lg, 16px); background-color: var(--rh-color-surface-lightest, #ffffff); - max-width: min(90%, 1140px); border-radius: var(--rh-border-radius-default, 3px); + border: 0; + box-shadow: var(--rh-box-shadow-xl, 0 8px 24px 3px rgba(21, 21, 21, 0.35)); + box-sizing: border-box; color: var(--rh-color-text-primary-on-light, #151515); font-family: inherit; + inline-size: 100%; + margin-inline: auto; + max-block-size: var(--_box-max-block-size, calc(100vh - var(--rh-space-3xl, 48px))); + max-inline-size: var(--_box-width, min(90%, 1140px)); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--rh-space-xl, 24px); + padding-block-end: var(--rh-space-2xl, 32px); /* NOTE: don't cut off box-shadow at the bottom */ + position: relative; } :host([width]) [part='dialog'], @@ -73,122 +77,137 @@ section { --_box-width: 70rem; } -[part='content'] { - overflow-y: auto; - overscroll-behavior: contain; - max-height: var(--_box-max-height, calc(100vh - var(--rh-space-3xl, 48px))); - box-sizing: border-box; - border-radius: var(--rh-border-radius-default, 3px); +[part='header'] { + background-color: var(--rh-color-surface-lightest, #ffffff); + padding-block-end: var(--rh-space-sm, 6px); + position: sticky; + top: calc(-1 * var(--rh-space-xl, 24px)); } -[part='content'] ::slotted([slot='header']) { - margin-top: 0 !important; +[part='header'][hidden] + [part='body'] { + max-width: calc(100% - var(--rh-space-xl, 24px)); } -header { - position: sticky; - top: 0; - background-color: var(--rh-color-surface-lightest, #ffffff); +@media (min-width: 1200px) { + [part='dialog'] { + padding: var(--rh-space-2xl, 32px); + } + + [part='header'] { + top: calc(-1 * var(--rh-space-2xl, 32px)); + } + + [part='header'][hidden] + [part='body'] { + max-width: calc(100% - var(--rh-space-2xl, 32px)); + } +} + +[part='content'] ::slotted([slot='header']) { + margin-top: 0 !important; } -header ::slotted(:is(h1,h2,h3,h4,h5,h6)[slot='header']) { +[part='header'] ::slotted(:is(h1,h2,h3,h4,h5,h6)[slot='header']) { + font-family: var(--rh-font-family-heading, RedHatDisplay, 'Red Hat Display', 'Noto Sans Arabic', 'Noto Sans Hebrew', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Malayalam', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans Thai', Helvetica, Arial, sans-serif); font-size: var(--rh-font-size-heading-sm, 1.5rem); font-weight: var(--rh-font-weight-body-text-regular, 400); - font-family: 'Red Hat Display', RedHatDisplay, Overpass, Helvetica, sans-serif; } [part='close-button'] { color: var(--rh-color-icon-subtle, #707070); - background-color: transparent; - border: none; - margin: 0; - padding: 0; - text-align: left; - position: absolute; cursor: pointer; - line-height: 24px; - padding-block: 0.375rem; - padding-inline: var(--rh-space-lg, 16px); - top: 0; - right: calc(var(--rh-space-xl, 24px) / -3); + display: block; + inset-block-start: var(--rh-length-xs, 4px); + inset-inline-end: var(--rh-length-xs, 4px); + position: absolute; + z-index: 500; } -[part='close-button'] > svg { - font-size: 16px; - width: var(--rh-space-lg, 16px); - aspect-ratio: 1/1; +[part='close-button']::part(button) { + padding: var(--rh-space-xl, 24px); } -[part='close-button']:is(:hover, :focus-within, :focus-visible) svg:is(svg, :hover) { - fill: var(--rh-color-icon-secondary-on-light, #151515); +@media (min-width: 1200px) { + [part='close-button']::part(button) { + padding: var(--rh-space-2xl, 32px); + } } :host([position='top']) #dialog { - align-self: start; - margin-block: var(--rh-space-2xl, 32px); - margin-inline: var(--rh-space-lg, 16px); - width: 100%; - max-width: calc(100% - min(var(--rh-space-2xl, 32px) * 2, var(--rh-space-2xl, 32px))); - max-height: calc(100% - var(--_height-offset) - var(--_spacer-align-top)); + margin-block-start: 0; + max-inline-size: none; + inline-size: 100%; } -footer { - display: flex; +:host([position='top']) #content { + margin-inline: 0; + max-inline-size: calc(100% - min(var(--rh-space-2xl, 32px) * 2, var(--rh-space-2xl, 32px))); +} + +[part='footer'] { align-items: center; + display: flex; gap: var(--rh-space-md, 8px); } #rhds-wrapper { - display: contents; - font-family: 'Red Hat Text', RedHatText, Overpass, Helvetica, sans-serif; - --offset: var(--rh-space-md, 8px); --offset-top: var(--offset); --offset-right: var(--offset); -} -:host([type='video']) { - --rh-dialog-close-button-color: var(--rh-color-icon-secondary-on-dark, #ffffff); + display: contents; + font-family: var(--rh-font-family-body-text, RedHatText, 'Red Hat Text', 'Noto Sans Arabic', 'Noto Sans Hebrew', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Malayalam', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans Thai', Helvetica, Arial, sans-serif); } :host([type='video']) [part='close-button'] { - top: var(--offset-top); - right: var(--offset-right); - padding: var(--rh-space-sm, 6px); color: var(--rh-color-icon-secondary-on-dark, #ffffff); + inset-inline-end: var(--offset-right); + inset-block-start: var(--offset-top); +} + +:host([type='video']) #close-button::part(button) { + padding-inline: var(--rh-space-md, 8px); + padding-block: var(--rh-space-lg, 16px); +} + +:host([type='video']) #close-button::part(icon) { + filter: drop-shadow(1px 0 1px var(--rh-dialog-backdrop-background-color)); } :host([type='video']) [part='content'] { + background-color: var(--rh-color-surface-darkest, #151515); overflow: hidden; + padding: 0; } -:host([type='video'][open]) [part='overlay'] { +:host([type='video']) { --_gray-90-rgb: var(--rh-color-gray-90-rgb, 31 31 31); + --rh-dialog-backdrop-background-color: rgb(var(--_gray-90-rgb) / var(--rh-opacity-60, 60%)); - background-color: rgb(var(--_gray-90-rgb) / var(--rh-opacity-60, 60%)); + background-color: + var(--rh-dialog-overlay-background-color, + var(--rh-dialog-backdrop-background-color)); } :host([type='video'][open]) [part='dialog'] { --_aspect-ratio: var(--rh-dialog-video-aspect-ratio, 16/9); aspect-ratio: var(--_aspect-ratio); - max-width: min(90%, calc(90vh * var(--_aspect-ratio) + var(--offset-top))); + max-inline-size: min(90%, calc(90vh * var(--_aspect-ratio) + var(--offset-top))); padding: 0; - margin: 0; } :host([type='video']) #rhds-wrapper.mobile [part='close-button'] { --offset-right: var(--rh-space-sm, 6px); } -:host([type='video']) #container, :host([type='video']) [part='content'], :host([type='video']) ::slotted(:not([slot])) { aspect-ratio: var(--rh-dialog-video-aspect-ratio, 16/9); + max-inline-size: none; + inline-size: 100%; +} - /* account for a 1px descrepency between dialog and container aspect ratio */ - width: calc(100% + 1px); - position: absolute; +:host([type='video']) ::slotted(:not([slot])) { inset: 0; - max-height: none; + position: absolute; } diff --git a/elements/rh-dialog/rh-dialog.ts b/elements/rh-dialog/rh-dialog.ts index bb09ebd1d5..189c39d3fe 100644 --- a/elements/rh-dialog/rh-dialog.ts +++ b/elements/rh-dialog/rh-dialog.ts @@ -14,6 +14,7 @@ import { query } from 'lit/decorators/query.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import '@rhds/elements/rh-surface/rh-surface.js'; +import '@rhds/elements/rh-button/rh-button.js'; export class DialogCancelEvent extends Event { constructor() { @@ -60,11 +61,13 @@ async function pauseYoutube(iframe: HTMLIFrameElement) { * @cssprop {} --rh-dialog-video-aspect-ratio * @cssprop {} [--rh-dialog-close-button-color=var(--rh-color-icon-secondary-on-dark, #ffffff)] * Sets the dialog close button color. + * @cssprop {} [--rh-dialog-backdrop-background-color=rgba(3, 3, 3, 0.62)] + * Sets the background color for the native HTML dialog element's `backdrop` pseudo-element + * @cssprop {} [--rh-dialog-overlay-background-color=transparent] + * Deprecated. Sets the background color for the `#overlay` `
`. Use `--rh-dialog-backdrop-background-color` instead. */ @customElement('rh-dialog') export class RhDialog extends LitElement { - static readonly version = '{{version}}'; - static readonly styles = [styles]; protected static closeOnOutsideClick = true; @@ -80,11 +83,19 @@ export class RhDialog extends LitElement { */ @property({ reflect: true }) position?: 'top'; + /** + * Use `accessible-label="My custom label"` to add an `aria-label` to the `` element. + * Defaults to the name of the dialog trigger if no attribute is set and no headings exist in the modal. + * See Dialog's Accessibility page for more info. + */ + @property({ attribute: 'accessible-label' }) accessibleLabel?: string; + @property({ type: Boolean, reflect: true }) open = false; /** Optional ID of the trigger element */ @property() trigger?: string; + /** Use `type="video"` to embed a video player into a dialog. */ @property({ reflect: true }) type?: 'video'; /** @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue */ @@ -92,9 +103,13 @@ export class RhDialog extends LitElement { #screenSize = new ScreenSizeController(this); - @query('#overlay') private overlay?: HTMLElement | null; - @query('#dialog') private dialog?: HTMLElement | null; - @query('#close-button') private closeButton?: HTMLElement | null; + @query('#dialog') private dialog!: HTMLDialogElement; + @query('#content') private content!: HTMLElement; + @query('#close-button') private closeButton!: HTMLElement; + + get videoColorPalette() { + return this.type === 'video' ? 'dark' : 'lightest'; + } #headerId = getRandomId(); #triggerElement: HTMLElement | null = null; @@ -102,71 +117,65 @@ export class RhDialog extends LitElement { #body: Element[] = []; #headings: Element[] = []; #cancelling = false; + #lastTabbable: HTMLElement = this.closeButton; #slots = new SlotController(this, null, 'header', 'description', 'footer'); connectedCallback() { super.connectedCallback(); - this.addEventListener('keydown', this.onKeydown); + this.addEventListener('keydown', this.#onKeyDown); this.addEventListener('click', this.onClick); } + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('keydown', this.#onKeyDown); + this.#triggerElement?.removeEventListener('click', this.onTriggerClick); + } + render() { const headerId = (this.#header || this.#headings.length) ? this.#headerId : undefined; - const headerLabel = this.#triggerElement ? this.#triggerElement.innerText : undefined; + const triggerLabel = this.#triggerElement ? this.#triggerElement.innerText : undefined; const hasHeader = this.#slots.hasSlotted('header'); const hasDescription = this.#slots.hasSlotted('description'); const hasFooter = this.#slots.hasSlotted('footer'); - const { mobile } = this.#screenSize; return html` -
-
-
+
`; } - disconnectedCallback() { - super.disconnectedCallback(); - this.removeEventListener('keydown', this.onKeydown); - this.#triggerElement?.removeEventListener('click', this.onTriggerClick); - } - @initializer() protected async _init() { await this.updateComplete; @@ -207,8 +216,8 @@ export class RhDialog extends LitElement { // This prevents background scroll document.body.style.overflow = 'hidden'; await this.updateComplete; - // Set the focus to the container - this.dialog?.focus(); + // 's automatically set focus to the first focusable element in the modal, + // no need to set it here. this.dispatchEvent(new DialogOpenEvent(this.#triggerElement)); } else { // Return scrollability @@ -218,10 +227,6 @@ export class RhDialog extends LitElement { await this.updateComplete; - if (this.#triggerElement) { - this.#triggerElement.focus(); - } - this.dispatchEvent(event); } } @@ -241,29 +246,73 @@ export class RhDialog extends LitElement { } @bound private onClick(event: MouseEvent) { - const { open, overlay, dialog } = this; + const { open, content } = this; if (open) { const path = event.composedPath(); const { closeOnOutsideClick } = this.constructor as typeof RhDialog; - if (closeOnOutsideClick && path.includes(overlay!) && !path.includes(dialog!)) { + if (closeOnOutsideClick && !path.includes(content!)) { event.preventDefault(); this.cancel(); } } } - @bound private onKeydown(event: KeyboardEvent) { + #trapFocus() { + // https://github.com/KittyGiraudel/focusable-selectors + // TODO: Add RHDS focusable elements (?) + const notInert = ':not([inert]):not([inert] *)'; + const notNegTabIndex = ':not([tabindex^="-"])'; + const notDisabled = ':not(:disabled)'; + const focusableSelectorList = [ + `a[href]${notInert}${notNegTabIndex}`, + `area[href]${notInert}${notNegTabIndex}`, + `input:not([type="hidden"]):not([type="radio"])${notInert}${notNegTabIndex}${notDisabled}`, + `input[type="radio"]${notInert}${notNegTabIndex}${notDisabled}`, + `select${notInert}${notNegTabIndex}${notDisabled}`, + `textarea${notInert}${notNegTabIndex}${notDisabled}`, + `button${notInert}${notNegTabIndex}${notDisabled}`, + `details${notInert} > summary:first-of-type${notNegTabIndex}`, + `details:not(:has(> summary))${notInert}${notNegTabIndex}`, + `iframe${notInert}${notNegTabIndex}`, + `audio[controls]${notInert}${notNegTabIndex}`, + `video[controls]${notInert}${notNegTabIndex}`, + `[contenteditable]${notInert}${notNegTabIndex}`, + `[tabindex]${notInert}${notNegTabIndex}`, + ]; + + const focusableSlottedElements = + this.querySelectorAll(focusableSelectorList.join(',')); + const hasLastElement = focusableSlottedElements.length > 0; + this.#lastTabbable = hasLastElement ? + focusableSlottedElements[focusableSlottedElements.length - 1] : this.closeButton; + } + + #handleTab(event: KeyboardEvent) { + // No focusable elements except close button: + if (this.#lastTabbable === this.closeButton) { + event.preventDefault(); + this.closeButton.focus(); + return; + } + // With focusable elements in dialog: + if (document.activeElement === this.#lastTabbable) { + event.preventDefault(); + this.closeButton.focus(); + } + } + + #handleShiftTab(event: KeyboardEvent) { + if (document.activeElement === this && this.shadowRoot?.activeElement === this.closeButton) { + event.preventDefault(); + this.#lastTabbable.focus(); + } + } + + #onKeyDown(event: KeyboardEvent) { switch (event.key) { - case 'Tab': - if (event.target === this.closeButton) { - event.preventDefault(); - this.dialog?.focus(); - } - return; case 'Escape': case 'Esc': - event.preventDefault(); this.cancel(); return; case 'Enter': @@ -272,11 +321,18 @@ export class RhDialog extends LitElement { this.showModal(); } return; + case 'Tab': + if (event.shiftKey) { + this.#handleShiftTab(event); + return; + } + this.#handleTab(event); } } private async cancel() { this.#cancelling = true; + this.close(); this.open = false; await this.updateComplete; this.#cancelling = false; @@ -294,7 +350,12 @@ export class RhDialog extends LitElement { * ``` */ @bound toggle() { - this.open = !this.open; + if (!this.open) { + this.showModal(); + this.open = true; + } else { + this.close(); + } } /** @@ -304,6 +365,8 @@ export class RhDialog extends LitElement { * ``` */ @bound show() { + this.dialog?.showModal(); + this.#trapFocus(); this.open = true; } @@ -324,6 +387,7 @@ export class RhDialog extends LitElement { this.returnValue = returnValue; } + this.dialog?.close(); this.open = false; } }