diff --git a/src/pattern-library/components/patterns/prototype/TabbedDialogPage.tsx b/src/pattern-library/components/patterns/prototype/TabbedDialogPage.tsx new file mode 100644 index 000000000..8ba9e57af --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/TabbedDialogPage.tsx @@ -0,0 +1,215 @@ +//import type { ComponentChildren } from 'preact'; +import classnames from 'classnames'; +import type { JSX } from 'preact'; +import { useState } from 'preact/hooks'; + +import { + Button, + CopyIcon, + IconButton, + Input, + InputGroup, + TabList, + Tab, + CancelIcon, + Card, + CardActions, + CardTitle, + SocialTwitterIcon, + SocialFacebookIcon, + EmailIcon, +} from '../../../../'; +import type { PresentationalProps } from '../../../../types'; +import Library from '../../Library'; +import Dialog from './import-export/Dialog'; + +type DividerProps = PresentationalProps & { + variant: 'full' | 'center' | 'custom'; +} & JSX.HTMLAttributes; + +function Divider({ variant }: DividerProps) { + return ( +
+ ); +} + +export default function TabbedDialogPage() { + const [panelOpen, setPanelOpen] = useState(false); + const [selectedTab, setSelectedTab] = useState('share'); + return ( + + +

TODO

+
+ + + NB: This section is a work in progress. + + + + NB: The disabled Import tab is rendered in the prototyped + dialog here to demonstrate what a disabled tab might look like, but + would not appear to users in this manner. + + +
+ + +
+ {panelOpen && ( + setPanelOpen(false)} + restoreFocus + > +
+ + setSelectedTab('share')} + > + Share + + setSelectedTab('export')} + > + Export + + setSelectedTab('import')} + > + Import + + + setPanelOpen(false)} + variant="custom" + size="sm" + /> +
+ +
+ Share annotations from GroupName +
+

+ + Use this link to share these annotations with + anyone: + +

+ + + + + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ Export from GroupName +
+

+ 2 annotations will be exported with + the following file name: +

+ + + + +
+
+
+ Import into GroupName +
+

+ TODO: We will mock this up when we work on the + import part of this project. +

