diff --git a/.vscode/settings.json b/.vscode/settings.json index d2adc91..895524a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,22 @@ { // https://github.com/Microsoft/vscode-css-languageservice/blob/main/docs/customData.md // https://stackoverflow.com/questions/42520229/vs-code-and-intellisense-for-css-grid-and-css-modules - "css.customData": [".vscode/custom.css-data.json"] + "css.customData": [".vscode/custom.css-data.json"], + + // Editor (code) + "editor.insertSpaces": true, // Insert spaces when pressing Tab + "editor.tabSize": 2, + "editor.detectIndentation": true, // Detect tabSize/insertSpaces automatically when opening a file + "editor.renderWhitespace": "selection", // Render whitespace as visible when selecting text + "editor.wordWrap": "wordWrapColumn", + "editor.wordWrapColumn": 120, + "editor.rulers": [120], + "editor.formatOnSave": false, // Disable auto-format + "editor.formatOnPaste": false, + "editor.comments.ignoreEmptyLines": false, + "files.insertFinalNewline": true, // Insert a newline at the end of the file when saving + "files.trimTrailingWhitespace": false, // Do not trim trailing whitespace + "editor.trimAutoWhitespace": false, + "files.eol": "\n", + "javascript.preferences.quoteStyle": "single", } diff --git a/src/assets/icons/_icons.ts b/src/assets/icons/_icons.ts index a065710..b811d7d 100644 --- a/src/assets/icons/_icons.ts +++ b/src/assets/icons/_icons.ts @@ -50,4 +50,5 @@ export const icons = { 'user': {}, 'warning': {}, 'workflows': {}, + 'close-x': {}, } as const satisfies Record; diff --git a/src/assets/icons/close-x.svg b/src/assets/icons/close-x.svg new file mode 100644 index 0000000..0df5d96 --- /dev/null +++ b/src/assets/icons/close-x.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/overlays/Modal/Modal.module.scss b/src/components/overlays/Modal/Modal.module.scss index 480717a..289fb43 100644 --- a/src/components/overlays/Modal/Modal.module.scss +++ b/src/components/overlays/Modal/Modal.module.scss @@ -14,60 +14,80 @@ /* --bk-modal-background-color: color-mix(in srgb, var(--bk-panel-background-color) 80%, transparent); */ --bk-modal-background-color: var(--bk-panel-background-color); - --bk-modal-inset: calc(var(--bk-sizing-3) + var(--bk-sizing-2)); - margin: auto; padding: 0; - width: var(--bk-sizing-9); - max-width: calc(100% - 6px - 2em); height: fit-content; - min-height: 60%; - max-height: calc(100% - 6px - 2em); + min-height: bk.$spacing-16; + max-width: calc(100% - bk.$spacing-6); + max-height: calc(100% - bk.$spacing-6); box-shadow: 0 8px 10px 1px rgba(0 0 0 / 14%), 0 3px 14px 2px rgba(0 0 0 / 12%), 0 5px 5px -3px rgba(0 0 0 / 3%); background-color: var(--bk-modal-background-color); - border-radius: var(--bk-sizing-2); + border-radius: bk.$sizing-2; display: none; /* flex */ flex-direction: column; + &.bk-modal-small { + // 484px + width: calc(484 * bk.$size-1); + } + &.bk-modal-medium { + // 684px + width: calc(684 * bk.$size-1); + } + &.bk-modal-large { + // 784px + width: calc(784 * bk.$size-1); + } + &.bk-modal-x-large { + // 906px + width: calc(906 * bk.$size-1); + } + &.bk-modal-fullscreen { + width: calc(100% - bk.$spacing-3); + height: calc(100% - bk.$spacing-3); + } .bk-modal__header { position: sticky; top: 0; - padding: var(--bk-sizing-2) var(--bk-modal-inset); + padding: 0; background: var(--bk-modal-background-color); - --header-shadow-size: calc(var(--bk-sizing-1) / 2); - box-shadow: 0 var(--header-shadow-size) 0 0 rgba(0 0 0 / 12%); - /* Clip everything except the bottom shadow (-1px for weird clipping behavior with scroll) */ - clip-path: inset(-1px -1px calc(-1 * var(--header-shadow-size)) -1px); - display: flex; flex-direction: row; align-items: baseline; + margin-bottom: bk.$spacing-7; h1 { - font-size: 1.4rem; - font-weight: 300; - text-transform: uppercase; + font-size: 16px; // do not match bk variable sizes + font-weight: bk.$font-weight-semibold; } :nth-child(1 of :global(.action)) { margin-left: auto; } - :global(.action) { - align-self: center; - } } - + .bk-modal__close { + position: absolute; + right: bk.$spacing-8; + top: bk.$spacing-8; + z-index: 1; + } + .bk-modal__container { + padding: bk.$spacing-8; + padding-bottom: bk.$spacing-9; + flex-direction: column; + overflow: hidden; + display: flex; + flex: 1; + } .bk-modal__content { flex: 1; /* Make sure we cover all available space */ - padding: var(--bk-modal-inset); - padding-top: var(--bk-sizing-3); + overflow: auto; } - /* Variant: slide out */ --modal-slide-out-inset: var(--bk-sizing-3); &:is(.bk-modal--slide-out-left, .bk-modal--slide-out-right) { @@ -107,7 +127,6 @@ } } - /* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#animating_dialogs */ opacity: 0; @@ -142,7 +161,8 @@ overlay var(--transition-time) allow-discrete, background-color var(--transition-time); } - .bk-modal:modal::backdrop { + .bk-modal:modal::backdrop, + .bk-modal-spinner::backdrop { background-color: rgb(0 0 0 / 20%); backdrop-filter: blur(5px); /* Should be in px, not rem (blur effect should be constant) */ } @@ -153,14 +173,13 @@ } .bk-modal-spinner { - .bk-modal__header { - display: none; - } + outline: none !important; // prevent blue border on Esc pressing .bk-modal__content { display: flex; justify-content: center; align-items: center; + overflow: hidden; } } } diff --git a/src/components/overlays/Modal/Modal.stories.tsx b/src/components/overlays/Modal/Modal.stories.tsx index 84ba506..1bc4af3 100644 --- a/src/components/overlays/Modal/Modal.stories.tsx +++ b/src/components/overlays/Modal/Modal.stories.tsx @@ -32,7 +32,7 @@ type ModalWithTriggerProps = Omit, 'active' | }; const ModalWithTrigger = ({ triggerLabel = 'Open modal', ...modalProps }: ModalWithTriggerProps) => { const [active, setActive] = React.useState(false); - const onClose = React.useCallback(() => { setActive(false); }, [setActive]); + const onClose = React.useCallback(() => { setActive(false); }, []); return ( <> @@ -41,24 +41,76 @@ const ModalWithTrigger = ({ triggerLabel = 'Open modal', ...modalProps }: ModalW ); }; -export const Interactive: Story = { +const reusableModalChildren: React.JSX.Element = ( + <> + +

Modal title

+
+ +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do iusmod tempor incididunt ut labore et dolore magna aliqua.

+ + + +

Submodal title

+
+ +

This is a submodal

+ +
+
+ + +
+ This is a modal footer with eventual action buttons + +); + +export const ModalSizeSmall: Story = { + render: () => ( + + {reusableModalChildren} + + ), +}; + +export const ModalSizeMedium: Story = { + render: () => ( + + {reusableModalChildren} + + ), +}; + +export const ModalSizeLarge: Story = { + render: () => ( + + {reusableModalChildren} + + ), +}; + +export const ModalSizeXLarge: Story = { + render: () => ( + + {reusableModalChildren} + + ), +}; + +export const ModalSizeFullScreen: Story = { + render: () => ( + + {reusableModalChildren} + + ), +}; + +export const ModalUncloseable: Story = { render: () => ( - -
-

This is a modal

- - -
-

This is a submodal

- -
-
- - -
+ + {reusableModalChildren} ), - play: async ({ canvasElement }) => {}, }; type ModalWithSpinnerTriggerProps = Omit, 'active' | 'onClose'> & { @@ -72,20 +124,21 @@ const ModalWithSpinnerTrigger = ({ triggerLabel = 'Open modal with spinner (it w setActive(false); }, 5000); } - const onClose = React.useCallback(() => { setActive(false); }, [setActive]); + const onClose = React.useCallback(() => { setActive(false); }, []); return ( <> - + ); }; export const ModalWithSpinner: Story = { render: () => ( - - + + + + ), - play: async ({ canvasElement }) => {}, }; diff --git a/src/components/overlays/Modal/Modal.tsx b/src/components/overlays/Modal/Modal.tsx index 245ca15..7cb6817 100644 --- a/src/components/overlays/Modal/Modal.tsx +++ b/src/components/overlays/Modal/Modal.tsx @@ -2,119 +2,210 @@ |* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { classNames as cx, type ClassNameArgument } from '../../../util/componentUtil.ts'; +import { type ClassNameArgument, classNames as cx } from '../../../util/componentUtil.ts'; import * as React from 'react'; -import cl from './Modal.module.scss'; +import { Icon } from '../../graphics/Icon/Icon.tsx'; +import cl from './Modal.module.scss'; export { cl as ModalClassNames }; -/* -const useClickOutside = (ref: React.RefObject, callback: () => void) => { - const handleEvent = React.useCallback((event: React.PointerEvent) => { - if (ref && ref.current) { - if (ref.current.contains(event.target as Node)) { - React.setState({ hasClickedOutside: false }); - } else { - React.setState({ hasClickedOutside: true }); - } - } - }, [ref]); - - React.useEffect(() => { - if (!window.PointerEvent) { return; } - - document.addEventListener('pointerdown', handleEvent); - - return () => { - if (window.PointerEvent) { - document.removeEventListener('pointerdown', handleEvent); - } else { - document.removeEventListener('mousedown', handleEvent); - document.removeEventListener('touchstart', handleEvent); - } - } - }, []); - return [ref, hasClickedOutside]; -}; -*/ +type ModalHeaderProps = React.PropsWithChildren<{ + unstyled?: boolean; + className?: ClassNameArgument; +}>; +/* Modal Header component */ +const ModalHeader = ({ children, unstyled, className }: ModalHeaderProps) => ( +
+ {children} +
+); -export type ModalProps = React.PropsWithChildren<{ - unstyled?: boolean, - active: boolean, - className?: ClassNameArgument, - onClose: () => void, - closeable?: boolean, +type ModalContentProps = React.PropsWithChildren<{ + unstyled?: boolean; + className?: ClassNameArgument; +}>; + +/* Modal Content component */ +const ModalContent = ({ children, unstyled, className }: ModalContentProps) => ( +
+ {children} +
+); + +type ModalFooterProps = React.PropsWithChildren<{ + unstyled?: boolean; + className?: ClassNameArgument; +}>; + +/* Modal Footer component */ +const ModalFooter = ({ children, unstyled, className }: ModalFooterProps) => ( +
+ {children} +
+); + +type ModalProps = React.PropsWithChildren<{ + unstyled?: boolean; + size?: 'small' | 'medium' | 'large' | 'x-large' | 'fullscreen'; + active: boolean; + className?: ClassNameArgument; + onClose: () => void; + closeable?: boolean; }>; /** * Modal component. */ -export const Modal = ({ children, unstyled, className, active, onClose, closeable = true }: ModalProps) => { +const Modal = ({ + children, + unstyled, + className, + size = 'medium', + closeable = true, + active, + onClose, +}: ModalProps) => { const dialogRef = React.useRef(null); - + // Sync the `active` flag with the DOM dialog React.useEffect(() => { const dialog = dialogRef.current; - if (dialog === null) { return; } - + if (dialog === null) { + return; + } + if (active && !dialog.open) { dialog.showModal(); } else if (!active && dialog.open) { dialog.close(); } }, [active]); - + // Sync the dialog close event with the `active` flag - const handleCloseEvent = React.useCallback((event: Event): void => { - const dialog = dialogRef.current; - if (dialog === null) { return; } - - if (active && event.target === dialog) { - onClose(); - } - }, [active, onClose]); + const handleCloseEvent = React.useCallback( + (event: Event): void => { + const dialog = dialogRef.current; + if (dialog === null) { + return; + } + + if (active && event.target === dialog) { + onClose(); + } + }, + [active, onClose], + ); React.useEffect(() => { const dialog = dialogRef.current; - if (dialog === null) { return; } + if (dialog === null) { + return; + } dialog.addEventListener('close', handleCloseEvent); - return () => { dialog.removeEventListener('close', handleCloseEvent); }; + return () => { + dialog.removeEventListener('close', handleCloseEvent); + }; }, [handleCloseEvent]); - + const close = React.useCallback(() => { onClose(); }, [onClose]); - - const handleDialogClick = React.useCallback((event: React.MouseEvent) => { - const dialog = dialogRef.current; - if (dialog !== null && event.target === dialog && closeable) { - // Note: clicking the backdrop just results in an event where the target is the `` element. In order to - // distinguish between the backdrop and the modal content, we assume that the `` is fully covered by - // another element. In our case, `bk-modal__content` must cover the whole `` otherwise this will not work. - close(); + + const handleDialogClick = React.useCallback( + (event: React.MouseEvent) => { + const dialog = dialogRef.current; + if (dialog !== null && event.target === dialog && closeable) { + // Note: clicking the backdrop just results in an event where the target is the `` element. In order to + // distinguish between the backdrop and the modal content, we assume that the `` is fully covered by + // another element. In our case, `bk-modal__content` must cover the whole `` otherwise this will not work. + close(); + } + }, + [close, closeable], + ); + + // prevent closing dialog with Esc key + const handleKeyDown = React.useCallback((event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + if (closeable) { + event.preventDefault(); } - }, [close]); - + }, [closeable]); + React.useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + return ( - + -
-

Title

- {closeable && ()} -
- -
+ {closeable && ( + + )} +
{children} -
+
); }; + +Modal.Content = ModalContent; +Modal.Header = ModalHeader; +Modal.Footer = ModalFooter; + +export { Modal };