From 85d11ec1858a81abe6b39d45bce0a4817fca297e Mon Sep 17 00:00:00 2001 From: mkrause Date: Mon, 30 Dec 2024 01:05:27 +0100 Subject: [PATCH 01/33] Rework Dialog component + focus outline refactoring. --- biome.jsonc | 6 ++++++ src/components/containers/Dialog/Dialog.tsx | 1 - src/components/containers/Panel/Panel.module.scss | 1 + src/layouts/util/Scroller.tsx | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 255afc92..fbb44450 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -33,6 +33,12 @@ "style": { "useImportType": "off", "noUnusedTemplateLiteral": "off" + }, + "a11y": { + // Putting `tabindex` on non-interactive elements is often necessary, for example for scrolling content. + // https://ux.stackexchange.com/questions/119952/when-is-it-wrong-to-put-tabindex-0-on-non-interactive-content + // https://stackoverflow.com/questions/53597209/tabindex-on-el-that-are-interactive-under-certain-conditions + "noNoninteractiveTabindex": "off" } } } diff --git a/src/components/containers/Dialog/Dialog.tsx b/src/components/containers/Dialog/Dialog.tsx index 95948216..03b23cea 100644 --- a/src/components/containers/Dialog/Dialog.tsx +++ b/src/components/containers/Dialog/Dialog.tsx @@ -17,7 +17,6 @@ import cl from './Dialog.module.scss'; export { cl as DialogClassNames }; - export type ActionIconProps = ComponentProps & { /** There must be `label` on an icon-only button, for accessibility. */ label: Required>['label'], diff --git a/src/components/containers/Panel/Panel.module.scss b/src/components/containers/Panel/Panel.module.scss index 21b1f72b..6abd9082 100644 --- a/src/components/containers/Panel/Panel.module.scss +++ b/src/components/containers/Panel/Panel.module.scss @@ -9,6 +9,7 @@ --bk-panel-border-color: #{bk.$theme-card-border-default}; overflow: hidden; + overflow-y: auto; min-height: 4rem; padding: 22px 26px; // FIXME diff --git a/src/layouts/util/Scroller.tsx b/src/layouts/util/Scroller.tsx index 889127e1..253a1159 100644 --- a/src/layouts/util/Scroller.tsx +++ b/src/layouts/util/Scroller.tsx @@ -1,5 +1,5 @@ -import { classNames as cx } from '../../util/componentUtil.tsx'; +import { classNames as cx } from '../../util/componentUtil.ts'; import cl from './Scroller.module.scss'; From 36562e6a67915cd6659f53792653020230b95bc9 Mon Sep 17 00:00:00 2001 From: mkrause Date: Mon, 30 Dec 2024 18:32:25 +0100 Subject: [PATCH 02/33] Implement new ModalProvider component (first draft). --- .../ModalProvider/ModalProvider.module.scss | 37 +++++++++++++++ .../ModalProvider/ModalProvider.stories.tsx | 36 ++++++++++++++ .../overlays/ModalProvider/ModalProvider.tsx | 40 ++++++++++++++++ .../ModalProvider/useControlledDialog.ts | 47 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/components/overlays/ModalProvider/ModalProvider.module.scss create mode 100644 src/components/overlays/ModalProvider/ModalProvider.stories.tsx create mode 100644 src/components/overlays/ModalProvider/ModalProvider.tsx create mode 100644 src/components/overlays/ModalProvider/useControlledDialog.ts diff --git a/src/components/overlays/ModalProvider/ModalProvider.module.scss b/src/components/overlays/ModalProvider/ModalProvider.module.scss new file mode 100644 index 00000000..49e7b20d --- /dev/null +++ b/src/components/overlays/ModalProvider/ModalProvider.module.scss @@ -0,0 +1,37 @@ +/* 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; + + overflow-y: auto; + + 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..2294ae60 --- /dev/null +++ b/src/components/overlays/ModalProvider/ModalProvider.stories.tsx @@ -0,0 +1,36 @@ +/* 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 { Button } from '../../actions/Button/Button.tsx'; + +import { ModalProvider } from './ModalProvider.tsx'; + + +type ModalProviderArgs = React.ComponentProps; +type Story = StoryObj; + +export default { + component: ModalProvider, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + }, + args: { + }, + render: (args) => , +} satisfies Meta; + + +export const ModalProviderStandard: Story = { + args: { + children: activate => - - - ); -}; - -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.tsx b/src/components/overlays/ModalProvider/ModalProvider.tsx index 6e752227..ad51b3d7 100644 --- a/src/components/overlays/ModalProvider/ModalProvider.tsx +++ b/src/components/overlays/ModalProvider/ModalProvider.tsx @@ -59,6 +59,9 @@ export type ModalProviderProps = { /** Whether to allow users to close the modal manually. */ allowUserClose?: undefined | boolean, + /** Whether clicking on the backdrop should close the modal. Default: true */ + shouldCloseOnBackdropClick?: undefined | boolean, + /** How long to keep the dialog in the DOM for exit animation purposes. Default: 3 seconds. */ unmountDelay?: undefined | number, }; @@ -72,7 +75,8 @@ export const ModalProvider = Object.assign( children, dialog, activeDefault = false, - allowUserClose = false, + allowUserClose = true, + shouldCloseOnBackdropClick = true, unmountDelay = 3000, // ms } = props; @@ -91,7 +95,7 @@ export const ModalProvider = Object.assign( const dialogProps = useModalDialog(modalRef, { allowUserClose, - shouldCloseOnBackdropClick: allowUserClose, + shouldCloseOnBackdropClick, }); return ( 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 - - - + )} diff --git a/src/layouts/util/Scroller.module.scss b/src/layouts/util/Scroller.module.scss index c24f2d12..016141ea 100644 --- a/src/layouts/util/Scroller.module.scss +++ b/src/layouts/util/Scroller.module.scss @@ -4,6 +4,8 @@ @layer baklava.overrides { .bk-util-scroller { + overscroll-behavior: none; + // stylelint-disable-next-line declaration-property-value-disallowed-list &.bk-util-scroller--vertical { overflow-y: auto; } diff --git a/src/layouts/util/Scroller.tsx b/src/layouts/util/Scroller.tsx index 815056b1..f15e911c 100644 --- a/src/layouts/util/Scroller.tsx +++ b/src/layouts/util/Scroller.tsx @@ -8,7 +8,14 @@ import cl from './Scroller.module.scss'; type UseScrollerArgs = { + /** + * Set this to true if you know for sure there is a focusable child inside the scroller that can be used + * for keyboard scrolling purposes. If true, this scroller will not be made focusable. Default: false. + */ + hasFocusableChild?: undefined | boolean, + /** Whether to include scroller styling automatically. Default: true. */ includeStyling?: undefined | boolean, + /** Which direction(s) can be scrolled in. Default: vertical. */ scrollDirection?: undefined | 'vertical' | 'horizontal' | 'both', }; @@ -25,6 +32,7 @@ type ScrollerProps = { */ export const useScroller = (args: UseScrollerArgs = {}): ScrollerProps => { const { + hasFocusableChild = false, includeStyling = true, scrollDirection = 'vertical', } = args; @@ -37,7 +45,7 @@ export const useScroller = (args: UseScrollerArgs = {}): ScrollerProps => { ); return { - tabIndex: 0, + tabIndex: hasFocusableChild ? undefined : 0, className: includeStyling ? className : undefined, }; }; diff --git a/src/styling/defs.scss b/src/styling/defs.scss index dcd73e64..a75a07e2 100644 --- a/src/styling/defs.scss +++ b/src/styling/defs.scss @@ -45,6 +45,10 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + + // Add a little bit of padding to prevent parts of characters getting cut off in some browsers. For example, + // try the letter "J" and view it in Safari, the bottom extender of the "J" overflows the text box and gets cut off. + padding-inline: 0.3ch; } @mixin flex-center() { diff --git a/src/styling/global/accessibility.scss b/src/styling/global/accessibility.scss index abe36733..38076e1f 100644 --- a/src/styling/global/accessibility.scss +++ b/src/styling/global/accessibility.scss @@ -2,17 +2,25 @@ |* 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/. */ +@mixin focus-hidden($override-layer: true) { + --bk-focus-color-anim: transparent; // Start transparent for animation purposes + + &, &.pseudo-focus-visible { + box-shadow: none if($override-layer, !important, null); + outline: none if($override-layer, !important, null); + } +} + // Configure an element with a focus outline outset from the element. -@mixin focus-outset($override-layer: true) { +@mixin focus-outset($selector: '&:focus-visible, &.pseudo-focus-visible', $override-layer: true) { --bk-focus-color-anim: transparent; // Start transparent for animation purposes box-shadow: none if($override-layer, !important, null); // Remove focus-inset, if any - outline: var(--bk-focus-outline-width) solid var(--bk-focus-color-anim) if($override-layer, !important, null); - // Add `.pseudo-focus-visible` for visual testing purposes - &:focus-visible, &.pseudo-focus-visible { - --bk-focus-color-anim: var(--bk-focus-outline-color); + // Should add `.pseudo-focus-visible` here for visual testing purposes + #{$selector} { + --bk-focus-color-anim: var(--bk-focus-outline-color) #{if($override-layer, !important, null)}; } @media (prefers-reduced-motion: no-preference) { @@ -22,7 +30,7 @@ // Configure an element with a focus outline inset into the element. This can be useful for elements // that have overflow hidden and thus can't have an exterior outline. -@mixin focus-inset($override-layer: true) { +@mixin focus-inset($selector: '&:focus-visible, &.pseudo-focus-visible', $override-layer: true) { --bk-focus-color-anim: transparent; // Start transparent for animation purposes outline: none if($override-layer, !important, null); // Remove focus-outset, if any @@ -35,7 +43,7 @@ inset calc(-1 * $w) calc(-1 * $w) var(--bk-focus-color-anim) if($override-layer, !important, null); // Add `.pseudo-focus-visible` for visual testing purposes - &:focus-visible, &.pseudo-focus-visible { + #{$selector} { --bk-focus-color-anim: var(--bk-focus-outline-color); } diff --git a/src/styling/global/reset.scss b/src/styling/global/reset.scss index d355573f..288c474a 100644 --- a/src/styling/global/reset.scss +++ b/src/styling/global/reset.scss @@ -85,13 +85,14 @@ whether it's going to be used as a modal or not, if we also want exit transitions. This reset assumes you do *not* apply an exit transition. If you do want an exit transition, you must also apply - the `position: fixed` (etc.) styling on the base `dialog`. + the `position: fixed` (etc.) styling on the base `dialog`. Or you can use `aria-model="true"` and opt in to base + styling that way. - https://github.com/w3c/csswg-drafts/issues/9912 - "User-agent styles for top layer transitions" - https://github.com/whatwg/html/pull/9387#issuecomment-1599722425 - https://issues.chromium.org/issues/40270744 - `:-internal-dialog-in-top-layer` */ - dialog:modal { + dialog[aria-modal='true'], dialog:modal { position: fixed; inset: 0; @@ -99,10 +100,11 @@ //place-self: center; // In the future can use this instead of `margin: auto` width: fit-content; height: fit-content; - } - dialog::backdrop { - position: fixed; - inset: 0; - background: rgba(0 0 0 / 20%); + + &::backdrop { + position: fixed; + inset: 0; + background: rgba(0 0 0 / 20%); + } } } From b3e1007d6d3be4e3478687d0db1fa0a156dbcd41 Mon Sep 17 00:00:00 2001 From: mkrause Date: Sun, 5 Jan 2025 22:57:52 +0100 Subject: [PATCH 31/33] Refactor useConfirmationModal(). --- .../DialogModal/DialogModal.stories.tsx | 17 ++++--- .../overlays/DialogModal/DialogModal.tsx | 45 +++++++++++++++---- src/util/types.ts | 6 +++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/components/overlays/DialogModal/DialogModal.stories.tsx b/src/components/overlays/DialogModal/DialogModal.stories.tsx index 495f8629..5fcb5b1e 100644 --- a/src/components/overlays/DialogModal/DialogModal.stories.tsx +++ b/src/components/overlays/DialogModal/DialogModal.stories.tsx @@ -121,7 +121,7 @@ export const DialogModalWithRef: Story = { const DialogModalControlledWithSubject = (props: React.ComponentProps) => { type Subject = { name: string }; - const modal = DialogModal.useModalWithSubject(null); + const modal = DialogModal.useModalWithSubject(); return (
@@ -147,10 +147,11 @@ export const DialogModalWithSubject: Story = { const DialogModalControlledConfirmation = (props: React.ComponentProps) => { type Subject = { name: string }; - const deleteConfirmer = DialogModal.useConfirmationModal(null, { + const [deleted, setDeleted] = React.useState(new Set()); + const deleteConfirmer = DialogModal.useConfirmationModal({ actionLabel: 'Delete', - onConfirm() { globalThis.alert('Confirmed'); }, - onCancel() { globalThis.alert('Canceled'); }, + onConfirm(subject) { setDeleted(deleted => new Set([...deleted, subject.name])); }, + onCancel(subject) { console.log(`Canceled deleting ${subject.name}`); }, }); return ( @@ -164,12 +165,16 @@ const DialogModalControlledConfirmation = (props: React.ComponentPropsA single details modal will be used, filled in with the subject based on which name was pressed.

-