diff --git a/src/hooks/useId.ts b/src/hooks/useId.ts index 65337a68..1b6a0e6d 100644 --- a/src/hooks/useId.ts +++ b/src/hooks/useId.ts @@ -18,6 +18,21 @@ export function resetUuid() { } } +/** + * Generate a valid HTML id from prefix and key. + * Sanitizes the key by replacing invalid characters with hyphens. + * @param prefix - The prefix for the id + * @param key - The key from React element, may contain spaces or invalid characters + * @returns A valid HTML id string + */ +export function getId(prefix: string, key: string): string { + // Valid id characters: letters, digits, hyphen, underscore, colon, period + // Replace all invalid characters (including spaces) with hyphens to preserve length + const sanitizedKey = key.replace(/[^a-zA-Z0-9_.:-]/g, '-'); + + return `${prefix}-${sanitizedKey}`; +} + const useOriginId = getUseId(); export default useOriginId diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index fc096500..86125daa 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render } from '@testing-library/react'; import * as React from 'react'; import { renderToString } from 'react-dom/server'; import useId from '../src/hooks/useId'; +import { getId } from '../src/hooks/useId'; import useLayoutEffect from '../src/hooks/useLayoutEffect'; import useMemo from '../src/hooks/useMemo'; import useMergedState from '../src/hooks/useMergedState'; @@ -663,6 +664,18 @@ describe('hooks', () => { errorSpy.mockRestore(); process.env.NODE_ENV = originEnv; }); + + it('should sanitize keys with invalid characters', () => { + expect(getId('item', 'hello world')).toBe('item-hello-world'); + expect(getId('tab', 'user@name#123')).toBe('tab-user-name-123'); + expect(getId('panel', 'test/path\\file')).toBe('panel-test-path-file'); + expect(getId('menu', 'key with multiple spaces')).toBe( + 'menu-key-with--multiple---spaces', + ); + expect(getId('btn', 'valid-key_123:456.789')).toBe( + 'btn-valid-key_123:456.789', + ); + }); }); describe('useMobile', () => {