Skip to content

Commit

Permalink
[WNMGDS-2427] Filter-dialog prototype for doc site theme and version …
Browse files Browse the repository at this point in the history
…switching (#2599)

* We never did actually export `NativeDialog` even though it looked like it

Remove the export line from our index so it's not confusing. The export never worked. We haven't released this component to the public.

* Basic, untested FilterDialog commponent added to docs package

* WIP: Started with a dumb component and logical component, but...

The problem with that is that the versions list is dynamic; it's based on the currently selected theme. That means I need logic inside the component that renders the dialog to dynamically change the options in the version dropdown based on the theme

* Moved it all into one component, and it makes sense

* Hook it up and start working out kinks in version/theme switcher

Problem right now is that if you previously had one theme selected and you select a different one, it will think you changed versions too because the version numbers don't match. While I could convert all versions to the core version equivalent to compare, what if it can't find the corresponding core version and has to fall back? Then the comparison would be wrong. But that doesn't happen, because there are no versions that don't have core equivalents. So maybe that'll actually work

* Fixed `useTheme` causing a second render for every component that uses it

It would always start with "core" and then switch to the actual current value. That second render was causing problems in the theme switcher

* Reorganize modules and create a basic ThemeVersionSection for the sidenav

* Use inverse colors

* Fix bug in my useTheme fix

* Some work on styling

* Styling for the triggering button

* Add arrow icon to the "Change settings" button

* Use theme `displayName` in sidebar

* Remove old theme and version switcher components

* Turns out we don't need the onExit function

* Add some basic unit tests

* Try to fix lost selection message with custom getA11ySelectionMessage fn

Unfortunately it does not work because of the debouncing done inside Downshift. [Here's my explanation](downshift-js/downshift#1244):

> For what it's worth, I have just come across an issue with the existing a11y-message implementation and specifically [how updateA11yStatus is debounced](https://github.com/downshift-js/downshift/blob/master/src/hooks/utils.js#L85). In my application, I have one dropdown on a page that modifies the available options for another dropdown further down the page. (This isn't ideal from an accessibility standpoint, but [the WCAG docs](https://www.w3.org/WAI/WCAG21/Understanding/on-input) do say we can do that if we warn users that it's going to happen, and it will take a while before we can design out all the instances of it across multiple applications.) I wanted to write a custom `getA11ySelectionMessage` function that would determine when it's one of those incidentally changed dropdowns rather than the one the user is interacting with so that only the desired `${itemToString(selectedItem)} has been selected.` gets printed to the a11y-message div. However, due to the debouncing, none of the other `getA11ySelectionMessage` functions even get called except for the last one, which is the one I don't want to announce. If Downshift called those `getA11yMessage` functions instead of passing them to `updateA11yStatus` and then ignored them if they returned, say, `undefined`, this plan would work. But right now the agency from each individual dropdown is taken from it to determine whether its status message should be announced. It's getting lost in the debounce.

* Get rid of new function that didn't work

See previous commit

* Pivot to using two dialogs

This should be a much more straightforward experience and not create accessibility problems with the theme dropdown dynamically changing the options of the version dropdown.

* Move focus back to the triggering button when closing a filter dialog

Need to figure out a clean way of making this the easy thing to do for teams

* Add a close button

* Add the border radius used for dropdowns

* Allow the close icon label to be announced

Forgot that it is off by default

* Show which design system theme we're on in the page (tab) title

* Fix HealthCare.gov theme text wrapping

Don't constrain the theme/version section more than we need to

* Update snapshot

* Better screen reader text for the theme and version buttons

* Move the theme and version section out of side nav on mobile

* Fine tune the spacing after making the hit box large enough

* Fix mobile spacing on page header

* Fixes according to feedback

* Don't let multiple dialogs be open at once

* Update this prop's documentation to make it more clear what they need to do

* Update snapshot
  • Loading branch information
pwolfert committed Aug 8, 2023
1 parent c8f8dcf commit 610c360
Show file tree
Hide file tree
Showing 23 changed files with 710 additions and 147 deletions.
10 changes: 4 additions & 6 deletions packages/design-system/src/components/Dialog/Dialog.tsx
Expand Up @@ -76,12 +76,10 @@ export interface BaseDialogProps extends AnalyticsOverrideProps {
*/
onEnter?(): void;
/**
* This function needs to handles the state change of exiting (or deactivating) the modal.
* Maybe it's just a wrapper around `setState()`; or maybe you use some more involved
* Flux-inspired state management — whatever the case, this module leaves the state
* management up to you instead of making assumptions.
* That also makes it easier to create your own "close modal" buttons; because you
* have the function that closes the modal right there, written by you, at your disposal.
* Called when the user triggers an exit event, like by clicking the close
* button or pressing the ESC key. The parent of this component is
* responsible for showing or not showing the dialog, so you need to use this
* callback to make that happen. The dialog does not hide or remove itself.
*/
onExit(event: React.MouseEvent | React.KeyboardEvent): void;
/**
Expand Down
6 changes: 6 additions & 0 deletions packages/design-system/src/components/Drawer/Drawer.tsx
Expand Up @@ -47,6 +47,12 @@ export interface DrawerProps {
* Enables "sticky" position of Drawer footer element.
*/
isFooterSticky?: boolean;
/**
* Called when the user activates the close button or presses the ESC key if
* focus trapping is enabled. The parent of this component is responsible for
* showing or not showing the drawer, so you need to use this callback to
* make that happen. The dialog does not hide itself.
*/
onCloseClick: (event: React.MouseEvent | React.KeyboardEvent) => void;
}

Expand Down
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { useRef, useEffect, useLayoutEffect, DialogHTMLAttributes } from 'react';
import dialogPolyfill from './polyfill';

interface NativeDialogProps extends Omit<DialogHTMLAttributes<HTMLElement>, 'children'> {
export interface NativeDialogProps extends Omit<DialogHTMLAttributes<HTMLElement>, 'children'> {
children: React.ReactNode;
/**
* Pass `true` to have the dialog close when its backdrop pseudo-element is clicked
Expand All @@ -26,7 +26,7 @@ interface NativeDialogProps extends Omit<DialogHTMLAttributes<HTMLElement>, 'chi
showModal?: boolean;
}

const NativeDialog = ({
export const NativeDialog = ({
children,
exit,
showModal,
Expand Down
1 change: 0 additions & 1 deletion packages/design-system/src/components/index.ts
Expand Up @@ -14,7 +14,6 @@ export * from './HelpDrawer';
export * from './IdleTimeout';
export * from './InlineError';
export * from './MonthPicker';
export * from './NativeDialog';
export * from './Pagination';
export * from './PrivacySettingsDialog';
export * from './Review';
Expand Down
2 changes: 1 addition & 1 deletion packages/design-system/src/styles/components/_Dialog.scss
Expand Up @@ -40,7 +40,7 @@ dialog.fixed {
border: none;
box-shadow: var(--shadow-base);
box-sizing: border-box;
color: inherit; // Needed to override user-agent styles `canvasText`
color: var(--color-base); // Needed to override user-agent styles `canvasText`
display: inline-block;
margin-top: $spacer-6;
max-width: var(--measure-base);
Expand Down
35 changes: 35 additions & 0 deletions packages/docs/src/components/layout/FilterDialog/CloseButton.tsx
@@ -0,0 +1,35 @@
import React from 'react';
import classNames from 'classnames';
import { CloseIconThin } from '@cmsgov/design-system';

interface BaseCloseButtonProps {
/**
* Additional classes to be added to the root dialog element.
*/
className?: string;
/**
* A custom `id` attribute for the dialog element
*/
id?: string;
}

export type CloseButtonProps = Omit<
React.ComponentPropsWithRef<'button'>,
keyof BaseCloseButtonProps
> &
BaseCloseButtonProps;

/**
*
*/
export const CloseButton = ({ className, ...buttonAttributes }: CloseButtonProps) => (
<button
className={classNames('ds-c-close-button', className)}
type="button"
{...buttonAttributes}
>
<CloseIconThin ariaHidden={false} />
</button>
);

export default CloseButton;
@@ -0,0 +1,40 @@
import React from 'react';
import FilterDialog from './FilterDialog';
import { render, screen } from '@testing-library/react';
import { Button } from '@cmsgov/design-system';

const defaultProps = {
actions: (
<>
<Button variation="solid">Submit</Button>
<Button variation="ghost">Cancel</Button>
</>
),
children: 'Hello, this is the dialog content.',
className: 'a-custom-class',
heading: 'FilterDialog heading',
onExit: jest.fn(),
};

function renderFilterDialog(overwriteProps = {}) {
const props = Object.assign({}, defaultProps, overwriteProps);
return render(<FilterDialog {...props} />);
}

describe('FilterDialog', () => {
it('renders a dialog', () => {
renderFilterDialog();
expect(screen.getByRole('dialog')).toMatchSnapshot();
});

it('passes a ref to the heading', () => {
const headingRef = React.createRef<HTMLHeadingElement>();
renderFilterDialog({ headingRef });
expect(headingRef.current.textContent).toEqual(defaultProps.heading);
});

it('allows a custom headingLevel to be set', () => {
renderFilterDialog({ headingLevel: '5' });
expect(screen.getByText(defaultProps.heading).tagName).toEqual('H5');
});
});
86 changes: 86 additions & 0 deletions packages/docs/src/components/layout/FilterDialog/FilterDialog.tsx
@@ -0,0 +1,86 @@
import React, { useRef } from 'react';
import NativeDialog from '@cmsgov/design-system/src/components/NativeDialog/NativeDialog.tsx';
import uniqueId from 'lodash/uniqueId';
import classNames from 'classnames';
import mergeRefs from '@cmsgov/design-system/src/components/utilities/mergeRefs.ts';
import CloseButton from './CloseButton';

export interface FilterDialogProps {
/**
* Buttons or other HTML to be rendered in the "actions" bar at the bottom of
* the dialog. Should include a button for applying the user's selections and
* one for closing the dialog.
*/
actions: React.ReactNode;
/**
* The main dialog content
*/
children: React.ReactNode;
/**
* Additional classes to be added to the root dialog element.
*/
className?: string;
/**
* Text for the FilterDialog heading. Required because the `heading` will be focused on mount.
*/
heading: string | React.ReactNode;
/**
* A unique `id` to be used on heading element to label multiple instances of FilterDialog.
*/
headingId?: string;
/**
* Heading type to override default `<h3>`
*/
headingLevel?: '1' | '2' | '3' | '4' | '5';
/**
* Ref to heading element
*/
headingRef?: React.MutableRefObject<any>;
/**
* A custom `id` attribute for the dialog element
*/
id?: string;
/**
* Called when the user triggers an exit event, like by pressing the ESC key.
* The parent of this component is responsible for showing or not showing the
* dialog, so you need to use this callback to make that happen. The dialog
* does not hide itself.
*/
onExit(event: React.MouseEvent | React.KeyboardEvent): void;
}

/**
*
*/
export const FilterDialog = (props: FilterDialogProps) => {
const id = useRef(props.id || uniqueId('filter-dialog-')).current;
const headingRef = mergeRefs([props.headingRef, useRef()]);
const headingId = props.headingId ?? `${id}__heading`;
const Heading = `h${props.headingLevel}` as const;

return (
<NativeDialog
className={classNames(props.className, 'ds-c-filter-dialog')}
// We're not using the NativeDialog as a modal, so exit is never called
exit={() => {}}
id={id}
>
<div className="ds-c-filter-dialog__window" tabIndex={-1} aria-labelledby={headingId}>
<div className="ds-c-filter-dialog__header">
<Heading id={headingId} className="ds-c-filter-dialog__heading" ref={headingRef}>
{props.heading}
</Heading>
<CloseButton className="ds-c-filter-dialog__close" onClick={props.onExit} />
</div>
<div className={classNames('ds-c-filter-dialog__body')}>{props.children}</div>
<div className="ds-c-filter-dialog__actions">{props.actions}</div>
</div>
</NativeDialog>
);
};

FilterDialog.defaultProps = {
headingLevel: '3',
};

export default FilterDialog;
@@ -0,0 +1,38 @@
import React from 'react';
import { createContext, useState, useContext, useRef } from 'react';
import uniqueId from 'lodash/uniqueId';

export const FilterDialogContext = createContext(null);

/**
* This is just copied and pasted from our unreleased `DrawerManager` component
* and hook. I just want something that will make sure only one is open at a
* time, but in the future this could possibly be expanded to help render
* a UI for mobile where someone can click "View all filters" and get one
* dialog that is an aggregate of all the individual ones.
*
* The problem I'm seeing with this strategy is that I can't actually render
* the provider and use the hook within the same component. I could see someone
* wanting to both define the FilterDialogManager and use the hook to manage
* the individual dialogs in the set in the same component, which doesn't work.
* This idea will need a lot of work. Going to leave it as-is for the doc site
* prototype, though.
*/
export const FilterDialogManager = (props: any) => {
const [currentID, setCurrentID] = useState(null);

return <FilterDialogContext.Provider value={{ currentID, setCurrentID }} {...props} />;
};

export const useFilterDialogManager = () => {
const { currentID, setCurrentID } = useContext(FilterDialogContext);
const id = useRef(uniqueId('filterDialogManagerID')).current;

const isOpen = currentID === id;
const toggleClick = () => setCurrentID(isOpen ? null : id);
const closeClick = () => setCurrentID(null);

return { toggleClick, closeClick, isOpen };
};

export default FilterDialogManager;
@@ -0,0 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`FilterDialog renders a dialog 1`] = `
<dialog
class="a-custom-class ds-c-filter-dialog"
id="filter-dialog-1"
open=""
role="dialog"
>
<div
aria-labelledby="filter-dialog-1__heading"
class="ds-c-filter-dialog__window"
tabindex="-1"
>
<div
class="ds-c-filter-dialog__header"
>
<h3
class="ds-c-filter-dialog__heading"
id="filter-dialog-1__heading"
>
FilterDialog heading
</h3>
<button
class="ds-c-close-button ds-c-filter-dialog__close"
type="button"
>
<svg
aria-hidden="false"
aria-labelledby="icon-2__title"
class="ds-c-icon ds-c-icon--close ds-c-icon--close-thin "
focusable="false"
id="icon-2"
role="img"
viewBox="-2 -2 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="icon-2__title"
>
Close
</title>
<path
d="M0 13.0332964L13.0332964 0M13.0332964 13.0332964L0 0"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-width="2"
/>
</svg>
</button>
</div>
<div
class="ds-c-filter-dialog__body"
>
Hello, this is the dialog content.
</div>
<div
class="ds-c-filter-dialog__actions"
>
<button
class="ds-c-button ds-c-button--solid"
type="button"
>
Submit
</button>
<button
class="ds-c-button ds-c-button--ghost"
type="button"
>
Cancel
</button>
</div>
</div>
</dialog>
`;
2 changes: 2 additions & 0 deletions packages/docs/src/components/layout/FilterDialog/index.ts
@@ -0,0 +1,2 @@
export * from './FilterDialog';
export * from './FilterDialogManager';
20 changes: 13 additions & 7 deletions packages/docs/src/components/layout/Layout.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import Footer from './DocSiteFooter';
import Navigation from './DocSiteNavigation';
import SideNav from './SideNav/SideNav';
import PageHeader from './PageHeader';
import TableOfContents from './TableOfContents';
import TableOfContentsMobile from './TableOfContentsMobile';
Expand All @@ -15,6 +15,9 @@ import {
import { withPrefix } from 'gatsby';

import '../../styles/index.scss';
import { getThemeData } from './SideNav/themeVersionData';
import ThemeVersionSection from './SideNav/ThemeVersionSection';
import FilterDialogManager from './FilterDialog/FilterDialogManager';

interface LayoutProps {
/**
Expand Down Expand Up @@ -57,10 +60,8 @@ const Layout = ({
tableOfContentsData,
}: LayoutProps) => {
const env = 'prod';

const tabTitle = frontmatter?.title
? `${frontmatter.title} - CMS Design System`
: 'CMS Design System';
const baseTitle = theme === 'core' ? 'CMS Design System' : getThemeData(theme).longName;
const tabTitle = frontmatter?.title ? `${frontmatter.title} - ${baseTitle}` : baseTitle;

const pageId = slug ? `page--${slug.replace('/', '_')}` : null;

Expand Down Expand Up @@ -88,13 +89,18 @@ const Layout = ({
<HeaderFullWidth />

<div className="ds-l-row ds-u-margin--0 full-height">
<Navigation location={location} />
<FilterDialogManager>
<SideNav location={location} />
<div className="ds-u-md-display--none ds-u-padding-x--3 ds-u-padding-top--2">
<ThemeVersionSection />
</div>
</FilterDialogManager>
<main
id="main"
className="ds-l-md-col ds-u-padding--0 ds-u-padding-bottom--4 ds-u-padding-top--2 page-main"
>
{pageHeader ? pageHeader : <PageHeader frontmatter={frontmatter} theme={theme} />}
<article className="ds-u-md-display--flex ds-u-padding-x--3 ds-u-sm-padding-x--6 ds-u-sm-padding-bottom--6 ds-u-sm-padding-top--1 ds-u-padding-bottom--3 page-body">
<article className="ds-u-md-display--flex ds-u-padding-x--3 ds-u-sm-padding-x--6 ds-u-sm-padding-bottom--6 ds-u-padding-top--1 ds-u-padding-bottom--3 page-body">
<div className="ds-l-row">
<div className="ds-l-lg-col--9">
<div className="ds-u-display--block ds-u-lg-display--none ds-u-margin-bottom--3">
Expand Down

0 comments on commit 610c360

Please sign in to comment.