diff --git a/package.json b/package.json index c350b9adb70..e2d7f5f8d33 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "dev:generate-graphql-schema": "pnpm runts ./test/generateGraphQLSchema.ts", "dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts", "dev:generate-types": "pnpm runts ./test/generateTypes.ts", + "dev:pattern-library": "pnpm --filter @tools/pattern-library dev", "dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod", "dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts", diff --git a/packages/ui/src/elements/Banner/Banner.stories.tsx b/packages/ui/src/elements/Banner/Banner.stories.tsx new file mode 100644 index 00000000000..8d78eb72905 --- /dev/null +++ b/packages/ui/src/elements/Banner/Banner.stories.tsx @@ -0,0 +1,19 @@ +'use client' +import React from 'react' + +import { Banner } from './index.js' + +export const meta = { + description: 'Contextual notification banner for displaying status messages.', + title: 'Elements / Banner', +} + +export const Default = () => This is a default banner message. + +export const Info = () => Your changes have been saved successfully. + +export const Success = () => Document published successfully. + +export const Error = () => ( + An error occurred while saving. Please try again. +) diff --git a/packages/ui/src/elements/Banner/documentation.md b/packages/ui/src/elements/Banner/documentation.md new file mode 100644 index 00000000000..9ea0e41ef30 --- /dev/null +++ b/packages/ui/src/elements/Banner/documentation.md @@ -0,0 +1,53 @@ +# Banner + +The Banner component displays contextual notification messages in the Payload admin panel. It supports four semantic type variants (default, info, success, error), an optional icon with configurable alignment, and can function as a plain container, a clickable button, or a navigable link depending on which props are provided. + +## Import + +```tsx +import { Banner } from '@payloadcms/ui' +``` + +## Usage + +```tsx +Your changes have been saved as a draft. +``` + +## Props + +| Prop | Type | Default | Description | +| ----------- | --------------------------------------------- | ----------- | ----------------------------------------------------------------------------- | +| `type` | `'default' \| 'info' \| 'success' \| 'error'` | `'default'` | Controls the color and semantic intent of the banner | +| `children` | `ReactNode` | — | Content rendered inside the banner | +| `icon` | `ReactNode` | — | Optional icon element rendered alongside the content | +| `alignIcon` | `'left' \| 'right'` | `'right'` | Side of the banner where the icon is placed (only applies when `icon` is set) | +| `to` | `string` | — | When provided, renders the banner as a `` navigating to this path | +| `onClick` | `MouseEventHandler` | — | When provided (without `to`), renders the banner as a ` + +export const Secondary = () => + +export const Subtle = () => + +export const Destructive = () => + +export const Ghost = () => + +export const Disabled = () => ( + +) + +export const Large = () => ( + +) + +export const WithIcon = () => ( + +) diff --git a/packages/ui/src/elements/Button/documentation.md b/packages/ui/src/elements/Button/documentation.md new file mode 100644 index 00000000000..e6fb9fc333a --- /dev/null +++ b/packages/ui/src/elements/Button/documentation.md @@ -0,0 +1,64 @@ +# Button + +The Button component is the primary action trigger in the Payload admin panel. It handles navigation, form submission, and destructive operations. + +## Import + +```tsx +import { Button } from '@payloadcms/ui' +``` + +## Usage + +```tsx + +``` + +## Variants + +| Style | Use case | +| ------------- | ---------------------------------- | +| `primary` | Main call-to-action (Save, Submit) | +| `secondary` | Secondary actions (Cancel, Back) | +| `subtle` | Low-emphasis actions | +| `destructive` | Irreversible actions (Delete) | +| `ghost` | Minimal visual weight | +| `pill` | Tag-style buttons | + +## Props + +| Prop | Type | Default | Description | +| ------------- | --------------------- | ----------- | -------------------------- | +| `buttonStyle` | string | `'primary'` | Visual style variant | +| `size` | `'medium' \| 'large'` | `'medium'` | Button size | +| `disabled` | boolean | `false` | Disables interaction | +| `icon` | ReactNode | — | Icon shown alongside label | +| `onClick` | function | — | Click handler | + +## Examples + +### Primary action + +```tsx + +``` + +### Destructive action + +```tsx + +``` + +### Large secondary button + +```tsx + +``` diff --git a/packages/ui/src/fields/Checkbox/Checkbox.stories.tsx b/packages/ui/src/fields/Checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000000..bfaaf08c79a --- /dev/null +++ b/packages/ui/src/fields/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,63 @@ +'use client' +import React, { useState } from 'react' + +import { CheckboxInput } from './Input.js' + +export const meta = { + description: 'Boolean toggle rendered as a styled checkbox.', + title: 'Fields / Checkbox', +} + +export const Unchecked = () => { + const [checked, setChecked] = useState(false) + return ( + setChecked((prev) => !prev)} + /> + ) +} + +export const Checked = () => { + const [checked, setChecked] = useState(true) + return ( + setChecked((prev) => !prev)} + /> + ) +} + +export const PartiallyChecked = () => { + const [checked, setChecked] = useState(false) + return ( + setChecked((prev) => !prev)} + partialChecked + /> + ) +} + +export const ReadOnly = () => ( + {}} readOnly /> +) + +export const Required = () => { + const [checked, setChecked] = useState(false) + return ( + setChecked((prev) => !prev)} + required + /> + ) +} diff --git a/packages/ui/src/fields/Checkbox/documentation.md b/packages/ui/src/fields/Checkbox/documentation.md new file mode 100644 index 00000000000..fe6fcd56b4c --- /dev/null +++ b/packages/ui/src/fields/Checkbox/documentation.md @@ -0,0 +1,82 @@ +# CheckboxInput + +The `CheckboxInput` component is the presentational layer for the Payload checkbox field. It renders a styled checkbox with a custom check/indeterminate icon, an associated label, and optional slots for content before and after the input. Use this component directly when you need a standalone checkbox outside of a Payload form context. The connected form component is `CheckboxField`. + +## Import + +```tsx +import { CheckboxInput } from '@payloadcms/ui' +``` + +## Usage + +```tsx + setIsActive(e.target.checked)} +/> +``` + +## Props + +| Prop | Type | Default | Description | +| ---------------- | ------------------------------------------------------ | ------- | ---------------------------------------------------------------------------------- | +| `onToggle` | `(event: React.ChangeEvent) => void` | — | **Required.** Called when the checkbox state changes | +| `checked` | `boolean` | — | Whether the checkbox is checked | +| `partialChecked` | `boolean` | — | When `true` and `checked` is `false`, renders the indeterminate (`LineIcon`) state | +| `label` | `string \| Record` | — | Label text rendered next to the checkbox via `FieldLabel` | +| `name` | `string` | — | Native `name` attribute; also used for `aria-labelledby` and `title` | +| `id` | `string` | — | Native `id` for the ``; auto-generated via `useId` if omitted | +| `readOnly` | `boolean` | — | Disables the checkbox; also forced `true` before client hydration | +| `required` | `boolean` | — | Marks the field as required | +| `localized` | `boolean` | — | Shows a localization indicator on the label | +| `className` | `string` | — | Additional CSS class names for the root wrapper | +| `inputRef` | `React.RefObject` | — | Ref forwarded to the underlying `` element | +| `BeforeInput` | `ReactNode` | — | Slot rendered before the checkbox input group | +| `AfterInput` | `ReactNode` | — | Slot rendered after the checkbox input group | +| `Label` | `ReactNode` | — | Custom label component; overrides the default `FieldLabel` | +| `Error` | `ReactNode` | — | Custom error component rendered inside the input group | + +## Variants + +### Basic controlled checkbox + +```tsx +const [accepted, setAccepted] = React.useState(false) + + setAccepted(e.target.checked)} +/> +``` + +### Indeterminate (partial) state + +Used for "select all" controls where only some child items are selected: + +```tsx + +``` + +### Read-only display + +```tsx + {}} +/> +``` diff --git a/packages/ui/src/fields/Email/Email.stories.tsx b/packages/ui/src/fields/Email/Email.stories.tsx new file mode 100644 index 00000000000..537474671cd --- /dev/null +++ b/packages/ui/src/fields/Email/Email.stories.tsx @@ -0,0 +1,97 @@ +'use client' +import React, { useState } from 'react' + +export const meta = { + description: 'Email address input with built-in format validation.', + title: 'Fields / Email', +} + +// EmailField uses useField() internally, so we use a direct input approach +// that mirrors the field's rendered output without requiring a Form context. + +export const Default = () => { + const [value, setValue] = useState('') + return ( +
+ +
+ setValue(e.target.value)} + type="email" + value={value} + /> +
+
+ ) +} + +export const WithValue = () => { + const [value, setValue] = useState('hello@example.com') + return ( +
+ +
+ setValue(e.target.value)} + type="email" + value={value} + /> +
+
+ ) +} + +export const ReadOnly = () => ( +
+ +
+ {}} + type="email" + value="readonly@example.com" + /> +
+
+) + +export const WithError = () => { + const [value, setValue] = useState('') + return ( +
+ +
+
This field is required.
+ setValue(e.target.value)} + type="email" + value={value} + /> +
+
+ ) +} diff --git a/packages/ui/src/fields/Email/documentation.md b/packages/ui/src/fields/Email/documentation.md new file mode 100644 index 00000000000..d0b7c8fcc60 --- /dev/null +++ b/packages/ui/src/fields/Email/documentation.md @@ -0,0 +1,98 @@ +# EmailField + +The `EmailField` component is the full Payload email field, wired up to the form context via `useField`. It renders a labelled `` with the `.field-type.email` wrapper and `.form-input` CSS classes. The field handles its own validation, error display, and description — making it suitable for use inside any Payload admin form. + +For a standalone presentational email input outside of a Payload form, render the HTML `` directly instead. + +## Import + +```tsx +import { EmailField } from '@payloadcms/ui' +``` + +## Usage + +The `EmailField` is a fully-connected field component and receives its configuration from the Payload form context. You pass it as a custom field component in your collection config: + +```tsx +import { EmailField } from '@payloadcms/ui' + +// Inside a Payload collection admin config: +{ + name: 'contactEmail', + type: 'email', + admin: { + components: { + Field: EmailField, + }, + }, +} +``` + +## Props + +`EmailField` implements the `EmailFieldClientComponent` contract from Payload. The relevant admin-level options are drawn from the field config: + +| Prop / Admin Config | Type | Default | Description | +| -------------------------- | --------------------------------------- | ------- | ---------------------------------------------------------- | +| `field.label` | `string \| Record` | — | Label rendered above the input | +| `field.required` | `boolean` | — | Marks the field as required | +| `field.admin.placeholder` | `string \| Record` | — | Placeholder text; supports i18n locale map | +| `field.admin.description` | `string \| (() => string) \| ReactNode` | — | Helper text rendered below the field | +| `field.admin.autoComplete` | `string` | — | Native `autocomplete` attribute forwarded to the `` | +| `field.admin.className` | `string` | — | Additional CSS class for the root wrapper | +| `readOnly` | `boolean` | — | Disables the input | +| `validate` | `EmailFieldValidation` | — | Custom validation function | +| `path` | `string` | — | Field path within the form data tree | + +## Variants + +### Basic email field config + +```tsx +{ + name: 'email', + type: 'email', + label: 'Email Address', + required: true, + admin: { + placeholder: 'user@example.com', + description: 'We will never share your email with third parties.', + autoComplete: 'email', + }, +} +``` + +### Read-only email field + +```tsx +{ + name: 'primaryEmail', + type: 'email', + label: 'Primary Email', + admin: { + readOnly: true, + description: 'Contact support to change your primary email address.', + }, +} +``` + +### Email field with custom validation + +```tsx +import type { EmailFieldValidation } from 'payload' + +const corporateEmailOnly: EmailFieldValidation = (value) => { + if (value && !value.endsWith('@company.com')) { + return 'Only corporate email addresses are allowed.' + } + return true +} + +{ + name: 'workEmail', + type: 'email', + label: 'Work Email', + validate: corporateEmailOnly, +} +``` diff --git a/packages/ui/src/fields/FieldDescription/FieldDescription.stories.tsx b/packages/ui/src/fields/FieldDescription/FieldDescription.stories.tsx new file mode 100644 index 00000000000..7d4f5947941 --- /dev/null +++ b/packages/ui/src/fields/FieldDescription/FieldDescription.stories.tsx @@ -0,0 +1,36 @@ +'use client' +import React from 'react' + +import { FieldDescription } from './index.js' + +export const meta = { + description: 'Renders help text below a field to guide users.', + title: 'Fields / FieldDescription', +} + +export const Default = () => ( + +) + +export const LongDescription = () => ( + +) + +export const WithMarginTop = () => ( + +) + +export const WithMarginBottom = () => ( + +) diff --git a/packages/ui/src/fields/FieldDescription/documentation.md b/packages/ui/src/fields/FieldDescription/documentation.md new file mode 100644 index 00000000000..d76559913a4 --- /dev/null +++ b/packages/ui/src/fields/FieldDescription/documentation.md @@ -0,0 +1,54 @@ +# FieldDescription + +The `FieldDescription` component renders helper text below (or above) a form field. It is purely presentational and stateless — it renders nothing when `description` is falsy. Labels are resolved through `getTranslation` so the `description` prop accepts plain strings, i18n locale maps, and translation functions equally. All Payload field components use `FieldDescription` internally; use it directly when building custom fields or standalone form elements. + +## Import + +```tsx +import { FieldDescription } from '@payloadcms/ui' +``` + +## Usage + +```tsx + +``` + +## Props + +| Prop | Type | Default | Description | +| ----------------- | --------------------------------------- | ------- | ---------------------------------------------------------------------------------- | +| `description` | `string \| (() => string) \| ReactNode` | — | The description content to render. Nothing is rendered when this is falsy. | +| `path` | `string` | — | Field path; appended to the class name as `field-description-{path}` for targeting | +| `className` | `string` | — | Additional CSS class names for the root `
` | +| `marginPlacement` | `'above' \| 'below'` | — | Adds a `field-description--margin-above` or `--margin-below` modifier class | + +## Variants + +### Plain string description + +```tsx + +``` + +### Description rendered above the field + +```tsx + +``` + +### i18n locale map description + +```tsx + +``` diff --git a/packages/ui/src/fields/Number/Number.stories.tsx b/packages/ui/src/fields/Number/Number.stories.tsx new file mode 100644 index 00000000000..0e4c7d70c89 --- /dev/null +++ b/packages/ui/src/fields/Number/Number.stories.tsx @@ -0,0 +1,123 @@ +'use client' +import React, { useState } from 'react' + +import { InputStepper } from '../../elements/InputStepper/index.js' +import { FieldDescription } from '../FieldDescription/index.js' +import { FieldLabel } from '../FieldLabel/index.js' +import { fieldBaseClass } from '../shared/index.js' + +export const meta = { + description: 'Numeric input with increment/decrement stepper controls.', + title: 'Fields / Number', +} + +const baseClass = 'number' + +export const Default = () => { + const [value, setValue] = useState('') + return ( +
+ +
+
+ setValue(e.target.value)} + step={1} + type="number" + value={value} + /> + setValue((prev) => (prev === '' ? -1 : Number(prev) - 1))} + onIncrement={() => setValue((prev) => (prev === '' ? 1 : Number(prev) + 1))} + /> +
+
+
+ ) +} + +export const WithValue = () => { + const [value, setValue] = useState(29.99) + return ( +
+ +
+
+ setValue(e.target.value)} + step={0.01} + type="number" + value={value} + /> + setValue((prev) => Math.max(0, Number(prev) - 0.01))} + onIncrement={() => setValue((prev) => Number(prev) + 0.01)} + /> +
+
+
+ ) +} + +export const WithMinMax = () => { + const [value, setValue] = useState(3) + return ( +
+ +
+ +
+ setValue(e.target.value)} + step={1} + type="number" + value={value} + /> + setValue((prev) => Math.max(1, Number(prev) - 1))} + onIncrement={() => setValue((prev) => Math.min(5, Number(prev) + 1))} + /> +
+
+
+ ) +} + +export const ReadOnly = () => ( +
+ +
+
+ {}} + type="number" + value={1042} + /> + {}} onIncrement={() => {}} /> +
+
+
+) diff --git a/packages/ui/src/fields/Number/documentation.md b/packages/ui/src/fields/Number/documentation.md new file mode 100644 index 00000000000..783c108e559 --- /dev/null +++ b/packages/ui/src/fields/Number/documentation.md @@ -0,0 +1,100 @@ +# NumberField + +The `NumberField` component is the fully-connected Payload number field. It renders a labelled `` accompanied by `InputStepper` increment/decrement buttons, and wires directly into the Payload form context via `useField`. The field enforces optional `min`, `max`, and `step` constraints, supports a `hasMany` tag mode for entering multiple numeric values, and handles its own validation, error, and description display. + +## Import + +```tsx +import { NumberField } from '@payloadcms/ui' +``` + +## Usage + +The `NumberField` is a fully-connected field component. Use it as a custom field component in your Payload collection config: + +```tsx +import { NumberField } from '@payloadcms/ui' + +// Inside a Payload collection admin config: +{ + name: 'price', + type: 'number', + admin: { + components: { + Field: NumberField, + }, + }, +} +``` + +## Props + +`NumberField` implements the `NumberFieldClientComponent` contract from Payload. Configuration is drawn from the field config: + +| Prop / Admin Config | Type | Default | Description | +| ------------------------- | --------------------------------------- | ----------- | ------------------------------------------------------------------- | +| `field.label` | `string \| Record` | — | Label rendered above the input | +| `field.required` | `boolean` | — | Marks the field as required | +| `field.min` | `number` | `-Infinity` | Minimum allowed value; clamps stepper and validates | +| `field.max` | `number` | `Infinity` | Maximum allowed value; clamps stepper and validates | +| `field.hasMany` | `boolean` | `false` | Enables multi-value tag mode via `ReactSelect` | +| `field.maxRows` | `number` | `Infinity` | Maximum number of values when `hasMany` is `true` | +| `field.admin.step` | `number` | `1` | Increment/decrement amount used by the stepper and the native input | +| `field.admin.placeholder` | `string \| Record` | — | Placeholder text; supports i18n locale map | +| `field.admin.description` | `string \| (() => string) \| ReactNode` | — | Helper text rendered below the field | +| `field.admin.className` | `string` | — | Additional CSS class for the root wrapper | +| `readOnly` | `boolean` | — | Disables both the input and the stepper buttons | +| `validate` | `function` | — | Custom validation function | +| `onChange` | `(value: number) => void` | — | Optional external change callback | +| `path` | `string` | — | Field path within the form data tree | + +## Variants + +### Integer quantity with min/max bounds + +```tsx +{ + name: 'quantity', + type: 'number', + label: 'Quantity', + min: 0, + max: 999, + admin: { + step: 1, + description: 'Available stock for this product.', + }, +} +``` + +### Decimal step for pricing + +```tsx +{ + name: 'price', + type: 'number', + label: 'Price (USD)', + min: 0, + admin: { + step: 0.01, + placeholder: '0.00', + description: 'Enter the price in US dollars.', + }, +} +``` + +### Multi-value numeric tags + +```tsx +{ + name: 'ratings', + type: 'number', + label: 'Ratings', + hasMany: true, + maxRows: 5, + min: 1, + max: 5, + admin: { + description: 'Enter up to 5 rating values between 1 and 5.', + }, +} +``` diff --git a/packages/ui/src/fields/Point/Point.stories.tsx b/packages/ui/src/fields/Point/Point.stories.tsx new file mode 100644 index 00000000000..6446e461590 --- /dev/null +++ b/packages/ui/src/fields/Point/Point.stories.tsx @@ -0,0 +1,154 @@ +'use client' +import React, { useState } from 'react' + +import { InputStepper } from '../../elements/InputStepper/index.js' +import { FieldDescription } from '../FieldDescription/index.js' +import { FieldLabel } from '../FieldLabel/index.js' +import { fieldBaseClass } from '../shared/index.js' + +export const meta = { + description: 'Longitude/latitude coordinate pair input for geographic point data.', + title: 'Fields / Point', +} + +const baseClass = 'point' + +export const Default = () => { + const [longitude, setLongitude] = useState('') + const [latitude, setLatitude] = useState('') + return ( +
+
    +
  • + +
    + setLongitude(e.target.value)} + placeholder="Longitude" + type="number" + value={longitude} + /> + setLongitude((prev) => (prev === '' ? -1 : Number(prev) - 1))} + onIncrement={() => setLongitude((prev) => (prev === '' ? 1 : Number(prev) + 1))} + /> +
    +
  • +
  • + +
    + setLatitude(e.target.value)} + placeholder="Latitude" + type="number" + value={latitude} + /> + setLatitude((prev) => (prev === '' ? -1 : Number(prev) - 1))} + onIncrement={() => setLatitude((prev) => (prev === '' ? 1 : Number(prev) + 1))} + /> +
    +
  • +
