Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/useid-prefix-and-textinput-adoption.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 7 additions & 7 deletions packages/react/src/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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'
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'
Expand Down Expand Up @@ -129,9 +129,9 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
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(
Expand Down Expand Up @@ -222,8 +222,8 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
[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

Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/Textarea/Textarea.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -92,8 +93,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
const [screenReaderMessage, setScreenReaderMessage] = React.useState<string>('')
const characterCounterRef = useRef<CharacterCounter | null>(null)

const characterCountId = useId()
const characterCountStaticMessageId = useId()
const characterCountId = useId(undefined, 'character-count')
const characterCountStaticMessageId = useId(undefined, 'character-count-message')

// Initialize character counter
useEffect(() => {
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/hooks/__tests__/useId.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 10 additions & 3 deletions packages/react/src/hooks/useId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading