Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-friends-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@kaizen/components': patch
---

`Collapsible` now accepts a `ref` that exposes a `ButtonRef` (`{ focus }`) for programmatically focusing the toggle button. Use this to restore focus after async operations instead of relying on `querySelector`. `ButtonRef` is also now exported from `@kaizen/components` for use in consumer typings.
2 changes: 1 addition & 1 deletion packages/components/src/ButtonV1/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type { CustomButtonProps } from './GenericButton'
export type { ButtonRef, CustomButtonProps } from './GenericButton'
export * from './Button'
export * from './IconButton'
129 changes: 127 additions & 2 deletions packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react'
import React, { useRef } from 'react'
import { queryByTestId, render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { Collapsible } from './Collapsible'
import { Collapsible, type CollapsibleRef } from './Collapsible'
const user = userEvent.setup()

describe('<Collapsible />', () => {
Expand Down Expand Up @@ -137,4 +137,129 @@
expect(section).toHaveAttribute('inert')
})
})

describe('Ref support', () => {
it('exposes a ref to the toggle button with focus method', () => {
const ref = React.createRef<CollapsibleRef>()
render(
<Collapsible ref={ref} id="1" title="First panel">
First panel content
</Collapsible>,
)

expect(ref.current).toBeDefined()
expect(ref.current?.focus).toBeDefined()
expect(typeof ref.current?.focus).toBe('function')
})

it('allows focusing the toggle button via ref', () => {
const ref = React.createRef<CollapsibleRef>()
const { getByTestId } = render(
<Collapsible ref={ref} id="1" title="First panel">
First panel content
</Collapsible>,
)

const button = getByTestId('collapsible-button-1')
const focusSpy = vi.spyOn(button, 'focus')

ref.current?.focus()

expect(focusSpy).toHaveBeenCalled()
})

it('ref works in controlled mode', () => {
const ref = React.createRef<CollapsibleRef>()
const { rerender, getByTestId } = render(
<Collapsible ref={ref} id="1" title="First panel" open controlled>
First panel content
</Collapsible>,
)

const button = getByTestId('collapsible-button-1')
const focusSpy = vi.spyOn(button, 'focus')

ref.current?.focus()

expect(focusSpy).toHaveBeenCalled()

// Verify ref is still functional after re-render
rerender(
<Collapsible ref={ref} id="1" title="First panel" open={false} controlled>
First panel content
</Collapsible>,
)

ref.current?.focus()
expect(focusSpy).toHaveBeenCalledTimes(2)
})

it('ref works in uncontrolled mode', () => {
const ref = React.createRef<CollapsibleRef>()
const { getByTestId } = render(
<Collapsible ref={ref} id="1" title="First panel" open>
First panel content
</Collapsible>,
)

const button = getByTestId('collapsible-button-1')
const focusSpy = vi.spyOn(button, 'focus')

ref.current?.focus()

expect(focusSpy).toHaveBeenCalled()
})

it('ref is accessible when no id prop is provided (uses fallback)', () => {
const ref = React.createRef<CollapsibleRef>()
const { container } = render(
<Collapsible ref={ref} title="First panel">
First panel content
</Collapsible>,
)

expect(ref.current).toBeDefined()
expect(ref.current?.focus).toBeDefined()

// Find the button element (querySelector with * selector would also match the title div)
const button = container.querySelector<HTMLButtonElement>('button[data-testid*="collapsible-button-"]')!

Check failure on line 225 in packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx

View workflow job for this annotation

GitHub Actions / eslint

Replace `'button[data-testid*="collapsible-button-"]'` with `⏎········'button[data-testid*="collapsible-button-"]',⏎······`
expect(button).toBeTruthy()
const focusSpy = vi.spyOn(button, 'focus')

ref.current?.focus()

expect(focusSpy).toHaveBeenCalled()
})

it('supports useRef hook pattern for ref management', async () => {
const TestComponent = (): JSX.Element => {
const collapsibleRef = useRef<CollapsibleRef>(null)

return (
<>
<button
type="button"
onClick={() => collapsibleRef.current?.focus()}
data-testid="focus-button"
>
Focus Collapsible
</button>
<Collapsible ref={collapsibleRef} id="1" title="First panel">
First panel content
</Collapsible>
</>
)
}

const { getByTestId } = render(<TestComponent />)

const button = getByTestId('collapsible-button-1')
const focusSpy = vi.spyOn(button, 'focus')

const focusButton = getByTestId('focus-button')
await user.click(focusButton)

expect(focusSpy).toHaveBeenCalled()
})
})
})
218 changes: 116 additions & 102 deletions packages/components/src/Collapsible/Collapsible/Collapsible.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useId, useState, type HTMLAttributes } from 'react'
import React, { forwardRef, useId, useRef, useState, type HTMLAttributes, type Ref } from 'react'
import classnames from 'classnames'
import AnimateHeight from 'react-animate-height'
import { IconButton } from '~components/ButtonV1'
Expand Down Expand Up @@ -36,118 +36,132 @@ export type CollapsibleProps = {
controlled?: boolean
} & OverrideClassName<HTMLAttributes<HTMLDivElement>>

