From f778425c13cd650cd60a253cd12bbe21a26c5799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= Date: Sat, 15 Feb 2025 19:50:58 +0100 Subject: [PATCH 01/14] Update Github Workflows to use Ubuntu 24.04 --- .github/workflows/broken-links-check.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/external-links-check.yml | 2 +- .github/workflows/git.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/pull-request-meta.yml | 2 +- .github/workflows/release-management.yml | 10 +++++----- .github/workflows/test.yml | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/broken-links-check.yml b/.github/workflows/broken-links-check.yml index 1bced87a9..f43277e2c 100644 --- a/.github/workflows/broken-links-check.yml +++ b/.github/workflows/broken-links-check.yml @@ -6,7 +6,7 @@ on: jobs: broken_link_check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 name: Check react-ui.io for broken links steps: - name: Check for broken links diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b375c1090..02a7aa0e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: build: name: Build distribution CSS and JS - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: node: [ 20, 22 ] diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f41c6e6a0..b72003019 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ on: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: # required for all workflows diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ad5b324e..d2216abb8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,7 @@ permissions: jobs: build: name: Build Docs - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/external-links-check.yml b/.github/workflows/external-links-check.yml index 92344590e..c7378df9a 100644 --- a/.github/workflows/external-links-check.yml +++ b/.github/workflows/external-links-check.yml @@ -6,7 +6,7 @@ on: jobs: broken_link_check: name: Markdown link check - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: gaurav-nelson/github-action-markdown-link-check@v1 diff --git a/.github/workflows/git.yml b/.github/workflows/git.yml index d1d25a465..855d0a7ea 100644 --- a/.github/workflows/git.yml +++ b/.github/workflows/git.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: block-merge-with-autosquash-commits: name: Block merge with autosquash commits - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Block merge with autosquash commits diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 672a5af7e..06db22543 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: lint: name: Lint - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/pull-request-meta.yml b/.github/workflows/pull-request-meta.yml index 98edccca6..98175f98f 100644 --- a/.github/workflows/pull-request-meta.yml +++ b/.github/workflows/pull-request-meta.yml @@ -7,7 +7,7 @@ on: jobs: process_pr_meta: name: Process PR meta - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Assign to author uses: kentaro-m/auto-assign-action@v2.0.0 # Specify also the minor version because v2 does not exist diff --git a/.github/workflows/release-management.yml b/.github/workflows/release-management.yml index 83e304819..d1895b1e6 100644 --- a/.github/workflows/release-management.yml +++ b/.github/workflows/release-management.yml @@ -7,7 +7,7 @@ on: jobs: test_and_build: name: Test and build - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 outputs: version: ${{ steps.check_package_version.outputs.version }} version_changed: ${{ steps.check_package_version.outputs.changed }} @@ -51,7 +51,7 @@ jobs: contents: write needs: [test_and_build] if: needs.test_and_build.outputs.version_changed == 'false' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Draft release on GitHub uses: release-drafter/release-drafter@v6 @@ -62,7 +62,7 @@ jobs: name: Publish release draft needs: [test_and_build] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 @@ -86,7 +86,7 @@ jobs: name: Publish to npm needs: [test_and_build, publish_release_draft_on_version_bump] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 @@ -112,7 +112,7 @@ jobs: contents: write needs: [test_and_build, publish_release_draft_on_version_bump] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 082adc4c6..5a94a3745 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: name: Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 From 87ee7df12cf327eb0cbf51317fe2666d076f4437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= Date: Sat, 15 Feb 2025 19:51:52 +0100 Subject: [PATCH 02/14] Fix Node version in `docker/node/Dockerfile` to match supported Node version --- docker/node/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/node/Dockerfile b/docker/node/Dockerfile index 0cc062167..ff1346e36 100644 --- a/docker/node/Dockerfile +++ b/docker/node/Dockerfile @@ -1,3 +1,3 @@ -FROM node:20 +FROM node:22 RUN mkdir /workspace WORKDIR /workspace From 425fa1519a00870776ffb8dbc99406ab92b70265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= Date: Thu, 30 May 2024 21:02:57 +0200 Subject: [PATCH 03/14] Re-implement `Modal` component using HTMLDialogElement (#461) --- src/components/Grid/Grid.module.scss | 4 +- src/components/Modal/Modal.jsx | 146 ++++-- src/components/Modal/Modal.module.scss | 41 +- src/components/Modal/README.md | 449 ++++++++++++++++-- src/components/Modal/__tests__/Modal.test.jsx | 7 +- src/components/Modal/_animations.scss | 9 + .../Modal/_helpers/dialogOnCancelHandler.js | 28 ++ .../Modal/_helpers/dialogOnClickHandler.js | 46 ++ .../Modal/_helpers/dialogOnCloseHandler.js | 28 ++ .../Modal/_helpers/dialogOnKeyDownHandler.js | 57 +++ .../Modal/_helpers/getPositionClassName.js | 2 +- src/components/Modal/_hooks/useModalFocus.js | 115 +---- src/components/Modal/_settings.scss | 3 - src/components/Modal/_theme.scss | 1 + src/styles/settings/_z-indexes.scss | 2 - src/theme.scss | 1 + webpack.config.babel.js | 2 +- 17 files changed, 739 insertions(+), 202 deletions(-) create mode 100644 src/components/Modal/_animations.scss create mode 100644 src/components/Modal/_helpers/dialogOnCancelHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnClickHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnCloseHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnKeyDownHandler.js delete mode 100644 src/styles/settings/_z-indexes.scss diff --git a/src/components/Grid/Grid.module.scss b/src/components/Grid/Grid.module.scss index e8a520d2b..bcc14f941 100644 --- a/src/components/Grid/Grid.module.scss +++ b/src/components/Grid/Grid.module.scss @@ -20,8 +20,8 @@ // // 2. Apply custom property value that is defined within current breakpoint, see 1. // -// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values as -// empty. That makes the other value of the shorthand property unexpectedly used for both axes. +// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values +// as empty. That makes the other value of the shorthand property unexpectedly used for both axes. @use "sass:map"; @use "../../styles/tools/spacing"; diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx index fd95d7a1a..d4c968b49 100644 --- a/src/components/Modal/Modal.jsx +++ b/src/components/Modal/Modal.jsx @@ -1,9 +1,18 @@ import PropTypes from 'prop-types'; -import React, { useRef } from 'react'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import { createPortal } from 'react-dom'; import { withGlobalProps } from '../../providers/globalProps'; import { classNames } from '../../utils/classNames'; import { transferProps } from '../../utils/transferProps'; +import { dialogOnCancelHandler } from './_helpers/dialogOnCancelHandler'; +import { dialogOnClickHandler } from './_helpers/dialogOnClickHandler'; +import { dialogOnCloseHandler } from './_helpers/dialogOnCloseHandler'; +import { dialogOnKeyDownHandler } from './_helpers/dialogOnKeyDownHandler'; import { getPositionClassName } from './_helpers/getPositionClassName'; import { getSizeClassName } from './_helpers/getSizeClassName'; import { useModalFocus } from './_hooks/useModalFocus'; @@ -12,44 +21,34 @@ import styles from './Modal.module.scss'; const preRender = ( children, - childrenWrapperRef, - closeButtonRef, + dialogRef, position, - restProps, size, + events, + restProps, ) => ( -
{ - e.preventDefault(); - if (closeButtonRef?.current != null) { - closeButtonRef.current.click(); - } - }} - role="presentation" + -
{ - e.stopPropagation(); - }} - ref={childrenWrapperRef} - role="presentation" - > - {children} -
-
+ {children} + ); export const Modal = ({ + allowCloseOnBackdropClick, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, autoFocus, children, closeButtonRef, + dialogRef, portalId, position, preventScrollUnderneath, @@ -57,45 +56,86 @@ export const Modal = ({ size, ...restProps }) => { - const childrenWrapperRef = useRef(); + const internalDialogRef = useRef(); - useModalFocus( - autoFocus, - childrenWrapperRef, - primaryButtonRef, - closeButtonRef, - ); + useEffect(() => { + internalDialogRef.current.showModal(); + }, []); + // We need to have a reference to the dialog element to be able to call its methods, + // but at the same time we want to expose this reference to the parent component for + // case someone wants to call dialog methods from outside the component. + useImperativeHandle(dialogRef, () => internalDialogRef.current); + + useModalFocus(autoFocus, internalDialogRef, primaryButtonRef); useModalScrollPrevention(preventScrollUnderneath); + const onCancel = useCallback( + (e) => dialogOnCancelHandler(e, closeButtonRef, restProps.onCancel), + [closeButtonRef, restProps.onCancel], + ); + const onClick = useCallback( + (e) => dialogOnClickHandler(e, closeButtonRef, internalDialogRef, allowCloseOnBackdropClick), + [allowCloseOnBackdropClick, closeButtonRef, internalDialogRef], + ); + const onClose = useCallback( + (e) => dialogOnCloseHandler(e, closeButtonRef, restProps.onClose), + [closeButtonRef, restProps.onClose], + ); + const onKeyDown = useCallback( + (e) => dialogOnKeyDownHandler( + e, + closeButtonRef, + primaryButtonRef, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, + ), + [ + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, + closeButtonRef, + primaryButtonRef, + ], + ); + const events = { + onCancel, + onClick, + onClose, + onKeyDown, + }; + if (portalId === null) { return preRender( children, - childrenWrapperRef, - closeButtonRef, + internalDialogRef, position, - restProps, size, + events, + restProps, ); } return createPortal( preRender( children, - childrenWrapperRef, - closeButtonRef, + internalDialogRef, position, - restProps, size, + events, + restProps, ), document.getElementById(portalId), ); }; Modal.defaultProps = { + allowCloseOnBackdropClick: true, + allowCloseOnEscapeKey: true, + allowPrimaryActionOnEnterKey: true, autoFocus: true, children: null, closeButtonRef: null, + dialogRef: null, portalId: null, position: 'center', preventScrollUnderneath: window.document.body, @@ -104,6 +144,18 @@ Modal.defaultProps = { }; Modal.propTypes = { + /** + * If `true`, the `Modal` can be closed by clicking on the backdrop. + */ + allowCloseOnBackdropClick: PropTypes.bool, + /** + * If `true`, the `Modal` can be closed by pressing the Escape key. + */ + allowCloseOnEscapeKey: PropTypes.bool, + /** + * If `true`, the `Modal` can be submitted by pressing the Enter key. + */ + allowPrimaryActionOnEnterKey: PropTypes.bool, /** * If `true`, focus the first input element in the `Modal`, or primary button (referenced by the `primaryButtonRef` * prop), or other focusable element when the `Modal` is opened. If there are none or `autoFocus` is set to `false`, @@ -121,12 +173,20 @@ Modal.propTypes = { */ children: PropTypes.node, /** - * Reference to close button element. It is used to close modal when Escape key is pressed or the backdrop is clicked. + * Reference to close button element. It is used to close modal when Escape key is pressed + * or the backdrop is clicked. */ closeButtonRef: PropTypes.shape({ // eslint-disable-next-line react/forbid-prop-types current: PropTypes.any, }), + /** + * Reference to dialog element + */ + dialogRef: PropTypes.shape({ + // eslint-disable-next-line react/forbid-prop-types + current: PropTypes.any, + }), /** * If set, modal is rendered in the React Portal with that ID. */ diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index d7fa011d1..a340ac766 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -1,9 +1,15 @@ +// 1. Modal uses element that uses the browser's built-in dialog functionality, so that: +// * visibility of the .root element and its backdrop is managed by the browser +// * positioning of the .root element and its backdrop is managed by the browser +// * z-index of the .root element and its backdrop is not needed as dialog is rendered in browser's Top layer + @use "sass:map"; @use "../../styles/theme/typography"; @use "../../styles/tools/accessibility"; @use "../../styles/tools/breakpoint"; @use "../../styles/tools/reset"; @use "../../styles/tools/spacing"; +@use "animations"; @use "settings"; @use "theme"; @@ -13,18 +19,16 @@ --rui-local-max-width: calc(100% - (2 * var(--rui-local-outer-spacing))); --rui-local-max-height: calc(100% - (2 * var(--rui-local-outer-spacing))); - position: fixed; - left: 50%; - z-index: settings.$z-index; - display: flex; flex-direction: column; max-width: var(--rui-local-max-width); max-height: var(--rui-local-max-height); + padding: 0; overflow-y: auto; + color: inherit; + border-width: 0; border-radius: settings.$border-radius; background: theme.$background; box-shadow: theme.$box-shadow; - transform: translateX(-50%); overscroll-behavior: contain; @include breakpoint.up(sm) { @@ -32,14 +36,20 @@ } } - .backdrop { - position: fixed; - top: 0; - left: 0; - z-index: settings.$backdrop-z-index; - width: 100vw; - height: 100vh; + .root[open] { + display: flex; + + @media (prefers-reduced-motion: no-preference) { + animation: fade-in theme.$animation-duration ease-out; + } + } + + .root[open]::backdrop { background: theme.$backdrop-background; + + @media (prefers-reduced-motion: no-preference) { + animation: inherit; + } } .isRootSizeSmall { @@ -64,17 +74,12 @@ } .isRootSizeAuto { - width: auto; min-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, min-width)}); max-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, max-width)}); } - .isRootPositionCenter { - top: 50%; - transform: translate(-50%, -50%); - } - .isRootPositionTop { top: var(--rui-local-outer-spacing); + bottom: auto; } } diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md index 7ee9efa2f..ec8655bef 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -92,11 +92,24 @@ See [API](#api) for all available options. - **Modal actions** should correspond to the modal purpose, too. E.g. “Delete” tells better what happens rather than “OK”. -- Modal **automatically focuses the first non-disabled form field** by default - which allows users to confirm the modal by hitting the enter key. When no - field is found then the primary button (in the footer) is focused. To turn +- While native `` (that is used under the hood) can be present in DOM, + modal is a more feature-rich component that provides more control over the + modal behavior and shall be **removed from DOM when closed**. + +- Modal **automatically focuses the first non-disabled form field** by default. + When no field is found then the primary button (in the footer) is focused. To turn this feature off, set the `autofocus` prop to `false`. +- Modal **submits the form when the user presses the `Enter` key** . A click is + programmatically triggered on the primary button in this case. To turn this + feature off, set the `allowPrimaryActionOnEnterKey` prop to `false`. + +- Modal **closes when the user presses the `Escape` key**. A click is + programmatically triggered on the close button in this case. To turn this + feature off, set the `allowCloseOnEscapeKey` prop to `false`. Modal can be + also **closed by clicking on the backdrop**. To turn this feature off, + set the `allowCloseOnBackdropClick` prop to `false`. + - **Avoid stacking** of modals. While it may technically work, the modal is just not designed for that. @@ -114,7 +127,8 @@ Modal is decomposed into the following components: - [ModalFooter](#modalfooter) Using different combinations, you can compose different kinds of modals, -e.g. dialog modal, blocking modal, scrollable modal, etc. +e.g. dialog modal, [modal with form](#forms), [blocking modal](#interaction-blocking), +[scrollable modal](#scrolling-long-content), etc. ```docoff-react-preview React.createElement(() => { @@ -126,7 +140,7 @@ React.createElement(() => { React UI docs. You may not need it in your application. */} return ( -