Skip to content

Commit

Permalink
feat(a11y): improved LabelRequiredForA11y type definition
Browse files Browse the repository at this point in the history
This also updated the `*Props` types for the following components so the
`*Props` are always the "strict" `LabelRequiredForA11y` version:

- `Dialog`
- `FixedDialog`
- `Menu`
- `Sheet`

If you want to use the props without the strictness, you can use the
`Base*` prefixed version. So `BaseDialogProps` instead of `DialogProps`.
  • Loading branch information
mlaursen committed Aug 21, 2020
1 parent 9c6834a commit b7aa4fa
Show file tree
Hide file tree
Showing 7 changed files with 34 additions and 27 deletions.
6 changes: 3 additions & 3 deletions packages/dialog/src/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {

import useNestedDialogFixes from "./useNestedDialogFixes";

export interface DialogProps
export interface BaseDialogProps
extends OverridableCSSTransitionProps,
RenderConditionalPortalProps,
FocusContainerOptionsProps,
Expand Down Expand Up @@ -188,7 +188,7 @@ export interface DialogProps
component?: "div" | "nav";
}

type StrictProps = LabelRequiredForA11y<DialogProps>;
export type DialogProps = LabelRequiredForA11y<BaseDialogProps>;

// used to disable the overlay click-to-close functionality when the `modal` prop is enabled.
const noop = (): void => {};
Expand All @@ -206,7 +206,7 @@ const DEFAULT_DIALOG_TIMEOUT = {
exit: 150,
};

