From 484cf48b8f80a2b366ff1b4aa609f781bc0291b3 Mon Sep 17 00:00:00 2001 From: AliceMenzie Date: Tue, 23 Jun 2026 15:04:37 +1000 Subject: [PATCH 1/3] feat(Collapsible): expose ref for programmatic toggle button focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Collapsible in forwardRef, forwarding to the toggle IconButton via useImperativeHandle. Exposes ButtonRef ({ focus }) so consumers can programmatically focus the toggle after async operations — replacing fragile querySelector workarounds. Also exports ButtonRef from the ButtonV1 barrel so consumers can type their refs without reaching into internals. Closes KZN-4067 Co-Authored-By: Claude Sonnet 4.6 --- packages/components/src/ButtonV1/index.ts | 2 +- .../Collapsible/Collapsible.spec.tsx | 128 +++++++++- .../Collapsible/Collapsible/Collapsible.tsx | 219 ++++++++++-------- .../Collapsible/_docs/Collapsible.mdx | 35 +++ .../Collapsible/_docs/Collapsible.stories.tsx | 58 ++++- .../src/Collapsible/Collapsible/index.ts | 1 + .../src/Popover/_docs/Popover.stories.tsx | 71 +++++- 7 files changed, 405 insertions(+), 109 deletions(-) diff --git a/packages/components/src/ButtonV1/index.ts b/packages/components/src/ButtonV1/index.ts index 27da46e1566..f2a2307c718 100644 --- a/packages/components/src/ButtonV1/index.ts +++ b/packages/components/src/ButtonV1/index.ts @@ -1,3 +1,3 @@ -export type { CustomButtonProps } from './GenericButton' +export type { ButtonRef, CustomButtonProps } from './GenericButton' export * from './Button' export * from './IconButton' diff --git a/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx b/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx index 3965c7940db..5f175f01896 100644 --- a/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx +++ b/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx @@ -1,7 +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 { type ButtonRef } from '~components/ButtonV1' import { Collapsible } from './Collapsible' const user = userEvent.setup() @@ -137,4 +138,129 @@ describe('', () => { expect(section).toHaveAttribute('inert') }) }) + + describe('Ref support', () => { + it('exposes a ref to the toggle button with focus method', () => { + const ref = React.createRef() + render( + + First panel content + , + ) + + 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() + const { getByTestId } = render( + + First panel content + , + ) + + 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() + const { rerender, getByTestId } = render( + + First panel content + , + ) + + 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( + + First panel content + , + ) + + ref.current?.focus() + expect(focusSpy).toHaveBeenCalledTimes(2) + }) + + it('ref works in uncontrolled mode', () => { + const ref = React.createRef() + const { getByTestId } = render( + + First panel content + , + ) + + 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() + const { container } = render( + + First panel content + , + ) + + 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('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(null) + + return ( + <> + + + First panel content + + + ) + } + + const { getByTestId } = render() + + const button = getByTestId('collapsible-button-1') + const focusSpy = vi.spyOn(button, 'focus') + + const focusButton = getByTestId('focus-button') + await user.click(focusButton) + + expect(focusSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/components/src/Collapsible/Collapsible/Collapsible.tsx b/packages/components/src/Collapsible/Collapsible/Collapsible.tsx index bdf1fb853a9..644ed955bbb 100644 --- a/packages/components/src/Collapsible/Collapsible/Collapsible.tsx +++ b/packages/components/src/Collapsible/Collapsible/Collapsible.tsx @@ -1,7 +1,7 @@ -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' +import { IconButton, type ButtonRef } from '~components/ButtonV1' import { Heading } from '~components/Heading' import { Icon } from '~components/Icon' import { type OverrideClassName } from '~components/types/OverrideClassName' @@ -36,118 +36,131 @@ export type CollapsibleProps = { controlled?: boolean } & OverrideClassName> -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(open ?? false) - const getOpen = (): boolean | undefined => (controlled ? open : stateIsOpen) +export const Collapsible = forwardRef( + ( + { + children, + title, + renderHeader, + open, + group, + separated, + sticky, + noSectionPadding, + onToggle, + variant = 'default', + lazyLoad, + controlled, + classNameOverride, + id: propsId, + ...restProps + }: CollapsibleProps, + ref: Ref, + ) => { + const [stateIsOpen, setIsOpen] = useState(open ?? false) + const getOpen = (): boolean | undefined => (controlled ? open : stateIsOpen) - const fallbackId = useId() - const id = propsId ?? fallbackId + const fallbackId = useId() + const id = propsId ?? fallbackId + const buttonRef = useRef(null) - const handleSectionToggle = (): void => { - const newIsOpen = !getOpen() - if (!controlled) setIsOpen(newIsOpen) - onToggle?.(newIsOpen, id) - } + // Forward the ref passed to Collapsible to the internal button ref + React.useImperativeHandle(ref, () => buttonRef.current ?? undefined, []) - const handleButtonPress = (event: React.MouseEvent): void => { - event.stopPropagation() - handleSectionToggle() - } + const handleSectionToggle = (): void => { + const newIsOpen = !getOpen() + if (!controlled) setIsOpen(newIsOpen) + onToggle?.(newIsOpen, id) + } - const buttonId = `${id}-button` - const sectionId = `${id}-section` - const isOpen = getOpen() - const isContainer = !group || separated + const handleButtonPress = (event: React.MouseEvent): void => { + event.stopPropagation() + handleSectionToggle() + } - return ( -
- {/* 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 buttonId = `${id}-button` + const sectionId = `${id}-section` + const isOpen = getOpen() + const isContainer = !group || separated + + return (
- {renderHeader !== undefined ? ( - renderHeader(title) - ) : ( -
- - {title} - + {/* 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 */} +
+ {renderHeader !== undefined ? ( + renderHeader(title) + ) : ( +
+ + {title} + +
+ )} +
+ + } + type="button" + aria-expanded={isOpen} + aria-controls={sectionId} + data-testid={`collapsible-button-${id}`} + id={buttonId} + onClick={handleButtonPress} + classNameOverride={styles.chevronButton} + />
- )} -
- - } - type="button" - aria-expanded={isOpen} - aria-controls={sectionId} - data-testid={`collapsible-button-${id}`} - id={buttonId} - onClick={handleButtonPress} - classNameOverride={styles.chevronButton} - />
-
- {(!lazyLoad || isOpen) && ( - -
- {children} -
-
- )} -
- ) -} +
+ {children} +
+ + )} +
+ ) + }, +) Collapsible.displayName = 'Collapsible' diff --git a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx index 7e6cea2fa82..efa45b993ee 100644 --- a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx +++ b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx @@ -36,3 +36,38 @@ Control the open state using a `useState` (or similar) by setting the `controlle and defining the `open` and `onToggle` props. + +### Programmatic Focus + +Use a `ref` to programmatically focus the toggle button. This is useful when you need to restore focus after an async operation, such as in sortable lists where items are saved or deleted. + +The `ref` exposes a `ButtonRef` interface with a `.focus()` method that focuses the underlying toggle button element. + + + +**Example usage:** + +```tsx +import { useRef } from 'react' +import { Collapsible, type ButtonRef } from '@kaizen/components' + +export const MyComponent = () => { + const collapsibleRef = useRef(null) + + const handleSaveAndFocus = async () => { + // Perform async operation (e.g., API call) + await saveData() + // Focus the toggle button after the operation completes + collapsibleRef.current?.focus() + } + + return ( + <> + + + {/* Collapsible content */} + + + ) +} +``` diff --git a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx index 12fc98074b2..d7965224a0b 100644 --- a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx +++ b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx @@ -1,10 +1,10 @@ -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import { type Meta, type StoryObj } from '@storybook/react' import { Heading } from '~components/Heading' import { Icon } from '~components/Icon' import { SingleSelect } from '~components/SingleSelect' import { Text } from '~components/Text' -import { Collapsible } from '../index' +import { Collapsible, type ButtonRef } from '../index' const meta = { title: 'Components/Collapsibles/Collapsible', @@ -102,6 +102,60 @@ export const Controlled: Story = { parameters: { docs: { source: { code: controlledSourceCode } } }, } +export const WithProgrammaticFocus: Story = { + args: { + title: 'Collapsible with Programmatic Focus', + }, + render: (args) => { + const collapsibleRef = useRef(null) + const [isLoading, setIsLoading] = useState(false) + + const handleSaveAndFocus = async (): Promise => { + setIsLoading(true) + // Simulate async operation (e.g., API call) + await new Promise((resolve) => setTimeout(resolve, 1000)) + setIsLoading(false) + // Focus the toggle button after the async operation + collapsibleRef.current?.focus() + } + + return ( +
+
+ + + {isLoading + ? 'Saving... The button will be focused after the operation completes.' + : 'Click to simulate an async operation, then programmatically focus the collapsible toggle.'} + +
+ + + After the async operation (like a save in a sortable list), the focus is moved to the + toggle button. This allows keyboard users to immediately interact with the collapsible + without needing to manually navigate. + + +
+ ) + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates programmatically focusing the toggle button after an async operation. This is useful for sortable lists where items are saved and you want to restore focus to the toggle button.', + }, + }, + }, +} + export const WithSingleSelect: Story = { args: { title: 'Single Collapsible', diff --git a/packages/components/src/Collapsible/Collapsible/index.ts b/packages/components/src/Collapsible/Collapsible/index.ts index c9bf93fcc73..0eddd8da5e5 100644 --- a/packages/components/src/Collapsible/Collapsible/index.ts +++ b/packages/components/src/Collapsible/Collapsible/index.ts @@ -1 +1,2 @@ export * from './Collapsible' +export { type ButtonRef } from '~components/ButtonV1' diff --git a/packages/components/src/Popover/_docs/Popover.stories.tsx b/packages/components/src/Popover/_docs/Popover.stories.tsx index a74bc0b8308..64fd6ba53c2 100644 --- a/packages/components/src/Popover/_docs/Popover.stories.tsx +++ b/packages/components/src/Popover/_docs/Popover.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { type Meta, type StoryObj } from '@storybook/react' import { Popover as PopoverComponent, usePopover } from '../index' @@ -24,7 +24,7 @@ const PopoverTemplate: Story = { - + Popover body that explains something useful. Optional link
@@ -42,3 +42,70 @@ export const Playground: Story = { ), ], } + +export const OpenAndClose: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false) + const [referenceElementRef, Popover] = usePopover() + + return ( +
+ + {isOpen && ( + setIsOpen(false)}> + Popover body that explains something useful. Optional link + + )} +
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const OpenOnHover: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false) + const [referenceElementRef, Popover] = usePopover() + + return ( +
+ + {isOpen && ( + + Popover body that explains something useful. Optional link + + )} +
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} From a3e0e95a5d99546070280e993a30277597c1b504 Mon Sep 17 00:00:00 2001 From: AliceMenzie Date: Tue, 23 Jun 2026 15:14:03 +1000 Subject: [PATCH 2/3] chore: changeset --- .changeset/five-friends-post.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/five-friends-post.md diff --git a/.changeset/five-friends-post.md b/.changeset/five-friends-post.md new file mode 100644 index 00000000000..17f314c642e --- /dev/null +++ b/.changeset/five-friends-post.md @@ -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. From 29828057806fbb95075e4b79c40b5b9c12fbe95f Mon Sep 17 00:00:00 2001 From: AliceMenzie Date: Tue, 23 Jun 2026 17:15:03 +1000 Subject: [PATCH 3/3] refactor(Collapsible): replace ButtonRef with CollapsibleRef Define CollapsibleRef = { focus: () => void } in Collapsible itself so the public API has no dependency on ButtonV1 internals. The useImperativeHandle factory now constructs the object directly rather than forwarding the internal ButtonRef. Co-Authored-By: Claude Sonnet 4.6 --- .../Collapsible/Collapsible.spec.tsx | 17 ++++++++--------- .../src/Collapsible/Collapsible/Collapsible.tsx | 13 +++++++------ .../Collapsible/_docs/Collapsible.mdx | 6 +++--- .../Collapsible/_docs/Collapsible.stories.tsx | 4 ++-- .../src/Collapsible/Collapsible/index.ts | 1 - 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx b/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx index 5f175f01896..d882bb6cc5a 100644 --- a/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx +++ b/packages/components/src/Collapsible/Collapsible/Collapsible.spec.tsx @@ -2,8 +2,7 @@ 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 { type ButtonRef } from '~components/ButtonV1' -import { Collapsible } from './Collapsible' +import { Collapsible, type CollapsibleRef } from './Collapsible' const user = userEvent.setup() describe('', () => { @@ -141,7 +140,7 @@ describe('', () => { describe('Ref support', () => { it('exposes a ref to the toggle button with focus method', () => { - const ref = React.createRef() + const ref = React.createRef() render( First panel content @@ -154,7 +153,7 @@ describe('', () => { }) it('allows focusing the toggle button via ref', () => { - const ref = React.createRef() + const ref = React.createRef() const { getByTestId } = render( First panel content @@ -170,7 +169,7 @@ describe('', () => { }) it('ref works in controlled mode', () => { - const ref = React.createRef() + const ref = React.createRef() const { rerender, getByTestId } = render( First panel content @@ -196,7 +195,7 @@ describe('', () => { }) it('ref works in uncontrolled mode', () => { - const ref = React.createRef() + const ref = React.createRef() const { getByTestId } = render( First panel content @@ -212,7 +211,7 @@ describe('', () => { }) it('ref is accessible when no id prop is provided (uses fallback)', () => { - const ref = React.createRef() + const ref = React.createRef() const { container } = render( First panel content @@ -223,7 +222,7 @@ describe('', () => { expect(ref.current?.focus).toBeDefined() // Find the button element (querySelector with * selector would also match the title div) - const button = container.querySelector('button[data-testid*="collapsible-button-"]')! + const button = container.querySelector('button[data-testid*="collapsible-button-"]')! expect(button).toBeTruthy() const focusSpy = vi.spyOn(button, 'focus') @@ -234,7 +233,7 @@ describe('', () => { it('supports useRef hook pattern for ref management', async () => { const TestComponent = (): JSX.Element => { - const collapsibleRef = useRef(null) + const collapsibleRef = useRef(null) return ( <> diff --git a/packages/components/src/Collapsible/Collapsible/Collapsible.tsx b/packages/components/src/Collapsible/Collapsible/Collapsible.tsx index 644ed955bbb..bcdb809788f 100644 --- a/packages/components/src/Collapsible/Collapsible/Collapsible.tsx +++ b/packages/components/src/Collapsible/Collapsible/Collapsible.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useId, useRef, useState, type HTMLAttributes, type Ref } from 'react' import classnames from 'classnames' import AnimateHeight from 'react-animate-height' -import { IconButton, type ButtonRef } from '~components/ButtonV1' +import { IconButton } from '~components/ButtonV1' import { Heading } from '~components/Heading' import { Icon } from '~components/Icon' import { type OverrideClassName } from '~components/types/OverrideClassName' @@ -36,7 +36,9 @@ export type CollapsibleProps = { controlled?: boolean } & OverrideClassName> -export const Collapsible = forwardRef( +export type CollapsibleRef = { focus: () => void } + +export const Collapsible = forwardRef( ( { children, @@ -55,17 +57,16 @@ export const Collapsible = forwardRef( id: propsId, ...restProps }: CollapsibleProps, - ref: Ref, + ref: Ref, ) => { const [stateIsOpen, setIsOpen] = useState(open ?? false) const getOpen = (): boolean | undefined => (controlled ? open : stateIsOpen) const fallbackId = useId() const id = propsId ?? fallbackId - const buttonRef = useRef(null) + const buttonRef = useRef(null) - // Forward the ref passed to Collapsible to the internal button ref - React.useImperativeHandle(ref, () => buttonRef.current ?? undefined, []) + React.useImperativeHandle(ref, () => ({ focus: () => buttonRef.current?.focus() }), []) const handleSectionToggle = (): void => { const newIsOpen = !getOpen() diff --git a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx index efa45b993ee..94c68a6369a 100644 --- a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx +++ b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.mdx @@ -41,7 +41,7 @@ and defining the `open` and `onToggle` props. Use a `ref` to programmatically focus the toggle button. This is useful when you need to restore focus after an async operation, such as in sortable lists where items are saved or deleted. -The `ref` exposes a `ButtonRef` interface with a `.focus()` method that focuses the underlying toggle button element. +The `ref` exposes a `CollapsibleRef` interface with a `.focus()` method that focuses the underlying toggle button element. @@ -49,10 +49,10 @@ The `ref` exposes a `ButtonRef` interface with a `.focus()` method that focuses ```tsx import { useRef } from 'react' -import { Collapsible, type ButtonRef } from '@kaizen/components' +import { Collapsible, type CollapsibleRef } from '@kaizen/components' export const MyComponent = () => { - const collapsibleRef = useRef(null) + const collapsibleRef = useRef(null) const handleSaveAndFocus = async () => { // Perform async operation (e.g., API call) diff --git a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx index d7965224a0b..03b058ecf3a 100644 --- a/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx +++ b/packages/components/src/Collapsible/Collapsible/_docs/Collapsible.stories.tsx @@ -4,7 +4,7 @@ import { Heading } from '~components/Heading' import { Icon } from '~components/Icon' import { SingleSelect } from '~components/SingleSelect' import { Text } from '~components/Text' -import { Collapsible, type ButtonRef } from '../index' +import { Collapsible, type CollapsibleRef } from '../index' const meta = { title: 'Components/Collapsibles/Collapsible', @@ -107,7 +107,7 @@ export const WithProgrammaticFocus: Story = { title: 'Collapsible with Programmatic Focus', }, render: (args) => { - const collapsibleRef = useRef(null) + const collapsibleRef = useRef(null) const [isLoading, setIsLoading] = useState(false) const handleSaveAndFocus = async (): Promise => { diff --git a/packages/components/src/Collapsible/Collapsible/index.ts b/packages/components/src/Collapsible/Collapsible/index.ts index 0eddd8da5e5..c9bf93fcc73 100644 --- a/packages/components/src/Collapsible/Collapsible/index.ts +++ b/packages/components/src/Collapsible/Collapsible/index.ts @@ -1,2 +1 @@ export * from './Collapsible' -export { type ButtonRef } from '~components/ButtonV1'