diff --git a/packages/main/cypress/specs/Dialog.cy.tsx b/packages/main/cypress/specs/Dialog.cy.tsx index a9ded9140d87..6b70d8d59fb5 100644 --- a/packages/main/cypress/specs/Dialog.cy.tsx +++ b/packages/main/cypress/specs/Dialog.cy.tsx @@ -1844,3 +1844,255 @@ describe("Native drag-and-drop in draggable dialogs", () => { }); }); }); + +describe("Fullscreen Button", () => { + it("should show fullscreen button when showFullscreenButton is set", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("exist") + .and("be.visible"); + }); + + it("should not show fullscreen button when showFullscreenButton is not set", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("not.exist"); + }); + + it("should toggle stretch property when fullscreen button is clicked", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + + cy.get("#dialog").then($dialog => { + ($dialog.get(0) as Dialog)._toggleFullscreen(); + }); + + cy.get("#dialog") + .should("have.attr", "stretch"); + + cy.get("#dialog").then($dialog => { + ($dialog.get(0) as Dialog)._toggleFullscreen(); + }); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + }); + + it("should show full-screen icon when not stretched and exit-full-screen when stretched", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("have.attr", "icon", "full-screen"); + + cy.get("#dialog").then($dialog => { + ($dialog.get(0) as Dialog)._toggleFullscreen(); + }); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("have.attr", "icon", "exit-full-screen"); + }); + + it("should have correct tooltip based on stretch state", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("have.attr", "tooltip", "Maximize"); + + cy.get("#dialog").then($dialog => { + ($dialog.get(0) as Dialog)._toggleFullscreen(); + }); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("have.attr", "tooltip", "Restore"); + }); + + it("should have aria-keyshortcuts attribute", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .shadow() + .find("button") + .should("have.attr", "aria-keyshortcuts", "Shift+Control+F"); + }); + + it("should toggle fullscreen with Shift+Ctrl+F keyboard shortcut", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + + cy.get("#input").realClick(); + cy.realPress(["Shift", "Control", "f"]); + + cy.get("#dialog") + .should("have.attr", "stretch"); + + cy.realPress(["Shift", "Control", "f"]); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + }); + + it("should toggle fullscreen on header double-click", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + + cy.get("#dialog").then($dialog => { + const dialog = $dialog.get(0) as Dialog; + const headerRoot = dialog.shadowRoot!.querySelector(".ui5-popup-header-root")!; + const event = new MouseEvent("dblclick", { bubbles: true, composed: true }); + Object.defineProperty(event, "target", { value: headerRoot }); + headerRoot.dispatchEvent(event); + }); + + cy.get("#dialog") + .should("have.attr", "stretch"); + }); + + it("should not toggle fullscreen on double-click when showFullscreenButton is false", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-popup-header-root") + .realClick({ clickCount: 2 }); + + cy.get("#dialog") + .should("not.have.attr", "stretch"); + }); + + it("should reset drag/resize state when toggling fullscreen", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog").then($dialog => { + const dialog = $dialog.get(0) as Dialog; + dialog._draggedOrResized = true; + Object.assign(dialog.style, { top: "100px", left: "100px", width: "400px", height: "300px" }); + }); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .realClick(); + + cy.get("#dialog").then($dialog => { + const dialog = $dialog.get(0) as Dialog; + expect(dialog._draggedOrResized).to.be.false; + expect(dialog.style.width).to.equal(""); + expect(dialog.style.height).to.equal(""); + }); + }); + + it("should display header when only showFullscreenButton is set", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-popup-header-root") + .should("exist"); + }); + + it("should reflect stretch state if stretch is initially true", () => { + cy.mount( + + + + ); + + cy.get("#dialog").ui5DialogOpened(); + + cy.get("#dialog") + .shadow() + .find(".ui5-dialog-fullscreen-btn") + .should("have.attr", "icon", "exit-full-screen") + .and("have.attr", "tooltip", "Restore"); + + cy.get("#dialog").invoke("prop", "open", false); + }); +}); diff --git a/packages/main/src/Dialog.ts b/packages/main/src/Dialog.ts index 9596e0562044..06c09ce71dbb 100644 --- a/packages/main/src/Dialog.ts +++ b/packages/main/src/Dialog.ts @@ -16,6 +16,8 @@ import "@ui5/webcomponents-icons/dist/error.js"; import "@ui5/webcomponents-icons/dist/alert.js"; import "@ui5/webcomponents-icons/dist/sys-enter-2.js"; import "@ui5/webcomponents-icons/dist/information.js"; +import "@ui5/webcomponents-icons/dist/full-screen.js"; +import "@ui5/webcomponents-icons/dist/exit-full-screen.js"; import { DIALOG_HEADER_ARIA_ROLE_DESCRIPTION, @@ -25,6 +27,8 @@ import { DIALOG_HEADER_ARIA_LABEL, DIALOG_CONTENT_ARIA_LABEL, DIALOG_FOOTER_ARIA_LABEL, + DIALOG_FULLSCREEN_MAXIMIZE, + DIALOG_FULLSCREEN_RESTORE, } from "./generated/i18n/i18n-defaults.js"; // Template @@ -39,6 +43,10 @@ import PopupAccessibleRole from "./types/PopupAccessibleRole.js"; */ const STEP_SIZE = 16; +const FULLSCREEN_BUTTON_ACCESSIBILITY_ATTRIBUTES = { + ariaKeyShortcuts: "Shift+Control+F", +}; + type ValueStateWithIcon = ValueState.Negative | ValueState.Critical | ValueState.Positive | ValueState.Information; /** * Defines the icons corresponding to the dialog's state. @@ -169,6 +177,18 @@ class Dialog extends Popup { @property({ type: Boolean }) resizable = false; + /** + * Defines whether a fullscreen toggle button is shown in the dialog header. + * When pressed, it toggles the `stretch` property. + * The fullscreen button is not available on phone devices. + * + * **Note:** The fullscreen toggle can also be triggered by pressing Shift+Ctrl+F or by double-clicking the dialog header. + * @default false + * @public + */ + @property({ type: Boolean }) + showFullscreenButton = false; + /** * Defines the state of the `Dialog`. * @@ -187,6 +207,7 @@ class Dialog extends Popup { _resizeMouseMoveHandler: (e: MouseEvent) => void; _resizeMouseUpHandler: (e: MouseEvent) => void; _dragStartHandler: (e: DragEvent) => void; + _fullscreenKeydownHandler: (e: KeyboardEvent) => void; _y?: number; _x?: number; _isRTL?: boolean; @@ -235,6 +256,7 @@ class Dialog extends Popup { this._resizeMouseUpHandler = this._onResizeMouseUp.bind(this); this._dragStartHandler = this._handleDragStart.bind(this); + this._fullscreenKeydownHandler = this._onFullscreenKeydown.bind(this); } static _isHeader(element: HTMLElement) { @@ -279,7 +301,7 @@ class Dialog extends Popup { * Determines if the header should be shown. */ get _displayHeader() { - return this.header.length || this.headerText || this.draggable || this.resizable; + return this.header.length || this.headerText || this.draggable || this.resizable || this.showFullscreenButton; } get _movable() { @@ -291,7 +313,25 @@ class Dialog extends Popup { } get _showResizeHandle() { - return this.resizable && this.onDesktop; + return this.resizable && this.onDesktop && !this.stretch; + } + + get _showFullscreenButton() { + return this.showFullscreenButton && !this.onPhone; + } + + get _fullscreenButtonIcon() { + return this.stretch ? "exit-full-screen" : "full-screen"; + } + + get _fullscreenButtonTooltip() { + return this.stretch + ? Dialog.i18nBundle.getText(DIALOG_FULLSCREEN_RESTORE) + : Dialog.i18nBundle.getText(DIALOG_FULLSCREEN_MAXIMIZE); + } + + get _fullscreenButtonAccessibilityAttributes() { + return FULLSCREEN_BUTTON_ACCESSIBILITY_ATTRIBUTES; } get _minHeight() { @@ -375,11 +415,15 @@ class Dialog extends Popup { _attachBrowserEvents() { this._attachScreenResizeHandler(); this._registerDragHandler(); + if (this.showFullscreenButton) { + document.addEventListener("keydown", this._fullscreenKeydownHandler); + } } _detachBrowserEvents() { this._detachScreenResizeHandler(); this._deregisterDragHandler(); + document.removeEventListener("keydown", this._fullscreenKeydownHandler); } _attachScreenResizeHandler() { @@ -432,6 +476,51 @@ class Dialog extends Popup { /** * Event handlers */ + _toggleFullscreen() { + if (this.onPhone) { + return; + } + + const wasStretched = this.stretch; + this.stretch = !this.stretch; + + this._revertSize(); + this._draggedOrResized = false; + + if (wasStretched) { + requestAnimationFrame(() => { + if (this.open) { + this._center(); + } + }); + } + } + + _onHeaderDblClick(e: MouseEvent) { + if (!this._showFullscreenButton) { + return; + } + + const target = e.target as HTMLElement; + const headerRoot = this._root.querySelector(".ui5-popup-header-root"); + if (target !== headerRoot && !target.classList.contains("ui5-popup-header-text")) { + return; + } + + this._toggleFullscreen(); + } + + _onFullscreenKeydown(e: KeyboardEvent) { + if (this._showFullscreenButton && this._isFullscreenShortcut(e)) { + e.preventDefault(); + this._toggleFullscreen(); + } + } + + _isFullscreenShortcut(e: KeyboardEvent) { + return (e.key === "f" || e.key === "F") && e.ctrlKey && e.shiftKey && !e.altKey; + } + _onDragMouseDown(e: MouseEvent) { // allow dragging only on the header if (!this._movable || !this.draggable || !Dialog._isHeader(e.target as HTMLElement)) { diff --git a/packages/main/src/DialogTemplate.tsx b/packages/main/src/DialogTemplate.tsx index 3575a770d021..226e865ba219 100644 --- a/packages/main/src/DialogTemplate.tsx +++ b/packages/main/src/DialogTemplate.tsx @@ -3,6 +3,7 @@ import type Dialog from "./Dialog.js"; import PopupTemplate from "./PopupTemplate.js"; import Title from "./Title.js"; import Icon from "./Icon.js"; +import Button from "./Button.js"; export default function DialogTemplate(this: Dialog) { return PopupTemplate.call(this, { @@ -24,6 +25,7 @@ function beforeContent(this: Dialog) { tabIndex={this._headerTabIndex} onKeyDown={this._onDragOrResizeKeyDown} onMouseDown={this._onDragMouseDown} + onDblClick={this.showFullscreenButton ? this._onHeaderDblClick : undefined} part="header" // state={this.state} > @@ -36,6 +38,18 @@ function beforeContent(this: Dialog) { {this.headerText} } + {this._showFullscreenButton && + + } + {this.resizable ? this.draggable ? diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index a151c7fc8801..a09c2f24e67d 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -852,6 +852,13 @@ DIALOG_CONTENT_ARIA_LABEL=Content #XACT: ARIA label for the Dialog footer region DIALOG_FOOTER_ARIA_LABEL=Footer + +#XACT: Tooltip for dialog fullscreen button (maximize) +DIALOG_FULLSCREEN_MAXIMIZE=Maximize + +#XACT: Tooltip for dialog fullscreen button (restore) +DIALOG_FULLSCREEN_RESTORE=Restore + #XFLD: A colon to separate the "label" from an input. In some languages there might be a different symbol used for such a colon LABEL_COLON=: diff --git a/packages/main/src/themes/Dialog.css b/packages/main/src/themes/Dialog.css index a99dfbbcb206..2cbd0ef5c52c 100644 --- a/packages/main/src/themes/Dialog.css +++ b/packages/main/src/themes/Dialog.css @@ -29,7 +29,7 @@ cursor: move; } -:host([draggable]) .ui5-popup-header-root * { +:host([draggable]) .ui5-popup-header-root *:not(.ui5-dialog-fullscreen-btn) { cursor: auto; } @@ -139,6 +139,16 @@ color: var(--sapButton_Lite_TextColor); } +.ui5-popup-header-text { + flex: 1; + min-width: 0; + justify-content: flex-start; +} + +:host([on-phone]) .ui5-dialog-fullscreen-btn { + display: none; +} + :host::backdrop { background-color: var(--_ui5_popup_block_layer_background); opacity: var(--_ui5_popup_block_layer_opacity); diff --git a/packages/main/test/pages/Dialog.html b/packages/main/test/pages/Dialog.html index 160b30e7151e..5758fd24b02b 100644 --- a/packages/main/test/pages/Dialog.html +++ b/packages/main/test/pages/Dialog.html @@ -35,6 +35,7 @@ +

Open Dialog with Input @@ -82,6 +83,12 @@ Open draggable & resizable dialog

+ Open fullscreen dialog +
+
+ Open fullscreen dialog (long title) +
+
Open RTL draggable & resizable dialog

@@ -439,6 +446,26 @@ + +

This dialog has a fullscreen toggle button in the header.

+

Press the fullscreen button or use Shift+Ctrl+F to toggle fullscreen mode.

+

You can also double-click the header to toggle.

+ + +
+ Close +
+
+ + +

Compare the title position with and without the fullscreen button.

+

With flex:1 approach, the title shifts toward the start instead of being visually centered in the header.

+ +
+ Close +
+
+

Move this dialog around the screen by dragging it by its header.

@@ -898,6 +925,10 @@ scrollHelper.style.display = cbScrollable.checked ? "block" : "none"; }); + cbRtl.addEventListener("ui5-change", function () { + document.documentElement.dir = cbRtl.checked ? "rtl" : "ltr"; + }); + let preventClosing = true; btnOpenDialog.addEventListener("click", function () { @@ -1000,6 +1031,10 @@ window["resizable-custom-header-close"].addEventListener("click", function () { window["resizable-dialog-custom-header"].open = false; }); window["draggable-and-resizable-open"].addEventListener("click", function () { window["draggable-and-resizable-dialog"].open = true; }); window["draggable-and-resizable-close"].addEventListener("click", function () { window["draggable-and-resizable-dialog"].open = false; }); + window["fullscreen-open"].addEventListener("click", function () { window["fullscreen-dialog"].open = true; }); + window["fullscreen-close"].addEventListener("click", function () { window["fullscreen-dialog"].open = false; }); + window["fullscreen-long-title-open"].addEventListener("click", function () { window["fullscreen-long-title"].open = true; }); + window["fullscreen-long-title-close"].addEventListener("click", function () { window["fullscreen-long-title"].open = false; }); window["rtl-draggable-and-resizable-open"].addEventListener("click", function () { window["rtl-draggable-and-resizable-dialog"].open = true; }); window["rtl-draggable-and-resizable-close"].addEventListener("click", function () { window["rtl-draggable-and-resizable-dialog"].open = false; }); window["rtl-maxwidth-resizable-open"].addEventListener("click", function () { window["rtl-maxwidth-resizable-dialog"].open = true; }); diff --git a/packages/website/docs/_components_pages/main/Dialog.mdx b/packages/website/docs/_components_pages/main/Dialog.mdx index 403387ab4706..539c059b0c53 100644 --- a/packages/website/docs/_components_pages/main/Dialog.mdx +++ b/packages/website/docs/_components_pages/main/Dialog.mdx @@ -4,6 +4,7 @@ slug: ../Dialog import Basic from "../../_samples/main/Dialog/Basic/Basic.md"; import DraggableAndResizable from "../../_samples/main/Dialog/DraggableAndResizable/DraggableAndResizable.md"; +import Fullscreen from "../../_samples/main/Dialog/Fullscreen/Fullscreen.md"; import BarInDialog from "../../_samples/main/Dialog/BarInDialog/BarInDialog.md"; import WithState from "../../_samples/main/Dialog/WithState/WithState.md"; @@ -19,6 +20,10 @@ import WithState from "../../_samples/main/Dialog/WithState/WithState.md"; ### Draggable and Resizable +### Fullscreen +Users can toggle between standard and full screen size. The full screen button is positioned top-right in the dialog's title bar. The fullscreen toggle can also be triggered by pressing Shift+Ctrl+F or by double-clicking the dialog header. + + ### Usage of Bar as header/footer The Bar component can be used as header and/or footer of the Dialog diff --git a/packages/website/docs/_samples/main/Dialog/Fullscreen/Fullscreen.md b/packages/website/docs/_samples/main/Dialog/Fullscreen/Fullscreen.md new file mode 100644 index 000000000000..0c062a836e84 --- /dev/null +++ b/packages/website/docs/_samples/main/Dialog/Fullscreen/Fullscreen.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + + diff --git a/packages/website/docs/_samples/main/Dialog/Fullscreen/main.js b/packages/website/docs/_samples/main/Dialog/Fullscreen/main.js new file mode 100644 index 000000000000..380d54aca254 --- /dev/null +++ b/packages/website/docs/_samples/main/Dialog/Fullscreen/main.js @@ -0,0 +1,21 @@ +import "@ui5/webcomponents/dist/Dialog.js"; +import "@ui5/webcomponents/dist/Button.js"; +import "@ui5/webcomponents/dist/Toolbar.js"; +import "@ui5/webcomponents/dist/ToolbarButton.js"; + +var dialogOpener = document.getElementById("dialogOpener"); +var dialog = document.getElementById("dialog"); +var dialogClosers = [...dialog.querySelectorAll(".dialogCloser")]; + +dialogOpener.accessibilityAttributes = { + hasPopup: "dialog", + controls: dialog.id, +}; +dialogOpener.addEventListener("click", () => { + dialog.open = true; +}); +dialogClosers.forEach(btn => { + btn.addEventListener("click", () => { + dialog.open = false; + }); +}) diff --git a/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.html b/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.html new file mode 100644 index 000000000000..8e162573f4e6 --- /dev/null +++ b/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.html @@ -0,0 +1,29 @@ + + + + + + + + Sample + + + + + + Open Fullscreen Dialog + + +
This dialog has a fullscreen toggle button in the header.
+
Click the fullscreen button or press Shift+Ctrl+F to toggle fullscreen mode.
+
You can also double-click the header to toggle.
+ + + +
+ + + + + + diff --git a/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.tsx b/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.tsx new file mode 100644 index 000000000000..9bae0850e0f4 --- /dev/null +++ b/packages/website/docs/_samples/main/Dialog/Fullscreen/sample.tsx @@ -0,0 +1,50 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import { useState } from "react"; +import ButtonClass from "@ui5/webcomponents/dist/Button.js"; +import DialogClass from "@ui5/webcomponents/dist/Dialog.js"; +import ToolbarClass from "@ui5/webcomponents/dist/Toolbar.js"; +import ToolbarButtonClass from "@ui5/webcomponents/dist/ToolbarButton.js"; + +const Button = createReactComponent(ButtonClass); +const Dialog = createReactComponent(DialogClass); +const Toolbar = createReactComponent(ToolbarClass); +const ToolbarButton = createReactComponent(ToolbarButtonClass); + +function App() { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + <> + + + setDialogOpen(false)} + > +
This dialog has a fullscreen toggle button in the header.
+
+ Click the fullscreen button or press Shift+Ctrl+F to toggle fullscreen + mode. +
+
You can also double-click the header to toggle.
+ + setDialogOpen(false)} + /> + +
+ + ); +} + +export default App;