From 829dcdf030869c4d3185561bc22a82b7cd6fce35 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:03:17 -0700 Subject: [PATCH 1/6] initial form search wd --- .../FormSearch/form-search-utils.ts | 26 ++ .../FormSearch/form-search.stories.tsx | 95 +++++++ .../FormSearch/form-search.test.tsx | 79 ++++++ src/components/FormSearch/form-search.tsx | 236 ++++++++++++++++++ .../TextInput/text-input.stories.tsx | 57 +---- src/index.ts | 7 + src/types/cfpb-design-system.d.ts | 36 +++ 7 files changed, 480 insertions(+), 56 deletions(-) create mode 100644 src/components/FormSearch/form-search-utils.ts create mode 100644 src/components/FormSearch/form-search.stories.tsx create mode 100644 src/components/FormSearch/form-search.test.tsx create mode 100644 src/components/FormSearch/form-search.tsx diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts new file mode 100644 index 0000000000..77598f76a9 --- /dev/null +++ b/src/components/FormSearch/form-search-utils.ts @@ -0,0 +1,26 @@ +/** Locate the native search field inside ``. */ +export const getFormSearchNativeInput = ( + element: HTMLElement | null, +): HTMLInputElement | null => { + if (!element || element.tagName !== 'CFPB-FORM-SEARCH') { + return null; + } + + const searchInput = element.shadowRoot?.querySelector( + 'cfpb-form-search-input', + ); + + return ( + searchInput?.shadowRoot?.querySelector( + 'input[type="search"]', + ) ?? null + ); +}; + +/** Locate the submit control inside ``. */ +export const getFormSearchSubmitButton = ( + element: HTMLElement | null, +): HTMLButtonElement | null => + element?.shadowRoot?.querySelector( + 'button[type="submit"]', + ) ?? null; diff --git a/src/components/FormSearch/form-search.stories.tsx b/src/components/FormSearch/form-search.stories.tsx new file mode 100644 index 0000000000..db8f300f21 --- /dev/null +++ b/src/components/FormSearch/form-search.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import { FormSearch } from './form-search'; + +const SAMPLE_SUGGESTIONS = [ + { label: 'How do I add money to my prepaid card?' }, + { label: 'What are credit card “add-on products?”' }, + { label: 'How do I qualify for an advertised 0% auto financing?' }, + { label: 'Can I make additional payments on my student loan?' }, + { label: 'How do I tell if I have a fixed or adjustable rate mortgage?' }, +]; + +const meta: Meta = { + title: 'Components (Draft)/Form search', + tags: ['autodocs', 'web-component'], + component: FormSearch, + parameters: { + docs: { + description: { + component: ` +Wraps the DS \`\` web component: search field, clear control, +optional typeahead suggestions, and submit button. + +Source: [Reference for custom elements — Search widget](https://cfpb.github.io/design-system/components/reference-for-custom-elements) + `, + }, + }, + }, + argTypes: { + showSubmitButton: { control: 'boolean' }, + disabled: { control: 'boolean' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'q', + placeholder: 'Enter your search term(s)', + submitLabel: 'Search', + }, +}; + +export const WithTypeahead: Story = { + name: 'With typeahead', + args: { + ...Default.args, + suggestions: SAMPLE_SUGGESTIONS, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('searchbox'); + + await userEvent.click(input); + await userEvent.type(input, 'pre'); + + await expect(canvas.getByText(SAMPLE_SUGGESTIONS[0].label)).toBeVisible(); + }, +}; + +export const CustomSubmitButton: Story = { + name: 'Custom submit button', + args: { + ...Default.args, + submitLabel: 'Find answers', + submitAriaLabel: 'Find answers to your question', + }, +}; + +export const WithoutSubmitButton: Story = { + name: 'Without submit button', + args: { + ...Default.args, + showSubmitButton: false, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const ValidationError: Story = { + name: 'Validation error', + args: { + ...Default.args, + validation: 'error', + value: 'a'.repeat(80), + }, +}; diff --git a/src/components/FormSearch/form-search.test.tsx b/src/components/FormSearch/form-search.test.tsx new file mode 100644 index 0000000000..6934983967 --- /dev/null +++ b/src/components/FormSearch/form-search.test.tsx @@ -0,0 +1,79 @@ +import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fn } from 'storybook/test'; +import { FormSearch } from './form-search'; +import { getFormSearchSubmitButton } from './form-search-utils'; + +const waitForFormSearchUpgrade = async ( + id?: string, +): Promise => { + await customElements.whenDefined('cfpb-form-search'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + return id + ? document.querySelector(`#${id}`) + : document.querySelector('cfpb-form-search'); +}; + +describe('', () => { + beforeEach(() => { + CfpbFormSearch.init(); + }); + + it('renders the cfpb-form-search web component', async () => { + render(); + + const element = await waitForFormSearchUpgrade('search-test'); + expect(element).toBeInTheDocument(); + }); + + it('renders typeahead suggestions in a hidden list', () => { + render( + , + ); + + expect( + screen.getByText('First suggestion', { hidden: true }), + ).toBeInTheDocument(); + expect(screen.getByText('Linked', { hidden: true })).toHaveAttribute( + 'href', + '/linked', + ); + }); + + it('calls onSubmit with the current value', async () => { + const onSubmit = fn(); + + render( + , + ); + + const element = await waitForFormSearchUpgrade('search-submit'); + expect(element).not.toBeNull(); + + fireEvent.submit(document.getElementById('search-submit-form')!); + + expect(onSubmit).toHaveBeenCalledWith('mortgage'); + }); + + it('can hide the submit button', async () => { + render(); + + const element = await waitForFormSearchUpgrade('search-no-btn'); + + await waitFor(() => { + expect(getFormSearchSubmitButton(element)?.hidden).toBe(true); + }); + }); +}); diff --git a/src/components/FormSearch/form-search.tsx b/src/components/FormSearch/form-search.tsx new file mode 100644 index 0000000000..9d9a0231a3 --- /dev/null +++ b/src/components/FormSearch/form-search.tsx @@ -0,0 +1,236 @@ +import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; +import { + forwardRef, + useEffect, + useId, + useImperativeHandle, + useRef, + type FormEvent, + type ReactElement, + type Ref, +} from 'react'; +import { noOp } from '../../utils/no-op'; +import { + getFormSearchNativeInput, + getFormSearchSubmitButton, +} from './form-search-utils'; + +CfpbFormSearch.init(); + +export type FormSearchValidation = 'error' | 'success' | 'warning'; + +export interface FormSearchSuggestion { + /** Display text used for typeahead matching. */ + label: string; + href?: string; + disabled?: boolean; +} + +export interface FormSearchProperties { + /** Accessible label for the search icon control. */ + label?: string; + name?: string; + /** Controlled search string (synced to the web component `value` property). */ + value?: string; + /** Initial value when uncontrolled. */ + defaultValue?: string; + placeholder?: string; + maxlength?: number; + disabled?: boolean; + validation?: FormSearchValidation; + /** `aria-label` on the search input. */ + ariaLabelInput?: string; + /** `aria-label` on the clear (reset) control. */ + ariaLabelButton?: string; + /** Submit button visible label (DS web component defaults to “Search”). */ + submitLabel?: string; + /** Submit button `aria-label` (DS default: “Search for term(s)”). */ + submitAriaLabel?: string; + /** When false, hides the DS submit button (e.g. submit via parent form only). */ + showSubmitButton?: boolean; + /** Typeahead suggestions; rendered as a hidden list for the web component slot. */ + suggestions?: FormSearchSuggestion[]; + className?: string; + id?: string; + action?: string; + method?: 'get' | 'post'; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; + onClear?: () => void; +} + +export interface FormSearchHandle { + /** The underlying `` element. */ + element: HTMLElement | null; + focus: () => void; +} + +const toDomBoolean = (flag?: boolean): boolean | undefined => + flag ? true : undefined; + +/** + * Search widget wrapping the DS `` web component (input, optional + * typeahead, and submit). Place inside or alongside a `
`; the component renders + * its own form wrapper so Enter and the submit button trigger `onSubmit`. + * + * @see https://cfpb.github.io/design-system/components/reference-for-custom-elements + */ +export const FormSearch = forwardRef(function FormSearch( + { + label, + name, + value, + defaultValue, + placeholder = 'Enter your search term(s)', + maxlength = 75, + disabled = false, + validation, + ariaLabelInput = 'Search input', + ariaLabelButton = 'Clear search', + submitLabel = 'Search', + submitAriaLabel = 'Search for term(s)', + showSubmitButton = true, + suggestions, + className, + id, + action, + method = 'get', + onChange = noOp, + onSubmit = noOp, + onClear = noOp, + }: FormSearchProperties, + reference: Ref, +): ReactElement { + const generatedId = useId(); + const elementId = id ?? generatedId.replace(/:/g, ''); + const elementReference = useRef(null); + const isControlled = value !== undefined; + + useImperativeHandle(reference, () => ({ + element: elementReference.current, + focus: () => { + const input = getFormSearchNativeInput(elementReference.current); + input?.focus(); + }, + })); + + useEffect(() => { + const element = elementReference.current; + if (!element) return; + + if (isControlled) { + (element as HTMLElement & { value: string }).value = value; + } else if (defaultValue !== undefined) { + (element as HTMLElement & { value: string }).value = defaultValue; + } + }, [defaultValue, isControlled, value]); + + useEffect(() => { + const element = elementReference.current; + if (!element) return; + + const input = getFormSearchNativeInput(element); + if (!input) return; + + const handleInput = (): void => { + const currentValue = + (element as HTMLElement & { value: string }).value ?? input.value; + onChange(currentValue); + }; + + input.addEventListener('input', handleInput); + return () => input.removeEventListener('input', handleInput); + }, [onChange]); + + useEffect(() => { + const element = elementReference.current; + if (!element) return; + + const handleClear = (): void => { + onClear(); + onChange(''); + }; + + const searchInput = element.shadowRoot?.querySelector( + 'cfpb-form-search-input', + ); + searchInput?.addEventListener('clear', handleClear); + return () => searchInput?.removeEventListener('clear', handleClear); + }, [onChange, onClear]); + + useEffect(() => { + const element = elementReference.current; + if (!element) return; + + let cancelled = false; + + const syncSubmitButton = (): void => { + const button = getFormSearchSubmitButton(element); + if (!button) return; + + button.textContent = submitLabel; + button.setAttribute('aria-label', submitAriaLabel); + button.hidden = !showSubmitButton; + }; + + void customElements.whenDefined('cfpb-form-search').then(() => { + if (cancelled) return; + requestAnimationFrame(syncSubmitButton); + }); + + return () => { + cancelled = true; + }; + }, [showSubmitButton, submitAriaLabel, submitLabel]); + + const handleFormSubmit = (event: FormEvent): void => { + event.preventDefault(); + const element = elementReference.current as HTMLElement & { value: string }; + const currentValue = element?.value ?? ''; + onSubmit(currentValue); + }; + + const suggestionList = + suggestions && suggestions.length > 0 ? ( + + ) : null; + + return ( + + + {suggestionList} + + + ); +}); + +FormSearch.displayName = 'FormSearch'; + +export default FormSearch; diff --git a/src/components/TextInput/text-input.stories.tsx b/src/components/TextInput/text-input.stories.tsx index 0b5b565376..221a0e3e87 100644 --- a/src/components/TextInput/text-input.stories.tsx +++ b/src/components/TextInput/text-input.stories.tsx @@ -1,8 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState } from 'react'; import { expect, userEvent, within } from 'storybook/test'; -import { Button, Icon, TextInput } from '~/src/index'; -import type { TextInputProperties } from './text-input'; +import { TextInput } from '~/src/index'; const meta: Meta = { title: 'Components (Verified)/Text inputs/Text input', @@ -104,56 +102,3 @@ export const FullWidth: Story = { isFullWidth: true, }, }; - -export const SearchInput: Story = { - name: 'Search input', - args: { - ...Enabled.args, - value: '', - placeholder: 'Enter your search term(s)', - name: 'SearchInput', - id: 'SearchInput', - type: 'search', - isFullWidth: false, - className: 'a-text-input__full', - }, - render: (args) => { - const { value: initialValue, ...restArgs } = args as TextInputProperties & { - value: string; - }; - const [value, setValue] = useState(initialValue ?? ''); - return ( -
{ - e.preventDefault(); - }} - onReset={() => setValue('')} - > -
-
- - setValue(e.target.value)} - /> - -
-
-
- ); - }, -}; diff --git a/src/index.ts b/src/index.ts index 9a7d7dec82..42de103cb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,13 @@ export { Divider } from './components/Divider/divider'; export { Expandable } from './components/Expandable/expandable'; export { ExpandableGroup } from './components/Expandable/expandable-group'; export { Fieldset } from './components/Fieldset/fieldset'; +export { + FormSearch, + type FormSearchHandle, + type FormSearchProperties, + type FormSearchSuggestion, + type FormSearchValidation, +} from './components/FormSearch/form-search'; export { Footer, WebsiteFooter, diff --git a/src/types/cfpb-design-system.d.ts b/src/types/cfpb-design-system.d.ts index d36ea7233f..17d9e9494e 100644 --- a/src/types/cfpb-design-system.d.ts +++ b/src/types/cfpb-design-system.d.ts @@ -1,6 +1,42 @@ +import type { HTMLAttributes } from 'react'; + declare module '@cfpb/cfpb-design-system' { export class CfpbTagline extends HTMLElement { static init(): void; isLarge: boolean; } + + export class CfpbFormSearch extends HTMLElement { + static init(): void; + disabled: boolean; + validation: string; + label: string; + name: string; + value: string; + placeholder: string; + maxlength: number; + ariaLabelInput: string; + ariaLabelButton: string; + } +} + +declare global { + namespace JSX { + interface IntrinsicElements { + 'cfpb-form-search': HTMLAttributes & { + disabled?: boolean; + validation?: string; + label?: string; + name?: string; + value?: string; + placeholder?: string; + maxlength?: number; + 'aria-label-input'?: string; + 'aria-label-button'?: string; + }; + 'cfpb-tagline': HTMLAttributes & { + isLarge?: boolean; + }; + } + } } From 8353a5903707514316c275e1aeb7bf7b6bc7b773 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:43:39 -0700 Subject: [PATCH 2/6] make input work --- .storybook/main.js | 8 +- .storybook/preview.js | 9 + .../FormSearch/form-search-utils.ts | 160 ++++++++++++ .../FormSearch/form-search.stories.tsx | 62 ++++- .../FormSearch/form-search.test.tsx | 92 ++++++- src/components/FormSearch/form-search.tsx | 245 +++++++++++------- src/types/cfpb-design-system.d.ts | 2 + 7 files changed, 471 insertions(+), 107 deletions(-) diff --git a/.storybook/main.js b/.storybook/main.js index 4a60775617..2ecf2dcf4e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -2,7 +2,13 @@ import turbosnap from 'vite-plugin-turbosnap'; export default { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], - staticDirs: ['../src/assets/'], + staticDirs: [ + '../src/assets/', + { + from: '../node_modules/@cfpb/cfpb-design-system/src/components/cfpb-icons/icons', + to: '/icons', + }, + ], addons: [ '@storybook/addon-links', diff --git a/.storybook/preview.js b/.storybook/preview.js index a709f1a0da..0c8f7c4a42 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,3 +1,4 @@ +import { setSharedConfig } from '@cfpb/cfpb-design-system'; import React from 'react'; import { buildArgsParam } from 'storybook/internal/router'; import { useArgs, useGlobals } from 'storybook/preview-api'; @@ -5,6 +6,14 @@ import '@fontsource-variable/source-sans-3'; import '../src/assets/styles/_shared.scss'; import themeCFPB from './themeCFPB'; +const storybookBase = import.meta.env?.BASE_URL ?? '/'; +const iconBase = storybookBase.endsWith('/') ? storybookBase : `${storybookBase}/`; + +// Required for inside form-search and other DS web components (see DS docs site main.js). +setSharedConfig({ + iconPath: `${iconBase}icons/`, +}); + const responsivePreviewQueryParameter = 'responsivePreview'; /** Query key read by `.storybook/preview-head.html` inside nested “All viewports” iframes. */ diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts index 77598f76a9..fca7f08572 100644 --- a/src/components/FormSearch/form-search-utils.ts +++ b/src/components/FormSearch/form-search-utils.ts @@ -1,3 +1,163 @@ +export type FormSearchElement = HTMLElement & { + value: string; + name?: string; + label?: string; + placeholder?: string; + maxlength?: number; + disabled?: boolean; + validation?: string; + ariaLabelInput?: string; + ariaLabelButton?: string; +}; + +export interface FormSearchElementProps { + name?: string; + label?: string; + placeholder?: string; + maxlength?: number; + disabled?: boolean; + validation?: string; + ariaLabelInput?: string; + ariaLabelButton?: string; +} + +/** Read the current query string from the web component (host property or native input). */ +export const getFormSearchValue = (element: HTMLElement | null): string => { + if (!element) { + return ''; + } + + const host = element as FormSearchElement; + + if (host.value) { + return host.value; + } + + return getFormSearchNativeInput(element)?.value ?? ''; +}; + +/** Apply React props to the custom element as properties (avoids attribute churn on re-render). */ +export const applyFormSearchElementProps = ( + element: FormSearchElement, + { + name, + label, + placeholder, + maxlength, + disabled, + validation, + ariaLabelInput, + ariaLabelButton, + }: FormSearchElementProps, +): void => { + if (name !== undefined) { + element.name = name; + } + + if (label !== undefined) { + element.label = label; + } + + if (placeholder !== undefined) { + element.placeholder = placeholder; + } + + if (maxlength !== undefined) { + element.maxlength = maxlength; + } + + element.disabled = Boolean(disabled); + + if (ariaLabelInput !== undefined) { + element.ariaLabelInput = ariaLabelInput; + } + + if (ariaLabelButton !== undefined) { + element.ariaLabelButton = ariaLabelButton; + } + + if (validation) { + element.validation = validation; + } else { + element.removeAttribute('validation'); + } +}; + +/** Sync submit button label, aria-label, and visibility inside the shadow tree. */ +export const syncFormSearchSubmitButton = ( + element: HTMLElement | null, + { + submitLabel, + submitAriaLabel, + showSubmitButton, + }: { + submitLabel: string; + submitAriaLabel: string; + showSubmitButton: boolean; + }, +): void => { + const button = getFormSearchSubmitButton(element); + if (!button) { + return; + } + + button.textContent = submitLabel; + button.setAttribute('aria-label', submitAriaLabel); + button.hidden = !showSubmitButton; +}; + +/** Run `callback` once the search widget shadow tree exposes the native input. */ +export const whenFormSearchReady = ( + element: HTMLElement | null, + callback: (element: HTMLElement) => void | (() => void), +): (() => void) => { + if (!element) { + return () => undefined; + } + + let dispose: void | (() => void); + let cancelled = false; + + const run = (): boolean => { + if (cancelled) { + return false; + } + + dispose?.(); + dispose = undefined; + + const cleanup = callback(element); + if (!cleanup) { + return false; + } + + dispose = cleanup; + return true; + }; + + if (!run()) { + void Promise.all([ + customElements.whenDefined('cfpb-form-search'), + customElements.whenDefined('cfpb-form-search-input'), + ]).then(() => { + if (cancelled) return; + + requestAnimationFrame(() => { + if (run() || cancelled) return; + + requestAnimationFrame(() => { + run(); + }); + }); + }); + } + + return () => { + cancelled = true; + dispose?.(); + }; +}; + /** Locate the native search field inside ``. */ export const getFormSearchNativeInput = ( element: HTMLElement | null, diff --git a/src/components/FormSearch/form-search.stories.tsx b/src/components/FormSearch/form-search.stories.tsx index db8f300f21..5ccd314c66 100644 --- a/src/components/FormSearch/form-search.stories.tsx +++ b/src/components/FormSearch/form-search.stories.tsx @@ -1,6 +1,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; import { expect, userEvent, within } from 'storybook/test'; -import { FormSearch } from './form-search'; +import { FormSearch, type FormSearchProperties } from './form-search'; + +const logSubmit = (value: string): void => { + // eslint-disable-next-line no-console -- Storybook demo feedback + console.log('FormSearch submit:', value); +}; + +const FormSearchWithSubmitLog = (args: FormSearchProperties) => ( + { + logSubmit(value); + args.onSubmit?.(value); + }} + /> +); const SAMPLE_SUGGESTIONS = [ { label: 'How do I add money to my prepaid card?' }, @@ -18,8 +34,14 @@ const meta: Meta = { docs: { description: { component: ` -Wraps the DS \`\` web component: search field, clear control, -optional typeahead suggestions, and submit button. +Minimal wrapper around the DS \`\` web component: search field, +clear control, optional typeahead (hidden \`
    \` slot), and submit button. +Use \`onChange\` / \`onSubmit\` for app logic; wrap in a parent \`
    \` only when +you need native form submission. + +**App setup:** call \`setSharedConfig({ iconPath: '/your/icons/' })\` from +\`@cfpb/cfpb-design-system\` before rendering (icons load from that URL). Storybook +configures this in \`.storybook/preview.js\`. Source: [Reference for custom elements — Search widget](https://cfpb.github.io/design-system/components/reference-for-custom-elements) `, @@ -29,7 +51,13 @@ Source: [Reference for custom elements — Search widget](https://cfpb.github.io argTypes: { showSubmitButton: { control: 'boolean' }, disabled: { control: 'boolean' }, + // `value` forces controlled mode and blocks typing unless the parent updates it. + value: { control: false, table: { disable: true } }, + defaultValue: { control: 'text' }, + onChange: { action: 'changed' }, + onSubmit: { action: 'submitted' }, }, + render: (args) => , }; export default meta; @@ -44,6 +72,31 @@ export const Default: Story = { }, }; +export const Controlled: Story = { + render: (args) => { + const [value, setValue] = useState(args.defaultValue ?? ''); + + return ( + { + setValue(next); + args.onChange?.(next); + }} + onSubmit={(next) => { + logSubmit(next); + args.onSubmit?.(next); + }} + value={value} + /> + ); + }, + args: { + ...Default.args, + defaultValue: 'mortgage', + }, +}; + export const WithTypeahead: Story = { name: 'With typeahead', args: { @@ -89,7 +142,6 @@ export const ValidationError: Story = { name: 'Validation error', args: { ...Default.args, - validation: 'error', - value: 'a'.repeat(80), + defaultValue: 'a'.repeat(80), }, }; diff --git a/src/components/FormSearch/form-search.test.tsx b/src/components/FormSearch/form-search.test.tsx index 6934983967..4152ee13cc 100644 --- a/src/components/FormSearch/form-search.test.tsx +++ b/src/components/FormSearch/form-search.test.tsx @@ -1,15 +1,31 @@ import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; import { fn } from 'storybook/test'; import { FormSearch } from './form-search'; -import { getFormSearchSubmitButton } from './form-search-utils'; +import { + getFormSearchNativeInput, + getFormSearchSubmitButton, +} from './form-search-utils'; -const waitForFormSearchUpgrade = async ( +const waitForFormSearchReady = async ( id?: string, ): Promise => { await customElements.whenDefined('cfpb-form-search'); - await new Promise((resolve) => requestAnimationFrame(resolve)); + await customElements.whenDefined('cfpb-form-search-input'); + + await waitFor(() => { + const element = id + ? document.querySelector(`#${id}`) + : document.querySelector('cfpb-form-search'); + + expect(getFormSearchNativeInput(element)).not.toBeNull(); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); return id ? document.querySelector(`#${id}`) @@ -24,7 +40,7 @@ describe('', () => { it('renders the cfpb-form-search web component', async () => { render(); - const element = await waitForFormSearchUpgrade('search-test'); + const element = await waitForFormSearchReady('search-test'); expect(element).toBeInTheDocument(); }); @@ -47,6 +63,55 @@ describe('', () => { ); }); + it('allows typing when uncontrolled', async () => { + const onChange = fn(); + + render(); + + const element = await waitForFormSearchReady('search-type'); + const input = getFormSearchNativeInput(element)!; + + fireEvent.input(input, { target: { value: 'hello' } }); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith('hello'); + }); + expect((element as HTMLElement & { value: string }).value).toBe('hello'); + }); + + it('allows typing when controlled with onChange', async () => { + const onChange = fn(); + + const Controlled = () => { + const [currentValue, setCurrentValue] = useState(''); + + return ( + { + setCurrentValue(next); + onChange(next); + }} + value={currentValue} + /> + ); + }; + + render(); + + const element = await waitForFormSearchReady('search-controlled'); + const input = getFormSearchNativeInput(element)!; + + fireEvent.input(input, { target: { value: 'hi' } }); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith('hi'); + }); + await waitFor(() => { + expect(element?.value).toBe('hi'); + }); + }); + it('calls onSubmit with the current value', async () => { const onSubmit = fn(); @@ -59,18 +124,27 @@ describe('', () => { />, ); - const element = await waitForFormSearchUpgrade('search-submit'); - expect(element).not.toBeNull(); + const element = await waitForFormSearchReady('search-submit'); - fireEvent.submit(document.getElementById('search-submit-form')!); + const searchInputHost = element?.shadowRoot?.querySelector( + 'cfpb-form-search-input', + ); + expect(searchInputHost).not.toBeNull(); - expect(onSubmit).toHaveBeenCalledWith('mortgage'); + fireEvent( + searchInputHost!, + new CustomEvent('enter-down', { bubbles: true, composed: true }), + ); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith('mortgage'); + }); }); it('can hide the submit button', async () => { render(); - const element = await waitForFormSearchUpgrade('search-no-btn'); + const element = await waitForFormSearchReady('search-no-btn'); await waitFor(() => { expect(getFormSearchSubmitButton(element)?.hidden).toBe(true); diff --git a/src/components/FormSearch/form-search.tsx b/src/components/FormSearch/form-search.tsx index 9d9a0231a3..d36e22edb8 100644 --- a/src/components/FormSearch/form-search.tsx +++ b/src/components/FormSearch/form-search.tsx @@ -4,18 +4,31 @@ import { useEffect, useId, useImperativeHandle, + useLayoutEffect, useRef, - type FormEvent, type ReactElement, type Ref, } from 'react'; import { noOp } from '../../utils/no-op'; import { + applyFormSearchElementProps, getFormSearchNativeInput, - getFormSearchSubmitButton, + getFormSearchValue, + syncFormSearchSubmitButton, + whenFormSearchReady, + type FormSearchElement, } from './form-search-utils'; -CfpbFormSearch.init(); +let formSearchInitialized = false; + +const ensureFormSearchInitialized = (): void => { + if (formSearchInitialized) { + return; + } + + CfpbFormSearch.init(); + formSearchInitialized = true; +}; export type FormSearchValidation = 'error' | 'success' | 'warning'; @@ -46,14 +59,12 @@ export interface FormSearchProperties { submitLabel?: string; /** Submit button `aria-label` (DS default: “Search for term(s)”). */ submitAriaLabel?: string; - /** When false, hides the DS submit button (e.g. submit via parent form only). */ + /** When false, hides the DS submit button. */ showSubmitButton?: boolean; - /** Typeahead suggestions; rendered as a hidden list for the web component slot. */ + /** Typeahead suggestions; rendered as a hidden list in the web component slot. */ suggestions?: FormSearchSuggestion[]; className?: string; id?: string; - action?: string; - method?: 'get' | 'post'; onChange?: (value: string) => void; onSubmit?: (value: string) => void; onClear?: () => void; @@ -65,13 +76,10 @@ export interface FormSearchHandle { focus: () => void; } -const toDomBoolean = (flag?: boolean): boolean | undefined => - flag ? true : undefined; - /** - * Search widget wrapping the DS `` web component (input, optional - * typeahead, and submit). Place inside or alongside a ``; the component renders - * its own form wrapper so Enter and the submit button trigger `onSubmit`. + * Thin React wrapper around the DS `` web component. + * Place inside a parent `` when you need native form submission; otherwise + * use `onSubmit` for app logic (e.g. Redux). * * @see https://cfpb.github.io/design-system/components/reference-for-custom-elements */ @@ -93,102 +101,170 @@ export const FormSearch = forwardRef(function FormSearch( suggestions, className, id, - action, - method = 'get', onChange = noOp, onSubmit = noOp, onClear = noOp, }: FormSearchProperties, reference: Ref, ): ReactElement { + ensureFormSearchInitialized(); + const generatedId = useId(); const elementId = id ?? generatedId.replace(/:/g, ''); - const elementReference = useRef(null); + const elementReference = useRef(null); + const hasAppliedDefaultValue = useRef(false); + const hasConnectedReference = useRef(false); const isControlled = value !== undefined; + const handlersReference = useRef({ onChange, onSubmit, onClear }); + handlersReference.current = { onChange, onSubmit, onClear }; + + const isControlledReference = useRef(isControlled); + isControlledReference.current = isControlled; + + const valueReference = useRef(value); + valueReference.current = value; + + const defaultValueReference = useRef(defaultValue); + defaultValueReference.current = defaultValue; + useImperativeHandle(reference, () => ({ element: elementReference.current, focus: () => { - const input = getFormSearchNativeInput(elementReference.current); - input?.focus(); + getFormSearchNativeInput(elementReference.current)?.focus(); }, })); useEffect(() => { const element = elementReference.current; - if (!element) return; - - if (isControlled) { - (element as HTMLElement & { value: string }).value = value; - } else if (defaultValue !== undefined) { - (element as HTMLElement & { value: string }).value = defaultValue; + if (!element) { + return; } - }, [defaultValue, isControlled, value]); + + applyFormSearchElementProps(element, { + ariaLabelButton, + ariaLabelInput, + disabled, + label, + maxlength, + name, + placeholder, + validation, + }); + }, [ + ariaLabelButton, + ariaLabelInput, + disabled, + label, + maxlength, + name, + placeholder, + validation, + ]); useEffect(() => { + if (!isControlled) { + return; + } + const element = elementReference.current; - if (!element) return; + if (!element) { + return; + } - const input = getFormSearchNativeInput(element); - if (!input) return; + const nextValue = value ?? ''; + if (element.value !== nextValue) { + element.value = nextValue; + } + }, [isControlled, value]); - const handleInput = (): void => { - const currentValue = - (element as HTMLElement & { value: string }).value ?? input.value; - onChange(currentValue); - }; + useLayoutEffect(() => { + const element = elementReference.current; + if (!element || hasConnectedReference.current) { + return undefined; + } - input.addEventListener('input', handleInput); - return () => input.removeEventListener('input', handleInput); - }, [onChange]); + return whenFormSearchReady(element, () => { + const input = getFormSearchNativeInput(element); + if (!input) { + return undefined; + } - useEffect(() => { - const element = elementReference.current; - if (!element) return; + hasConnectedReference.current = true; - const handleClear = (): void => { - onClear(); - onChange(''); - }; + if (isControlledReference.current) { + element.value = valueReference.current ?? ''; + } else if ( + defaultValueReference.current !== undefined && + !hasAppliedDefaultValue.current + ) { + element.value = defaultValueReference.current; + hasAppliedDefaultValue.current = true; + } - const searchInput = element.shadowRoot?.querySelector( - 'cfpb-form-search-input', - ); - searchInput?.addEventListener('clear', handleClear); - return () => searchInput?.removeEventListener('clear', handleClear); - }, [onChange, onClear]); + const submitButton = element.shadowRoot?.querySelector( + 'button[type="submit"]', + ); + const searchInputHost = element.shadowRoot?.querySelector( + 'cfpb-form-search-input', + ); - useEffect(() => { - const element = elementReference.current; - if (!element) return; + const handleInput = (): void => { + handlersReference.current.onChange(getFormSearchValue(element)); + }; - let cancelled = false; + const handleSubmit = (event: Event): void => { + event.preventDefault(); + event.stopImmediatePropagation(); + handlersReference.current.onSubmit(getFormSearchValue(element)); + }; - const syncSubmitButton = (): void => { - const button = getFormSearchSubmitButton(element); - if (!button) return; + const handleClear = (): void => { + handlersReference.current.onClear(); + if (!isControlledReference.current) { + element.value = ''; + } + handlersReference.current.onChange(''); + }; - button.textContent = submitLabel; - button.setAttribute('aria-label', submitAriaLabel); - button.hidden = !showSubmitButton; - }; + input.addEventListener('input', handleInput); + submitButton?.addEventListener('click', handleSubmit, true); + searchInputHost?.addEventListener('enter-down', handleSubmit); + searchInputHost?.addEventListener('clear', handleClear); - void customElements.whenDefined('cfpb-form-search').then(() => { - if (cancelled) return; - requestAnimationFrame(syncSubmitButton); + return () => { + hasConnectedReference.current = false; + input.removeEventListener('input', handleInput); + submitButton?.removeEventListener('click', handleSubmit, true); + searchInputHost?.removeEventListener('enter-down', handleSubmit); + searchInputHost?.removeEventListener('clear', handleClear); + }; }); + }, []); - return () => { - cancelled = true; + useEffect(() => { + const element = elementReference.current; + if (!element) { + return; + } + + const syncSubmit = (): void => { + syncFormSearchSubmitButton(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); }; - }, [showSubmitButton, submitAriaLabel, submitLabel]); - const handleFormSubmit = (event: FormEvent): void => { - event.preventDefault(); - const element = elementReference.current as HTMLElement & { value: string }; - const currentValue = element?.value ?? ''; - onSubmit(currentValue); - }; + if (hasConnectedReference.current) { + syncSubmit(); + return; + } + + void customElements.whenDefined('cfpb-form-search').then(() => { + requestAnimationFrame(syncSubmit); + }); + }, [showSubmitButton, submitAriaLabel, submitLabel]); const suggestionList = suggestions && suggestions.length > 0 ? ( @@ -206,28 +282,13 @@ export const FormSearch = forwardRef(function FormSearch( ) : null; return ( - - - {suggestionList} - - + {suggestionList} + ); }); diff --git a/src/types/cfpb-design-system.d.ts b/src/types/cfpb-design-system.d.ts index 17d9e9494e..d755c74ca8 100644 --- a/src/types/cfpb-design-system.d.ts +++ b/src/types/cfpb-design-system.d.ts @@ -1,6 +1,8 @@ import type { HTMLAttributes } from 'react'; declare module '@cfpb/cfpb-design-system' { + export function setSharedConfig(config: { iconPath: string }): void; + export class CfpbTagline extends HTMLElement { static init(): void; isLarge: boolean; From 384e5ad5f38103047068d4cf60f0f0b090955c73 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:17:13 -0700 Subject: [PATCH 3/6] save work --- .../FormSearch/form-search-utils.ts | 149 +++++++++++++-- .../FormSearch/form-search.stories.tsx | 1 + .../FormSearch/form-search.test.tsx | 24 +++ src/components/FormSearch/form-search.tsx | 174 ++++++++++++------ 4 files changed, 280 insertions(+), 68 deletions(-) diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts index fca7f08572..10a89404ff 100644 --- a/src/components/FormSearch/form-search-utils.ts +++ b/src/components/FormSearch/form-search-utils.ts @@ -50,39 +50,166 @@ export const applyFormSearchElementProps = ( ariaLabelButton, }: FormSearchElementProps, ): void => { - if (name !== undefined) { + if (name !== undefined && element.name !== name) { element.name = name; } - if (label !== undefined) { + if (label !== undefined && element.label !== label) { element.label = label; } - if (placeholder !== undefined) { + if (placeholder !== undefined && element.placeholder !== placeholder) { element.placeholder = placeholder; } if (maxlength !== undefined) { - element.maxlength = maxlength; + const parsedMaxlength = Number(maxlength); + if ( + !Number.isNaN(parsedMaxlength) && + element.maxlength !== parsedMaxlength + ) { + element.maxlength = parsedMaxlength; + } } - element.disabled = Boolean(disabled); + const isDisabled = Boolean(disabled); + if (element.disabled !== isDisabled) { + element.disabled = isDisabled; + } - if (ariaLabelInput !== undefined) { + if ( + ariaLabelInput !== undefined && + element.ariaLabelInput !== ariaLabelInput + ) { element.ariaLabelInput = ariaLabelInput; } - if (ariaLabelButton !== undefined) { + if ( + ariaLabelButton !== undefined && + element.ariaLabelButton !== ariaLabelButton + ) { element.ariaLabelButton = ariaLabelButton; } - if (validation) { - element.validation = validation; - } else { - element.removeAttribute('validation'); + // Only sync when the React prop is set; otherwise leave WC-driven validation + // (e.g. maxlength exceeded) intact. + if (validation !== undefined && (element.validation ?? '') !== validation) { + if (validation) { + element.validation = validation; + } else { + element.removeAttribute('validation'); + } + } +}; + +export interface FormSearchEventHandlers { + onInput: () => void; + onSubmit: (event: Event) => void; + onClear: () => void; +} + +/** + * Wire search events so they survive Lit re-renders (e.g. when `maxlength` changes). + * Uses shadow-root delegation for composed events and re-binds the native `` + * when the inner shadow tree is replaced. + */ +export const attachFormSearchShadowEvents = ( + element: FormSearchElement, + handlers: FormSearchEventHandlers, +): (() => void) | undefined => { + const shadowRoot = element.shadowRoot; + if (!shadowRoot) { + return undefined; + } + + let boundInput: HTMLInputElement | null = null; + + const handleNativeInput = (): void => { + handlers.onInput(); + }; + + const bindNativeInput = (): void => { + if (boundInput) { + boundInput.removeEventListener('input', handleNativeInput); + boundInput = null; + } + + const input = getFormSearchNativeInput(element); + if (!input) { + return; + } + + boundInput = input; + boundInput.addEventListener('input', handleNativeInput); + }; + + const handleSubmitClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof HTMLButtonElement) || target.type !== 'submit') { + return; + } + + handlers.onSubmit(event); + }; + + const handleEnterDown = (event: Event): void => { + handlers.onSubmit(event); + }; + + const handleClear = (): void => { + handlers.onClear(); + }; + + bindNativeInput(); + element.dataset.dsrFormSearchConnected = 'true'; + + const observer = new MutationObserver(() => { + bindNativeInput(); + }); + observer.observe(shadowRoot, { childList: true, subtree: true }); + + shadowRoot.addEventListener('click', handleSubmitClick, true); + shadowRoot.addEventListener('enter-down', handleEnterDown); + shadowRoot.addEventListener('clear', handleClear); + + return () => { + delete element.dataset.dsrFormSearchConnected; + observer.disconnect(); + if (boundInput) { + boundInput.removeEventListener('input', handleNativeInput); + } + shadowRoot.removeEventListener('click', handleSubmitClick, true); + shadowRoot.removeEventListener('enter-down', handleEnterDown); + shadowRoot.removeEventListener('clear', handleClear); + }; +}; + +/** Keep the native field's `maxLength` in sync after WC property updates. */ +export const syncFormSearchNativeMaxlength = ( + element: FormSearchElement, + maxlength?: number, +): void => { + if (maxlength === undefined) { + return; + } + + const parsedMaxlength = Number(maxlength); + if (Number.isNaN(parsedMaxlength)) { + return; + } + + const input = getFormSearchNativeInput(element); + if (input) { + input.maxLength = parsedMaxlength; } }; +/** Stable inner widget; survives Lit re-renders when props like `maxlength` change. */ +export const getFormSearchInputHost = ( + element: HTMLElement | null, +): HTMLElement | null => + element?.shadowRoot?.querySelector('cfpb-form-search-input') ?? null; + /** Sync submit button label, aria-label, and visibility inside the shadow tree. */ export const syncFormSearchSubmitButton = ( element: HTMLElement | null, diff --git a/src/components/FormSearch/form-search.stories.tsx b/src/components/FormSearch/form-search.stories.tsx index 5ccd314c66..c977eb1635 100644 --- a/src/components/FormSearch/form-search.stories.tsx +++ b/src/components/FormSearch/form-search.stories.tsx @@ -51,6 +51,7 @@ Source: [Reference for custom elements — Search widget](https://cfpb.github.io argTypes: { showSubmitButton: { control: 'boolean' }, disabled: { control: 'boolean' }, + maxlength: { control: { type: 'number', min: 1, max: 500 } }, // `value` forces controlled mode and blocks typing unless the parent updates it. value: { control: false, table: { disable: true } }, defaultValue: { control: 'text' }, diff --git a/src/components/FormSearch/form-search.test.tsx b/src/components/FormSearch/form-search.test.tsx index 4152ee13cc..53163cc4c5 100644 --- a/src/components/FormSearch/form-search.test.tsx +++ b/src/components/FormSearch/form-search.test.tsx @@ -7,6 +7,7 @@ import { FormSearch } from './form-search'; import { getFormSearchNativeInput, getFormSearchSubmitButton, + type FormSearchElement, } from './form-search-utils'; const waitForFormSearchReady = async ( @@ -21,6 +22,7 @@ const waitForFormSearchReady = async ( : document.querySelector('cfpb-form-search'); expect(getFormSearchNativeInput(element)).not.toBeNull(); + expect(element?.dataset.dsrFormSearchConnected).toBe('true'); }); await new Promise((resolve) => { @@ -79,6 +81,28 @@ describe('', () => { expect((element as HTMLElement & { value: string }).value).toBe('hello'); }); + it('allows typing after maxlength changes', async () => { + const onChange = fn(); + + const { rerender } = render( + , + ); + + const element = await waitForFormSearchReady('search-max'); + rerender(); + + await waitFor(() => { + expect((element as FormSearchElement).maxlength).toBe(50); + }); + + const input = getFormSearchNativeInput(element)!; + fireEvent.input(input, { target: { value: 'typed' } }); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith('typed'); + }); + }); + it('allows typing when controlled with onChange', async () => { const onChange = fn(); diff --git a/src/components/FormSearch/form-search.tsx b/src/components/FormSearch/form-search.tsx index d36e22edb8..8d256fc416 100644 --- a/src/components/FormSearch/form-search.tsx +++ b/src/components/FormSearch/form-search.tsx @@ -1,6 +1,7 @@ import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; import { forwardRef, + useCallback, useEffect, useId, useImperativeHandle, @@ -12,8 +13,10 @@ import { import { noOp } from '../../utils/no-op'; import { applyFormSearchElementProps, + attachFormSearchShadowEvents, getFormSearchNativeInput, getFormSearchValue, + syncFormSearchNativeMaxlength, syncFormSearchSubmitButton, whenFormSearchReady, type FormSearchElement, @@ -113,7 +116,7 @@ export const FormSearch = forwardRef(function FormSearch( const elementId = id ?? generatedId.replace(/:/g, ''); const elementReference = useRef(null); const hasAppliedDefaultValue = useRef(false); - const hasConnectedReference = useRef(false); + const disconnectEventsReference = useRef<(() => void) | null>(null); const isControlled = value !== undefined; const handlersReference = useRef({ onChange, onSubmit, onClear }); @@ -128,6 +131,54 @@ export const FormSearch = forwardRef(function FormSearch( const defaultValueReference = useRef(defaultValue); defaultValueReference.current = defaultValue; + const applyInitialElementValue = useCallback((element: FormSearchElement) => { + if (isControlledReference.current) { + element.value = valueReference.current ?? ''; + return; + } + + if ( + defaultValueReference.current !== undefined && + !hasAppliedDefaultValue.current + ) { + element.value = defaultValueReference.current; + hasAppliedDefaultValue.current = true; + } + }, []); + + const connectEvents = useCallback( + (element: FormSearchElement): boolean => { + disconnectEventsReference.current?.(); + disconnectEventsReference.current = null; + + const removeListeners = attachFormSearchShadowEvents(element, { + onInput: () => { + handlersReference.current.onChange(getFormSearchValue(element)); + }, + onSubmit: (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + handlersReference.current.onSubmit(getFormSearchValue(element)); + }, + onClear: () => { + handlersReference.current.onClear(); + if (!isControlledReference.current) { + element.value = ''; + } + handlersReference.current.onChange(''); + }, + }); + + if (!removeListeners) { + return false; + } + + disconnectEventsReference.current = removeListeners; + return true; + }, + [], + ); + useImperativeHandle(reference, () => ({ element: elementReference.current, focus: () => { @@ -151,14 +202,52 @@ export const FormSearch = forwardRef(function FormSearch( placeholder, validation, }); + + if (!disconnectEventsReference.current) { + return; + } + + let cancelled = false; + + const syncAfterLitUpdate = (): void => { + if (cancelled) { + return; + } + + const currentValue = getFormSearchValue(element); + syncFormSearchNativeMaxlength(element, maxlength); + + if (currentValue && element.value !== currentValue) { + element.value = currentValue; + } + + connectEvents(element); + syncFormSearchSubmitButton(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(syncAfterLitUpdate); + }); + + return () => { + cancelled = true; + }; }, [ ariaLabelButton, ariaLabelInput, + connectEvents, disabled, label, maxlength, name, placeholder, + showSubmitButton, + submitAriaLabel, + submitLabel, validation, ]); @@ -180,67 +269,38 @@ export const FormSearch = forwardRef(function FormSearch( useLayoutEffect(() => { const element = elementReference.current; - if (!element || hasConnectedReference.current) { + if (!element) { return undefined; } - return whenFormSearchReady(element, () => { - const input = getFormSearchNativeInput(element); - if (!input) { - return undefined; - } - - hasConnectedReference.current = true; + const setup = (): void => { + applyInitialElementValue(element); + syncFormSearchNativeMaxlength(element, maxlength); + connectEvents(element); + syncFormSearchSubmitButton(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); + }; - if (isControlledReference.current) { - element.value = valueReference.current ?? ''; - } else if ( - defaultValueReference.current !== undefined && - !hasAppliedDefaultValue.current - ) { - element.value = defaultValueReference.current; - hasAppliedDefaultValue.current = true; - } + if (element.shadowRoot && getFormSearchNativeInput(element)) { + setup(); + return () => disconnectEventsReference.current?.(); + } - const submitButton = element.shadowRoot?.querySelector( - 'button[type="submit"]', - ); - const searchInputHost = element.shadowRoot?.querySelector( - 'cfpb-form-search-input', - ); - - const handleInput = (): void => { - handlersReference.current.onChange(getFormSearchValue(element)); - }; - - const handleSubmit = (event: Event): void => { - event.preventDefault(); - event.stopImmediatePropagation(); - handlersReference.current.onSubmit(getFormSearchValue(element)); - }; - - const handleClear = (): void => { - handlersReference.current.onClear(); - if (!isControlledReference.current) { - element.value = ''; - } - handlersReference.current.onChange(''); - }; - - input.addEventListener('input', handleInput); - submitButton?.addEventListener('click', handleSubmit, true); - searchInputHost?.addEventListener('enter-down', handleSubmit); - searchInputHost?.addEventListener('clear', handleClear); - - return () => { - hasConnectedReference.current = false; - input.removeEventListener('input', handleInput); - submitButton?.removeEventListener('click', handleSubmit, true); - searchInputHost?.removeEventListener('enter-down', handleSubmit); - searchInputHost?.removeEventListener('clear', handleClear); - }; + return whenFormSearchReady(element, () => { + setup(); + return () => disconnectEventsReference.current?.(); }); - }, []); + }, [ + applyInitialElementValue, + connectEvents, + maxlength, + showSubmitButton, + submitAriaLabel, + submitLabel, + ]); useEffect(() => { const element = elementReference.current; @@ -256,7 +316,7 @@ export const FormSearch = forwardRef(function FormSearch( }); }; - if (hasConnectedReference.current) { + if (disconnectEventsReference.current) { syncSubmit(); return; } From e7a3b03bc2b50b154e347c74edc3da2186f8acf4 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:20:46 -0700 Subject: [PATCH 4/6] linting fixes --- .../FormSearch/form-search-utils.ts | 22 +++- .../FormSearch/form-search.test.tsx | 26 ++--- src/components/FormSearch/form-search.tsx | 104 ++++++++++++------ 3 files changed, 103 insertions(+), 49 deletions(-) diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts index 10a89404ff..5d3a49d96f 100644 --- a/src/components/FormSearch/form-search-utils.ts +++ b/src/components/FormSearch/form-search-utils.ts @@ -1,3 +1,19 @@ +import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; + +let formSearchInitialized = false; + +/** Register DS custom elements once per app (icons must be configured separately). */ +export const ensureFormSearchInitialized = (): void => { + if (formSearchInitialized) { + return; + } + + // Ambient types exist; ESLint's import resolver does not resolve this package. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- WC registration + CfpbFormSearch.init(); + formSearchInitialized = true; +}; + export type FormSearchElement = HTMLElement & { value: string; name?: string; @@ -239,7 +255,9 @@ export const whenFormSearchReady = ( callback: (element: HTMLElement) => void | (() => void), ): (() => void) => { if (!element) { - return () => undefined; + return (): void => { + /* no-op: element not mounted */ + }; } let dispose: void | (() => void); @@ -289,7 +307,7 @@ export const whenFormSearchReady = ( export const getFormSearchNativeInput = ( element: HTMLElement | null, ): HTMLInputElement | null => { - if (!element || element.tagName !== 'CFPB-FORM-SEARCH') { + if (element?.tagName !== 'CFPB-FORM-SEARCH') { return null; } diff --git a/src/components/FormSearch/form-search.test.tsx b/src/components/FormSearch/form-search.test.tsx index 53163cc4c5..8e6914ac1c 100644 --- a/src/components/FormSearch/form-search.test.tsx +++ b/src/components/FormSearch/form-search.test.tsx @@ -1,15 +1,21 @@ -import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { fn } from 'storybook/test'; import { FormSearch } from './form-search'; import { + getFormSearchInputHost, getFormSearchNativeInput, getFormSearchSubmitButton, type FormSearchElement, } from './form-search-utils'; +/* eslint-disable testing-library/no-node-access -- is not exposed via Testing Library roles */ +const getFormSearchHost = (id?: string): HTMLElement | null => + id + ? document.querySelector(`#${id}`) + : document.querySelector('cfpb-form-search'); + const waitForFormSearchReady = async ( id?: string, ): Promise => { @@ -17,9 +23,7 @@ const waitForFormSearchReady = async ( await customElements.whenDefined('cfpb-form-search-input'); await waitFor(() => { - const element = id - ? document.querySelector(`#${id}`) - : document.querySelector('cfpb-form-search'); + const element = getFormSearchHost(id); expect(getFormSearchNativeInput(element)).not.toBeNull(); expect(element?.dataset.dsrFormSearchConnected).toBe('true'); @@ -29,15 +33,11 @@ const waitForFormSearchReady = async ( requestAnimationFrame(() => requestAnimationFrame(resolve)); }); - return id - ? document.querySelector(`#${id}`) - : document.querySelector('cfpb-form-search'); + return getFormSearchHost(id); }; +/* eslint-enable testing-library/no-node-access */ describe('', () => { - beforeEach(() => { - CfpbFormSearch.init(); - }); it('renders the cfpb-form-search web component', async () => { render(); @@ -150,13 +150,11 @@ describe('', () => { const element = await waitForFormSearchReady('search-submit'); - const searchInputHost = element?.shadowRoot?.querySelector( - 'cfpb-form-search-input', - ); + const searchInputHost = getFormSearchInputHost(element); expect(searchInputHost).not.toBeNull(); fireEvent( - searchInputHost!, + searchInputHost, new CustomEvent('enter-down', { bubbles: true, composed: true }), ); diff --git a/src/components/FormSearch/form-search.tsx b/src/components/FormSearch/form-search.tsx index 8d256fc416..d078f50697 100644 --- a/src/components/FormSearch/form-search.tsx +++ b/src/components/FormSearch/form-search.tsx @@ -1,4 +1,3 @@ -import { CfpbFormSearch } from '@cfpb/cfpb-design-system'; import { forwardRef, useCallback, @@ -9,11 +8,13 @@ import { useRef, type ReactElement, type Ref, + type RefObject, } from 'react'; import { noOp } from '../../utils/no-op'; import { applyFormSearchElementProps, attachFormSearchShadowEvents, + ensureFormSearchInitialized, getFormSearchNativeInput, getFormSearchValue, syncFormSearchNativeMaxlength, @@ -22,15 +23,51 @@ import { type FormSearchElement, } from './form-search-utils'; -let formSearchInitialized = false; +const syncSubmitButtonState = ( + element: FormSearchElement | null, + { + showSubmitButton, + submitAriaLabel, + submitLabel, + }: { + showSubmitButton: boolean; + submitAriaLabel: string; + submitLabel: string; + }, +): void => { + syncFormSearchSubmitButton(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); +}; -const ensureFormSearchInitialized = (): void => { - if (formSearchInitialized) { - return; - } +const disconnectFormSearchEvents = ( + disconnectReference: RefObject<(() => void) | null>, +): void => { + disconnectReference.current?.(); +}; - CfpbFormSearch.init(); - formSearchInitialized = true; +const completeFormSearchConnection = ( + element: FormSearchElement, + disconnectReference: RefObject<(() => void) | null>, + applyInitialElementValue: (target: FormSearchElement) => void, + maxlength: number, + connectEvents: (target: FormSearchElement) => boolean, + submitButton: { + showSubmitButton: boolean; + submitAriaLabel: string; + submitLabel: string; + }, +): (() => void) => { + applyInitialElementValue(element); + syncFormSearchNativeMaxlength(element, maxlength); + connectEvents(element); + syncSubmitButtonState(element, submitButton); + + return () => { + disconnectFormSearchEvents(disconnectReference); + }; }; export type FormSearchValidation = 'error' | 'success' | 'warning'; @@ -113,7 +150,7 @@ export const FormSearch = forwardRef(function FormSearch( ensureFormSearchInitialized(); const generatedId = useId(); - const elementId = id ?? generatedId.replace(/:/g, ''); + const elementId = id ?? generatedId.replaceAll(':', ''); const elementReference = useRef(null); const hasAppliedDefaultValue = useRef(false); const disconnectEventsReference = useRef<(() => void) | null>(null); @@ -270,29 +307,30 @@ export const FormSearch = forwardRef(function FormSearch( useLayoutEffect(() => { const element = elementReference.current; if (!element) { - return undefined; + return; } - const setup = (): void => { - applyInitialElementValue(element); - syncFormSearchNativeMaxlength(element, maxlength); - connectEvents(element); - syncFormSearchSubmitButton(element, { - showSubmitButton, - submitAriaLabel, - submitLabel, - }); + const submitButton = { + showSubmitButton, + submitAriaLabel, + submitLabel, }; + const finishConnection = (): (() => void) => + completeFormSearchConnection( + element, + disconnectEventsReference, + applyInitialElementValue, + maxlength, + connectEvents, + submitButton, + ); + if (element.shadowRoot && getFormSearchNativeInput(element)) { - setup(); - return () => disconnectEventsReference.current?.(); + return finishConnection(); } - return whenFormSearchReady(element, () => { - setup(); - return () => disconnectEventsReference.current?.(); - }); + return whenFormSearchReady(element, () => finishConnection()); }, [ applyInitialElementValue, connectEvents, @@ -308,21 +346,21 @@ export const FormSearch = forwardRef(function FormSearch( return; } - const syncSubmit = (): void => { - syncFormSearchSubmitButton(element, { - showSubmitButton, - submitAriaLabel, - submitLabel, - }); + const submitOptions = { + showSubmitButton, + submitAriaLabel, + submitLabel, }; if (disconnectEventsReference.current) { - syncSubmit(); + syncSubmitButtonState(element, submitOptions); return; } void customElements.whenDefined('cfpb-form-search').then(() => { - requestAnimationFrame(syncSubmit); + requestAnimationFrame(() => { + syncSubmitButtonState(element, submitOptions); + }); }); }, [showSubmitButton, submitAriaLabel, submitLabel]); From ea1297f0b31d55c0e384e44330b247126d9f0920 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:31:37 -0700 Subject: [PATCH 5/6] update example --- .../FormSearch/form-search-utils.ts | 16 +++++++------- .../FormSearch/form-search.stories.tsx | 10 ++++----- src/components/FormSearch/form-search.tsx | 21 ++++++++++++++++--- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts index 5d3a49d96f..4b8ff95811 100644 --- a/src/components/FormSearch/form-search-utils.ts +++ b/src/components/FormSearch/form-search-utils.ts @@ -44,12 +44,14 @@ export const getFormSearchValue = (element: HTMLElement | null): string => { } const host = element as FormSearchElement; + const nativeValue = getFormSearchNativeInput(element)?.value; - if (host.value) { - return host.value; + // Prefer the live native field while typing; the host property can lag Lit by a frame. + if (nativeValue !== undefined) { + return nativeValue; } - return getFormSearchNativeInput(element)?.value ?? ''; + return host.value ?? ''; }; /** Apply React props to the custom element as properties (avoids attribute churn on re-render). */ @@ -145,16 +147,12 @@ export const attachFormSearchShadowEvents = ( }; const bindNativeInput = (): void => { - if (boundInput) { - boundInput.removeEventListener('input', handleNativeInput); - boundInput = null; - } - const input = getFormSearchNativeInput(element); - if (!input) { + if (!input || input === boundInput) { return; } + boundInput?.removeEventListener('input', handleNativeInput); boundInput = input; boundInput.addEventListener('input', handleNativeInput); }; diff --git a/src/components/FormSearch/form-search.stories.tsx b/src/components/FormSearch/form-search.stories.tsx index c977eb1635..677d8b7f32 100644 --- a/src/components/FormSearch/form-search.stories.tsx +++ b/src/components/FormSearch/form-search.stories.tsx @@ -75,15 +75,13 @@ export const Default: Story = { export const Controlled: Story = { render: (args) => { - const [value, setValue] = useState(args.defaultValue ?? ''); + const { defaultValue = '', ...rest } = args; + const [value, setValue] = useState(defaultValue); return ( { - setValue(next); - args.onChange?.(next); - }} + {...rest} + onChange={setValue} onSubmit={(next) => { logSubmit(next); args.onSubmit?.(next); diff --git a/src/components/FormSearch/form-search.tsx b/src/components/FormSearch/form-search.tsx index d078f50697..b0a8807688 100644 --- a/src/components/FormSearch/form-search.tsx +++ b/src/components/FormSearch/form-search.tsx @@ -154,6 +154,7 @@ export const FormSearch = forwardRef(function FormSearch( const elementReference = useRef(null); const hasAppliedDefaultValue = useRef(false); const disconnectEventsReference = useRef<(() => void) | null>(null); + const isInternalValueChangeReference = useRef(false); const isControlled = value !== undefined; const handlersReference = useRef({ onChange, onSubmit, onClear }); @@ -190,6 +191,7 @@ export const FormSearch = forwardRef(function FormSearch( const removeListeners = attachFormSearchShadowEvents(element, { onInput: () => { + isInternalValueChangeReference.current = true; handlersReference.current.onChange(getFormSearchValue(element)); }, onSubmit: (event) => { @@ -258,8 +260,7 @@ export const FormSearch = forwardRef(function FormSearch( element.value = currentValue; } - connectEvents(element); - syncFormSearchSubmitButton(element, { + syncSubmitButtonState(element, { showSubmitButton, submitAriaLabel, submitLabel, @@ -276,7 +277,6 @@ export const FormSearch = forwardRef(function FormSearch( }, [ ariaLabelButton, ariaLabelInput, - connectEvents, disabled, label, maxlength, @@ -298,7 +298,22 @@ export const FormSearch = forwardRef(function FormSearch( return; } + if (isInternalValueChangeReference.current) { + isInternalValueChangeReference.current = false; + return; + } + const nextValue = value ?? ''; + const nativeValue = getFormSearchNativeInput(element)?.value ?? ''; + + if (nativeValue === nextValue) { + if (element.value !== nextValue) { + element.value = nextValue; + } + + return; + } + if (element.value !== nextValue) { element.value = nextValue; } From 88f0ae0ae009edc9f09bdda8786a05d3555141fc Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:11:07 -0700 Subject: [PATCH 6/6] fix unit test, bubbling events --- src/components/FormSearch/form-search-utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/FormSearch/form-search-utils.ts b/src/components/FormSearch/form-search-utils.ts index 4b8ff95811..3df4ff69b9 100644 --- a/src/components/FormSearch/form-search-utils.ts +++ b/src/components/FormSearch/form-search-utils.ts @@ -183,7 +183,8 @@ export const attachFormSearchShadowEvents = ( observer.observe(shadowRoot, { childList: true, subtree: true }); shadowRoot.addEventListener('click', handleSubmitClick, true); - shadowRoot.addEventListener('enter-down', handleEnterDown); + // Capture so we run before Lit's @enter-down → #onClickSearch (setFormValue breaks in jsdom). + shadowRoot.addEventListener('enter-down', handleEnterDown, true); shadowRoot.addEventListener('clear', handleClear); return () => { @@ -193,7 +194,7 @@ export const attachFormSearchShadowEvents = ( boundInput.removeEventListener('input', handleNativeInput); } shadowRoot.removeEventListener('click', handleSubmitClick, true); - shadowRoot.removeEventListener('enter-down', handleEnterDown); + shadowRoot.removeEventListener('enter-down', handleEnterDown, true); shadowRoot.removeEventListener('clear', handleClear); }; };