const Dialog = forwardRef<HTMLDivElement, StrictProps>(function Dialog(
const Dialog = forwardRef<HTMLDivElement, DialogProps>(function Dialog(
{
component = "div",
tabIndex = -1,
Expand Down
12 changes: 6 additions & 6 deletions packages/dialog/src/FixedDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
OptionalFixedPositionOptions,
useFixedPositioning,
} from "@react-md/transition";
import { LabelRequiredForA11y, TOP_INNER_RIGHT_ANCHOR } from "@react-md/utils";
import { TOP_INNER_RIGHT_ANCHOR, LabelRequiredForA11y } from "@react-md/utils";

import Dialog, { DialogProps } from "./Dialog";
import Dialog, { BaseDialogProps } from "./Dialog";

export interface FixedDialogProps
extends Omit<DialogProps, "type">,
export interface BaseFixedDialogProps
extends Omit<BaseDialogProps, "type">,
Pick<OptionalFixedPositionOptions, "anchor"> {
/**
* The element the dialog should be fixed to. This can either be:
Expand All @@ -35,7 +35,7 @@ export interface FixedDialogProps
getOptions?: GetFixedPositionOptions;
}

type StrictProps = LabelRequiredForA11y<FixedDialogProps>;
export type FixedDialogProps = LabelRequiredForA11y<BaseFixedDialogProps>;

const DEFAULT_CLASSNAMES: CSSTransitionClassNames = {
appear: "rmd-dialog--fixed-enter",
Expand All @@ -51,7 +51,7 @@ const DEFAULT_CLASSNAMES: CSSTransitionClassNames = {
* be fix itself to another element. Another term for this component might be a
* "Pop out Dialog".
*/
const FixedDialog = forwardRef<HTMLDivElement, StrictProps>(
const FixedDialog = forwardRef<HTMLDivElement, FixedDialogProps>(
function FixedDialog(
{
fixedTo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
MenuRenderer,
} from "@react-md/menu";
import { Sheet, SheetProps } from "@react-md/sheet";
import { LabelRequiredForA11y, useAppSize } from "@react-md/utils";
import { useAppSize } from "@react-md/utils";

import styles from "./MobileActionSheet.module.scss";

Expand All @@ -23,10 +23,7 @@ const items = [
{ leftAddon: <DeleteSVGIcon />, children: "Delete collection" },
];

const MenuSheet: FC<LabelRequiredForA11y<SheetProps>> = ({
children,
...props
}) => {
const MenuSheet: FC<SheetProps> = ({ children, ...props }) => {
const { onRequestClose } = props;
const handleClick = useCallback(
(event: React.MouseEvent) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"isBinary": false
},
"src/Demo.tsx": {
"content": "import React, { FC, useCallback } from \"react\";\nimport {\n ArrowDropDownSVGIcon,\n DeleteSVGIcon,\n EditSVGIcon,\n LinkSVGIcon,\n ShareSVGIcon,\n} from \"@react-md/material-icons\";\nimport {\n defaultMenuRenderer,\n DropdownMenu,\n MenuRenderer,\n} from \"@react-md/menu\";\nimport { Sheet, SheetProps } from \"@react-md/sheet\";\nimport { LabelRequiredForA11y, useAppSize } from \"@react-md/utils\";\n\nimport styles from \"./MobileActionSheet.module.scss\";\n\nconst items = [\n { leftAddon: <ShareSVGIcon />, children: \"Share\" },\n { leftAddon: <LinkSVGIcon />, children: \"Get link\" },\n { leftAddon: <EditSVGIcon />, children: \"Edit name\" },\n { leftAddon: <DeleteSVGIcon />, children: \"Delete collection\" },\n];\n\nconst MenuSheet: FC<LabelRequiredForA11y<SheetProps>> = ({\n children,\n ...props\n}) => {\n const { onRequestClose } = props;\n const handleClick = useCallback(\n (event: React.MouseEvent) => {\n if (event.target !== event.currentTarget) {\n onRequestClose();\n }\n },\n [onRequestClose]\n );\n\n return (\n <Sheet\n {...props}\n className={styles.sheet}\n onRequestClose={onRequestClose}\n role=\"menu\"\n disableFocusOnMount\n position=\"bottom\"\n onClick={handleClick}\n >\n {children}\n </Sheet>\n );\n};\n\nconst renderSheet: MenuRenderer = ({\n // these props are only required for the `Menu` component, but not within the sheet\n // so we can just extract them and not pass them down\n horizontal: _horizontal,\n controlId: _controlId,\n anchor: _anchor,\n positionOptions: _positionOptions,\n closeOnResize: _closeOnResize,\n closeOnScroll: _closeOnScroll,\n ...props\n}) => <MenuSheet {...props} />;\n\nconst Demo: FC = () => {\n const { isTablet, isLandscape, isDesktop, isLargeDesktop } = useAppSize();\n const sheet = !isDesktop && !isLargeDesktop && !(isTablet && isLandscape);\n return (\n <DropdownMenu\n id=\"dropdown-menu-1\"\n items={items}\n dropdownIcon={<ArrowDropDownSVGIcon />}\n menuRenderer={sheet ? renderSheet : defaultMenuRenderer}\n >\n Dropdown\n </DropdownMenu>\n );\n};\n\nexport default Demo;\n",
"content": "import React, { FC, useCallback } from \"react\";\nimport {\n ArrowDropDownSVGIcon,\n DeleteSVGIcon,\n EditSVGIcon,\n LinkSVGIcon,\n ShareSVGIcon,\n} from \"@react-md/material-icons\";\nimport {\n defaultMenuRenderer,\n DropdownMenu,\n MenuRenderer,\n} from \"@react-md/menu\";\nimport { Sheet, SheetProps } from \"@react-md/sheet\";\nimport { useAppSize } from \"@react-md/utils\";\n\nimport styles from \"./MobileActionSheet.module.scss\";\n\nconst items = [\n { leftAddon: <ShareSVGIcon />, children: \"Share\" },\n { leftAddon: <LinkSVGIcon />, children: \"Get link\" },\n { leftAddon: <EditSVGIcon />, children: \"Edit name\" },\n { leftAddon: <DeleteSVGIcon />, children: \"Delete collection\" },\n];\n\nconst MenuSheet: FC<SheetProps> = ({ children, ...props }) => {\n const { onRequestClose } = props;\n const handleClick = useCallback(\n (event: React.MouseEvent) => {\n if (event.target !== event.currentTarget) {\n onRequestClose();\n }\n },\n [onRequestClose]\n );\n\n return (\n <Sheet\n {...props}\n className={styles.sheet}\n onRequestClose={onRequestClose}\n role=\"menu\"\n disableFocusOnMount\n position=\"bottom\"\n onClick={handleClick}\n >\n {children}\n </Sheet>\n );\n};\n\nconst renderSheet: MenuRenderer = ({\n // these props are only required for the `Menu` component, but not within the sheet\n // so we can just extract them and not pass them down\n horizontal: _horizontal,\n controlId: _controlId,\n anchor: _anchor,\n positionOptions: _positionOptions,\n closeOnResize: _closeOnResize,\n closeOnScroll: _closeOnScroll,\n ...props\n}) => <MenuSheet {...props} />;\n\nconst Demo: FC = () => {\n const { isTablet, isLandscape, isDesktop, isLargeDesktop } = useAppSize();\n const sheet = !isDesktop && !isLargeDesktop && !(isTablet && isLandscape);\n return (\n <DropdownMenu\n id=\"dropdown-menu-1\"\n items={items}\n dropdownIcon={<ArrowDropDownSVGIcon />}\n menuRenderer={sheet ? renderSheet : defaultMenuRenderer}\n >\n Dropdown\n </DropdownMenu>\n );\n};\n\nexport default Demo;\n",
"isBinary": false
},
"src/MobileActionSheet.module.scss": {
Expand Down
6 changes: 3 additions & 3 deletions packages/menu/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type MenuPositionOptions = Omit<
"container" | "element" | "anchor"
>;

export interface MenuProps
export interface BaseMenuProps
extends HTMLAttributes<HTMLDivElement>,
OverridableCSSTransitionProps,
RenderConditionalPortalProps {
Expand Down Expand Up @@ -136,7 +136,7 @@ export interface MenuProps
disableControlClickOkay?: boolean;
}

type StrictProps = LabelRequiredForA11y<MenuProps>;
export type MenuProps = LabelRequiredForA11y<BaseMenuProps>;

const block = bem("rmd-menu");

Expand All @@ -145,7 +145,7 @@ const block = bem("rmd-menu");
* out based on the `visible` prop as well as handle keyboard focus, closing
* when needed, etc.
*/
const Menu = forwardRef<HTMLDivElement, StrictProps>(function Menu(
const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
{
role = "menu",
tabIndex = -1,
Expand Down
10 changes: 5 additions & 5 deletions packages/sheet/src/Sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import React, { forwardRef, useRef, useState, useCallback } from "react";
import cn from "classnames";
import { Dialog, DialogProps } from "@react-md/dialog";
import { Dialog, BaseDialogProps } from "@react-md/dialog";
import { bem, LabelRequiredForA11y } from "@react-md/utils";
import { DEFAULT_SHEET_TIMEOUT, DEFAULT_SHEET_CLASSNAMES } from "./constants";

type AllowedDialogProps = Omit<
DialogProps,
BaseDialogProps,
"role" | "type" | "modal" | "forceContainer"
>;

export type SheetPosition = "top" | "right" | "bottom" | "left";
export type SheetHorizontalSize = "none" | "media" | "touch" | "static";
export type SheetVerticalSize = "none" | "touch" | "recommended";

export interface SheetProps extends AllowedDialogProps {
export interface BaseSheetProps extends AllowedDialogProps {
/**
* The role that the sheet should be rendered as. You'll normally want to keep
* this as the default of `"dialog"` unless you are implementing a mobile
Expand Down Expand Up @@ -60,7 +60,7 @@ export interface SheetProps extends AllowedDialogProps {
verticalSize?: SheetVerticalSize;
}

type StrictProps = LabelRequiredForA11y<SheetProps>;
export type SheetProps = LabelRequiredForA11y<BaseSheetProps>;

const block = bem("rmd-sheet");

Expand All @@ -69,7 +69,7 @@ const block = bem("rmd-sheet");
* to the edges of the viewport instead of centered or full page. This component
* is great for rendering a navigation tree or menus on mobile devices.
*/
const Sheet = forwardRef<HTMLDivElement, StrictProps>(function Sheet(
const Sheet = forwardRef<HTMLDivElement, SheetProps>(function Sheet(
{
className,
children,
Expand Down
18 changes: 14 additions & 4 deletions packages/utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,27 @@ export type ClassNameCloneableChild<T = {}> = ReactElement<
* This type allows you to require at least one of the provided keys. This is
* super helpful for things like `aria-label` or `aria-labelledby` when it's
* required for a11y.
*
* @see https://stackoverflow.com/questions/40510611/typescript-interface-require-one-of-two-properties-to-exist/49725198#49725198
*/
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
T,
Exclude<keyof T, Keys>
> &
{ [K in Keys]-?: Required<Pick<T, K>> }[Keys];
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];

interface LabelA11y {
export interface LabelA11y {
"aria-label"?: string;
"aria-labelledby"?: string;
}

export type LabelRequiredForA11y<T extends LabelA11y> = T &
RequireAtLeastOne<T, "aria-label" | "aria-labelledby">;
/**
* A small accessibility helper to ensure that either `aria-label` or
* `aria-labelledby` have been provided to a component.
*/
export type LabelRequiredForA11y<Props extends LabelA11y> = RequireAtLeastOne<
Props,
keyof LabelA11y
>;

0 comments on commit b7aa4fa

Please sign in to comment.