+
+
+
+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/pattern-library/components/patterns/prototype/import-export/Dialog.tsx b/src/pattern-library/components/patterns/prototype/import-export/Dialog.tsx new file mode 100644 index 000000000..987b14fa1 --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/import-export/Dialog.tsx @@ -0,0 +1,244 @@ +import classnames from 'classnames'; +import type { RefObject } from 'preact'; +import { Fragment } from 'preact'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; + +// NB: Imports changed here +import { + useClickAway, + useFocusAway, + useKeyPress, + useSyncedRef, + Panel, +} from '../../../../../'; +import type { + PresentationalProps, + TransitionComponent, + PanelProps, +} from '../../../../../'; +import { useUniqueId } from '../../../../../hooks/use-unique-id'; +import { downcastRef } from '../../../../../util/typing'; + +type ComponentProps = { + closeOnClickAway?: boolean; + closeOnEscape?: boolean; + closeOnFocusAway?: boolean; + /** + * Dialog _should_ be provided with a close handler. We have a few edge use + * cases, however, in which we need to render a "non-closeable" modal dialog. + */ + onClose?: () => void; + + /** + * Element that should take focus when the Dialog is first rendered. When not + * provided ("auto"), the dialog's outer element will take focus. Setting this + * prop to "manual" will opt out of focus routing. + */ + initialFocus?: RefObject | 'auto' | 'manual'; + + /** + * Restore focus to previously-focused element when unmounted/closed + */ + restoreFocus?: boolean; + + /** + * Optional TransitionComponent for open (mount) and close (unmount) transitions + */ + transitionComponent?: TransitionComponent; + + variant?: 'panel' | 'custom'; +}; + +// This component forwards a number of props on to `Panel` but always sets the +// `fullWidthHeader` prop to `true`. +export type DialogProps = PresentationalProps & + ComponentProps & + Omit; + +/** + * Customized version of Dialog to support tabbed dialogs. WIP. Allows for + * content other than `Panel`. + */ +const Dialog = function Dialog({ + closeOnClickAway = false, + closeOnEscape = false, + closeOnFocusAway = false, + children, + initialFocus = 'auto', + restoreFocus = false, + transitionComponent: TransitionComponent, + + variant = 'panel', + + classes, + elementRef, + + // Forwarded to Panel + buttons, + icon, + onClose, + paddingSize = 'md', + scrollable = true, + title, + + ...htmlAttributes +}: DialogProps) { + const modalRef = useSyncedRef(elementRef); + const restoreFocusEl = useRef( + document.activeElement as HTMLElement | null + ); + const [transitionComponentVisible, setTransitionComponentVisible] = + useState(false); + const isClosableDialog = !!onClose; + + const closeHandler = useCallback(() => { + if (TransitionComponent) { + // When a TransitionComponent is provided, the actual "onClose" will be + // called by that component, once the "out" transition has finished + setTransitionComponentVisible(false); + } else { + onClose?.(); + } + }, [onClose, TransitionComponent]); + + const initializeDialog = useCallback(() => { + if (initialFocus === 'manual') { + return; + } + if (initialFocus === 'auto') { + // An explicit `initialFocus` has not been set, so use automatic focus + // handling. Modern accessibility guidance is to focus the dialog itself + // rather than trying to be smart about focusing a particular control + // within the dialog. + modalRef.current?.focus(); + return; + } + + const focusEl = initialFocus?.current as HTMLElement & { + disabled?: boolean; + }; + + if (focusEl && !focusEl.disabled) { + focusEl.focus(); + } else { + // Fall back to focusing the modal itself + modalRef.current?.focus(); + } + }, [initialFocus, modalRef]); + + const onTransitionEnd = (direction: 'in' | 'out') => { + if (direction === 'in') { + initializeDialog(); + } else { + onClose?.(); + } + }; + + useClickAway(modalRef, closeHandler, { + enabled: closeOnClickAway, + }); + + useKeyPress(['Escape'], closeHandler, { + enabled: closeOnEscape, + }); + + useFocusAway(modalRef, closeHandler, { + enabled: closeOnFocusAway, + }); + + const dialogDescriptionId = useUniqueId('dialog-description'); + const Wrapper = useMemo( + () => TransitionComponent ?? Fragment, + [TransitionComponent] + ); + + useEffect(() => { + setTransitionComponentVisible(true); + if (!TransitionComponent) { + initializeDialog(); + } + + // We only want to run this effect once when the dialog is mounted. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useLayoutEffect( + /** + * Restore focus when component is unmounted, if `restoreFocus` is set. + */ + () => { + const restoreFocusTo = restoreFocusEl.current; + return () => { + if (restoreFocus && restoreFocusTo) { + restoreFocusTo.focus(); + } + }; + }, + // We only want to run this effect once when the dialog is mounted. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useLayoutEffect( + /** + * Try to assign the dialog an accessible description, using the content of + * the first paragraph of text within it. + * + * A limitation of this approach is that it doesn't update if the dialog's + * content changes after the initial render. + */ + () => { + const description = modalRef?.current?.querySelector('p'); + if (description) { + description.id = dialogDescriptionId; + modalRef.current!.setAttribute('aria-describedby', dialogDescriptionId); + } + }, + [dialogDescriptionId, modalRef] + ); + + return ( + +
+ {variant === 'panel' && ( + + {children} + + )} + {variant === 'custom' && <>{children}} +
+
+ ); +}; + +export default Dialog; diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index 083e8de01..853327744 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -31,6 +31,7 @@ import TabPage from './components/patterns/navigation/TabPage'; import LMSContentButtonPage from './components/patterns/prototype/LMSContentButtonPage'; import LMSContentSelectionPage from './components/patterns/prototype/LMSContentSelectionPage'; import SharedAnnotationsPage from './components/patterns/prototype/SharedAnnotationsPage'; +import TabbedDialogPage from './components/patterns/prototype/TabbedDialogPage'; import SliderPage from './components/patterns/transition/SliderPage'; export const componentGroups = { @@ -240,6 +241,12 @@ const routes: PlaygroundRoute[] = [ component: SliderPage, route: '/transitions-slider', }, + { + title: 'Import/Export Dialog', + group: 'prototype', + component: TabbedDialogPage, + route: '/tabbed-share-dialog', + }, { title: 'LMS: Content Button', group: 'prototype',