diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f6b2c5a2..c6362b09 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -63,10 +63,13 @@ const preview = { ], 'overlays', [ + 'ModalProvider', + 'SpinnerModal', + 'DialogModal', 'Tooltip', 'TooltipProvider', - 'Dropdown', - 'Modal', + 'DropdownMenu', + 'Toast', ], 'lists', [ diff --git a/app/lib.ts b/app/lib.ts index a9dee7ed..4a981e2e 100644 --- a/app/lib.ts +++ b/app/lib.ts @@ -62,9 +62,11 @@ export { Stepper } from '../src/components/navigations/Stepper/Stepper.tsx'; export { Tab, Tabs } from '../src/components/navigations/Tabs/Tabs.tsx'; // Overlays +export { ModalProvider } from '../src/components/overlays/ModalProvider/ModalProvider.tsx'; +export { SpinnerModal } from '../src/components/overlays/SpinnerModal/SpinnerModal.tsx'; +export { DialogModal } from '../src/components/overlays/DialogModal/DialogModal.tsx'; export { DropdownMenu } from '../src/components/overlays/DropdownMenu/DropdownMenu.tsx'; export { DropdownMenuProvider } from '../src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx'; -export { Modal } from '../src/components/overlays/Modal/Modal.tsx'; export { type ToastContent, ToastProvider, ToastMessage } from '../src/components/overlays/Toast/Toast.tsx'; export { Tooltip } from '../src/components/overlays/Tooltip/Tooltip.tsx'; export { TooltipProvider } from '../src/components/overlays/Tooltip/TooltipProvider.tsx'; diff --git a/package.json.js b/package.json.js index 3ff6f249..b3ae31c9 100644 --- a/package.json.js +++ b/package.json.js @@ -168,6 +168,7 @@ const packageConfig = { 'react': '^19.0.0', 'react-dom': '^19.0.0', 'react-error-boundary': '^4.1.2', + //'@uidotdev/usehooks': '^2.4.1', '@floating-ui/react': '^0.26.28', 'react-toastify': '^10.0.6', diff --git a/plopfile.ts b/plopfile.ts index 67fa7e80..05d824b5 100644 --- a/plopfile.ts +++ b/plopfile.ts @@ -64,7 +64,7 @@ const componentTemplate = { /** Whether this component should be unstyled. */ unstyled?: undefined | boolean, - /** Some property specific to \`{{{component-name}}}\` */ + /** Some property specific to \`{{{component-name}}}\`. */ variant?: undefined | 'x' | 'y', }>; {{"\\n"~}} @@ -78,12 +78,13 @@ const componentTemplate = { return ( <{{{element-type}}} {...propsRest} - className={cx({ - bk: true, - [cl['bk-{{{kebabCase component-name}}}']]: !unstyled, - [cl['bk-{{{kebabCase component-name}}}--x']]: variant === 'x', - [cl['bk-{{{kebabCase component-name}}}--y']]: variant === 'y', - }, propsRest.className)} + className={cx( + 'bk', + { [cl['bk-{{{kebabCase component-name}}}']]: !unstyled }, + { [cl['bk-{{{kebabCase component-name}}}--x']]: variant === 'x' }, + { [cl['bk-{{{kebabCase component-name}}}--y']]: variant === 'y' }, + propsRest.className, + )} /> ); }; @@ -173,12 +174,11 @@ const componentTemplate = { export default { component: {{{component-name}}}, + tags: ['autodocs'], parameters: { layout: '{{storybook-layout}}', }, - tags: ['autodocs'], - argTypes: { - }, + argTypes: {}, args: { children: 'Example', }, @@ -186,8 +186,12 @@ const componentTemplate = { } satisfies Meta<{{{component-name}}}Args>; - export const Standard: Story = { - name: '{{{component-name}}}', + export const {{{component-name}}}Standard: Story = {}; + + export const {{{component-name}}}WithVariant: Story = { + args: { + variant: 'x', + }, }; ` + '\n', }; diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 53d70056..a990ec51 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -32,9 +32,16 @@ export type ButtonProps = React.PropsWithChildren, /** What variant the button is, from higher prominance to lower. */ variant?: undefined | 'primary' | 'secondary' | 'tertiary', + /** + * Whether the button is disabled. This is meant for essentially permanent disabled buttons, not for buttons that + * are just temporarily non-interactive. Use `nonactive` for the latter. Disabled buttons cannot be focused. + */ + disabled?: undefined | boolean, + /** * Whether the button is in "nonactive" state. This is a variant of `disabled`, but instead of completely graying - * out the button, it only becomes a muted variation of the button's appearance. When true, also implies `disabled`. + * out the button, it only becomes a muted variation of the button's appearance. Nonactive buttons are useful for + * temporarily states such as while an action is currently ongoing. Nonactive buttons are still focusable. */ nonactive?: undefined | boolean, @@ -53,6 +60,7 @@ export const Button = (props: ButtonProps) => { unstyled = false, trimmed = false, label, + disabled = false, nonactive = false, variant = 'tertiary', onPress, @@ -63,24 +71,31 @@ export const Button = (props: ButtonProps) => { const [isPressPending, startPressTransition] = React.useTransition(); const isPending = isPressPending; - const isInteractive = !propsRest.disabled && !nonactive && !isPending; + const isInteractive = !disabled && !nonactive && !isPending; const isNonactive = nonactive || isPending; const handlePress = React.useCallback(() => { if (typeof onPress !== 'function') { return; } - startPressTransition(async () => { - await Promise.race([onPress(), timeout(asyncTimeout)]); - }); + const onPressResult = onPress(); + + // Note: do not start a transition unless `onPress` is actually async, because otherwise sync press actions + // will cause a brief rerender with disabled state and loading indicator, which messes with things like + // button focus. + if (onPressResult instanceof Promise) { + startPressTransition(async () => { + await Promise.race([onPressResult, timeout(asyncTimeout)]); + }); + } }, [onPress, asyncTimeout]); const handleClick = React.useCallback(async (event: React.MouseEvent) => { + if (!isInteractive) { return; } + // `onClick` should not be used in most cases, only if the consumer needs low level control over click events. // Instead, use `onPress` or a `
` component with `action`. props.onClick?.(event); // Call this first, to allow cancellation - if (!isInteractive) { return; } - if (typeof onPress === 'function') { event.preventDefault(); handlePress(); @@ -122,9 +137,10 @@ export const Button = (props: ButtonProps) => { return (
- - - ); -}; - -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: () => ( - - {reusableModalChildren} - - ), -}; - -type ModalWithSpinnerTriggerProps = Omit, 'active' | 'onClose'> & { - triggerLabel?: string, -}; -const ModalWithSpinnerTrigger = ({ triggerLabel = 'Open modal with spinner (it will close in 5 seconds)', ...modalProps }: ModalWithSpinnerTriggerProps) => { - const [active, setActive] = React.useState(false); - const onPress = () => { - setActive(true); - setTimeout(() => { - setActive(false); - }, 5000); - } - const onClose = React.useCallback(() => { setActive(false); }, []); - return ( - <> - - - - ); -}; - -export const ModalWithSpinner: Story = { - render: () => ( - - - - - - ), -}; diff --git a/src/components/overlays/Modal/Modal.tsx b/src/components/overlays/Modal/Modal.tsx deleted file mode 100644 index b36988de..00000000 --- a/src/components/overlays/Modal/Modal.tsx +++ /dev/null @@ -1,201 +0,0 @@ -/* Copyright (c) Fortanix, Inc. -|* 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 { type ClassNameArgument, classNames as cx } from '../../../util/componentUtil.ts'; -import * as React from 'react'; - -import { Icon } from '../../graphics/Icon/Icon.tsx'; - -import cl from './Modal.module.scss'; - -export { cl as ModalClassNames }; - - -type ModalHeaderProps = React.PropsWithChildren<{ - unstyled?: boolean; - className?: ClassNameArgument; -}>; - -/* Modal Header component */ -const ModalHeader = ({ children, unstyled, className }: ModalHeaderProps) => ( -
- {children} -
-); - -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. - */ -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 (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], - ); - React.useEffect(() => { - const dialog = dialogRef.current; - if (dialog === null) { - return; - } - - dialog.addEventListener('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(); - } - }, - [close, closeable], - ); - - // "onKeyDown" instead of "onCancel" as the latter is only fired after the cancel already initiated - const handleDialogKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Escape' && !closeable) { - event.preventDefault(); - } - }; - - return ( - - {closeable && ( - - )} -
- {children} -
-
- ); -}; - -Modal.Content = ModalContent; -Modal.Header = ModalHeader; -Modal.Footer = ModalFooter; - -export { Modal }; diff --git a/src/components/overlays/ModalProvider/ModalProvider.module.scss b/src/components/overlays/ModalProvider/ModalProvider.module.scss new file mode 100644 index 00000000..1991d481 --- /dev/null +++ b/src/components/overlays/ModalProvider/ModalProvider.module.scss @@ -0,0 +1,35 @@ +/* Copyright (c) Fortanix, Inc. +|* 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/. */ + +@use '../../../styling/defs.scss' as bk; + +@layer baklava.components { + .bk-modal-provider { + @include bk.component-base(bk-modal-provider); + + --bk-modal-provider-background-color: light-dark(#{bk.$color-neutral-700}, #{bk.$color-neutral-50}); + --bk-modal-provider-text-color: light-dark(#{bk.$color-neutral-50}, #{bk.$color-neutral-700}); + + cursor: default; + + max-width: 30rem; + max-height: 8lh; + + margin: bk.$sizing-s; + padding: bk.$sizing-s; + border-radius: bk.$sizing-s; + background: var(--bk-modal-provider-background-color); + + @include bk.text-layout; + color: var(--bk-modal-provider-text-color); + @include bk.font(bk.$font-family-body); + font-size: bk.$font-size-m; + + @media (prefers-reduced-motion: no-preference) { + --transition-duration: 150ms; + transition: + opacity var(--transition-duration) ease-out; + } + } +} diff --git a/src/components/overlays/ModalProvider/ModalProvider.stories.tsx b/src/components/overlays/ModalProvider/ModalProvider.stories.tsx new file mode 100644 index 00000000..3a42ed2f --- /dev/null +++ b/src/components/overlays/ModalProvider/ModalProvider.stories.tsx @@ -0,0 +1,67 @@ +/* Copyright (c) Fortanix, Inc. +|* 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 * as React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { LoremIpsum } from '../../../util/storybook/LoremIpsum.tsx'; + +import { Button } from '../../actions/Button/Button.tsx'; +import { Dialog } from '../../containers/Dialog/Dialog.tsx'; + +import { ModalProvider } from './ModalProvider.tsx'; + + +type ModalProviderArgs = React.ComponentProps; +type Story = StoryObj; + +export default { + tags: ['autodocs'], + component: ModalProvider, + parameters: { + layout: 'fullscreen', + }, + argTypes: { + }, + args: { + }, + render: (args) => , +} satisfies Meta; + + +export const ModalProviderStandard: Story = { + args: { + children: ({ activate }) => )} diff --git a/src/components/util/Dialog/useModalDialog.ts b/src/components/util/Dialog/useModalDialog.ts new file mode 100644 index 00000000..1ec32282 --- /dev/null +++ b/src/components/util/Dialog/useModalDialog.ts @@ -0,0 +1,182 @@ +/* Copyright (c) Fortanix, Inc. +|* 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 * as React from 'react'; + + +export type ModalDialogController = { + /** Whether the dialog should be active. */ + active: boolean, + /** Notify that the dialog has been opened. Change must be respected (otherwise no longer in sync). */ + activate: () => void, + /** Notify that the dialog has been closed. Change must be respected (otherwise no longer in sync). */ + deactivate: () => void, +}; + +export type UseModalDialogOptions = { + /** + * Whether to allow the user to close the modal through the browser (e.g. with the Escape key). Disabling this may + * be useful in cases where we need the user to stay in the modal (e.g. form validation, mandatory onboarding flows). + * Note: closing cannot be always be prevented (e.g. browser may force the dialog to close, or closed through JS). + */ + allowUserClose?: undefined | boolean, + + /** Whether the user clicking the backdrop should close the modal. Only valid if `allowUserClose` is true. */ + shouldCloseOnBackdropClick?: undefined | boolean, +}; + +export type ModalDialogProps = { + close: () => void, + dialogProps: React.ComponentProps<'dialog'>, +}; + +/* + * A utility hook to control the state of a element used as a modal (with `.showModal()`). + */ +export const useModalDialog = ( + controller: ModalDialogController, + options: UseModalDialogOptions, +): ModalDialogProps => { + const { + allowUserClose = true, + shouldCloseOnBackdropClick = true, + } = options; + + const dialogRef = React.useRef(null); + //const lastActiveElementRef = React.useRef(null); // Note: browsers track this automatically now + + const requestDialogClose = React.useCallback(() => { + const dialog = dialogRef.current; + if (!dialog) { console.warn(`Unable to close dialog: reference does not exist.`); return; } + + try { + dialog.close(); + } catch (error: unknown) { + console.error(`Failed to close dialog`, error); + } + }, []); + + // Sync active state with DOM state + const sync = () => { + const dialog = dialogRef.current; + if (!dialog) { return; } // Nothing to sync with + + const isDialogOpenModal = dialog.open && dialog.matches(':modal'); + + if (controller.active && !isDialogOpenModal) { // Should be active but isn't + // Save a reference to the last focused element before opening the modal + //lastActiveElementRef.current = document.activeElement; + + try { + dialog.open = false; // Make sure the dialog is not open as a non-modal (i.e. through `.show()`) + dialog.showModal(); + } catch (error: unknown) { + console.error(`Unable to open modal dialog`, error); + controller.deactivate(); + } + } else if (!controller.active && isDialogOpenModal) { // Should not be active but is + try { + dialog.close(); + } catch (error: unknown) { + console.error(`Unable to close modal dialog`, error); + controller.activate(); + } + } + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: `controller` is used in `sync` + React.useEffect(sync, [controller]); + + // The `beforetoggle` event can be used to detect when a modal opens. Note: browser support is poor currently, but + // it's okay since we generally control the activation, not the user. In non-supporting browsers, if someone were to + // // manually call `.showModal()` through devtools they could potentially cause a desync. + // https://caniuse.com/mdn-api_htmlelement_toggle_event_dialog_elements + const handleDialogBeforeToggle = React.useCallback((event: React.ToggleEvent) => { + if (event.newState === 'open') { + controller.activate(); + } + }, [controller]); + + // Handle dialog `cancel` event. This event is called when the user requests the dialog to close (e.g. with Escape + // key). Can be canceled, though browsers do not always respect the cancelation (e.g. Chrome still closes when the + // Escape key is hit twice). + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event + const handleDialogCancel = React.useCallback((event: React.SyntheticEvent) => { + if (!allowUserClose) { + event.preventDefault(); + } + }, [allowUserClose]); + + // Handle dialog `close` event. This event is called when the dialog has already been closed (cannot be canceled). + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event + const handleDialogClose = React.useCallback((event: React.SyntheticEvent) => { + // Stop this event from closing parent dialog elements + // XXX `close` events shouldn't bubble, but at least in Chrome v131 it seems like it does? + event.stopPropagation(); + + /* + // XXX This is probably not necessary, browsers do this out of the box already + // Restore focus to last active element before the user opened the modal + const lastActiveElement = lastActiveElementRef.current; + if (lastActiveElement && 'focus' in lastActiveElement && typeof lastActiveElement.focus === 'function') { + lastActiveElement.focus(); + } + */ + + controller.deactivate(); // Sync with the controller + }, [controller]); + + // Handle dialog `click` event + const handleDialogClick = React.useCallback((event: React.MouseEvent) => { + const dialog: HTMLDialogElement = event.currentTarget; + const target: EventTarget = event.target; + + // We want to determine whether the click was on the `` backdrop. For the event, the backdrop is considered + // just a part of the `` element. So we need to check both that the target is the `` *and also* + // that the click landed outside of the element content boundary. + // Source: https://stackoverflow.com/questions/25864259/how-to-close-dialog-tag-by-clicking-on-its-backdrop + let isClickOnBackdrop = false; + if (target === dialog) { + const rect = dialog.getBoundingClientRect(); + const isInsideDialog = rect.top <= event.clientY + && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX + && event.clientX <= rect.left + rect.width; + isClickOnBackdrop = !isInsideDialog; + } + + if (allowUserClose && shouldCloseOnBackdropClick && isClickOnBackdrop) { + controller.deactivate(); + } + }, [controller, allowUserClose, shouldCloseOnBackdropClick]); + + // Handle dialog `keydown` event + const handleDialogKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Escape' && !allowUserClose) { + event.preventDefault(); + } + }, [allowUserClose]); + + // Sync when the ref changes. This helps prevent time issues where `active` is set to `true`, but the dialog is not + // yet mounted (and thus the ref is `null`). In that case our sync useEffect will be too early. + const dialogRefCallback: React.RefCallback = (ref) => { + dialogRef.current = ref; + sync(); + }; + + return { + close: requestDialogClose, + dialogProps: { + ref: dialogRefCallback, + open: undefined, // Do not set `open`, leave it to the browser to manage automatically + + onBeforeToggle: handleDialogBeforeToggle, + onCancel: handleDialogCancel, + onClose: handleDialogClose, + // Note: we don't need keyboard accessibility for this `onClick`, the backdrop is not (and should not be) an + // interactive element. This is a visual only convenience, screen readers should use the other close mechanisms. + onClick: shouldCloseOnBackdropClick ? handleDialogClick : undefined, + onKeyDown: handleDialogKeyDown, + }, + }; +}; diff --git a/src/layouts/AppLayout/AppLayout.stories.tsx b/src/layouts/AppLayout/AppLayout.stories.tsx index b9627832..ea51b05a 100644 --- a/src/layouts/AppLayout/AppLayout.stories.tsx +++ b/src/layouts/AppLayout/AppLayout.stories.tsx @@ -2,11 +2,9 @@ |* 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 cx, { type Argument as ClassNameArgument } from 'classnames'; import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { userEvent, within } from '@storybook/test'; import { OverflowTester } from '../../util/storybook/OverflowTester.tsx'; import { Header } from './Header/Header.tsx'; @@ -17,13 +15,13 @@ import { Nav } from './Nav/Nav.tsx'; import { Button } from '../../components/actions/Button/Button.tsx'; import { Link } from '../../components/actions/Link/Link.tsx'; import { Panel } from '../../components/containers/Panel/Panel.tsx'; -import { Modal } from '../../components/overlays/Modal/Modal.tsx'; +import { DialogModal } from '../../components/overlays/DialogModal/DialogModal.tsx'; import { UserMenu } from './Header/UserMenu.tsx'; -import { AppLayout } from './AppLayout.tsx'; import { SolutionSelector } from './Header/SolutionSelector.tsx'; import { AccountSelector } from './Header/AccountSelector.tsx'; import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs.tsx'; +import { AppLayout } from './AppLayout.tsx'; type AppLayoutArgs = React.ComponentProps; @@ -41,21 +39,6 @@ export default { } satisfies Meta; -type ModalWithTriggerProps = Omit, 'active' | 'onClose'> & { - triggerLabel?: string, - content?: React.ComponentProps['children'], -}; -const ModalWithTrigger = ({ triggerLabel = 'Open modal', content, ...modalProps }: ModalWithTriggerProps) => { - const [active, setActive] = React.useState(false); - const onClose = React.useCallback(() => { setActive(false); }, [setActive]); - return ( - <> - - - - ); -}; - export const Standard: Story = { args: { children: ( @@ -97,9 +80,12 @@ export const Standard: Story = { Panel - - - +