Skip to content

Commit

Permalink
Merge branch 'main' of github.com:primer/react-brand into rezrah/subd…
Browse files Browse the repository at this point in the history
…omain-title-in-menu
  • Loading branch information
rezrah committed Jan 24, 2024
2 parents 1d727ea + f6f4c66 commit 71d390a
Show file tree
Hide file tree
Showing 12 changed files with 106 additions and 12 deletions.
6 changes: 6 additions & 0 deletions .changeset/angry-chairs-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@primer/react-brand': patch
---

- Improved keyboard navigation for FAQ and accordion components.
- Pressing the escape key will now return focus to the closest accordion toggle.
93 changes: 86 additions & 7 deletions packages/react/src/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {forwardRef} from 'react'
import React, {forwardRef, useCallback, useEffect, useRef} from 'react'
import clsx from 'clsx'

import {Heading} from '../'
Expand All @@ -12,11 +12,13 @@ import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/compone

/** * Main Stylesheet (as a CSS Module) */
import styles from './Accordion.module.css'
import {useProvidedRefOrCreate} from '../hooks/useRef'

export type AccordionRootProps = BaseProps<HTMLDetailsElement> & {
open?: boolean // Manually declared due to known issue with the native open attribute: https://github.com/facebook/react/issues/15486
children: React.ReactElement<AccordionHeadingProps | AccordionContentProps>[]
variant?: 'default' | 'emphasis'
ref?: React.RefObject<HTMLDetailsElement>
} & React.HTMLAttributes<HTMLDetailsElement>

