From 75f6c1697dbc3ebc690e52c84b731d95d48801e4 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 15 Sep 2026 12:57:59 -0400 Subject: [PATCH 1/2] initial TagField component --- packages/@react-spectrum/s2/exports/index.ts | 2 + packages/@react-spectrum/s2/src/Field.tsx | 17 +- packages/@react-spectrum/s2/src/TagField.tsx | 311 ++++++++++++++++++ .../s2/stories/TagField.stories.tsx | 99 ++++++ 4 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 packages/@react-spectrum/s2/src/TagField.tsx create mode 100644 packages/@react-spectrum/s2/stories/TagField.stories.tsx diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index f3b4ac44f67..e71e4068ed8 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -145,6 +145,7 @@ export { TableFooter } from '../src/TableView'; export {Tabs, TabList, Tab, TabPanel, TabsContext} from '../src/Tabs'; +export {TagField, TagFieldValue, TagFieldContext} from '../src/TagField'; export {TagGroup, Tag, TagGroupContext} from '../src/TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from '../src/TextField'; export {TimeField, TimeFieldContext} from '../src/TimeField'; @@ -289,6 +290,7 @@ export type { TableFooterProps } from '../src/TableView'; export type {TabsProps, TabProps, TabListProps, TabPanelProps} from '../src/Tabs'; +export type {TagFieldProps} from '../src/TagField'; export type {TagGroupProps, TagProps} from '../src/TagGroup'; export type {TextFieldProps, TextAreaProps, TextFieldRef} from '../src/TextField'; export type {TimeFieldProps} from '../src/TimeField'; diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx index 221be8f67b1..cc38634b0c6 100644 --- a/packages/@react-spectrum/s2/src/Field.tsx +++ b/packages/@react-spectrum/s2/src/Field.tsx @@ -257,19 +257,28 @@ export const FieldGroup = forwardRef(function FieldGroup( {...otherProps} onPointerDown={e => { // Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding) + let target = getEventTarget(e) as HTMLElement; if ( e.pointerType === 'mouse' && - !(getEventTarget(e) as Element).closest('button,input,textarea,[role="button"]') + !target.isContentEditable && + !target.closest('button,input,textarea,[contenteditable],[role="button"]') ) { e.preventDefault(); - (e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus(); + ( + e.currentTarget.querySelector('input, textarea, [contenteditable]') as HTMLElement + )?.focus(); } }} onTouchEnd={e => { let target = getEventTarget(e) as HTMLElement; - if (!target.isContentEditable && !target.closest('button,input,textarea,[role="button"]')) { + if ( + !target.isContentEditable && + !target.closest('button,input,textarea,[contenteditable],[role="button"]') + ) { e.preventDefault(); - (e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus(); + ( + e.currentTarget.querySelector('input, textarea, [contenteditable]') as HTMLElement + )?.focus(); } }} style={props.UNSAFE_style} diff --git a/packages/@react-spectrum/s2/src/TagField.tsx b/packages/@react-spectrum/s2/src/TagField.tsx new file mode 100644 index 00000000000..d0ff4133983 --- /dev/null +++ b/packages/@react-spectrum/s2/src/TagField.tsx @@ -0,0 +1,311 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + TokenField as AriaTokenField, + TokenFieldProps as AriaTokenFieldProps, + Token, + TokenFieldSegment, + TokenFieldValue, + TokenInput, + TokenInputRenderProps, + TokenRenderProps +} from 'react-aria-components/TokenField'; +import {baseColor, size, space, style} from '../style' with {type: 'macro'}; +import {ContextValue} from 'react-aria-components/slots'; +import { + control, + controlSize, + field, + getAllowedOverrides, + StylesPropWithHeight, + UnsafeStyles +} from './style-utils' with {type: 'macro'}; +import {createContext, forwardRef, useContext} from 'react'; +import {css} from '../style/style-macro' with {type: 'macro'}; +import { + DOMRef, + DOMRefValue, + GlobalDOMAttributes, + HelpTextProps, + SpectrumLabelableProps +} from '@react-types/shared'; +import {FieldGroup, FieldLabel, HelpText} from './Field'; +import {FormContext, useFormProps} from './Form'; +import {useDOMRef} from './useDOMRef'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +/** + * A value for a {@link TagField} that splits text into tokens on comma, space, or newline + * boundaries. Provide it as the `defaultValue` or `value` prop to seed the field with tags. + */ +export class TagFieldValue extends TokenFieldValue { + tokenize(text: string): TokenFieldSegment[] { + let parts = text.split(/[,\s\u200B]/); + + let segments: TokenFieldSegment[] = parts.map((part, i) => { + if (i === parts.length - 1 || part.length === 0) { + return {type: 'text', text: part}; + } + return {type: 'token', text: part}; + }); + + if (parts.at(-1)?.length === 0) { + segments.pop(); + } + return segments; + } + + toString(): string { + return this.segments.map(seg => seg.text).join(', '); + } +} + +export interface TagFieldProps + extends + Omit< + AriaTokenFieldProps, + | 'allowsNewlines' + | 'role' + | 'children' + | 'className' + | 'style' + | 'render' + | keyof GlobalDOMAttributes + >, + UnsafeStyles, + SpectrumLabelableProps, + HelpTextProps { + /** + * The size of the tag field. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL'; + /** Placeholder text shown when the field is empty. */ + placeholder?: string; + /** Whether the field is in an invalid state. */ + isInvalid?: boolean; + /** + * Spectrum-defined styles, returned by the `style()` macro. Set a `maxHeight` here to make the + * field scroll once the wrapped tags exceed the given height. + */ + styles?: StylesPropWithHeight; +} + +export const TagFieldContext = + createContext, DOMRefValue>>(null); + +const gap = { + S: 6, + M: 8, + L: 12, + XL: 16 +} as const; + +const itemHeight = controlSize(); + +// The editable area. Grows to at least one line, wraps tokens onto new lines, and centers a +// single line vertically. The FieldGroup provides the horizontal padding and border. +const inputStyles = style({ + flexGrow: 1, + minWidth: 0, + boxSizing: 'border-box', + padding: 16, + outlineStyle: 'none', + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + lineHeight: { + size: { + S: size(itemHeight.size.S + gap.S), + M: size(itemHeight.default + gap.M), + L: size(itemHeight.size.L + gap.L), + XL: size(itemHeight.size.XL + gap.XL) + } + }, + marginY: { + size: { + S: space(-gap.S / 2), + M: space(-gap.M / 2), + L: space(-gap.L / 2), + XL: space(-gap.XL / 2) + } + }, + color: { + default: 'inherit', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + '--s2TagFieldPlaceholder': { + type: 'color', + value: { + default: 'gray-600', + forcedColors: 'GrayText' + } + } +}); + +// Show the placeholder via a pseudo-element when the input is empty (contentEditable elements +// don't support the native placeholder attribute). The color is set by inputStyles above. +const placeholderStyles = css(` + &:empty::before{ + content: attr(data-placeholder); + color: var(--s2TagFieldPlaceholder); + } +`); + +// FieldGroup overrides so the field grows with wrapped tags and scrolls once it hits a max height. +const fieldGroupStyles = style({ + height: 'auto', + minHeight: 0, + alignSelf: 'stretch', + alignItems: 'start', + padding: 0, + overflowY: 'auto' +}); + +// A token styled like an S2 Tag, without a remove button. +const tokenStyles = style({ + ...control({shape: 'default', icon: true}), + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'baseline', + boxSizing: 'border-box', + maxWidth: 'full', + borderStyle: 'none', + marginEnd: { + size: { + S: size(gap.S), + M: gap.M, + L: gap.L, + XL: gap.XL + } + }, + transition: 'default', + backgroundColor: { + default: baseColor('gray-100'), + isSelected: baseColor('neutral'), + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonFace', + isSelected: 'Highlight' + } + }, + color: { + default: baseColor('neutral'), + isSelected: 'gray-25', + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isSelected: 'HighlightText', + isDisabled: 'GrayText' + } + }, + cursor: 'default' +}); + +/** + * A TagField allows users to enter a list of tags, keywords, or categories. Tags wrap onto + * multiple lines as they are added, and the field scrolls once it reaches a maximum height. + */ +export const TagField = forwardRef(function TagField( + props: TagFieldProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, TagFieldContext); + let domRef = useDOMRef(ref); + let formContext = useContext(FormContext); + // oxlint-disable-next-line react/react-compiler + props = useFormProps(props); + let { + label, + description, + errorMessage, + placeholder, + necessityIndicator, + labelPosition = 'top', + labelAlign = 'start', + isInvalid = false, + isDisabled, + isRequired, + size = 'M', + contextualHelp, + value, + defaultValue, + UNSAFE_style, + UNSAFE_className = '', + styles, + ...tokenFieldProps + } = props; + + // Default to a TagFieldValue so typed text tokenizes on comma/space/newline out of the box. + if (value == null && defaultValue == null) { + defaultValue = new TagFieldValue([]); + } + + return ( + + + {label} + + + + + inputStyles({...renderProps, size}) + (placeholder ? ' ' + placeholderStyles : '') + }> + {segment => ( + tokenStyles({...renderProps, size})}> + {segment.text} + + )} + + + + + {errorMessage} + + + ); +}); diff --git a/packages/@react-spectrum/s2/stories/TagField.stories.tsx b/packages/@react-spectrum/s2/stories/TagField.stories.tsx new file mode 100644 index 00000000000..599e97a1cfb --- /dev/null +++ b/packages/@react-spectrum/s2/stories/TagField.stories.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Button} from '../src/Button'; +import {Form} from '../src/Form'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; +import {TagField, TagFieldValue} from '../src/TagField'; + +const meta: Meta = { + component: TagField, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + onChange: {table: {category: 'Events'}}, + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + contextualHelp: {table: {disable: true}} + }, + args: { + label: 'Categories', + placeholder: 'Add a tag…', + styles: style({width: 288}) + }, + title: 'TagField' +}; + +export default meta; + +type Story = StoryObj; + +const tags = new TagFieldValue([ + {type: 'token', text: 'Architecture'}, + {type: 'token', text: 'Design'}, + {type: 'token', text: 'Development'}, + {type: 'token', text: 'Marketing'}, + {type: 'token', text: 'Sales'} +]); + +export const Example: Story = { + args: { + defaultValue: tags + } +}; + +export const Empty: Story = {}; + +export const MaxHeight: Story = { + args: { + defaultValue: new TagFieldValue([ + {type: 'token', text: 'Architecture'}, + {type: 'token', text: 'Design'}, + {type: 'token', text: 'Development'}, + {type: 'token', text: 'Marketing'}, + {type: 'token', text: 'Sales'}, + {type: 'token', text: 'Engineering'}, + {type: 'token', text: 'Research'}, + {type: 'token', text: 'Operations'}, + {type: 'token', text: 'Finance'}, + {type: 'token', text: 'Legal'}, + {type: 'token', text: 'Support'}, + {type: 'token', text: 'Product'} + ]), + styles: style({width: 288, maxHeight: 112}) + } +}; + +export const Validation: Story = { + render: args => ( +
+ + + + ), + args: { + isRequired: true + } +}; + +export const Disabled: Story = { + args: { + defaultValue: tags, + isDisabled: true + } +}; From d6b2c8cd745ef0b272077c78f0a864fef6549fd8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 15 Sep 2026 15:00:25 -0400 Subject: [PATCH 2/2] support icons and avatars --- packages/@react-spectrum/s2/src/TagField.tsx | 97 ++++++++++++++++--- .../s2/stories/TagField.stories.tsx | 48 +++++++++ 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TagField.tsx b/packages/@react-spectrum/s2/src/TagField.tsx index d0ff4133983..60748ac5e22 100644 --- a/packages/@react-spectrum/s2/src/TagField.tsx +++ b/packages/@react-spectrum/s2/src/TagField.tsx @@ -18,10 +18,13 @@ import { TokenFieldValue, TokenInput, TokenInputRenderProps, - TokenRenderProps + TokenRenderProps, + TokenSegment } from 'react-aria-components/TokenField'; +import {AvatarContext} from './Avatar'; import {baseColor, size, space, style} from '../style' with {type: 'macro'}; -import {ContextValue} from 'react-aria-components/slots'; +import {centerBaseline} from './CenterBaseline'; +import {ContextValue, Provider} from 'react-aria-components/slots'; import { control, controlSize, @@ -30,7 +33,7 @@ import { StylesPropWithHeight, UnsafeStyles } from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {css} from '../style/style-macro' with {type: 'macro'}; import { DOMRef, @@ -41,6 +44,10 @@ import { } from '@react-types/shared'; import {FieldGroup, FieldLabel, HelpText} from './Field'; import {FormContext, useFormProps} from './Form'; +import {IconContext} from './Icon'; +import {ImageContext} from './Image'; +import {TextContext as RACTextContext} from 'react-aria-components/Text'; +import {Text, TextContext} from './Content'; import {useDOMRef} from './useDOMRef'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -100,6 +107,12 @@ export interface TagFieldProps * field scroll once the wrapped tags exceed the given height. */ styles?: StylesPropWithHeight; + /** + * A render function that returns the contents of each tag. Use it to customize the tag, for + * example by adding an [Icon](Icon), [Avatar](Avatar), or [Image](Image). Defaults to the + * segment's text. + */ + children?: (segment: TokenSegment) => ReactNode; } export const TagFieldContext = @@ -120,7 +133,15 @@ const inputStyles = style flexGrow: 1, minWidth: 0, boxSizing: 'border-box', - padding: 16, + paddingX: 16, + paddingY: { + size: { + S: space(16 - gap.S / 2), + M: space(16 - gap.M / 2), + L: space(16 - gap.L / 2), + XL: space(16 - gap.XL / 2) + } + }, outlineStyle: 'none', whiteSpace: 'pre-wrap', overflowWrap: 'break-word', @@ -132,14 +153,6 @@ const inputStyles = style XL: size(itemHeight.size.XL + gap.XL) } }, - marginY: { - size: { - S: space(-gap.S / 2), - M: space(-gap.M / 2), - L: space(-gap.L / 2), - XL: space(-gap.XL / 2) - } - }, color: { default: 'inherit', isDisabled: { @@ -180,7 +193,7 @@ const tokenStyles = style({ ...control({shape: 'default', icon: true}), display: 'inline-flex', alignItems: 'center', - verticalAlign: 'baseline', + verticalAlign: 'middle', boxSizing: 'border-box', maxWidth: 'full', borderStyle: 'none', @@ -215,6 +228,59 @@ const tokenStyles = style({ cursor: 'default' }); +const avatarSize = { + S: 16, + M: 20, + L: 24, + XL: 28 +} as const; + +const tokenTextStyles = style({order: 1, truncate: true}); +const tokenIconStyles = style({ + size: '1lh', + marginStart: '--iconMargin', + flexShrink: 0, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); +const tokenIconRender = centerBaseline({slot: 'icon', styles: style({order: 0})}); +const tokenAvatarStyles = style({order: 0}); +const tokenImageStyles = style({ + size: '1lh', + flexShrink: 0, + order: 0, + aspectRatio: 'square', + objectFit: 'contain', + borderRadius: 'sm' +}); + +// Provides the icon, text, avatar, and image slots inside a tag so custom content +// (e.g. icons, avatars, or images) aligns correctly, matching Tag from TagGroup. +function TagToken({ + size = 'M', + children +}: { + size?: TagFieldProps['size']; + children: ReactNode; +}): ReactNode { + return ( + tokenStyles({...renderProps, size})}> + + {typeof children === 'string' ? {children} : children} + + + ); +} + /** * A TagField allows users to enter a list of tags, keywords, or categories. Tags wrap onto * multiple lines as they are added, and the field scrolls once it reaches a maximum height. @@ -243,6 +309,7 @@ export const TagField = forwardRef(function TagField( contextualHelp, value, defaultValue, + children, UNSAFE_style, UNSAFE_className = '', styles, @@ -296,9 +363,7 @@ export const TagField = forwardRef(function TagField( inputStyles({...renderProps, size}) + (placeholder ? ' ' + placeholderStyles : '') }> {segment => ( - tokenStyles({...renderProps, size})}> - {segment.text} - + {children ? children(segment) : segment.text} )} diff --git a/packages/@react-spectrum/s2/stories/TagField.stories.tsx b/packages/@react-spectrum/s2/stories/TagField.stories.tsx index 599e97a1cfb..71438f079c1 100644 --- a/packages/@react-spectrum/s2/stories/TagField.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TagField.stories.tsx @@ -10,11 +10,15 @@ * governing permissions and limitations under the License. */ +import {Avatar} from '../src/Avatar'; +import BookmarkIcon from '../s2wf-icons/S2_Icon_Bookmark_20_N.svg'; import {Button} from '../src/Button'; import {Form} from '../src/Form'; +import {Image} from '../src/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style' with {type: 'macro'}; import {TagField, TagFieldValue} from '../src/TagField'; +import {Text} from '../src/Content'; const meta: Meta = { component: TagField, @@ -97,3 +101,47 @@ export const Disabled: Story = { isDisabled: true } }; + +export const WithIcons: Story = { + args: { + defaultValue: tags, + children: segment => ( + <> + + {segment.text} + + ) + } +}; + +const people = new TagFieldValue([ + {type: 'token', text: 'Alex Miller'}, + {type: 'token', text: 'Sarah Jones'}, + {type: 'token', text: 'David Kim'}, + {type: 'token', text: 'Emma Watson'} +]); + +export const WithAvatars: Story = { + args: { + label: 'People', + defaultValue: people, + children: segment => ( + <> + + {segment.text} + + ) + } +}; + +export const WithImages: Story = { + args: { + defaultValue: tags, + children: segment => ( + <> + + {segment.text} + + ) + } +};