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 new file mode 100644 index 0000000000..3df4ff69b9 --- /dev/null +++ b/src/components/FormSearch/form-search-utils.ts @@ -0,0 +1,330 @@ +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; + 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; + const nativeValue = getFormSearchNativeInput(element)?.value; + + // Prefer the live native field while typing; the host property can lag Lit by a frame. + if (nativeValue !== undefined) { + return nativeValue; + } + + return host.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) { + element.name = name; + } + + if (label !== undefined && element.label !== label) { + element.label = label; + } + + if (placeholder !== undefined && element.placeholder !== placeholder) { + element.placeholder = placeholder; + } + + if (maxlength !== undefined) { + const parsedMaxlength = Number(maxlength); + if ( + !Number.isNaN(parsedMaxlength) && + element.maxlength !== parsedMaxlength + ) { + element.maxlength = parsedMaxlength; + } + } + + const isDisabled = Boolean(disabled); + if (element.disabled !== isDisabled) { + element.disabled = isDisabled; + } + + if ( + ariaLabelInput !== undefined && + element.ariaLabelInput !== ariaLabelInput + ) { + element.ariaLabelInput = ariaLabelInput; + } + + if ( + ariaLabelButton !== undefined && + element.ariaLabelButton !== ariaLabelButton + ) { + element.ariaLabelButton = ariaLabelButton; + } + + // 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 => { + const input = getFormSearchNativeInput(element); + if (!input || input === boundInput) { + return; + } + + boundInput?.removeEventListener('input', handleNativeInput); + 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); + // 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 () => { + delete element.dataset.dsrFormSearchConnected; + observer.disconnect(); + if (boundInput) { + boundInput.removeEventListener('input', handleNativeInput); + } + shadowRoot.removeEventListener('click', handleSubmitClick, true); + shadowRoot.removeEventListener('enter-down', handleEnterDown, true); + 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, + { + 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 (): void => { + /* no-op: element not mounted */ + }; + } + + 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, +): HTMLInputElement | null => { + if (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..677d8b7f32 --- /dev/null +++ b/src/components/FormSearch/form-search.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { expect, userEvent, within } from 'storybook/test'; +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?' }, + { 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: ` +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) + `, + }, + }, + }, + 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' }, + onChange: { action: 'changed' }, + onSubmit: { action: 'submitted' }, + }, + render: (args) => , +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'q', + placeholder: 'Enter your search term(s)', + submitLabel: 'Search', + }, +}; + +export const Controlled: Story = { + render: (args) => { + const { defaultValue = '', ...rest } = args; + const [value, setValue] = useState(defaultValue); + + return ( + { + logSubmit(next); + args.onSubmit?.(next); + }} + value={value} + /> + ); + }, + args: { + ...Default.args, + defaultValue: 'mortgage', + }, +}; + +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, + defaultValue: '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..8e6914ac1c --- /dev/null +++ b/src/components/FormSearch/form-search.test.tsx @@ -0,0 +1,175 @@ +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 => { + await customElements.whenDefined('cfpb-form-search'); + await customElements.whenDefined('cfpb-form-search-input'); + + await waitFor(() => { + const element = getFormSearchHost(id); + + expect(getFormSearchNativeInput(element)).not.toBeNull(); + expect(element?.dataset.dsrFormSearchConnected).toBe('true'); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + + return getFormSearchHost(id); +}; +/* eslint-enable testing-library/no-node-access */ + +describe('', () => { + + it('renders the cfpb-form-search web component', async () => { + render(); + + const element = await waitForFormSearchReady('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('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 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(); + + 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(); + + render( + , + ); + + const element = await waitForFormSearchReady('search-submit'); + + const searchInputHost = getFormSearchInputHost(element); + expect(searchInputHost).not.toBeNull(); + + 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 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 new file mode 100644 index 0000000000..b0a8807688 --- /dev/null +++ b/src/components/FormSearch/form-search.tsx @@ -0,0 +1,410 @@ +import { + forwardRef, + useCallback, + useEffect, + useId, + useImperativeHandle, + useLayoutEffect, + useRef, + type ReactElement, + type Ref, + type RefObject, +} from 'react'; +import { noOp } from '../../utils/no-op'; +import { + applyFormSearchElementProps, + attachFormSearchShadowEvents, + ensureFormSearchInitialized, + getFormSearchNativeInput, + getFormSearchValue, + syncFormSearchNativeMaxlength, + syncFormSearchSubmitButton, + whenFormSearchReady, + type FormSearchElement, +} from './form-search-utils'; + +const syncSubmitButtonState = ( + element: FormSearchElement | null, + { + showSubmitButton, + submitAriaLabel, + submitLabel, + }: { + showSubmitButton: boolean; + submitAriaLabel: string; + submitLabel: string; + }, +): void => { + syncFormSearchSubmitButton(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); +}; + +const disconnectFormSearchEvents = ( + disconnectReference: RefObject<(() => void) | null>, +): void => { + disconnectReference.current?.(); +}; + +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'; + +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. */ + showSubmitButton?: boolean; + /** Typeahead suggestions; rendered as a hidden list in the web component slot. */ + suggestions?: FormSearchSuggestion[]; + className?: string; + id?: string; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; + onClear?: () => void; +} + +export interface FormSearchHandle { + /** The underlying `` element. */ + element: HTMLElement | null; + focus: () => void; +} + +/** + * 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 + */ +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, + onChange = noOp, + onSubmit = noOp, + onClear = noOp, + }: FormSearchProperties, + reference: Ref, +): ReactElement { + ensureFormSearchInitialized(); + + const generatedId = useId(); + const elementId = id ?? generatedId.replaceAll(':', ''); + 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 }); + 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; + + 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: () => { + isInternalValueChangeReference.current = true; + 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: () => { + getFormSearchNativeInput(elementReference.current)?.focus(); + }, + })); + + useEffect(() => { + const element = elementReference.current; + if (!element) { + return; + } + + applyFormSearchElementProps(element, { + ariaLabelButton, + ariaLabelInput, + disabled, + label, + maxlength, + name, + 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; + } + + syncSubmitButtonState(element, { + showSubmitButton, + submitAriaLabel, + submitLabel, + }); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(syncAfterLitUpdate); + }); + + return () => { + cancelled = true; + }; + }, [ + ariaLabelButton, + ariaLabelInput, + disabled, + label, + maxlength, + name, + placeholder, + showSubmitButton, + submitAriaLabel, + submitLabel, + validation, + ]); + + useEffect(() => { + if (!isControlled) { + return; + } + + const element = elementReference.current; + if (!element) { + 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; + } + }, [isControlled, value]); + + useLayoutEffect(() => { + const element = elementReference.current; + if (!element) { + return; + } + + const submitButton = { + showSubmitButton, + submitAriaLabel, + submitLabel, + }; + + const finishConnection = (): (() => void) => + completeFormSearchConnection( + element, + disconnectEventsReference, + applyInitialElementValue, + maxlength, + connectEvents, + submitButton, + ); + + if (element.shadowRoot && getFormSearchNativeInput(element)) { + return finishConnection(); + } + + return whenFormSearchReady(element, () => finishConnection()); + }, [ + applyInitialElementValue, + connectEvents, + maxlength, + showSubmitButton, + submitAriaLabel, + submitLabel, + ]); + + useEffect(() => { + const element = elementReference.current; + if (!element) { + return; + } + + const submitOptions = { + showSubmitButton, + submitAriaLabel, + submitLabel, + }; + + if (disconnectEventsReference.current) { + syncSubmitButtonState(element, submitOptions); + return; + } + + void customElements.whenDefined('cfpb-form-search').then(() => { + requestAnimationFrame(() => { + syncSubmitButtonState(element, submitOptions); + }); + }); + }, [showSubmitButton, submitAriaLabel, submitLabel]); + + 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..d755c74ca8 100644 --- a/src/types/cfpb-design-system.d.ts +++ b/src/types/cfpb-design-system.d.ts @@ -1,6 +1,44 @@ +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; } + + 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; + }; + } + } }