Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/@react-spectrum/ai/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export {
} from '../src/PromptField';
export {ResponseStatus, ResponseStatusTitle, ResponseStatusPanel} from '../src/ResponseStatus';
export {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat';
export {TokenSegmentList} from '../src/TokenSegmentList';
export {TokenFieldValue} from 'react-aria-components/TokenField';
export {UserMessage} from '../src/UserMessage';

export type {AttachmentProps, AttachmentListProps} from '../src/AttachmentList';
Expand All @@ -39,5 +39,5 @@ export type {
ResponseStatusPanelProps
} from '../src/ResponseStatus';
export type {ChatProps, ThreadProps, ThreadItemProps, ThreadScrollButtonProps} from '../src/Chat';
export type {TokenSegmentListOptions} from '../src/TokenSegmentList';
export type {TokenFieldValueOptions} from 'react-aria-components/TokenField';
export type {UserMessageProps} from '../src/UserMessage';
1 change: 1 addition & 0 deletions packages/@react-spectrum/ai/intl/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"messagefeedback.thumbDown": "Bad response",
"messagefeedback.thumbUp": "Good response",
"responsestatus.loading": "Loading",
"promptfield.label": "Prompt",
"promptfield.placeholder": "Ready to get started? Ask a question, share an idea, or add a task."
}
83 changes: 46 additions & 37 deletions packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton';
import Attach from '@react-spectrum/s2/icons/Attach';
import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList';
import {Autocomplete} from 'react-aria-components/Autocomplete';
import {Button} from '@react-spectrum/s2/Button';
import {Cell} from './loader/data';
import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline';
import {
baseColor,
color,
css,
iconStyle,
style,
StyleString
} from '@react-spectrum/s2/style' with {type: 'macro'};
import {Button} from '@react-spectrum/s2/Button';
import {Cell} from './loader/data';
import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline';
import {
createContext,
createRef,
Expand All @@ -38,9 +39,9 @@ import {
Direction,
Position,
TokenFieldSegment,
TokenSegment,
TokenSegmentList
} from './TokenSegmentList';
TokenFieldValue,
TokenSegment
} from 'react-stately/useTokenFieldState';
import {IconContext} from '@react-spectrum/s2';
import {Image, Text} from '@react-spectrum/s2/Card';
// @ts-ignore
Expand All @@ -51,7 +52,13 @@ import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Men
import {PixelLoader} from './loader/react';
import Plus from '@react-spectrum/s2/icons/Add';
import {Popover, PopoverProps} from '@react-spectrum/s2/Popover';
import {positionToDOMRange, Token, TokenField, TokenProps} from './TokenField';
import {
positionToDOMRange,
Token,
TokenField,
TokenInput,
TokenProps
} from 'react-aria-components/TokenField';
import {PromptFieldContainer} from './PromptFieldContainer';
import {PromptFocusContext} from './Chat';
import Send from '@react-spectrum/s2/icons/ArrowUpSend';
Expand All @@ -68,7 +75,7 @@ export interface PromptFieldAttachment {
export interface PromptFieldProps {
children: React.ReactNode;
acceptedAttachmentTypes?: string[];
onSubmit?: (prompt: TokenSegmentList, attachments: PromptFieldAttachment[]) => void;
onSubmit?: (prompt: TokenFieldValue, attachments: PromptFieldAttachment[]) => void;
isGenerating?: boolean;
onStop?: () => void;
onAddAttachments?: (attachments: PromptFieldAttachment[]) => void;
Expand All @@ -81,8 +88,8 @@ interface PromptFieldState {
attachments: PromptFieldAttachment[];
setAttachments: React.Dispatch<React.SetStateAction<PromptFieldAttachment[]>>;
acceptedAttachmentTypes?: string[];
prompt: TokenSegmentList;
setPrompt: React.Dispatch<React.SetStateAction<TokenSegmentList>>;
prompt: TokenFieldValue;
setPrompt: React.Dispatch<React.SetStateAction<TokenFieldValue>>;
inputRef: React.RefObject<HTMLDivElement | null>;
onSubmit?: () => void;
onStop?: () => void;
Expand All @@ -93,7 +100,7 @@ interface PromptFieldState {

// TODO: make this customizable
const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g;
class AutoLinkingSegmentList extends TokenSegmentList {
class AutoLinkingTokenFieldValue extends TokenFieldValue {
tokenize(text: string): TokenFieldSegment[] {
if (text.length === 0) {
return [{type: 'text', text}];
Expand Down Expand Up @@ -123,7 +130,7 @@ class AutoLinkingSegmentList extends TokenSegmentList {
const PromptFieldContext = createContext<PromptFieldState>({
attachments: [],
setAttachments: () => {},
prompt: new AutoLinkingSegmentList([]),
prompt: new AutoLinkingTokenFieldValue([]),
setPrompt: () => {},
inputRef: createRef(),
isGenerating: false
Expand Down Expand Up @@ -152,7 +159,7 @@ export function PromptField(props: PromptFieldProps) {
onRemoveAttachments,
variant = 'balanced'
} = props;
let [prompt, setPrompt] = useState<TokenSegmentList>(new AutoLinkingSegmentList([]));
let [prompt, setPrompt] = useState<TokenFieldValue>(new AutoLinkingTokenFieldValue([]));
let [attachments, setAttachments] = useState<PromptFieldAttachment[]>([]);

// Not using RAC DropZone because it adds its own focusable button,
Expand Down Expand Up @@ -190,7 +197,7 @@ export function PromptField(props: PromptFieldProps) {
}

props.onSubmit?.(prompt, attachments);
setPrompt(new AutoLinkingSegmentList([]));
setPrompt(new AutoLinkingTokenFieldValue([]));
setAttachments([]);
inputRef.current?.focus();
};
Expand Down Expand Up @@ -335,10 +342,10 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
<TokenField
value={prompt}
onChange={setPrompt}
multiline
aria-label="Prompt"
data-placeholder={placeholder || stringFormatter.format('promptfield.placeholder')}
ref={inputRef}
allowsNewlines
className={style({flexGrow: 1})}
aria-label={stringFormatter.format('promptfield.label')}
onSubmit={onSubmit}
onFocus={e => {
if (e.isTrusted) {
setFocused(true);
Expand Down Expand Up @@ -370,26 +377,28 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
}
}
: undefined
}
onSubmit={onSubmit}
className={
css('&:empty::before { content: attr(data-placeholder); }') +
style({
flexGrow: 1,
font: 'body',
color: {
default: 'gray-800',
':empty': {
default: 'transparent-overlay-600',
forcedColors: 'GrayText'
}
},
width: 'full',
outlineStyle: 'none',
cursor: 'text'
})
}>
{children || (segment => <PromptToken>{segment.text}</PromptToken>)}
<TokenInput
data-placeholder={placeholder || stringFormatter.format('promptfield.placeholder')}
ref={inputRef}
className={renderProps =>
css('&:empty::before { content: attr(data-placeholder); }') +
style({
font: 'body',
color: {
default: baseColor('neutral'),
':empty': {
default: 'gray-600',
forcedColors: 'GrayText'
}
},
width: 'full',
outlineStyle: 'none',
cursor: 'text'
})(renderProps)
}>
{children || (segment => <PromptToken>{segment.text}</PromptToken>)}
</TokenInput>
</TokenField>
<PromptTokenFieldPopover
filterAnchor={filterAnchor}
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/ai/stories/Chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
Thread,
ThreadItem,
ThreadScrollButton,
TokenSegmentList,
TokenFieldValue,
UserMessage
} from '@react-spectrum/ai';
import type {Meta} from '@storybook/react';
Expand Down Expand Up @@ -217,7 +217,7 @@ export function StreamingChat() {
let [isGenerating, setGenerating] = useState(false);
let timeouts = useRef<NodeJS.Timeout[]>([]);

function handleSend(prompt: TokenSegmentList) {
function handleSend(prompt: TokenFieldValue) {
setGenerating(true);
// user message added first so its announcement plays before
setMessages(prev => [
Expand Down Expand Up @@ -566,7 +566,7 @@ export function VirtualizedChat() {
let nextId = useRef(initialResponses.length);
let lastMessage = messages.at(-1);
let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending';
function handleSend(prompt: TokenSegmentList) {
function handleSend(prompt: TokenFieldValue) {
setMessages(prev => [
...prev,
{id: nextId.current++, type: 'user', content: prompt.toString()},
Expand Down
6 changes: 6 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default Layout;
import docs from 'docs:react-aria-components';
import vanillaDocs from 'docs:vanilla-starter/CommandPalette';
import '../../tailwind/tailwind.css';
import {InlineAlert, Heading, Content} from '@react-spectrum/s2'

export const tags = ['combobox', 'typeahead', 'input'];
export const version = 'rc';
Expand Down Expand Up @@ -110,6 +111,11 @@ export const description = 'Allows users to search or filter a list of suggestio

</ExampleSwitcher>

<InlineAlert variant="informative" UNSAFE_style={{maxWidth: 500}}>
<Heading>Autocomplete vs. ComboBox</Heading>
<Content>Use [ComboBox](ComboBox) to **select one or more values** from a pre-defined set of options. Use Autocomplete to **filter a collection** or provide **text completions**.</Content>
</InlineAlert>

## Content

Autocomplete filters a collection component using a [TextField](TextField) or [SearchField](SearchField). It can be used to build UI patterns such as command palettes, searchable menus, filterable selects, and more.
Expand Down
5 changes: 5 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/ComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export const description = 'Combines a text input with a listbox, allowing users

</ExampleSwitcher>

<InlineAlert variant="informative" UNSAFE_style={{maxWidth: 500}}>
<Heading>Autocomplete vs. ComboBox</Heading>
<Content>Use ComboBox to **select one or more values** from a pre-defined set of options. Use [Autocomplete](Autocomplete) to **filter a collection** or provide **text completions**.</Content>
</InlineAlert>

## Content

`ComboBox` reuses the `ListBox` component, following the [Collection Components API](collections?component=ComboBox). It supports ListBox features such as static and dynamic collections, sections, disabled items, links, text slots, asynchronous loading, etc. See the [ListBox docs](ListBox) for more details.
Expand Down
59 changes: 59 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/ComboBoxTokenFieldValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {Key} from '@react-types/shared';
import {TokenFieldValue} from 'react-aria-components/TokenField';

export class ComboBoxTokenFieldValue extends TokenFieldValue<Key> {
getSelectedKeys(): Key[] {
return this.segments.filter(seg => seg.type === 'token').map(seg => seg.value!);
}

getInputValue(): string {
let segment = this.segments[this.caretPosition.index];
return segment?.type === 'text' ? segment.text : '';
}

setSelectedKeys(keys: Key[]): ComboBoxTokenFieldValue {
let selectedKeys = this.getSelectedKeys();
let added: Key[] = [];
for (let key of keys) {
if (!selectedKeys.includes(key)) {
added.push(key);
}
}
let removed = new Set();
for (let key of selectedKeys) {
if (!keys.includes(key)) {
removed.add(key);
}
}

let value = this;
for (let key of removed) {
let index = value.segments.findIndex(seg => seg.type === 'token' && seg.value! === key);
value = value.replaceRangeWithSegments(
{index: index, offset: 0},
{index: index, offset: value.segments[index]?.text.length ?? 0},
[],
false
);
}

if (added.length > 0) {
let segment = value.segments[value.caretPosition.index];
value = value.replaceRangeWithSegments(
{index: value.caretPosition.index, offset: 0},
segment?.type === 'text'
? {
index: value.caretPosition.index,
offset: value.segments[value.caretPosition.index]?.text.length ?? 0
}
: {index: value.caretPosition.index, offset: 0},
added.map(value => {
return {type: 'token', text: String(value), value: value};
}),
false
);
}

return value;
}
}
23 changes: 23 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {type TokenFieldSegment, TokenFieldValue} from 'react-aria-components/TokenField';

export class TagFieldValue extends TokenFieldValue {
tokenize(text: string): TokenFieldSegment[] {
let parts = text.split(/[, \n]/);

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(', ');
}
}
Loading
Loading