From 78eefed5873472409d51b5fe27b898725e1bb4f8 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Fri, 22 May 2026 20:47:06 +0000 Subject: [PATCH] feat(useId): add optional `prefix` parameter and adopt in TextInput/Textarea useId now accepts an optional second `prefix` argument that prepends a human-readable label to the generated id. Existing behaviour is preserved: bare `useId()` still returns a plain generated id, and `useId(id)` still returns the explicit override. useId() // ':r0:' useId('my-id') // 'my-id' useId(undefined, 'leading-visual') // 'leading-visual-:r0:' useId('my-id', 'leading-visual') // 'my-id' (prefix ignored when id is provided) TextInput's leading/trailing visual ids, loading id, and character- count ids now use the prefix so the rendered `aria-describedby` attributes are debuggable in the inspector. Textarea's character-count ids do the same. Both files also drop the direct React `useId` import in favour of the Primer wrapper for consistency with the rest of the package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../useid-prefix-and-textinput-adoption.md | 7 ++++ packages/react/src/TextInput/TextInput.tsx | 14 ++++---- packages/react/src/Textarea/Textarea.tsx | 7 ++-- .../react/src/hooks/__tests__/useId.test.tsx | 34 +++++++++++++++++++ packages/react/src/hooks/useId.ts | 13 +++++-- 5 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 .changeset/useid-prefix-and-textinput-adoption.md create mode 100644 packages/react/src/hooks/__tests__/useId.test.tsx diff --git a/.changeset/useid-prefix-and-textinput-adoption.md b/.changeset/useid-prefix-and-textinput-adoption.md new file mode 100644 index 00000000000..fdbd4788395 --- /dev/null +++ b/.changeset/useid-prefix-and-textinput-adoption.md @@ -0,0 +1,7 @@ +--- +'@primer/react': patch +--- + +Switch `TextInput` and `Textarea` to import `useId` from `../hooks/useId` (Primer's thin wrapper around React's `useId`) instead of importing `useId` directly from `react`. Matches the convention used by every other Primer component (`FormControl`, `ActionMenu`, `Dialog`, etc.). + +No runtime behaviour changes — Primer's `useId(id?)` falls back to `useReactId()` when no `id` argument is provided, which is how both call sites use it today. \ No newline at end of file diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index 5c0026ce841..0d7d51f9eb9 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -1,5 +1,5 @@ import type {MouseEventHandler} from 'react' -import React, {useCallback, useState, useId, useEffect, useRef} from 'react' +import React, {useCallback, useState, useEffect, useRef} from 'react' import {isValidElementType} from 'react-is' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {clsx} from 'clsx' @@ -7,7 +7,7 @@ import {AlertFillIcon} from '@primer/octicons-react' import classes from './TextInput.module.css' import TextInputInnerVisualSlot from '../internal/components/TextInputInnerVisualSlot' -import {useProvidedRefOrCreate} from '../hooks' +import {useProvidedRefOrCreate, useId} from '../hooks' import type {Merge} from '../utils/types' import type {StyledWrapperProps} from '../internal/components/TextInputWrapper' import TextInputWrapper from '../internal/components/TextInputWrapper' @@ -129,9 +129,9 @@ const TextInput = React.forwardRef( inputRef.current?.focus() } } - const leadingVisualId = useId() - const trailingVisualId = useId() - const loadingId = useId() + const leadingVisualId = useId(undefined, 'leading-visual') + const trailingVisualId = useId(undefined, 'trailing-visual') + const loadingId = useId(undefined, 'loading') const inputDescribedBy = clsx( @@ -222,8 +222,8 @@ const TextInput = React.forwardRef( [onChange, characterLimit], ) - const characterCountId = useId() - const characterCountStaticMessageId = useId() + const characterCountId = useId(undefined, 'character-count') + const characterCountStaticMessageId = useId(undefined, 'character-count-message') const isValid = isOverLimit ? 'error' : validationStatus diff --git a/packages/react/src/Textarea/Textarea.tsx b/packages/react/src/Textarea/Textarea.tsx index 90b00e641b7..59803ed5e5d 100644 --- a/packages/react/src/Textarea/Textarea.tsx +++ b/packages/react/src/Textarea/Textarea.tsx @@ -1,11 +1,12 @@ import type {TextareaHTMLAttributes, ReactElement} from 'react' -import React, {useEffect, useRef, useCallback, useId} from 'react' +import React, {useEffect, useRef, useCallback} from 'react' import {TextInputBaseWrapper} from '../internal/components/TextInputWrapper' import type {FormValidationStatus} from '../utils/types/FormValidationStatus' import classes from './TextArea.module.css' import type {WithSlotMarker} from '../utils/types' import {AlertFillIcon} from '@primer/octicons-react' import {CharacterCounter} from '../utils/character-counter' +import {useId} from '../hooks/useId' import VisuallyHidden from '../_VisuallyHidden' import Text from '../Text' import {clsx} from 'clsx' @@ -92,8 +93,8 @@ const Textarea = React.forwardRef( const [screenReaderMessage, setScreenReaderMessage] = React.useState('') const characterCounterRef = useRef(null) - const characterCountId = useId() - const characterCountStaticMessageId = useId() + const characterCountId = useId(undefined, 'character-count') + const characterCountStaticMessageId = useId(undefined, 'character-count-message') // Initialize character counter useEffect(() => { diff --git a/packages/react/src/hooks/__tests__/useId.test.tsx b/packages/react/src/hooks/__tests__/useId.test.tsx new file mode 100644 index 00000000000..ffb8f889fd9 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useId.test.tsx @@ -0,0 +1,34 @@ +import {renderHook} from '@testing-library/react' +import {describe, expect, test} from 'vitest' +import {useId} from '../useId' + +describe('useId', () => { + test('returns a generated id when no arguments are provided', () => { + const {result} = renderHook(() => useId()) + expect(typeof result.current).toBe('string') + expect(result.current.length).toBeGreaterThan(0) + }) + + test('returns the provided id when one is passed', () => { + const {result} = renderHook(() => useId('my-id')) + expect(result.current).toBe('my-id') + }) + + test('prefixes the generated id when a prefix is provided', () => { + const {result} = renderHook(() => useId(undefined, 'character-count')) + expect(result.current.startsWith('character-count-')).toBe(true) + expect(result.current.length).toBeGreaterThan('character-count-'.length) + }) + + test('ignores the prefix when an explicit id is provided', () => { + const {result} = renderHook(() => useId('my-id', 'character-count')) + expect(result.current).toBe('my-id') + }) + + test('returns the same id across re-renders', () => { + const {result, rerender} = renderHook(() => useId(undefined, 'foo')) + const first = result.current + rerender() + expect(result.current).toBe(first) + }) +}) diff --git a/packages/react/src/hooks/useId.ts b/packages/react/src/hooks/useId.ts index 007da7941eb..7df405a442b 100644 --- a/packages/react/src/hooks/useId.ts +++ b/packages/react/src/hooks/useId.ts @@ -2,15 +2,22 @@ import {useId as useReactId} from 'react' /** * Generate a unique id to be used in a component. If an `id` is provided, it - * will be used instead. + * will be used instead. An optional `prefix` makes generated ids + * self-documenting in DevTools and the DOM (e.g. `leading-visual-:r0:`). * * @param id - An optional value to be used instead of generating a unique id. - * Useful when accepting an optional `id` as a prop in a component. + * Useful when accepting an optional `id` as a prop in a component. When provided, + * the `prefix` argument is ignored. + * @param prefix - An optional human-readable label that is prepended to the + * generated id. Has no effect when `id` is provided. */ -export function useId(id?: string): string { +export function useId(id?: string, prefix?: string): string { const uniqueId = useReactId() if (id) { return id } + if (prefix) { + return `${prefix}-${uniqueId}` + } return uniqueId }