+
+ ) +} + +export const WithValue = () => { + const [longitude, setLongitude] = useState(-73.935242) + const [latitude, setLatitude] = useState(40.73061) + return ( +
+
    +
  • + +
    + setLongitude(e.target.value)} + type="number" + value={longitude} + /> + setLongitude((prev) => Number(prev) - 1)} + onIncrement={() => setLongitude((prev) => Number(prev) + 1)} + /> +
    +
  • +
  • + +
    + setLatitude(e.target.value)} + type="number" + value={latitude} + /> + setLatitude((prev) => Number(prev) - 1)} + onIncrement={() => setLatitude((prev) => Number(prev) + 1)} + /> +
    +
  • +
+ +
+ ) +} + +export const ReadOnly = () => ( +
+
    +
  • + +
    + {}} + type="number" + value={2.3522} + /> + {}} onIncrement={() => {}} /> +
    +
  • +
  • + +
    + {}} + type="number" + value={48.8566} + /> + {}} onIncrement={() => {}} /> +
    +
  • +
+
+) diff --git a/packages/ui/src/fields/Point/documentation.md b/packages/ui/src/fields/Point/documentation.md new file mode 100644 index 00000000000..222b440ca63 --- /dev/null +++ b/packages/ui/src/fields/Point/documentation.md @@ -0,0 +1,93 @@ +# PointField + +The `PointField` component is the fully-connected Payload geographic point field. It renders two labelled `` elements — one for longitude and one for latitude — each paired with an `InputStepper` for increment/decrement control. The pair of values is stored as a `[longitude, latitude]` tuple. The component wires directly into the Payload form context via `useField` and handles its own validation, error, and description display. + +## Import + +```tsx +import { PointField } from '@payloadcms/ui' +``` + +## Usage + +The `PointField` is a fully-connected field component. Use it as a custom field component in your Payload collection config: + +```tsx +import { PointField } from '@payloadcms/ui' + +// Inside a Payload collection admin config: +{ + name: 'location', + type: 'point', + admin: { + components: { + Field: PointField, + }, + }, +} +``` + +## Props + +`PointField` implements the `PointFieldClientComponent` contract from Payload. Configuration is drawn from the field config: + +| Prop / Admin Config | Type | Default | Description | +| ------------------------- | --------------------------------------- | ------- | ---------------------------------------------------------------------- | +| `field.label` | `string \| Record` | — | Base label prepended to "Longitude" and "Latitude" sub-labels | +| `field.required` | `boolean` | — | Marks both coordinate inputs as required | +| `field.admin.step` | `number` | `1` | Increment/decrement amount for both stepper buttons | +| `field.admin.placeholder` | `string \| Record` | — | Placeholder text for both coordinate inputs; supports i18n locale map | +| `field.admin.description` | `string \| (() => string) \| ReactNode` | — | Helper text rendered below the field pair | +| `field.admin.className` | `string` | — | Additional CSS class for the root wrapper | +| `readOnly` | `boolean` | — | Disables both inputs and stepper buttons | +| `validate` | `PointFieldValidation` | — | Custom validation function; receives the `[longitude, latitude]` tuple | +| `path` | `string` | — | Field path within the form data tree | + +## Stored value format + +The field stores and receives values as a two-element tuple: `[longitude, latitude]`. Index `0` is longitude, index `1` is latitude — matching the GeoJSON coordinate order. + +## Variants + +### Basic location field + +```tsx +{ + name: 'storeLocation', + type: 'point', + label: 'Store Location', + admin: { + description: 'Enter the geographic coordinates of the store.', + }, +} +``` + +### High-precision coordinates with decimal step + +```tsx +{ + name: 'gpsCoordinates', + type: 'point', + label: 'GPS Coordinates', + admin: { + step: 0.000001, + placeholder: '0.000000', + description: 'Six decimal places provide ~0.1 m accuracy.', + }, +} +``` + +### Required read-only display + +```tsx +{ + name: 'originPoint', + type: 'point', + label: 'Origin', + required: true, + admin: { + readOnly: true, + description: 'Set automatically on creation.', + }, +} +``` diff --git a/packages/ui/src/fields/Select/Select.stories.tsx b/packages/ui/src/fields/Select/Select.stories.tsx new file mode 100644 index 00000000000..8ce37d3b279 --- /dev/null +++ b/packages/ui/src/fields/Select/Select.stories.tsx @@ -0,0 +1,114 @@ +'use client' +import React, { useState } from 'react' + +import { SelectInput } from './Input.js' + +export const meta = { + description: 'Dropdown select for choosing from a fixed set of options.', + title: 'Fields / Select', +} + +const statusOptions = [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, +] + +const priorityOptions = [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Critical', value: 'critical' }, +] + +export const Default = () => { + const [value, setValue] = useState(null) + return ( + setValue(selected ? (selected as { value: string }).value : null)} + options={statusOptions} + path="status" + value={value} + /> + ) +} + +export const WithValue = () => { + const [value, setValue] = useState('published') + return ( + setValue(selected ? (selected as { value: string }).value : null)} + options={statusOptions} + path="status" + value={value} + /> + ) +} + +export const NotClearable = () => { + const [value, setValue] = useState('medium') + return ( + setValue(selected ? (selected as { value: string }).value : null)} + options={priorityOptions} + path="priority" + value={value} + /> + ) +} + +export const ReadOnly = () => ( + {}} + options={statusOptions} + path="category" + readOnly + value="published" + /> +) + +export const WithError = () => { + const [value, setValue] = useState(null) + return ( + setValue(selected ? (selected as { value: string }).value : null)} + options={statusOptions} + path="required" + required + showError + value={value} + /> + ) +} + +export const MultiSelect = () => { + const [value, setValue] = useState(['low', 'high']) + return ( + { + if (Array.isArray(selected)) { + setValue(selected.map((opt: { value: string }) => opt.value)) + } else { + setValue([]) + } + }} + options={priorityOptions} + path="tags" + value={value} + /> + ) +} diff --git a/packages/ui/src/fields/Select/documentation.md b/packages/ui/src/fields/Select/documentation.md new file mode 100644 index 00000000000..c18bc5a5e43 --- /dev/null +++ b/packages/ui/src/fields/Select/documentation.md @@ -0,0 +1,114 @@ +# SelectInput + +The `SelectInput` component is the presentational layer for the Payload select field. It renders a labelled dropdown or multi-select backed by `ReactSelect`, with optional description and error state. Use this component directly for a standalone select outside of a Payload form context. The connected form component is `SelectField`. + +## Import + +```tsx +import { SelectInput } from '@payloadcms/ui' +``` + +## Usage + +```tsx + setStatus((selected as { value: string }).value)} +/> +``` + +## Props + +| Prop | Type | Default | Description | +| --------------- | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------- | +| `name` | `string` | — | **Required.** Native `name` passed through for form serialisation | +| `path` | `string` | — | **Required.** Field path used to generate the wrapper `id` | +| `options` | `{ label: string \| Record; value: string }[]` | — | Array of selectable options; labels support i18n locale maps | +| `value` | `string \| string[]` | — | Controlled selected value(s) | +| `onChange` | `ReactSelectAdapterProps['onChange']` | — | Called with the full react-select option object(s) on selection change | +| `label` | `string \| Record` | — | Label rendered above the select via `FieldLabel` | +| `hasMany` | `boolean` | `false` | Enables multi-select mode | +| `isClearable` | `boolean` | `true` | Shows a clear button to reset the selection | +| `isSortable` | `boolean` | `true` | Allows drag-to-reorder selected values in multi-select mode | +| `readOnly` | `boolean` | — | Disables the select | +| `required` | `boolean` | — | Marks the field as required and adds a visual indicator to the label | +| `placeholder` | `string \| (() => string)` | — | Placeholder text when no option is selected | +| `description` | `string \| (() => string) \| ReactNode` | — | Helper text rendered below the field via `FieldDescription` | +| `showError` | `boolean` | — | When `true`, applies error styling and renders the `FieldError` component | +| `localized` | `boolean` | — | Shows a localization indicator on the label | +| `className` | `string` | — | Additional CSS class names for the root wrapper | +| `id` | `string` | — | Additional `id` passed to the underlying `ReactSelect` instance | +| `filterOption` | `ReactSelectAdapterProps['filterOption']` | — | Custom option filter function | +| `onInputChange` | `ReactSelectAdapterProps['onInputChange']` | — | Called when the text in the search input changes | +| `BeforeInput` | `ReactNode` | — | Slot rendered before the `ReactSelect` element | +| `AfterInput` | `ReactNode` | — | Slot rendered after the `ReactSelect` element | +| `Label` | `ReactNode` | — | Custom label component; overrides the default `FieldLabel` | +| `Error` | `ReactNode` | — | Custom error component; overrides the default `FieldError` | +| `Description` | `ReactNode` | — | Custom description component; overrides the default `FieldDescription` | +| `style` | `React.CSSProperties` | — | Inline styles applied to the root wrapper | + +## Variants + +### Single-select with placeholder + +```tsx + setCategory((selected as { value: string }).value)} +/> +``` + +### Multi-select with description + +```tsx + setTags((selected as { value: string }[]).map((opt) => opt.value))} +/> +``` + +### Read-only select + +```tsx + {}} +/> +``` diff --git a/packages/ui/src/fields/Text/Text.stories.tsx b/packages/ui/src/fields/Text/Text.stories.tsx new file mode 100644 index 00000000000..060cc987eeb --- /dev/null +++ b/packages/ui/src/fields/Text/Text.stories.tsx @@ -0,0 +1,77 @@ +'use client' +import React, { useState } from 'react' + +import { TextInput } from './Input.js' + +export const meta = { + description: 'Single-line text input for short string values.', + title: 'Fields / Text', +} + +export const Default = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="title" + value={value} + /> + ) +} + +export const WithValue = () => { + const [value, setValue] = useState('my-document-slug') + return ( + setValue(e.target.value)} path="slug" value={value} /> + ) +} + +export const WithPlaceholder = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="email" + placeholder="e.g. hello@example.com" + value={value} + /> + ) +} + +export const ReadOnly = () => ( + {}} + path="readOnly" + readOnly + value="Cannot edit this" + /> +) + +export const WithError = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="required" + showError + value={value} + /> + ) +} + +export const Required = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="title" + required + value={value} + /> + ) +} diff --git a/packages/ui/src/fields/Text/documentation.md b/packages/ui/src/fields/Text/documentation.md new file mode 100644 index 00000000000..28a22f92068 --- /dev/null +++ b/packages/ui/src/fields/Text/documentation.md @@ -0,0 +1,74 @@ +# TextInput + +The `TextInput` component is the presentational layer for the Payload text field. It renders a labelled single-line `` with optional description, error state, and slot nodes for custom content before and after the input. Use this component directly when you need a standalone, uncontrolled text input outside of a Payload form context. + +## Import + +```tsx +import { TextInput } from '@payloadcms/ui' +``` + +## Usage + +```tsx + setValue(e.target.value)} /> +``` + +## Props + +| Prop | Type | Default | Description | +| ------------- | -------------------------------------------- | ------- | ---------------------------------------------------------------------------- | +| `path` | `string` | — | **Required.** Field path used as the `name` and `id` attributes on the input | +| `label` | `string \| Record` | — | Field label rendered above the input via `FieldLabel` | +| `value` | `string` | — | Controlled value of the input | +| `onChange` | `(e: ChangeEvent) => void` | — | Change handler for single-value mode | +| `readOnly` | `boolean` | — | Disables the input when `true` | +| `required` | `boolean` | — | Marks the field as required and adds a visual indicator to the label | +| `placeholder` | `string \| Record` | — | Placeholder text; supports i18n locale map | +| `description` | `string \| (() => string) \| ReactNode` | — | Helper text rendered below the input via `FieldDescription` | +| `showError` | `boolean` | — | When `true`, applies error styling and renders the `FieldError` component | +| `className` | `string` | — | Additional CSS class names for the root wrapper | +| `localized` | `boolean` | — | Shows a localization indicator on the label | +| `rtl` | `boolean` | — | Sets `data-rtl` on the input to enable right-to-left text direction | +| `hasMany` | `boolean` | — | Switches to multi-value tag mode backed by `ReactSelect` | +| `maxRows` | `number` | — | Maximum number of tags when `hasMany` is `true` | +| `BeforeInput` | `ReactNode` | — | Slot rendered immediately before the `` element | +| `AfterInput` | `ReactNode` | — | Slot rendered immediately after the `` element | +| `Label` | `ReactNode` | — | Custom label component; overrides the default `FieldLabel` | +| `Error` | `ReactNode` | — | Custom error component; overrides the default `FieldError` | +| `Description` | `ReactNode` | — | Custom description component; overrides the default `FieldDescription` | +| `inputRef` | `React.RefObject` | — | Ref forwarded to the underlying `` element | +| `style` | `React.CSSProperties` | — | Inline styles applied to the root wrapper | + +## Variants + +### Required field with description + +```tsx + setSeoTitle(e.target.value)} +/> +``` + +### Read-only display + +```tsx + +``` + +### Field in error state + +```tsx + setUsername(e.target.value)} +/> +``` diff --git a/packages/ui/src/fields/Textarea/Textarea.stories.tsx b/packages/ui/src/fields/Textarea/Textarea.stories.tsx new file mode 100644 index 00000000000..666466e16d1 --- /dev/null +++ b/packages/ui/src/fields/Textarea/Textarea.stories.tsx @@ -0,0 +1,84 @@ +'use client' +import React, { useState } from 'react' + +import { TextareaInput } from './Input.js' + +export const meta = { + description: 'Multi-line text input for longer string values.', + title: 'Fields / Textarea', +} + +export const Default = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="description" + value={value} + /> + ) +} + +export const WithValue = () => { + const [value, setValue] = useState( + 'This is some longer content that spans multiple lines and describes the document in detail.', + ) + return ( + setValue(e.target.value)} + path="body" + value={value} + /> + ) +} + +export const WithPlaceholder = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="summary" + placeholder="Enter a brief summary..." + value={value} + /> + ) +} + +export const WithCustomRows = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="notes" + rows={8} + value={value} + /> + ) +} + +export const ReadOnly = () => ( + {}} + path="readOnly" + readOnly + value="This content cannot be edited by the user." + /> +) + +export const Required = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + path="notes" + required + value={value} + /> + ) +} diff --git a/packages/ui/src/fields/Textarea/documentation.md b/packages/ui/src/fields/Textarea/documentation.md new file mode 100644 index 00000000000..393fb21a997 --- /dev/null +++ b/packages/ui/src/fields/Textarea/documentation.md @@ -0,0 +1,73 @@ +# TextareaInput + +The `TextareaInput` component is the presentational layer for the Payload textarea field. It renders a labelled `