diff --git a/.changeset/quiet-pans-smile.md b/.changeset/quiet-pans-smile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-pans-smile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index fb38084db5e..ea14c88fa67 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -13,6 +13,10 @@ "import": "./dist/primitives/button/index.js", "types": "./dist/primitives/button/index.d.ts" }, + "./input": { + "import": "./dist/primitives/input/index.js", + "types": "./dist/primitives/input/index.d.ts" + }, "./tabs": { "import": "./dist/primitives/tabs/index.js", "types": "./dist/primitives/tabs/index.d.ts" diff --git a/packages/headless/src/primitives/input/README.md b/packages/headless/src/primitives/input/README.md new file mode 100644 index 00000000000..eeb0cfc547a --- /dev/null +++ b/packages/headless/src/primitives/input/README.md @@ -0,0 +1,12 @@ +# Input + +An unstyled native input primitive for standalone fields and compound controls. It supports the shared `render` escape hatch and reflects native state through `data-disabled`, `data-invalid`, and `data-readonly` attributes for styling. + +```tsx +import { Input } from '@clerk/headless/input'; + +; +``` diff --git a/packages/headless/src/primitives/input/index.ts b/packages/headless/src/primitives/input/index.ts new file mode 100644 index 00000000000..f47a3e09061 --- /dev/null +++ b/packages/headless/src/primitives/input/index.ts @@ -0,0 +1 @@ +export { Input, type InputProps } from './input'; diff --git a/packages/headless/src/primitives/input/input.test.tsx b/packages/headless/src/primitives/input/input.test.tsx new file mode 100644 index 00000000000..56591e2b4f8 --- /dev/null +++ b/packages/headless/src/primitives/input/input.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Input } from './input'; + +afterEach(() => cleanup()); + +describe('Input', () => { + it('renders a native input and forwards its props and ref', () => { + const ref = React.createRef(); + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input.tagName).toBe('INPUT'); + expect(input).toHaveAttribute('name', 'name'); + expect(ref.current).toBe(input); + }); + + it('reflects native state for styling', () => { + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Name' }); + expect(input).toHaveAttribute('data-disabled', ''); + expect(input).toHaveAttribute('data-invalid', ''); + expect(input).toHaveAttribute('data-readonly', ''); + }); + + it('supports the headless render escape hatch', () => { + render( + } + data-input='grouped' + />, + ); + + const input = screen.getByRole('textbox', { name: 'Biography' }); + expect(input.tagName).toBe('TEXTAREA'); + expect(input).toHaveAttribute('data-input', 'grouped'); + }); +}); diff --git a/packages/headless/src/primitives/input/input.tsx b/packages/headless/src/primitives/input/input.tsx new file mode 100644 index 00000000000..753e104125e --- /dev/null +++ b/packages/headless/src/primitives/input/input.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, useRender } from '../../utils'; + +/** Props for the unstyled input primitive. */ +export type InputProps = ComponentProps<'input'>; + +/** + * An unstyled native input with render-prop support and reflected state attributes. + * Styled layers can use it for standalone fields or place it inside compound controls. + */ +export const Input = React.forwardRef(function Input( + { render, disabled = false, readOnly = false, 'aria-invalid': ariaInvalid, ...otherProps }, + ref, +) { + const invalid = ariaInvalid === true || ariaInvalid === 'true'; + + return useRender({ + defaultTagName: 'input', + render, + ref, + state: { disabled, invalid, readOnly }, + stateAttributesMapping: { + disabled: value => (value ? { 'data-disabled': '' } : null), + invalid: value => (value ? { 'data-invalid': '' } : null), + readOnly: value => (value ? { 'data-readonly': '' } : null), + }, + props: { + disabled, + readOnly, + 'aria-invalid': ariaInvalid, + ...otherProps, + }, + }); +}); diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 0722ae12526..e71a08ab0ff 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ entry: { 'primitives/accordion/index': 'src/primitives/accordion/index.ts', 'primitives/button/index': 'src/primitives/button/index.ts', + 'primitives/input/index': 'src/primitives/input/index.ts', 'primitives/tabs/index': 'src/primitives/tabs/index.ts', 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', 'primitives/popover/index': 'src/primitives/popover/index.ts', diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index afe67254bce..4ecf861a2a1 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -71,6 +71,7 @@ const docModules: Record> = { drawer: dynamic(() => import('../stories/drawer.mdx')), 'file-upload': dynamic(() => import('../stories/file-upload.mdx')), flow: dynamic(() => import('../stories/flow.mdx')), + input: dynamic(() => import('../stories/input.primitive.mdx')), menu: dynamic(() => import('../stories/menu.mdx')), otp: dynamic(() => import('../stories/otp.mdx')), popover: dynamic(() => import('../stories/popover.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 8f326162f60..42d2efb43ad 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -62,6 +62,7 @@ import { Sizes as IconFrameSizes, Treatments as IconFrameTreatments, } from '../stories/icon-frame.stories'; +import { meta as inputPrimitiveMeta } from '../stories/input.primitive.stories'; import { Default, Disabled as InputDisabled, @@ -348,6 +349,7 @@ const dialogModule: StoryModule = { meta: dialogMeta }; const drawerModule: StoryModule = { meta: drawerMeta }; const fileUploadModule: StoryModule = { meta: fileUploadMeta }; const flowModule: StoryModule = { meta: flowMeta }; +const inputPrimitiveModule: StoryModule = { meta: inputPrimitiveMeta }; const menuModule: StoryModule = { meta: menuMeta }; const otpModule: StoryModule = { meta: otpMeta }; const popoverModule: StoryModule = { meta: popoverMeta }; @@ -520,6 +522,7 @@ export const registry: StoryModule[] = [ drawerModule, fileUploadModule, flowModule, + inputPrimitiveModule, menuModule, otpModule, popoverModule, diff --git a/packages/swingset/src/stories/input.primitive.mdx b/packages/swingset/src/stories/input.primitive.mdx new file mode 100644 index 00000000000..02e16757595 --- /dev/null +++ b/packages/swingset/src/stories/input.primitive.mdx @@ -0,0 +1,74 @@ +import * as InputStories from './input.primitive.stories'; + +# Input + +An unstyled native input from `@clerk/headless`. It provides the shared `render` escape hatch and reflects native disabled, invalid, and read-only state as `data-*` attributes, but ships no styles. + +## Example + +The demo renders the raw primitive with only the browser's native appearance. + + + +## Usage + +```tsx +import { Input } from '@clerk/headless/input'; + +; +``` + +Use `render` when a styled layer or compound control needs to supply another input-like element: + +```tsx +} + aria-label='Biography' +/> +``` + +## Parts + +| Part | Default Element | Description | +| ------- | --------------- | ---------------------------------------------------------------- | +| `Input` | `` | Native input with polymorphic rendering and reflected state data | + +`Input` accepts the shared `render` prop and all standard attributes for its default element. + +## Props + +| Prop | Type | Default | Description | +| -------------- | -------------------------------- | ----------- | -------------------------------------------------------- | +| `render` | `ReactElement \| RenderFunction` | `` | Replaces the default element while merging props and ref | +| `disabled` | `boolean` | `false` | Disables the native control | +| `readOnly` | `boolean` | `false` | Makes the native control read-only | +| `aria-invalid` | `boolean \| 'true' \| 'false'` | `undefined` | Exposes the control's validation state | + +All other native input attributes pass through unchanged. + +## Styling + +The primitive exposes state through attributes that any styling system can target: + +| Attribute | Description | +| --------------- | ------------------------------------- | +| `data-disabled` | Present when `disabled` is `true` | +| `data-invalid` | Present when `aria-invalid` is `true` | +| `data-readonly` | Present when `readOnly` is `true` | + +```css +input[data-invalid] { + border-color: red; +} +``` diff --git a/packages/swingset/src/stories/input.primitive.stories.tsx b/packages/swingset/src/stories/input.primitive.stories.tsx new file mode 100644 index 00000000000..453daffeec5 --- /dev/null +++ b/packages/swingset/src/stories/input.primitive.stories.tsx @@ -0,0 +1,24 @@ +import { Input } from '@clerk/headless/input'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Primitives', + title: 'Input', + source: 'packages/headless/src/primitives/input/index.ts', +}; + +export function Default() { + return ( +
+ + +
+ ); +}