type ValidRootChildren = {
Expand All @@ -26,17 +28,27 @@ type ValidRootChildren = {

export const AccordionRoot = forwardRef<HTMLDetailsElement, AccordionRootProps>(
({children, className, open = false, variant = 'default', ...rest}, ref) => {
const detailsRef = useProvidedRefOrCreate(ref as React.RefObject<HTMLDetailsElement>)
const [isOpen, setIsOpen] = React.useState(open)

const {AccordionHeading: HeadingChild, AccordionContent: AccordionContentChild} = React.Children.toArray(
children,
).reduce<ValidRootChildren>(
(acc, child) => {
if (React.isValidElement(child) && typeof child.type !== 'string') {
if (child.type === AccordionContent) {
acc.AccordionContent = child as React.ReactElement<AccordionContentProps>
acc.AccordionContent = React.cloneElement(child as React.ReactElement<AccordionContentProps>, {
open: isOpen,
handleOpen: (newValue: boolean) => setIsOpen(newValue),
parentRef: detailsRef,
}) as React.ReactElement<AccordionContentProps>
}
if (child.type === AccordionHeading) {
acc.AccordionHeading = React.cloneElement(child as React.ReactElement, {
variant,
open: isOpen,
handleOpen: (newValue: boolean) => setIsOpen(newValue),
parentRef: detailsRef,
}) as React.ReactElement<AccordionHeadingProps>
}
}
Expand All @@ -48,9 +60,9 @@ export const AccordionRoot = forwardRef<HTMLDetailsElement, AccordionRootProps>(
return (
<details
className={clsx(styles.Accordion, styles[`Accordion--${variant}`], className)}
open={open}
open={isOpen}
{...rest}
ref={ref}
ref={detailsRef}
>
{HeadingChild}
{AccordionContentChild}
Expand All @@ -65,10 +77,39 @@ type AccordionHeadingProps = BaseProps<HTMLHeadingElement> & {
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
reversedToggles?: boolean
variant?: 'default' | 'emphasis'
open?: boolean // private prop passed from AccordionRoot
handleOpen?: (boolean) => void // private prop passed from AccordionRoot
parentRef?: React.RefObject<HTMLDetailsElement> // private prop passed from AccordionRoot
}

export const AccordionHeading = forwardRef<HTMLHeadingElement, AccordionHeadingProps>(
({children, className, as = 'h4', variant = 'default', reversedToggles, ...rest}, ref) => {
(
{children, className, as = 'h4', variant = 'default', reversedToggles, open, handleOpen, parentRef, ...rest},
ref,
) => {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLDetailsElement, MouseEvent>) => {
event.preventDefault()
if (handleOpen) handleOpen(!open)
},
[handleOpen, open],
)

const handleKeyPress = useCallback(
(event: React.KeyboardEvent<HTMLDetailsElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (handleOpen) handleOpen(!open)
}

if (event.key === 'Escape') {
event.preventDefault()
if (handleOpen) handleOpen(false)
}
},
[handleOpen, open],
)

return (
<summary
className={clsx(
Expand All @@ -79,6 +120,8 @@ export const AccordionHeading = forwardRef<HTMLHeadingElement, AccordionHeadingP
)}
ref={ref}
{...rest}
onClick={handleClick}
onKeyDown={handleKeyPress}
>
<span aria-hidden="true" className={styles['Accordion__summary--collapsed']}>
{variant === 'emphasis' && <ChevronDownIcon size={24} fill="var(--brand-color-text-default)" />}
Expand All @@ -96,11 +139,47 @@ export const AccordionHeading = forwardRef<HTMLHeadingElement, AccordionHeadingP

type AccordionContentProps = BaseProps<HTMLElement> & {
children: React.ReactElement | React.ReactElement[]
open?: boolean // private prop passed from AccordionRoot
handleOpen?: (boolean) => void // private prop passed from AccordionRoot
parentRef?: React.RefObject<HTMLDetailsElement> // private prop passed from AccordionRoot
}

export function AccordionContent({children, className, ...rest}: AccordionContentProps) {
export function AccordionContent({children, className, open, handleOpen, parentRef, ...rest}: AccordionContentProps) {
const contentRef = useRef<HTMLElement>(null)

const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
const focusedElement = document.activeElement
if (contentRef.current && contentRef.current.contains(focusedElement)) {
// Close the accordion
if (handleOpen) handleOpen(false)
if (parentRef && parentRef.current) {
const summary = parentRef.current.querySelector('summary')
if (summary) summary.focus()
}
}
}
},
[parentRef, handleOpen],
)

useEffect(() => {
const contentEl = contentRef.current as HTMLElement | null

if (open && contentEl) {
contentEl.addEventListener('keydown', ev => handleKeyPress(ev))
}
return () => {
if (open && contentEl) {
contentEl.removeEventListener('keydown', ev => handleKeyPress(ev))
}
}
}, [open, handleKeyPress])

return (
<section className={clsx(styles.Accordion__content, className)} {...rest}>
<section className={clsx(styles.Accordion__content, className)} {...rest} ref={contentRef}>
{children}
</section>
)
Expand Down
19 changes: 14 additions & 5 deletions packages/react/src/FAQ/FAQ.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,17 +221,23 @@ export const Groups: StoryFn<typeof FAQ> = () => {
<FAQ.Question>What is GitHub Enterprise?</FAQ.Question>
<FAQ.Answer>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sapien sit ullamcorper id. Aliquam luctus sed
turpis felis nam pulvinar risus elementum.
Lorem ipsum dolor sit amet,{' '}
<a href="/" target="_blank" rel="noreferrer">
consectetur adipiscing elit
</a>
. In sapien sit ullamcorper id. Aliquam luctus sed turpis felis nam pulvinar risus elementum.
</p>
</FAQ.Answer>
</FAQ.Item>
<FAQ.Item>
<FAQ.Question>How can GitHub Enterprise be deployed?</FAQ.Question>
<FAQ.Answer>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sapien sit ullamcorper id. Aliquam luctus sed
turpis felis nam pulvinar risus elementum.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.{' '}
<a href="/" target="_blank" rel="noreferrer">
In sapien sit ullamcorper id.
</a>{' '}
Aliquam luctus sed turpis felis nam pulvinar risus elementum.
</p>
</FAQ.Answer>
</FAQ.Item>
Expand All @@ -240,7 +246,10 @@ export const Groups: StoryFn<typeof FAQ> = () => {
<FAQ.Answer>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sapien sit ullamcorper id. Aliquam luctus sed
turpis felis nam pulvinar risus elementum.
turpis felis{' '}
<a href="/" target="_blank" rel="noreferrer">
nam pulvinar risus elementum.
</a>
</p>
</FAQ.Answer>
</FAQ.Item>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 71d390a

Please sign in to comment.