From 86d95bf522258b30cab1e5388ef43b3726d1a872 Mon Sep 17 00:00:00 2001 From: mornieur Date: Tue, 21 Jul 2026 23:09:10 -0300 Subject: [PATCH 01/10] refactor: add internal component foundations --- src/components/atoms/Checkbox/index.tsx | 16 +-- src/components/atoms/Input/index.tsx | 29 ++-- .../molecules/Tabs/__tests__/Tabs.test.tsx | 68 ++++++++- src/components/molecules/Tabs/index.tsx | 34 ++--- .../callbacks/useStableCallback.test.tsx | 37 +++++ src/internal/callbacks/useStableCallback.ts | 16 +++ .../useIsomorphicLayoutEffect.test.tsx | 44 ++++++ .../effects/useIsomorphicLayoutEffect.ts | 8 ++ src/internal/events/composeEventHandlers.ts | 23 +++ src/internal/ids/fieldSlotIds.test.ts | 43 ++++++ src/internal/ids/fieldSlotIds.ts | 47 ++++++ src/internal/refs/composeRefs.test.tsx | 93 ++++++++++++ src/internal/refs/composeRefs.ts | 40 ++++++ src/internal/refs/useComposedRefs.ts | 15 ++ .../state/useControllableState.test.tsx | 136 ++++++++++++++++++ src/internal/state/useControllableState.ts | 69 +++++++++ 16 files changed, 661 insertions(+), 57 deletions(-) create mode 100644 src/internal/callbacks/useStableCallback.test.tsx create mode 100644 src/internal/callbacks/useStableCallback.ts create mode 100644 src/internal/effects/useIsomorphicLayoutEffect.test.tsx create mode 100644 src/internal/effects/useIsomorphicLayoutEffect.ts create mode 100644 src/internal/events/composeEventHandlers.ts create mode 100644 src/internal/ids/fieldSlotIds.test.ts create mode 100644 src/internal/ids/fieldSlotIds.ts create mode 100644 src/internal/refs/composeRefs.test.tsx create mode 100644 src/internal/refs/composeRefs.ts create mode 100644 src/internal/refs/useComposedRefs.ts create mode 100644 src/internal/state/useControllableState.test.tsx create mode 100644 src/internal/state/useControllableState.ts diff --git a/src/components/atoms/Checkbox/index.tsx b/src/components/atoms/Checkbox/index.tsx index 17ccdf4..ff45a2a 100644 --- a/src/components/atoms/Checkbox/index.tsx +++ b/src/components/atoms/Checkbox/index.tsx @@ -6,6 +6,7 @@ import { type InputHTMLAttributes, type ReactNode } from 'react'; +import { useComposedRefs } from '@/internal/refs/useComposedRefs'; import * as S from './styles'; export type CheckboxProps = Omit, 'size' | 'type'> & { @@ -47,18 +48,7 @@ const Checkbox = forwardRef( .filter(Boolean) .join(' '); - const setRefs = (node: HTMLInputElement | null) => { - inputRef.current = node; - - if (typeof ref === 'function') { - ref(node); - return; - } - - if (ref) { - ref.current = node; - } - }; + const composedRefs = useComposedRefs(inputRef, ref); useEffect(() => { if (inputRef.current) { @@ -70,7 +60,7 @@ const Checkbox = forwardRef( ( ) => { const inputRef = useRef(null); const generatedId = useId(); - const inputId = id ?? `feitoza-input-${generatedId}`; - const helperId = helperText ? `${inputId}-helper` : undefined; - const errorId = errorMessage ? `${inputId}-error` : undefined; + const inputId = resolveFieldControlId({ id, generatedId, prefix: 'feitoza-input' }); + const slotIds = createFieldSlotIds(inputId); + const helperId = helperText ? slotIds.helperTextId : undefined; + const errorId = errorMessage ? slotIds.errorTextId : undefined; const isInvalid = Boolean(invalid || errorMessage); - const describedBy = [ariaDescribedBy, helperId, isInvalid ? errorId : undefined] - .filter(Boolean) - .join(' '); - - const setRefs = (node: HTMLInputElement | null) => { - inputRef.current = node; - - if (typeof ref === 'function') { - ref(node); - return; - } - - if (ref) { - ref.current = node; - } - }; + const describedBy = composeAriaDescribedBy(ariaDescribedBy, helperId, isInvalid ? errorId : undefined); + const composedRefs = useComposedRefs(inputRef, ref); const handleControlMouseDown = (event: MouseEvent) => { if (disabled || event.target === inputRef.current) { @@ -96,7 +85,7 @@ const Input = forwardRef( {startIcon ? : null} { }); it('supports controlled value', () => { + const handleChange = vi.fn(); const { rerender } = render( - undefined}> + Overview Logs @@ -54,8 +55,13 @@ describe('Tabs', () => { expect(screen.getByText('Overview content')).toBeVisible(); + fireEvent.click(screen.getByRole('tab', { name: 'Logs' })); + + expect(handleChange).toHaveBeenCalledWith('logs'); + expect(screen.getByText('Overview content')).toBeVisible(); + rerender( - undefined}> + Overview Logs @@ -68,6 +74,64 @@ describe('Tabs', () => { expect(screen.getByText('Logs content')).toBeVisible(); }); + it('respects consumer prevented click handlers before internal state updates', () => { + render( + + + { + event.preventDefault(); + }} + > + Overview + + Logs + + Overview content + Logs content + + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Logs' })); + + expect(screen.getByText('Logs content')).toBeVisible(); + + fireEvent.click(screen.getByRole('tab', { name: 'Overview' })); + + expect(screen.getByText('Logs content')).toBeVisible(); + }); + + it('respects consumer prevented keyboard handlers before internal navigation', () => { + render( + + + { + if (event.key === 'ArrowRight') { + event.preventDefault(); + } + }} + > + Overview + + Metrics + + Overview content + Metrics content + + ); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + + overview.focus(); + fireEvent.keyDown(overview, { key: 'ArrowRight' }); + + expect(overview).toHaveFocus(); + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true'); + }); + it('supports keyboard navigation', () => { render(); diff --git a/src/components/molecules/Tabs/index.tsx b/src/components/molecules/Tabs/index.tsx index a7f2272..3b2b24f 100644 --- a/src/components/molecules/Tabs/index.tsx +++ b/src/components/molecules/Tabs/index.tsx @@ -4,11 +4,12 @@ import { useContext, useId, useMemo, - useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent } from 'react'; +import { composeEventHandlers } from '@/internal/events/composeEventHandlers'; +import { useControllableState } from '@/internal/state/useControllableState'; import * as S from './styles'; type TabsContextValue = { @@ -38,22 +39,19 @@ export type TabsRootProps = HTMLAttributes & { const Root = forwardRef( ({ value, defaultValue, onValueChange, ...props }, ref) => { const generatedId = useId(); - const [internalValue, setInternalValue] = useState(defaultValue ?? ''); - const currentValue = value ?? internalValue; + const [currentValue, setValue] = useControllableState({ + value, + defaultValue: defaultValue ?? '', + onChange: onValueChange + }); const context = useMemo( () => ({ value: currentValue, baseId: `feitoza-tabs-${generatedId}`, - setValue: (nextValue) => { - if (value === undefined) { - setInternalValue(nextValue); - } - - onValueChange?.(nextValue); - } + setValue }), - [currentValue, generatedId, onValueChange, value] + [currentValue, generatedId, setValue] ); return ( @@ -109,12 +107,6 @@ const Trigger = forwardRef( const contentId = `${context.baseId}-content-${value}`; const handleKeyDown = (event: KeyboardEvent) => { - onKeyDown?.(event); - - if (event.defaultPrevented) { - return; - } - if (event.key === 'ArrowRight') { event.preventDefault(); focusTab(event.currentTarget, 'next'); @@ -147,14 +139,12 @@ const Trigger = forwardRef( disabled={disabled} tabIndex={selected ? 0 : -1} $selected={selected} - onClick={(event) => { - onClick?.(event); - + onClick={composeEventHandlers(onClick, (event) => { if (!event.defaultPrevented && !disabled) { context.setValue(value); } - }} - onKeyDown={handleKeyDown} + })} + onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)} {...props} /> ); diff --git a/src/internal/callbacks/useStableCallback.test.tsx b/src/internal/callbacks/useStableCallback.test.tsx new file mode 100644 index 0000000..ed5c4ca --- /dev/null +++ b/src/internal/callbacks/useStableCallback.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useStableCallback } from './useStableCallback'; + +describe('useStableCallback', () => { + it('keeps the same function identity between rerenders', () => { + const { result, rerender } = renderHook(({ value }: { value: number }) => useStableCallback(() => value), { + initialProps: { value: 1 } + }); + + const firstCallback = result.current; + + rerender({ value: 2 }); + + expect(result.current).toBe(firstCallback); + }); + + it('always calls the latest implementation', () => { + const { result, rerender } = renderHook(({ value }: { value: string }) => useStableCallback(() => value), { + initialProps: { value: 'initial' } + }); + + expect(result.current()).toBe('initial'); + + rerender({ value: 'updated' }); + + expect(result.current()).toBe('updated'); + }); + + it('preserves arguments and return values', () => { + const spy = vi.fn((left: number, right: number) => left + right); + const { result } = renderHook(() => useStableCallback(spy)); + + expect(result.current(2, 3)).toBe(5); + expect(spy).toHaveBeenCalledWith(2, 3); + }); +}); diff --git a/src/internal/callbacks/useStableCallback.ts b/src/internal/callbacks/useStableCallback.ts new file mode 100644 index 0000000..490a84f --- /dev/null +++ b/src/internal/callbacks/useStableCallback.ts @@ -0,0 +1,16 @@ +import { useCallback, useRef, type MutableRefObject } from 'react'; +import { useIsomorphicLayoutEffect } from '@/internal/effects/useIsomorphicLayoutEffect'; + +export function useStableCallback( + callback: ((...args: TArgs) => TResult) | undefined +) { + const callbackRef = useRef(callback) as MutableRefObject<((...args: TArgs) => TResult) | undefined>; + + useIsomorphicLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return useCallback((...args: TArgs) => { + return callbackRef.current?.(...args); + }, []); +} diff --git a/src/internal/effects/useIsomorphicLayoutEffect.test.tsx b/src/internal/effects/useIsomorphicLayoutEffect.test.tsx new file mode 100644 index 0000000..a8ebcb7 --- /dev/null +++ b/src/internal/effects/useIsomorphicLayoutEffect.test.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; + +describe('useIsomorphicLayoutEffect', () => { + it('runs in a DOM environment without throwing', () => { + const effectSpy = vi.fn(); + + renderHook(() => { + useIsomorphicLayoutEffect(effectSpy, []); + }); + + expect(effectSpy).toHaveBeenCalledOnce(); + }); + + it('renders in SSR mode without layout effect warnings', async () => { + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + vi.resetModules(); + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + + const { useIsomorphicLayoutEffect: ssrSafeEffect } = await import('./useIsomorphicLayoutEffect'); + + function Example() { + ssrSafeEffect(() => undefined, []); + + return
SSR safe
; + } + + expect(() => renderToString()).not.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + vi.resetModules(); + errorSpy.mockRestore(); + + vi.stubGlobal('window', originalWindow); + vi.stubGlobal('document', originalDocument); + }); +}); diff --git a/src/internal/effects/useIsomorphicLayoutEffect.ts b/src/internal/effects/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000..15e601f --- /dev/null +++ b/src/internal/effects/useIsomorphicLayoutEffect.ts @@ -0,0 +1,8 @@ +import { useEffect, useLayoutEffect } from 'react'; + +const canUseDOM = + typeof window !== 'undefined' && + typeof document !== 'undefined' && + typeof document.createElement !== 'undefined'; + +export const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect; diff --git a/src/internal/events/composeEventHandlers.ts b/src/internal/events/composeEventHandlers.ts new file mode 100644 index 0000000..7b20461 --- /dev/null +++ b/src/internal/events/composeEventHandlers.ts @@ -0,0 +1,23 @@ +type EventWithDefaultPrevented = { + defaultPrevented: boolean; +}; + +type ComposeEventHandlersOptions = { + checkForDefaultPrevented?: boolean; +}; + +export function composeEventHandlers( + externalEventHandler: ((event: TEvent) => void) | undefined, + internalEventHandler: (event: TEvent) => void, + { checkForDefaultPrevented = true }: ComposeEventHandlersOptions = {} +) { + return (event: TEvent) => { + externalEventHandler?.(event); + + if (checkForDefaultPrevented && event.defaultPrevented) { + return; + } + + internalEventHandler(event); + }; +} diff --git a/src/internal/ids/fieldSlotIds.test.ts b/src/internal/ids/fieldSlotIds.test.ts new file mode 100644 index 0000000..a49f53a --- /dev/null +++ b/src/internal/ids/fieldSlotIds.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { composeAriaDescribedBy, createFieldSlotIds, resolveFieldControlId } from './fieldSlotIds'; + +describe('fieldSlotIds', () => { + it('derives slot ids from a provided control id', () => { + expect(createFieldSlotIds('service-name')).toEqual({ + baseId: 'service-name', + controlId: 'service-name', + labelId: 'service-name-label', + helperTextId: 'service-name-helper', + errorTextId: 'service-name-error' + }); + }); + + it('prefers consumer provided ids', () => { + expect( + resolveFieldControlId({ + id: 'custom-id', + generatedId: 'generated', + prefix: 'feitoza-input' + }) + ).toBe('custom-id'); + }); + + it('generates ids when the consumer did not provide one', () => { + expect( + resolveFieldControlId({ + generatedId: ':r0:', + prefix: 'feitoza-input' + }) + ).toBe('feitoza-input-:r0:'); + }); + + it('composes aria-describedby without invalid spaces or missing slots', () => { + expect( + composeAriaDescribedBy('external-description', '', undefined, 'helper-id', null, 'error-id') + ).toBe('external-description helper-id error-id'); + }); + + it('returns undefined when no described-by ids are present', () => { + expect(composeAriaDescribedBy('', undefined, null)).toBeUndefined(); + }); +}); diff --git a/src/internal/ids/fieldSlotIds.ts b/src/internal/ids/fieldSlotIds.ts new file mode 100644 index 0000000..6fd65fc --- /dev/null +++ b/src/internal/ids/fieldSlotIds.ts @@ -0,0 +1,47 @@ +export type FieldSlotIds = { + baseId: string; + controlId: string; + labelId: string; + helperTextId: string; + errorTextId: string; +}; + +type ResolveFieldControlIdOptions = { + id?: string; + generatedId?: string; + prefix: string; +}; + +export function resolveFieldControlId({ + id, + generatedId, + prefix +}: ResolveFieldControlIdOptions) { + if (id) { + return id; + } + + if (!generatedId) { + throw new Error('generatedId is required when an explicit id is not provided.'); + } + + return `${prefix}-${generatedId}`; +} + +export function createFieldSlotIds(controlId: string): FieldSlotIds { + return { + baseId: controlId, + controlId, + labelId: `${controlId}-label`, + helperTextId: `${controlId}-helper`, + errorTextId: `${controlId}-error` + }; +} + +export function composeAriaDescribedBy(...ids: Array) { + const value = ids + .filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + .join(' '); + + return value || undefined; +} diff --git a/src/internal/refs/composeRefs.test.tsx b/src/internal/refs/composeRefs.test.tsx new file mode 100644 index 0000000..4758bd6 --- /dev/null +++ b/src/internal/refs/composeRefs.test.tsx @@ -0,0 +1,93 @@ +import { render } from '@testing-library/react'; +import { createRef, useEffect, useRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { composeRefs } from './composeRefs'; +import { useComposedRefs } from './useComposedRefs'; + +describe('composeRefs', () => { + it('updates callback refs', () => { + const callbackRef = vi.fn(); + const composedRef = composeRefs(callbackRef); + const node = document.createElement('input'); + + composedRef(node); + + expect(callbackRef).toHaveBeenCalledWith(node); + }); + + it('updates object refs', () => { + const objectRef = createRef(); + const composedRef = composeRefs(objectRef); + const node = document.createElement('input'); + + composedRef(node); + + expect(objectRef.current).toBe(node); + }); + + it('updates multiple refs and ignores null refs', () => { + const callbackRef = vi.fn(); + const objectRef = createRef(); + const composedRef = composeRefs(callbackRef, undefined, objectRef, null); + const node = document.createElement('input'); + + composedRef(node); + + expect(callbackRef).toHaveBeenCalledWith(node); + expect(objectRef.current).toBe(node); + }); + + it('clears refs on unmount when no explicit cleanup exists', () => { + const callbackRef = vi.fn(); + const objectRef = createRef(); + const composedRef = composeRefs(callbackRef, objectRef); + const node = document.createElement('input'); + + composedRef(node); + composedRef(null); + + expect(callbackRef).toHaveBeenLastCalledWith(null); + expect(objectRef.current).toBeNull(); + }); + + it('returns cleanup support for callback refs that provide cleanup functions', () => { + const cleanup = vi.fn(); + const callbackRef = vi.fn(() => cleanup); + const objectRef = createRef(); + const composedRef = composeRefs(callbackRef, objectRef); + const node = document.createElement('input'); + + const composedCleanup = composedRef(node); + + expect(typeof composedCleanup).toBe('function'); + + composedCleanup?.(); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(objectRef.current).toBeNull(); + }); +}); + +describe('useComposedRefs', () => { + it('keeps callback identity stable while refs stay the same', () => { + const identities: Array<(node: HTMLInputElement | null) => void> = []; + const externalRef = createRef(); + + function Example() { + const localRef = useRef(null); + const composedRef = useComposedRefs(localRef, externalRef); + + useEffect(() => { + identities.push(composedRef); + }, [composedRef]); + + return ; + } + + const { rerender } = render(); + + rerender(); + + expect(identities).toHaveLength(1); + }); +}); diff --git a/src/internal/refs/composeRefs.ts b/src/internal/refs/composeRefs.ts new file mode 100644 index 0000000..488a1c3 --- /dev/null +++ b/src/internal/refs/composeRefs.ts @@ -0,0 +1,40 @@ +import type { MutableRefObject, Ref, RefCallback } from 'react'; + +type PossibleCleanup = void | (() => void); + +type CallbackRef = (value: T | null) => PossibleCleanup; + +function setRef(ref: Ref | undefined, value: T | null): PossibleCleanup { + if (typeof ref === 'function') { + return (ref as CallbackRef)(value); + } + + if (ref) { + (ref as MutableRefObject).current = value; + } +} + +export function composeRefs(...refs: Array | undefined>): RefCallback { + return (node) => { + const cleanups = refs.map((ref) => { + const cleanup = setRef(ref, node); + + return { cleanup, ref }; + }); + + if (!cleanups.some(({ cleanup }) => typeof cleanup === 'function')) { + return; + } + + return () => { + for (const { cleanup, ref } of cleanups) { + if (typeof cleanup === 'function') { + cleanup(); + continue; + } + + setRef(ref, null); + } + }; + }; +} diff --git a/src/internal/refs/useComposedRefs.ts b/src/internal/refs/useComposedRefs.ts new file mode 100644 index 0000000..4911103 --- /dev/null +++ b/src/internal/refs/useComposedRefs.ts @@ -0,0 +1,15 @@ +import { useCallback, useRef, type Ref, type RefCallback } from 'react'; +import { useIsomorphicLayoutEffect } from '@/internal/effects/useIsomorphicLayoutEffect'; +import { composeRefs } from './composeRefs'; + +export function useComposedRefs(...refs: Array | undefined>): RefCallback { + const refsRef = useRef(refs); + + useIsomorphicLayoutEffect(() => { + refsRef.current = refs; + }, [refs]); + + return useCallback((node: T | null) => { + return composeRefs(...refsRef.current)(node); + }, []); +} diff --git a/src/internal/state/useControllableState.test.tsx b/src/internal/state/useControllableState.test.tsx new file mode 100644 index 0000000..b593967 --- /dev/null +++ b/src/internal/state/useControllableState.test.tsx @@ -0,0 +1,136 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useControllableState } from './useControllableState'; + +describe('useControllableState', () => { + it('supports uncontrolled state with defaultValue', () => { + const { result } = renderHook(() => + useControllableState({ + defaultValue: 'overview' + }) + ); + + expect(result.current[0]).toBe('overview'); + + act(() => { + result.current[1]('metrics'); + }); + + expect(result.current[0]).toBe('metrics'); + }); + + it('supports controlled state', () => { + const onChange = vi.fn(); + const { result, rerender } = renderHook( + ({ value }: { value: string }) => + useControllableState({ + value, + defaultValue: 'overview', + onChange + }), + { + initialProps: { value: 'overview' } + } + ); + + act(() => { + result.current[1]('metrics'); + }); + + expect(result.current[0]).toBe('overview'); + expect(onChange).toHaveBeenCalledWith('metrics'); + + rerender({ value: 'metrics' }); + + expect(result.current[0]).toBe('metrics'); + }); + + it('supports functional updates', () => { + const { result } = renderHook(() => + useControllableState({ + defaultValue: 1 + }) + ); + + act(() => { + result.current[1]((previousValue) => previousValue + 1); + }); + + expect(result.current[0]).toBe(2); + }); + + it('uses the latest callback implementation', () => { + const firstCallback = vi.fn(); + const secondCallback = vi.fn(); + const { result, rerender } = renderHook( + ({ onChange }: { onChange: (value: string) => void }) => + useControllableState({ + defaultValue: 'overview', + onChange + }), + { + initialProps: { onChange: firstCallback } + } + ); + + rerender({ onChange: secondCallback }); + + act(() => { + result.current[1]('metrics'); + }); + + expect(firstCallback).not.toHaveBeenCalled(); + expect(secondCallback).toHaveBeenCalledWith('metrics'); + }); + + it('avoids duplicate callback notifications for equal values', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useControllableState({ + defaultValue: 'overview', + onChange + }) + ); + + act(() => { + result.current[1]('overview'); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not mutate controlled state internally', () => { + const { result } = renderHook(() => + useControllableState({ + value: 'overview', + onChange: () => undefined + }) + ); + + act(() => { + result.current[1]('metrics'); + }); + + expect(result.current[0]).toBe('overview'); + }); + + it('warns when usage switches between uncontrolled and controlled', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { rerender } = renderHook( + ({ value }: { value: string | undefined }) => + useControllableState({ + value, + defaultValue: 'overview' + }), + { + initialProps: { value: undefined } as { value: string | undefined } + } + ); + + rerender({ value: 'metrics' }); + + expect(errorSpy).toHaveBeenCalledOnce(); + + errorSpy.mockRestore(); + }); +}); diff --git a/src/internal/state/useControllableState.ts b/src/internal/state/useControllableState.ts new file mode 100644 index 0000000..9910fb9 --- /dev/null +++ b/src/internal/state/useControllableState.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useStableCallback } from '@/internal/callbacks/useStableCallback'; + +type SetStateAction = T | ((previousState: T) => T); + +type UseControllableStateParams = { + value?: T; + defaultValue?: T; + onChange?: (value: T) => void; +}; + +function resolveNextValue(nextValue: SetStateAction, currentValue: T) { + return typeof nextValue === 'function' + ? (nextValue as (previousState: T) => T)(currentValue) + : nextValue; +} + +export function useControllableState({ + value, + defaultValue, + onChange +}: UseControllableStateParams) { + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue as T); + const isControlled = value !== undefined; + const currentValue = isControlled ? value : uncontrolledValue; + const initialModeRef = useRef(isControlled); + const handleChange = useStableCallback(onChange); + + useEffect(() => { + if ( + process.env.NODE_ENV !== 'production' && + initialModeRef.current !== isControlled + ) { + console.error( + 'A component changed from %s to %s. This is unsupported. Decide between controlled or uncontrolled usage for the lifetime of the component.', + initialModeRef.current ? 'controlled' : 'uncontrolled', + isControlled ? 'controlled' : 'uncontrolled' + ); + } + }, [isControlled]); + + const setValue = useCallback( + (nextValue: SetStateAction) => { + if (isControlled) { + const resolvedValue = resolveNextValue(nextValue, currentValue as T); + + if (!Object.is(resolvedValue, currentValue)) { + handleChange?.(resolvedValue); + } + + return; + } + + setUncontrolledValue((previousValue) => { + const resolvedValue = resolveNextValue(nextValue, previousValue); + + if (!Object.is(resolvedValue, previousValue)) { + handleChange?.(resolvedValue); + return resolvedValue; + } + + return previousValue; + }); + }, + [currentValue, handleChange, isControlled] + ); + + return [currentValue as T, setValue] as const; +} From c3dbeb12ae9bf6253e9a13f10cd8c981054f974d Mon Sep 17 00:00:00 2001 From: mornieur Date: Tue, 21 Jul 2026 23:28:50 -0300 Subject: [PATCH 02/10] feat: add field architecture for textual controls --- .../atoms/Field/__tests__/Field.test.tsx | 86 ++++++++++ src/components/atoms/Field/context.ts | 32 ++++ src/components/atoms/Field/index.tsx | 149 ++++++++++++++++++ .../atoms/Field/stories/Field.stories.tsx | 84 ++++++++++ src/components/atoms/Field/styles.ts | 53 +++++++ .../atoms/Input/__tests__/Input.test.tsx | 7 + src/components/atoms/Input/index.tsx | 122 ++++++++------ .../atoms/Select/__tests__/Select.test.tsx | 11 ++ src/components/atoms/Select/index.tsx | 142 ++++++++++------- .../Textarea/__tests__/Textarea.test.tsx | 7 + src/components/atoms/Textarea/index.tsx | 118 ++++++++------ src/components/atoms/index.ts | 2 + 12 files changed, 662 insertions(+), 151 deletions(-) create mode 100644 src/components/atoms/Field/__tests__/Field.test.tsx create mode 100644 src/components/atoms/Field/context.ts create mode 100644 src/components/atoms/Field/index.tsx create mode 100644 src/components/atoms/Field/stories/Field.stories.tsx create mode 100644 src/components/atoms/Field/styles.ts diff --git a/src/components/atoms/Field/__tests__/Field.test.tsx b/src/components/atoms/Field/__tests__/Field.test.tsx new file mode 100644 index 0000000..df1169a --- /dev/null +++ b/src/components/atoms/Field/__tests__/Field.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import Field from '..'; +import Input from '../../Input'; + +describe('Field', () => { + it('associates the label with the nested control', () => { + render( + + Service name + + + ); + + expect(screen.getByLabelText('Service name')).toBeInTheDocument(); + }); + + it('associates helper and error text with the nested control', () => { + render( + + Workspace slug + + Use the public workspace slug. + Only lowercase letters are allowed. + + ); + + const input = screen.getByLabelText('Workspace slug'); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription( + 'Use the public workspace slug. Only lowercase letters are allowed.' + ); + }); + + it('renders optional text when required is false', () => { + render( + + Notes + + + ); + + expect(screen.getByText('Optional')).toBeInTheDocument(); + }); + + it('forwards ref, className, and style to the root element', () => { + const ref = createRef(); + const { container } = render( + + Styled field + + + ); + + expect(ref.current).toBe(container.firstChild); + expect(container.firstChild).toHaveClass('custom-field'); + expect(container.firstChild).toHaveStyle({ width: '320px' }); + }); + + it('uses an explicit controlId when provided', () => { + render( + + Controlled id + + + ); + + expect(screen.getByLabelText('Controlled id')).toHaveAttribute('id', 'custom-control'); + }); + + it('applies field-level disabled and required semantics to the nested control', () => { + render( + + Deployment target + + + ); + + const input = screen.getByRole('textbox', { name: /Deployment target/ }); + + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + }); +}); diff --git a/src/components/atoms/Field/context.ts b/src/components/atoms/Field/context.ts new file mode 100644 index 0000000..1a789c1 --- /dev/null +++ b/src/components/atoms/Field/context.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +export type FieldContextValue = { + controlId: string; + labelId: string; + helperTextId: string; + errorTextId: string; + disabled: boolean; + invalid: boolean; + required: boolean; + fullWidth: boolean; + optionalLabel?: ReactNode; + hasHelperText: boolean; + hasErrorText: boolean; +}; + +export const FieldContext = createContext(null); + +export function useOptionalFieldContext() { + return useContext(FieldContext); +} + +export function useFieldContext() { + const context = useOptionalFieldContext(); + + if (!context) { + throw new Error('Field subcomponents must be used within .'); + } + + return context; +} diff --git a/src/components/atoms/Field/index.tsx b/src/components/atoms/Field/index.tsx new file mode 100644 index 0000000..1e7bf60 --- /dev/null +++ b/src/components/atoms/Field/index.tsx @@ -0,0 +1,149 @@ +import { + Children, + forwardRef, + isValidElement, + useId, + type HTMLAttributes, + type LabelHTMLAttributes, + type ReactNode +} from 'react'; +import { + createFieldSlotIds, + resolveFieldControlId +} from '@/internal/ids/fieldSlotIds'; +import { FieldContext, useFieldContext } from './context'; +import * as S from './styles'; + +function hasChildType(children: ReactNode, component: unknown): boolean { + return Children.toArray(children).some((child) => { + if (!isValidElement(child)) { + return false; + } + + if (child.type === component) { + return true; + } + + return hasChildType((child.props as { children?: ReactNode }).children, component); + }); +} + +export type FieldRootProps = HTMLAttributes & { + controlId?: string; + disabled?: boolean; + invalid?: boolean; + required?: boolean; + fullWidth?: boolean; + optionalLabel?: ReactNode; +}; + +export type FieldLabelProps = LabelHTMLAttributes; + +export type FieldMessageProps = HTMLAttributes; + +const Label = forwardRef(({ children, htmlFor, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + {context.required ? : null} + {!context.required && context.optionalLabel ? ( + {context.optionalLabel} + ) : null} + + ); +}); + +Label.displayName = 'Field.Label'; + +const HelperText = forwardRef(({ children, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + + ); +}); + +HelperText.displayName = 'Field.HelperText'; + +const ErrorText = forwardRef(({ children, ...props }, ref) => { + const context = useFieldContext(); + + return ( + + {children} + + ); +}); + +ErrorText.displayName = 'Field.ErrorText'; + +const Root = forwardRef( + ( + { + id, + controlId, + children, + disabled = false, + invalid = false, + required = false, + fullWidth = false, + optionalLabel, + ...props + }, + ref + ) => { + const generatedId = useId(); + const resolvedControlId = resolveFieldControlId({ + id: controlId, + generatedId, + prefix: id ?? 'feitoza-field' + }); + const slotIds = createFieldSlotIds(resolvedControlId); + const hasHelperText = hasChildType(children, HelperText); + const hasErrorText = hasChildType(children, ErrorText); + + return ( + + + {children} + + + ); + } +); + +Root.displayName = 'Field.Root'; + +const Field = { + Root, + Label, + HelperText, + ErrorText +}; + +export { useFieldContext, useOptionalFieldContext } from './context'; +export default Field; diff --git a/src/components/atoms/Field/stories/Field.stories.tsx b/src/components/atoms/Field/stories/Field.stories.tsx new file mode 100644 index 0000000..b03b410 --- /dev/null +++ b/src/components/atoms/Field/stories/Field.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import type { ReactNode } from 'react'; +import Field from '..'; +import Input from '../../Input'; +import Select from '../../Select'; +import Textarea from '../../Textarea'; +import { semanticColors, space, typography } from '@/design-tokens'; + +const meta = { + title: 'Components/Field', + component: Field.Root, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Field provides the shared form anatomy for textual controls in FeitozaUI. It centralizes label, helper text, error text, invalid, required, and layout concerns without forcing the same structure onto selection controls yet.' + } + } + }, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const Surface = ({ children, width = 420 }: { children: ReactNode; width?: number }) => ( +
+ {children} +
+); + +export const InputComposition: Story = { + render: () => ( + + + API key alias + + The label and helper text are managed by Field. + + + ) +}; + +export const ErrorState: Story = { + render: () => ( + + + Workspace slug + + Use lowercase letters, numbers, and hyphens. + Only lowercase letters are allowed. + + + ) +}; + +export const MixedControls: Story = { + render: () => ( + +
+ + Environment + + Textual controls can share the same field anatomy. + + + Release notes +