export const Collapsible = ({
children,
title,
renderHeader,
open,
group,
separated,
sticky,
noSectionPadding,
onToggle,
variant = 'default',
lazyLoad,
controlled,
classNameOverride,
id: propsId,
...restProps
}: CollapsibleProps): JSX.Element => {
const [stateIsOpen, setIsOpen] = useState<boolean>(open ?? false)
const getOpen = (): boolean | undefined => (controlled ? open : stateIsOpen)
export type CollapsibleRef = { focus: () => void }

const fallbackId = useId()
const id = propsId ?? fallbackId
export const Collapsible = forwardRef<CollapsibleRef, CollapsibleProps>(
(
{
children,
title,
renderHeader,
open,
group,
separated,
sticky,
noSectionPadding,
onToggle,
variant = 'default',
lazyLoad,
controlled,
classNameOverride,
id: propsId,
...restProps
}: CollapsibleProps,
ref: Ref<CollapsibleRef>,
) => {
const [stateIsOpen, setIsOpen] = useState<boolean>(open ?? false)
const getOpen = (): boolean | undefined => (controlled ? open : stateIsOpen)

const handleSectionToggle = (): void => {
const newIsOpen = !getOpen()
if (!controlled) setIsOpen(newIsOpen)
onToggle?.(newIsOpen, id)
}
const fallbackId = useId()
const id = propsId ?? fallbackId
const buttonRef = useRef<CollapsibleRef>(null)

const handleButtonPress = (event: React.MouseEvent): void => {
event.stopPropagation()
handleSectionToggle()
}
React.useImperativeHandle(ref, () => ({ focus: () => buttonRef.current?.focus() }), [])

const buttonId = `${id}-button`
const sectionId = `${id}-section`
const isOpen = getOpen()
const isContainer = !group || separated
const handleSectionToggle = (): void => {
const newIsOpen = !getOpen()
if (!controlled) setIsOpen(newIsOpen)
onToggle?.(newIsOpen, id)
}

return (
<div
id={id}
className={classnames(
classNameOverride,
isContainer && styles.container,
group && !separated && styles.groupItem,
separated && styles.separated,
)}
data-testid={`collapsible-container-${id}`}
{...restProps} // `title` is missing because it is used for the header; requires breaking change to fix
>
{/* Disabling these a11y linting errors because there is an IconButton that mitigates these concerns. The onClick here is just an additional layer. */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
const handleButtonPress = (event: React.MouseEvent): void => {
event.stopPropagation()
handleSectionToggle()
}

const buttonId = `${id}-button`
const sectionId = `${id}-section`
const isOpen = getOpen()
const isContainer = !group || separated

return (
<div
id={id}
className={classnames(
styles.header,
isOpen && styles.open,
sticky && styles.sticky,
isOpen && variant === 'default' && styles.defaultVariant,
isOpen && variant === 'clear' && styles.clearVariant,
classNameOverride,
isContainer && styles.container,
group && !separated && styles.groupItem,
separated && styles.separated,
)}
style={sticky && { top: sticky.top }}
onClick={handleSectionToggle}
data-testid={`collapsible-header-${id}`}
data-testid={`collapsible-container-${id}`}
{...restProps} // `title` is missing because it is used for the header; requires breaking change to fix
>
{renderHeader !== undefined ? (
renderHeader(title)
) : (
<div className={styles.title} data-testid={`collapsible-button-title-${id}`}>
<Heading variant="heading-4" tag="span">
{title}
</Heading>
{/* Disabling these a11y linting errors because there is an IconButton that mitigates these concerns. The onClick here is just an additional layer. */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div
className={classnames(
styles.header,
isOpen && styles.open,
sticky && styles.sticky,
isOpen && variant === 'default' && styles.defaultVariant,
isOpen && variant === 'clear' && styles.clearVariant,
)}
style={sticky && { top: sticky.top }}
onClick={handleSectionToggle}
data-testid={`collapsible-header-${id}`}
>
{renderHeader !== undefined ? (
renderHeader(title)
) : (
<div className={styles.title} data-testid={`collapsible-button-title-${id}`}>
<Heading variant="heading-4" tag="span">
{title}
</Heading>
</div>
)}
<div>
<IconButton
ref={buttonRef}
label={title}
icon={
<Icon
name={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
isPresentational
/>
}
type="button"
aria-expanded={isOpen}
aria-controls={sectionId}
data-testid={`collapsible-button-${id}`}
id={buttonId}
onClick={handleButtonPress}
classNameOverride={styles.chevronButton}
/>
</div>
)}
<div>
<IconButton
label={title}
icon={
<Icon name={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'} isPresentational />
}
type="button"
aria-expanded={isOpen}
aria-controls={sectionId}
data-testid={`collapsible-button-${id}`}
id={buttonId}
onClick={handleButtonPress}
classNameOverride={styles.chevronButton}
/>
</div>
</div>
{(!lazyLoad || isOpen) && (
<AnimateHeight
height={isOpen ? 'auto' : 0}
disableDisplayNone
data-testid={`collapsible-section-${id}`}
>
<div
id={sectionId}
className={classnames(styles.section, noSectionPadding && styles.noPadding)}
role="region"
aria-labelledby={buttonId}
// TODO: Remove @ts-expect-error when upgrade to @types/react@19 that support the `inert` HTML attribute
// @ts-expect-error current react types don't yet support the `inert` HTML attribute
inert={isOpen ? undefined : true}
{(!lazyLoad || isOpen) && (
<AnimateHeight
height={isOpen ? 'auto' : 0}
disableDisplayNone
data-testid={`collapsible-section-${id}`}
>
{children}
</div>
</AnimateHeight>
)}
</div>
)
}
<div
id={sectionId}
className={classnames(styles.section, noSectionPadding && styles.noPadding)}
role="region"
aria-labelledby={buttonId}
// TODO: Remove @ts-expect-error when upgrade to @types/react@19 that support the `inert` HTML attribute
// @ts-expect-error current react types don't yet support the `inert` HTML attribute
inert={isOpen ? undefined : true}
>
{children}
</div>
</AnimateHeight>
)}
</div>
)
},
)

Collapsible.displayName = 'Collapsible'
Loading
Loading