From 8c5559cee77e0351b855b53320b02adf9e874a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benny=20Powers=20-=20=D7=A2=D7=9D=20=D7=99=D7=A9=D7=A8?= =?UTF-8?q?=D7=90=D7=9C=20=D7=97=D7=99!?= Date: Mon, 8 Jan 2024 17:16:15 +0200 Subject: [PATCH] fix(dialog): remove pfe dependency (#1392) * fix(dialog): remove pfe dependency * fix(dialog): css * fix: restore icon color --- .changeset/itchy-lands-design.md | 5 + elements/rh-dialog/rh-dialog.css | 132 +++++++++++++- elements/rh-dialog/rh-dialog.ts | 286 ++++++++++++++++++++++++++++++- 3 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 .changeset/itchy-lands-design.md diff --git a/.changeset/itchy-lands-design.md b/.changeset/itchy-lands-design.md new file mode 100644 index 0000000000..f2fd36e2cc --- /dev/null +++ b/.changeset/itchy-lands-design.md @@ -0,0 +1,5 @@ +--- +"@rhds/elements": patch +--- +``: remove the dependency on `@patternfly/elements`. +*NOTE*: The `open`, `close`, and `cancel` events are no longer the same object constructor type as ``, so `instanceof` checks may fail. diff --git a/elements/rh-dialog/rh-dialog.css b/elements/rh-dialog/rh-dialog.css index db4d377e10..e0da95e105 100644 --- a/elements/rh-dialog/rh-dialog.css +++ b/elements/rh-dialog/rh-dialog.css @@ -1,17 +1,51 @@ -#rhds-wrapper { - display: contents; - font-family: "Red Hat Text", RedHatText, Overpass, Helvetica, sans-serif; +:host { + display: block; + position: relative; - --offset: var(--rh-space-md, 8px); - --offset-top: var(--offset); - --offset-right: var(--offset); + --_spacer-align-top: var(--rh-space-md, 8px); + --_height-offset: min(var(--_spacer-align-top), var(--rh-space-3xl, 48px)); } -header ::slotted(:is(h1, h2, h3, h4, h5, h6)[slot="header"]) { - font-family: "Red Hat Display", RedHatDisplay, Overpass, Helvetica, sans-serif; +[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; +} + +[part="overlay"] { + position: fixed; + height: 100%; + width: 100%; + top: 0; + left: 0; + background-color: rgba(3, 3, 3, 0.62); } [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); @@ -19,18 +53,100 @@ header ::slotted(:is(h1, h2, h3, h4, h5, h6)[slot="header"]) { font-family: inherit; } +:host([width]) [part="dialog"], +:host([variant]) [part="dialog"] { + margin-inline: 0; +} + +:host([width="small"]) [part="dialog"], +:host([variant="small"]) [part="dialog"] { + --_box-width: 35rem; +} + +:host([width="medium"]) [part="dialog"], +:host([variant="medium"]) [part="dialog"] { + --_box-width: 52.5rem; +} + +:host([width="large"]) [part="dialog"], +:host([variant="large"]) [part="dialog"] { + --_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="content"] ::slotted([slot="header"]) { + margin-top: 0 !important; +} + +header { + position: sticky; + top: 0; + background-color: var(--rh-color-surface-lightest, #ffffff); +} + +header ::slotted(:is(h1,h2,h3,h4,h5,h6)[slot="header"]) { + 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); +} + +[part="close-button"] > svg { + font-size: 16px; + width: var(--rh-space-lg, 16px); + aspect-ratio: 1/1; } [part="close-button"]:is(:hover, :focus-within, :focus-visible) svg:is(svg, :hover) { fill: var(--rh-color-icon-secondary-on-light, #151515); } +: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)); +} + +footer { + display: flex; + align-items: center; + 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); } diff --git a/elements/rh-dialog/rh-dialog.ts b/elements/rh-dialog/rh-dialog.ts index b7d44a5781..3d8f57a8e7 100644 --- a/elements/rh-dialog/rh-dialog.ts +++ b/elements/rh-dialog/rh-dialog.ts @@ -1,15 +1,39 @@ -import { html } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import { classMap } from 'lit/directives/class-map.js'; -import { observed } from '@patternfly/pfe-core/decorators/observed.js'; -import { PfModal } from '@patternfly/elements/pf-modal/pf-modal.js'; +import { bound, initializer, observed } from '@patternfly/pfe-core/decorators.js'; +import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; import { ScreenSizeController } from '../../lib/ScreenSizeController.js'; import styles from './rh-dialog.css'; import '@rhds/elements/lib/elements/rh-context-provider/rh-context-provider.js'; +import { query } from 'lit/decorators/query.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +export class DialogCancelEvent extends Event { + constructor() { + super('cancel', { bubbles: true, cancelable: true }); + } +} + +export class DialogCloseEvent extends Event { + constructor() { + super('close', { bubbles: true, cancelable: true }); + } +} + +export class DialogOpenEvent extends Event { + constructor( + /** The trigger element which triggered the dialog to open */ + public trigger: HTMLElement | null + ) { + super('open', { bubbles: true, cancelable: true }); + } +} async function pauseYoutube(iframe: HTMLIFrameElement) { const { pauseVideo } = await import('./yt-api.js'); @@ -30,36 +54,280 @@ function openChanged(this: RhDialog, oldValue: unknown) { * A dialog displays important information to users without requiring them to navigate away from the page. * @summary Communicates information requiring user input or action * + * @fires {DialogOpenEvent} open - Fires when a user clicks on the trigger or manually opens a dialog. + * @fires {DialogCloseEvent} close - Fires when either a user clicks on either the close button or the overlay or manually closes a dialog. + * @fires {DialogCancelEvent} cancel + * + * @slot - The default slot can contain any type of content. When the header is not present this unnamed slot appear at the top of the dialog window (to the left of the close button). Otherwise it will appear beneath the header. + * @slot header - The header is an optional slot that appears at the top of the dialog window. It should be a header tag (h2-h6). + * @slot footer - Optional footer content. Good place to put action buttons. + * + * @csspart overlay - The dialog overlay which lies under the dialog and above the page body + * @csspart dialog - The dialog element + * @csspart content - The container for the dialog content + * @csspart header - The container for the optional dialog header + * @csspart description - The container for the optional dialog description in the header + * @csspart close-button - The dialog's close button + * @csspart footer - Actions footer container + * * @cssprop {} --rh-dialog-video-aspect-ratio * @cssprop {} --rh-dialog-close-button-color * Sets the dialog close button color. * {@default `var(--rh-color-icon-secondary-on-dark, #ffffff)`} */ @customElement('rh-dialog') -export class RhDialog extends PfModal { +export class RhDialog extends LitElement { static readonly version = '{{version}}'; - static readonly styles = [...PfModal.styles, styles]; + static readonly styles = [styles]; protected static closeOnOutsideClick = true; - #screenSize = new ScreenSizeController(this); + /** + * The `variant` controls the width of the dialog. + * There are three options: `small`, `medium` and `large`. The default is `large`. + */ + @property({ reflect: true }) variant?: 'small' | 'medium' | 'large'; - @property({ reflect: true }) type?: 'video'; + /** + * `position="top"` aligns the dialog with the top of the page + */ + @property({ reflect: true }) position?: 'top'; @observed(openChanged) - @property({ reflect: true, type: Boolean }) open = false; + @property({ type: Boolean, reflect: true }) open = false; + + /** Optional ID of the trigger element */ + @observed + @property() trigger?: string; + + @property({ reflect: true }) type?: 'video'; + + /** @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue */ + public returnValue = ''; + + #screenSize = new ScreenSizeController(this); + + @query('#overlay') private overlay?: HTMLElement | null; + @query('#dialog') private dialog?: HTMLElement | null; + @query('#close-button') private closeButton?: HTMLElement | null; + + #headerId = getRandomId(); + #triggerElement: HTMLElement | null = null; + #header: HTMLElement | null = null; + #body: Element[] = []; + #headings: Element[] = []; + #cancelling = false; + + #slots = new SlotController(this, null, 'header', 'description', 'footer'); + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keydown', this.onKeydown); + this.addEventListener('click', this.onClick); + } render() { + const headerId = (this.#header || this.#headings.length) ? this.#headerId : undefined; + const headerLabel = 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` - ${super.render()} +
+
+ +
`; } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.removeEventListener('keydown', this.onKeydown); + + this.#triggerElement?.removeEventListener('click', this.onTriggerClick); + } + + @initializer() + protected async _init() { + await this.updateComplete; + this.#header = this.querySelector(`[slot$="header"]`); + this.#body = [...this.querySelectorAll(`*:not([slot])`)]; + this.#headings = this.#body.filter(el => el.tagName.slice(0, 1) === 'H'); + + if (this.#triggerElement) { + this.#triggerElement.addEventListener('click', this.onTriggerClick); + this.removeAttribute('hidden'); + } + + if (this.#header) { + this.#header.id = this.#headerId; + } else if (this.#headings.length > 0) { + // Get the first heading in the dialog if it exists + this.#headings[0].id = this.#headerId; + } + } + + protected async _openChanged(oldValue?: boolean, newValue?: boolean) { + // loosening types to prevent running these effects in unexpected circumstances + // eslint-disable-next-line eqeqeq + if (oldValue == null || newValue == null || oldValue == newValue) { + return; + } else if (this.open) { + // This prevents background scroll + document.body.style.overflow = 'hidden'; + await this.updateComplete; + // Set the focus to the container + this.dialog?.focus(); + this.dispatchEvent(new DialogOpenEvent(this.#triggerElement)); + } else { + // Return scrollability + document.body.style.overflow = 'auto'; + + await this.updateComplete; + + if (this.#triggerElement) { + this.#triggerElement.focus(); + } + + this.dispatchEvent(this.#cancelling ? new DialogCancelEvent() : new DialogCloseEvent()); + } + } + + protected _triggerChanged() { + if (this.trigger) { + this.#triggerElement = (this.getRootNode() as Document | ShadowRoot).getElementById(this.trigger); + this.#triggerElement?.addEventListener('click', this.onTriggerClick); + } + } + + @bound private onTriggerClick(event: MouseEvent) { + event.preventDefault(); + this.showModal(); + } + + @bound private onClick(event: MouseEvent) { + const { open, overlay, dialog } = this; + if (open) { + const path = event.composedPath(); + const { closeOnOutsideClick } = this.constructor as typeof RhDialog; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (closeOnOutsideClick && path.includes(overlay!) && !path.includes(dialog!)) { + event.preventDefault(); + this.cancel(); + } + } + } + + @bound private 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': + if (event.target === this.#triggerElement) { + event.preventDefault(); + this.showModal(); + } + return; + } + } + + private async cancel() { + this.#cancelling = true; + this.open = false; + await this.updateComplete; + this.#cancelling = false; + } + + setTrigger(element: HTMLElement) { + this.#triggerElement = element; + this.#triggerElement.addEventListener('click', this.onTriggerClick); + } + + /** + * Manually toggles the dialog. + * ```js + * dialog.toggle(); + * ``` + */ + @bound toggle() { + this.open = !this.open; + } + + /** + * Manually opens the dialog. + * ```js + * dialog.show(); + * ``` + */ + @bound show() { + this.open = true; + } + + @bound showModal() { + // TODO: non-modal mode + this.show(); + } + + /** + * Manually closes the dialog. + * ```js + * dialog.close(); + * ``` + */ + @bound close(returnValue?: string) { + if (typeof returnValue === 'string') { + this.returnValue = returnValue; + } + + this.open = false; + } } declare global {