diff --git a/src/core/client/ui/components/RelativeTime/RelativeTime.tsx b/src/core/client/ui/components/RelativeTime/RelativeTime.tsx index 2f8d56715..136bbca4f 100644 --- a/src/core/client/ui/components/RelativeTime/RelativeTime.tsx +++ b/src/core/client/ui/components/RelativeTime/RelativeTime.tsx @@ -25,23 +25,21 @@ interface InnerProps { const defaultFormatter: Formatter = (value, unit, suffix, timestamp: string) => new Date(timestamp).toISOString(); -class RelativeTime extends React.Component { - public render() { - const { date, classes, live, className, formatter } = this.props; - return ( - - {({ timeagoFormatter }) => ( - - )} - - ); - } -} +const RelativeTime: React.StatelessComponent = props => { + const { date, classes, live, className, formatter } = props; + return ( + + {({ timeagoFormatter }) => ( + + )} + + ); +}; const enhanced = withForwardRef(withStyles(styles)(RelativeTime)); export type RelativeTimeProps = PropTypesOf; diff --git a/src/core/client/ui/components/TrapFocus/TrapFocus.mdx b/src/core/client/ui/components/TrapFocus/TrapFocus.mdx new file mode 100644 index 000000000..d5c209aa9 --- /dev/null +++ b/src/core/client/ui/components/TrapFocus/TrapFocus.mdx @@ -0,0 +1,39 @@ +--- +name: TrapFocus +menu: UI Kit +--- + +import { Playground } from 'docz' +import TrapFocus from './TrapFocus' + +# TrapFocus + +Traps focus inside component when using keyboard navigation for accessibility purposes. + +## Basic usage + +```ts +import React from "react"; + +class Dialog extends React.Component { + private firstFocusable: HTMLElement; + private lastFocusable: HTMLElement; + + private setFirstFocusable = (ref: HTMLElement) => (this.firstFocusable = ref); + private setLastFocusable = (ref: HTMLElement) => (this.lastFocusable = ref); + + public render() { + return ( +
+ + + + +
+ ); + } +} +``` diff --git a/src/core/client/ui/components/TrapFocus/TrapFocus.spec.tsx b/src/core/client/ui/components/TrapFocus/TrapFocus.spec.tsx new file mode 100644 index 000000000..f4932775f --- /dev/null +++ b/src/core/client/ui/components/TrapFocus/TrapFocus.spec.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { create } from "react-test-renderer"; +import Sinon from "sinon"; + +import { PropTypesOf } from "talk-ui/types"; +import TrapFocus from "./TrapFocus"; + +it("renders correctly", () => { + const props: PropTypesOf = { + firstFocusable: null, + lastFocusable: null, + children: ( + <> + child1 + child2 + + ), + }; + const renderer = create( +
+ +
+ ); + expect(renderer.toJSON()).toMatchSnapshot(); +}); + +it("Change focus to `lastFocusable` when focus reaches beginning", () => { + const fakeHTMLElementBegin = { focus: Sinon.spy() }; + const fakeHTMLElementEnd = { focus: Sinon.spy() }; + const props: PropTypesOf = { + firstFocusable: fakeHTMLElementBegin as any, + lastFocusable: fakeHTMLElementEnd as any, + children: ( + <> + child1 + child2 + + ), + }; + const renderer = create( +
+ +
+ ); + renderer.root.findAllByProps({ tabIndex: 0 })[0].props.onFocus(); + expect(fakeHTMLElementBegin.focus.called).toBe(false); + expect(fakeHTMLElementEnd.focus.called).toBe(true); +}); + +it("Change focus to `firstFocusable` when focus reaches the end", () => { + const fakeHTMLElementBegin = { focus: Sinon.spy() }; + const fakeHTMLElementEnd = { focus: Sinon.spy() }; + const props: PropTypesOf = { + firstFocusable: fakeHTMLElementBegin as any, + lastFocusable: fakeHTMLElementEnd as any, + children: ( + <> + child1 + child2 + + ), + }; + const renderer = create( +
+ +
+ ); + renderer.root.findAllByProps({ tabIndex: 0 })[1].props.onFocus(); + expect(fakeHTMLElementBegin.focus.called).toBe(true); + expect(fakeHTMLElementEnd.focus.called).toBe(false); +}); diff --git a/src/core/client/ui/components/TrapFocus/TrapFocus.tsx b/src/core/client/ui/components/TrapFocus/TrapFocus.tsx new file mode 100644 index 000000000..363c9f879 --- /dev/null +++ b/src/core/client/ui/components/TrapFocus/TrapFocus.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +export interface Focusable { + focus: () => void; +} + +interface TrapFocusProps { + firstFocusable: Focusable | null; + lastFocusable: Focusable | null; + children: React.ReactNode; +} + +export default class TrapFocus extends React.Component { + // Trap keyboard focus inside the dropdown until a value has been chosen. + public focusBegin = () => this.props.firstFocusable!.focus(); + public focusEnd = () => this.props.lastFocusable!.focus(); + + public render() { + return ( + <> +
+ {this.props.children} +
+ + ); + } +} diff --git a/src/core/client/ui/components/TrapFocus/__snapshots__/TrapFocus.spec.tsx.snap b/src/core/client/ui/components/TrapFocus/__snapshots__/TrapFocus.spec.tsx.snap new file mode 100644 index 000000000..b39ad808e --- /dev/null +++ b/src/core/client/ui/components/TrapFocus/__snapshots__/TrapFocus.spec.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+
+ + child1 + + + child2 + +
+
+`; diff --git a/src/core/client/ui/components/TrapFocus/index.ts b/src/core/client/ui/components/TrapFocus/index.ts new file mode 100644 index 000000000..965ecf53a --- /dev/null +++ b/src/core/client/ui/components/TrapFocus/index.ts @@ -0,0 +1,2 @@ +export * from "./TrapFocus"; +export { default } from "./TrapFocus"; diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 3d81f1aba..91ddaeb68 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -5,3 +5,4 @@ export { default as RelativeTime } from "./RelativeTime"; export { default as UIContext, UIContextProps } from "./UIContext"; export { default as Flex } from "./Flex"; export { default as MatchMedia } from "./MatchMedia"; +export { default as TrapFocus } from "./TrapFocus";