diff --git a/.oxlintrc.json b/.oxlintrc.json index 70c61a932a3..f6fcaf1ee5c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -371,7 +371,11 @@ } }, { - "files": ["packages/@react-spectrum/s2/**", "packages/dev/s2-docs/**"], + "files": [ + "packages/@react-spectrum/s2/**", + "packages/dev/s2-docs/**", + "packages/@react-spectrum/s2-ai/**" + ], "rules": { "react/react-in-jsx-scope": "off" } diff --git a/.storybook-s2/main.ts b/.storybook-s2/main.ts index 80e5a6f9d64..5f501e7bb2e 100644 --- a/.storybook-s2/main.ts +++ b/.storybook-s2/main.ts @@ -20,7 +20,8 @@ const localAddon = (rel: string) => fileURLToPath(import.meta.resolve(rel)); const config: StorybookConfig = { stories: [ './docs/*.mdx', - '../packages/@react-spectrum/s2/stories/*.stories.@(js|jsx|mjs|ts|tsx)' + '../packages/@react-spectrum/s2/stories/*.stories.@(js|jsx|mjs|ts|tsx)', + '../packages/@react-spectrum/s2-ai/stories/*.stories.@(js|jsx|mjs|ts|tsx)' ], addons: [ localAddon('./custom-addons/provider/preset.ts'), diff --git a/jest.config.js b/jest.config.js index 7254c140eb6..49d69cd0c80 100644 --- a/jest.config.js +++ b/jest.config.js @@ -93,6 +93,7 @@ module.exports = { moduleNameMapper: { '^bundle-text:.*\\.svg$': '/__mocks__/fileMock.js', '\\.svg$': '/__mocks__/svg.js', + '^@react-spectrum/s2/icons/.+$': '/__mocks__/svg.js', '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js', '\\.(css|styl)$': 'identity-obj-proxy' diff --git a/package.json b/package.json index 29c841fb4f1..4cb8df6cb83 100644 --- a/package.json +++ b/package.json @@ -275,6 +275,8 @@ "packages/@adobe/react-spectrum/src/color/*.tsx", "packages/@react-spectrum/s2/**/*.{js,ts,tsx}", "packages/@react-spectrum/s2/stories/**", + "packages/@react-spectrum/s2-ai/**/*.{js,ts,tsx}", + "packages/@react-spectrum/s2-ai/stories/**", ".storybook-s2/**", "packages/dev/s2-docs/**", "starters/**" diff --git a/packages/@react-spectrum/s2-ai/.gitignore b/packages/@react-spectrum/s2-ai/.gitignore new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/.gitignore @@ -0,0 +1 @@ + diff --git a/packages/@react-spectrum/s2-ai/README.md b/packages/@react-spectrum/s2-ai/README.md new file mode 100644 index 00000000000..75adcd8b512 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/README.md @@ -0,0 +1,3 @@ +# @react-spectrum/s2-ai + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. \ No newline at end of file diff --git a/packages/@react-spectrum/s2-ai/exports/AttachmentList.ts b/packages/@react-spectrum/s2-ai/exports/AttachmentList.ts new file mode 100644 index 00000000000..6daac523b4d --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/AttachmentList.ts @@ -0,0 +1 @@ +export {Attachment, AttachmentList} from '../src/AttachmentList'; diff --git a/packages/@react-spectrum/s2-ai/exports/HorizontalCard.ts b/packages/@react-spectrum/s2-ai/exports/HorizontalCard.ts new file mode 100644 index 00000000000..f67371b1f56 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/HorizontalCard.ts @@ -0,0 +1 @@ +export {BasicHorizontalCard, HorizontalCard, CardPreview} from '../src/HorizontalCard'; diff --git a/packages/@react-spectrum/s2-ai/exports/MessageFeedback.ts b/packages/@react-spectrum/s2-ai/exports/MessageFeedback.ts new file mode 100644 index 00000000000..cca83d4afb5 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/MessageFeedback.ts @@ -0,0 +1 @@ +export {MessageFeedback} from '../src/MessageFeedback'; diff --git a/packages/@react-spectrum/s2-ai/exports/MessageSource.ts b/packages/@react-spectrum/s2-ai/exports/MessageSource.ts new file mode 100644 index 00000000000..cba6dfde08a --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/MessageSource.ts @@ -0,0 +1,15 @@ +export { + MessageSource, + MessageSourceContext, + SourceList, + SourceListItem, + NumberBadge, + NumberBadgeContext +} from '../src/MessageSource'; + +export type { + MessageSourceProps, + SourceListProps, + SourceListItemProps, + NumberBadgeProps +} from '../src/MessageSource'; diff --git a/packages/@react-spectrum/s2-ai/exports/MessageSuggestion.ts b/packages/@react-spectrum/s2-ai/exports/MessageSuggestion.ts new file mode 100644 index 00000000000..277fc3f153f --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/MessageSuggestion.ts @@ -0,0 +1,6 @@ +export { + MessageSuggestion, + MessageSuggestionContext, + MessageSuggestionList, + MessageSuggestionListContext +} from '../src/MessageSuggestion'; diff --git a/packages/@react-spectrum/s2-ai/exports/ResponseStatus.ts b/packages/@react-spectrum/s2-ai/exports/ResponseStatus.ts new file mode 100644 index 00000000000..ed83462f18b --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/ResponseStatus.ts @@ -0,0 +1,12 @@ +export { + ResponseStatus, + ResponseStatusContext, + ResponseStatusTitle, + ResponseStatusPanel +} from '../src/ResponseStatus'; + +export type { + ResponseStatusProps, + ResponseStatusTitleProps, + ResponseStatusPanelProps +} from '../src/ResponseStatus'; diff --git a/packages/@react-spectrum/s2-ai/exports/UserMessage.ts b/packages/@react-spectrum/s2-ai/exports/UserMessage.ts new file mode 100644 index 00000000000..f5cbd1b36bf --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/UserMessage.ts @@ -0,0 +1 @@ +export {UserMessage} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/s2-ai/exports/index.ts b/packages/@react-spectrum/s2-ai/exports/index.ts new file mode 100644 index 00000000000..cf47b2e89c0 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/exports/index.ts @@ -0,0 +1,11 @@ +export {Attachment, AttachmentList} from '../src/AttachmentList'; +export {BasicHorizontalCard, HorizontalCard} from '../src/HorizontalCard'; +export {MessageFeedback} from '../src/MessageFeedback'; +export { + MessageSuggestion, + MessageSuggestionContext, + MessageSuggestionList, + MessageSuggestionListContext +} from '../src/MessageSuggestion'; +export {ResponseStatus, ResponseStatusPanel} from '../src/ResponseStatus'; +export {UserMessage} from '../src/UserMessage'; diff --git a/packages/@react-spectrum/s2-ai/intl/en-US.json b/packages/@react-spectrum/s2-ai/intl/en-US.json new file mode 100644 index 00000000000..8ac525f626b --- /dev/null +++ b/packages/@react-spectrum/s2-ai/intl/en-US.json @@ -0,0 +1,5 @@ +{ + "messagefeedback.thumbDown": "Bad response", + "messagefeedback.thumbUp": "Good response", + "responsestatus.loading": "Loading" +} diff --git a/packages/@react-spectrum/s2-ai/package.json b/packages/@react-spectrum/s2-ai/package.json new file mode 100644 index 00000000000..a1a6da83e53 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/package.json @@ -0,0 +1,87 @@ +{ + "name": "@react-spectrum/s2-ai", + "version": "1.0.0-alpha.1", + "description": "Spectrum 2 UI AI components in React", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "source": "exports/index.ts", + "files": [ + "dist", + "src", + "exports", + "*.js" + ], + "sideEffects": [ + "*.css" + ], + "main": "./dist/exports/index.cjs", + "module": "./dist/exports/index.mjs", + "types": "./dist/types/exports/index.d.ts", + "exports": { + ".": { + "source": "./exports/index.ts", + "types": "./dist/types/exports/index.d.ts", + "module": "./dist/exports/index.mjs", + "import": "./dist/exports/index.mjs", + "require": "./dist/exports/index.cjs" + }, + "./package.json": "./package.json", + "./*": { + "source": "./exports/*.ts", + "types": "./dist/types/exports/*.d.ts", + "import": "./dist/exports/*.mjs", + "require": "./dist/exports/*.cjs" + }, + "./private/*": null + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "npm pkg delete scripts devDependencies resolutions alias targets", + "postpack": "git checkout -- package.json" + }, + "devDependencies": { + "@react-aria/test-utils": "^1.0.0-alpha.8", + "@storybook/jest": "^0.2.3", + "@testing-library/dom": "^10.1.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.0.0", + "react": "^19.0.0-rc.1", + "react-dom": "^19.0.0-rc.1" + }, + "peerDependencies": { + "@internationalized/date": "^3.12.2", + "@internationalized/number": "^3.6.7", + "@react-spectrum/s2": "^1.4.0", + "@react-types/shared": "^3.35.0", + "react": "^19.0.0-rc.1", + "react-aria": "3.49.0", + "react-aria-components": "1.18.0", + "react-dom": "^19.0.0-rc.1", + "react-stately": "3.47.0" + }, + "browserslist": "last 2 Chrome versions, last 2 Safari versions, last 2 Firefox versions, last 2 Edge versions", + "targets": { + "module": false, + "main": false, + "types": false, + "exports-module": { + "source": "exports/*.ts", + "distDir": "dist", + "isLibrary": true, + "outputFormat": "esmodule", + "includeNodeModules": false + }, + "exports-main": { + "source": "exports/*.ts", + "distDir": "dist", + "isLibrary": true, + "outputFormat": "commonjs", + "includeNodeModules": false + } + } +} diff --git a/packages/@react-spectrum/s2-ai/src/AttachmentList.tsx b/packages/@react-spectrum/s2-ai/src/AttachmentList.tsx new file mode 100644 index 00000000000..1293e18a601 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/AttachmentList.tsx @@ -0,0 +1,160 @@ +/* + * 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 {AriaLabelingProps, DOMRef} from '@react-types/shared'; +import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {BasicHorizontalCard} from './HorizontalCard'; +import {Button} from 'react-aria-components/Button'; +import {CardProps} from '@react-spectrum/s2/Card'; +import Close from '@react-spectrum/s2/icons/Close'; +import {controlSize, getAllowedOverrides} from './style-utils-copy' with {type: 'macro'}; +import {forwardRef, useRef} from 'react'; +import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {pressScale} from '@react-spectrum/s2/pressScale'; +import {Tag, TagGroup, TagList} from 'react-aria-components/TagGroup'; +import {useDOMRef} from './useDOMRef'; + +const hoverBackground = { + default: 'gray-200', + isStaticColor: 'transparent-overlay-200' +} as const; + +const styles = style<{ + isDisabled: boolean; + isHovered: boolean; + isFocusVisible: boolean; + isPressed: boolean; + size: 'S' | 'M' | 'L' | 'XL'; +}>( + { + ...focusRing(), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + size: controlSize(), + flexShrink: 0, + borderRadius: 'full', + padding: 0, + borderStyle: 'none', + transition: 'default', + backgroundColor: { + default: 'gray-200', + isHovered: hoverBackground, + isFocusVisible: hoverBackground, + isPressed: hoverBackground + }, + '--iconPrimary': { + type: 'color', + value: { + default: baseColor('neutral'), + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isDisabled: 'GrayText' + } + } + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + disableTapHighlight: true + }, + getAllowedOverrides() +); + +const closeIconStyle = ({size = 'M'}) => { + if (size === 'S') return iconStyle({size: 'XS'}); + else if (size === 'M') return iconStyle({size: 'S'}); + else if (size === 'L') return iconStyle({size: 'M'}); + else if (size === 'XL') return iconStyle({size: 'L'}); + else return iconStyle({size: 'S'}); +}; + +const CloseButton = function CloseButton(props) { + let ref = useRef(null); + return ( + + ); +}; + +let attachmentListStyles = style({}, getAllowedOverrides()); + +export const AttachmentList = forwardRef(function AttachmentList( + props: any, + ref: DOMRef +) { + let domRef = useDOMRef(ref); + return ( + + + {props.children} + + + ); +}); + +export const Attachment = forwardRef(function Attachment( + props: CardProps & AriaLabelingProps, + ref: DOMRef +) { + let { + textValue, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + ...otherProps + } = props; + let domRef = useDOMRef(ref); + return ( + + {props.children} + {/** Definitely not a close button, though looks like one. */} +
+ +
+
+ ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/CenterBaseline.tsx b/packages/@react-spectrum/s2-ai/src/CenterBaseline.tsx new file mode 100644 index 00000000000..14f810eadd2 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/CenterBaseline.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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 {css, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {CSSProperties, ReactNode} from 'react'; +import {DOMAttributes} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import {StyleString} from './types'; + +interface CenterBaselineProps extends DOMAttributes { + style?: CSSProperties; + styles?: StyleString; + children: ReactNode; + slot?: string; +} + +const styles = style({ + display: 'flex', + alignItems: 'center' +}); + +export function CenterBaseline(props: CenterBaselineProps): ReactNode { + let domProps = filterDOMProps(props); + return ( +
+ {props.children} +
+ ); +} + +export const centerBaselineBefore = css( + '&::before { content: "\u00a0"; width: 0; visibility: hidden }' +); + +export function centerBaseline( + props: Omit = {} +): (icon: ReactNode) => ReactNode { + return (icon: ReactNode) => {icon}; +} diff --git a/packages/@react-spectrum/s2-ai/src/HorizontalCard.tsx b/packages/@react-spectrum/s2-ai/src/HorizontalCard.tsx new file mode 100644 index 00000000000..5f48e3e0793 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/HorizontalCard.tsx @@ -0,0 +1,866 @@ +/* + * 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 {ActionMenuContext} from '@react-spectrum/s2/ActionMenu'; +import {ButtonContext} from '@react-spectrum/s2/Button'; +import {Checkbox} from '@react-spectrum/s2/Checkbox'; +import { + color, + focusRing, + lightDark, + space, + style +} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {composeRenderProps} from 'react-aria-components/composeRenderProps'; +import {ContentContext} from '@react-spectrum/s2/Content'; +import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {DOMProps, DOMRef, DOMRefValue, GlobalDOMAttributes} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {FooterContext} from '@react-spectrum/s2/Footer'; +import {getAllowedOverrides} from './style-utils-copy' with {type: 'macro'}; +import {GridListItem, GridListItemProps} from 'react-aria-components/GridList'; +import {ImageContext} from '@react-spectrum/s2/Image'; +import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; +import {inertValue} from 'react-aria/private/utils/inertValue'; +import {Link} from 'react-aria-components/Link'; +import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; +import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import {pressScale} from '@react-spectrum/s2/pressScale'; +import {SkeletonContext, useIsSkeleton} from '@react-spectrum/s2/Skeleton'; +import type {StyleProps, UnsafeStyles} from './style-utils-copy'; +import {TextContext} from '@react-spectrum/s2/Text'; +import {useDOMRef} from './useDOMRef'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +interface CardRenderProps { + /** The size of the Card. */ + size: 'XS' | 'S' | 'M' | 'L' | 'XL'; +} + +export interface CardProps + extends + Omit< + GridListItemProps, + | 'className' + | 'style' + | 'render' + | 'children' + | 'onHoverChange' + | 'onHoverStart' + | 'onHoverEnd' + | 'onClick' + | keyof GlobalDOMAttributes + >, + StyleProps { + /** The children of the Card. */ + children: ReactNode | ((renderProps: CardRenderProps) => ReactNode); + /** + * The size of the Card. + * + * @default 'M' + */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; + /** + * The amount of internal padding within the Card. + * + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious'; + /** + * The visual style of the Card. + * + * @default 'primary' + */ + variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet'; +} + +const borderRadius = { + default: 'lg', + size: { + XS: 'default', + S: 'default' + }, + isBasic: 'default' +} as const; + +// Figma missing a lot of combinations of variant, tshirt, density +// Quiet Basic cards? +// Does Basic not participate in selection? +// Why is there a flipped horizontal card? +// Max width on contents for horizontal cards? Doesn't appear to be one that includes the preview because the preview can have any ratio and that +// causes the width grow. +// (Max) height on cards? Maybe that makes more sense. + +const onlyPreview = ':not(:has([data-slot=content])):not(:has([data-slot=preview]))'; + +let card = style( + { + display: 'flex', + flexDirection: 'row', + position: 'relative', + borderRadius, + '--s2-container-bg': { + type: 'backgroundColor', + value: { + variant: { + primary: 'elevated', + secondary: 'layer-1', + basic: 'layer-2' + }, + forcedColors: 'ButtonFace' + } + }, + backgroundColor: { + default: '--s2-container-bg', + variant: { + tertiary: 'transparent', + quiet: 'transparent' + } + }, + // TODO: No box shadow for basic, secondary, dark + // also none for basic tertiary + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated', + forcedColors: '[0 0 0 1px var(--hcm-buttonborder, ButtonBorder)]', + variant: { + tertiary: { + // Render border with box-shadow to avoid affecting layout. + default: `[0 0 0 2px ${color('gray-100')}]`, + isHovered: `[0 0 0 2px ${color('gray-200')}]`, + isFocusVisible: `[0 0 0 2px ${color('gray-200')}]`, + isSelected: 'none', + forcedColors: '[0 0 0 2px var(--hcm-buttonborder, ButtonBorder)]' + }, + quiet: 'none' + } + }, + forcedColorAdjust: 'none', + transition: 'default', + fontFamily: 'sans', + textDecoration: 'none', + overflow: { + default: 'clip', + variant: { + quiet: 'visible' + } + }, + contain: 'layout', + disableTapHighlight: true, + userSelect: { + isCardView: 'none' + }, + cursor: { + isLink: 'pointer' + }, + height: { + default: { + size: { + XS: 160, + S: 180, + M: 200, + L: 220, + XL: 240 + } + }, + isBasic: 68, + isCardView: 'full', + [onlyPreview]: 68 + }, + width: { + default: 'full', + [onlyPreview]: 'auto' + }, + aspectRatio: { + [onlyPreview]: '1/1' + }, + '--card-spacing': { + type: 'paddingTop', + value: { + density: { + compact: { + size: { + XS: '[6px]', + S: 8, + M: 12, + L: 16, + XL: 20 + } + }, + regular: { + size: { + XS: 8, + S: 12, + M: 16, + L: 20, + XL: 24 + } + }, + spacious: { + size: { + XS: 12, + S: 16, + M: 20, + L: 24, + XL: 28 + } + } + }, + [onlyPreview]: 0 + } + }, + '--card-padding-y': { + type: 'paddingTop', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + '--card-padding-x': { + type: 'paddingStart', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + paddingY: '--card-padding-y', + paddingX: '--card-padding-x', + boxSizing: 'border-box', + ...focusRing(), + outlineStyle: { + default: 'none', + isFocusVisible: 'solid', + // Focus ring moves to preview when quiet. + variant: { + quiet: 'none' + } + }, + '--basic-thumb-size': { + type: 'height', + value: { + default: 68, + size: { + XS: 24, + S: 26, + M: 32, + L: 36, + XL: 40 + }, + [onlyPreview]: 'full' + } + } + }, + getAllowedOverrides() +); + +let selectionIndicator = style({ + position: 'absolute', + inset: 0, + zIndex: 2, + borderRadius, + pointerEvents: 'none', + borderWidth: 2, + borderStyle: 'solid', + borderColor: 'gray-1000', + transition: 'default', + opacity: { + default: 0, + isSelected: 1 + }, + // Quiet cards with no checkbox have an extra inner stroke + // to distinguish the selection indicator from the preview. + outlineColor: lightDark('transparent-white-600', 'transparent-black-600'), + outlineOffset: -4, + outlineStyle: { + default: 'none', + isStrokeInner: 'solid' + }, + outlineWidth: 2 +}); + +let preview = style({ + position: 'relative', + transition: 'default', + overflow: 'clip', + marginY: 'calc(var(--card-padding-y) * -1)', + marginStart: 'calc(var(--card-padding-x) * -1)', + marginEnd: { + ':last-child': 'calc(var(--card-padding-x) * -1)' + }, + borderRadius: { + isQuiet: borderRadius + }, + boxShadow: { + isQuiet: { + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated' + } + }, + ...focusRing(), + outlineStyle: { + default: 'none', + isQuiet: { + isFocusVisible: 'solid' + } + } +}); + +const image = style({ + height: 'full', + aspectRatio: '1/1', + objectFit: 'cover', + userSelect: 'none', + pointerEvents: 'none' +}); + +let title = style({ + font: 'title', + fontSize: { + size: { + XS: 'title-xs', + S: 'title-xs', + M: 'title-sm', + L: 'title', + XL: 'title-lg' + } + }, + lineClamp: 3, + gridArea: 'title' +}); + +let description = style({ + font: 'body', + fontSize: { + size: { + XS: 'body-2xs', + S: 'body-2xs', + M: 'body-xs', + L: 'body-sm', + XL: 'body' + } + }, + lineClamp: 3, + gridArea: 'description' +}); + +let content = style({ + display: 'grid', + // By default, all elements are displayed in a stack. + // If an action menu is present, place it next to the title. + gridTemplateColumns: { + default: ['1fr'], + ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] + }, + gridTemplateAreas: { + default: ['title', 'description'], + ':has([data-slot=menu])': ['title menu', 'description description'] + }, + columnGap: 4, + flexGrow: 1, + alignItems: 'baseline', + alignContent: 'start', + rowGap: { + size: { + XS: 4, + S: 4, + M: space(6), + L: space(6), + XL: 8 + } + }, + paddingStart: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingEnd: { + default: 'calc(var(--card-spacing) * 1.5 / 2)', + ':last-child': 0 + } +}); + +let actionMenu = style({ + gridArea: 'menu', + // Don't cause the row to expand, preserve gap between title and description text. + // Would use -100% here but it doesn't work in Firefox. + marginY: 'calc(-1 * self(height))' +}); + +let footer = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8 +}); + +export const InternalCardViewContext = createContext({ + ElementType: 'div' as 'div' | typeof GridListItem, + layout: 'grid' as 'grid' | 'waterfall' +}); +export const CardContext = + createContext, DOMRefValue>>(null); + +interface InternalCardContextValue { + isQuiet: boolean; + size: 'XS' | 'S' | 'M' | 'L' | 'XL'; + isSelected: boolean; + isHovered: boolean; + isFocusVisible: boolean; + isPressed: boolean; + isCheckboxSelection: boolean; +} + +const InternalCardContext = createContext({ + isQuiet: false, + size: 'M', + isSelected: false, + isHovered: false, + isFocusVisible: false, + isPressed: false, + isCheckboxSelection: true +}); + +const actionButtonSize = { + XS: 'XS', + S: 'XS', + M: 'S', + L: 'M', + XL: 'L' +} as const; + +const Card = forwardRef(function Card( + props: CardProps & {isBasic?: boolean}, + ref: DOMRef +) { + [props] = useSpectrumContextProps(props, ref, CardContext); + let {ElementType} = useContext(InternalCardViewContext); + let domRef = useDOMRef(ref); + let { + isBasic = false, + density = 'regular', + size = 'M', + variant = 'primary', + UNSAFE_className = '', + UNSAFE_style, + styles, + id, + ...otherProps + } = props; + let isQuiet = variant === 'quiet'; + let isSkeleton = useIsSkeleton(); + let children = ( + + + {typeof props.children === 'function' ? props.children({size}) : props.children} + + + ); + + let press = pressScale(domRef, UNSAFE_style); + if (ElementType === 'div' && !isSkeleton && props.href) { + // Standalone Card that has an href should be rendered as a Link. + // NOTE: In this case, the card must not contain interactive elements. + return ( + + UNSAFE_className + + card( + {...renderProps, size, density, variant, isBasic, isCardView: false, isLink: true}, + styles + ) + } + style={renderProps => + // Only the preview in quiet cards scales down on press + variant === 'quiet' ? UNSAFE_style : press(renderProps) + }> + {renderProps => ( + + {children} + + )} + + ); + } + + if (ElementType === 'div' || isSkeleton) { + return ( +
+ + {children} + +
+ ); + } + + return ( + + UNSAFE_className + + card( + {...renderProps, isCardView: true, isLink: !!props.href, size, density, variant, isBasic}, + styles + ) + } + style={renderProps => + // Only the preview in quiet cards scales down on press + variant === 'quiet' ? UNSAFE_style : press(renderProps) + }> + {({selectionMode, selectionBehavior, isHovered, isFocusVisible, isSelected, isPressed}) => ( + + {/* Selection indicator and checkbox move inside the preview for quiet cards */} + {!isQuiet && } + {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && ( + + )} + {/* this makes the :first-child selector work even with the checkbox */} +
{children}
+
+ )} +
+ ); +}); + +function SelectionIndicator() { + let {size, isSelected, isQuiet, isCheckboxSelection} = useContext(InternalCardContext); + return ( +
+ ); +} + +function CardCheckbox() { + let {size} = useContext(InternalCardContext); + return ( +
+ +
+ ); +} + +export interface CardPreviewProps extends UnsafeStyles, DOMProps { + children: ReactNode; +} + +export const CardPreview = forwardRef(function CardPreview( + props: CardPreviewProps, + ref: DOMRef +) { + let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = + useContext(InternalCardContext); + let {UNSAFE_className = '', UNSAFE_style} = props; + let domRef = useDOMRef(ref); + return ( +
+ {isQuiet && } + {isQuiet && isCheckboxSelection && } +
+ {props.children} +
+
+ ); +}); + +const collection = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: { + default: 4, + size: { + XS: 2, + S: 2 + } + } +}); + +const collectionImage = style({ + width: 'full', + aspectRatio: { + default: 'square', + ':nth-last-child(4):first-child': '3/2' + }, + gridColumnEnd: { + ':nth-last-child(4):first-child': 'span 3' + }, + objectFit: 'cover', + pointerEvents: 'none', + userSelect: 'none' +}); + +export const CollectionCardPreview = forwardRef(function CollectionCardPreview( + props: CardPreviewProps, + ref: DOMRef +) { + let {size} = useContext(InternalCardContext)!; + return ( + +
+ + {props.children} + +
+
+ ); +}); + +const buttonSize = { + XS: 'S', + S: 'S', + M: 'M', + L: 'L', + XL: 'XL' +} as const; + +export const HorizontalCard = forwardRef(function HorizontalCard( + props: CardProps, + ref: DOMRef +) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); + +export const BasicHorizontalCard = forwardRef(function BasicHorizontalCard( + props: CardProps, + ref: DOMRef +) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/MessageFeedback.tsx b/packages/@react-spectrum/s2-ai/src/MessageFeedback.tsx new file mode 100644 index 00000000000..8e59709fe18 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/MessageFeedback.tsx @@ -0,0 +1,114 @@ +/* + * 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 {AriaLabelingProps, DOMProps, DOMRef, DOMRefValue, Selection} from '@react-types/shared'; +import {ContextValue, SlotProps} from 'react-aria-components/slots'; +import {createContext, forwardRef} from 'react'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import type {StyleProps} from './style-utils-copy'; +import ThumbDown from '@react-spectrum/s2/icons/ThumbDown'; +import ThumbUp from '@react-spectrum/s2/icons/ThumbUp'; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; +import {ToggleButtonGroup} from '@react-spectrum/s2/ToggleButtonGroup'; +import {useDOMRef} from './useDOMRef'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export type MessageFeedbackValue = 'up' | 'down' | null; + +export interface MessageFeedbackProps extends DOMProps, AriaLabelingProps, StyleProps, SlotProps { + /** The selected feedback value (controlled). */ + value?: MessageFeedbackValue; + /** The default feedback value (uncontrolled). */ + defaultValue?: MessageFeedbackValue; + /** Called when the selection changes, including when toggled off (value=null). */ + onChange?: (value: MessageFeedbackValue) => void; + /** Whether the feedback controls are disabled. */ + isDisabled?: boolean; + /** Accessible label for the thumbs up button. */ + thumbUpLabel?: string; + /** Accessible label for the thumbs down button. */ + thumbDownLabel?: string; +} + +export const MessageFeedbackContext = + createContext, DOMRefValue>>(null); + +function selectionToValue(selection: Selection): MessageFeedbackValue { + let [first] = selection as Iterable; + return first === 'up' || first === 'down' ? first : null; +} + +/** + * MessageFeedback collects thumbs up / thumbs down feedback on an AI response. + */ +export const MessageFeedback = forwardRef(function MessageFeedback( + props: MessageFeedbackProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, MessageFeedbackContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2-ai'); + let domRef = useDOMRef(ref); + let { + value, + defaultValue, + onChange, + isDisabled, + thumbUpLabel, + thumbDownLabel, + UNSAFE_className = '', + UNSAFE_style, + styles + } = props; + + let handleSelectionChange = (selection: Selection): void => { + onChange?.(selectionToValue(selection)); + }; + + let toKeys = (v: MessageFeedbackValue | undefined): Iterable | undefined => { + if (v === undefined) { + return undefined; + } + return v === null ? [] : [v]; + }; + let selectedKeys = toKeys(value); + let defaultSelectedKeys = toKeys(defaultValue); + + return ( + + + + + + + + + ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/MessageSource.tsx b/packages/@react-spectrum/s2-ai/src/MessageSource.tsx new file mode 100644 index 00000000000..108da44823d --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/MessageSource.tsx @@ -0,0 +1,268 @@ +/* + * 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 { + AriaLabelingProps, + DOMProps, + DOMRef, + DOMRefValue, + forwardRefType +} from '@react-types/shared'; +import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ContextValue, SlotProps} from 'react-aria-components/slots'; +import { + Disclosure, + DisclosurePanel, + DisclosurePanelProps, + DisclosureProps, + DisclosureTitle +} from '@react-spectrum/s2/Disclosure'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import { + getAllowedOverrides, + StyleProps, + UnsafeStyles +} from './style-utils-copy' with {type: 'macro'}; +import {Link, LinkProps} from 'react-aria-components/Link'; +import {NumberFormatter} from '@internationalized/number'; +import React, {createContext, forwardRef, useContext} from 'react'; +import {useDOMRef} from './useDOMRef'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface MessageSourceProps extends Omit { + label: string; +} + +export const MessageSourceContext = + createContext, DOMRefValue>>(null); + +const MessageSourceInternalContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); + +/** + * Message sources display references associated with a system message. Associating the source to + * the output builds trust and transparency in the conversation. + */ +export const MessageSource = (forwardRef as forwardRefType)(function MessageSource( + props: MessageSourceProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, MessageSourceContext); + let {label, children, size = 'M', ...otherProps} = props; + + return ( + + + + {label} + {children} + + + + ); +}); + +// SourceList injects a 1-based index so SourceListItem +// can render it without needing an explicit prop. +const SourceListIndexContext = createContext(1); + +const listStyles = style({ + listStyleType: 'none', + padding: 0, + margin: 0, + display: 'flex', + flexDirection: 'column', + gap: 4 +}); + +const itemStyles = style({ + display: 'flex', + alignItems: 'center', + gap: 8 +}); + +export interface SourceListProps extends DisclosurePanelProps {} + +/** + * A SourceList displays an ordered list of sources inside a MessageSource. + * Wrap SourceListItem children inside to have them numbered automatically. + */ +export const SourceList = (forwardRef as forwardRefType)(function SourceList( + props: SourceListProps, + ref: DOMRef +) { + let {children, ...otherProps} = props; + + let numberedChildren = React.Children.map(children, (child, i) => ( + {child} + )); + + return ( + +
    {numberedChildren}
+
+ ); +}); + +const linkStyles = style({ + ...focusRing(), + font: { + size: { + S: 'body-sm', + M: 'body', + L: 'body-lg', + XL: 'body-xl' + } + }, + borderRadius: 'sm', + color: baseColor('neutral'), + disableTapHighlight: true +}); + +export interface SourceListItemProps + extends Omit, UnsafeStyles, DOMProps { + /** The content of the source list item. */ + children: React.ReactNode; +} + +/** + * A SourceListItem represents a single source within a SourceList. + * The item number is provided automatically by the parent SourceList. + */ +export const SourceListItem = (forwardRef as forwardRefType)(function SourceListItem( + props: SourceListItemProps, + ref: DOMRef +) { + let index = useContext(SourceListIndexContext); + let {size} = useContext(MessageSourceInternalContext); + let {children, UNSAFE_style, UNSAFE_className = '', ...otherProps} = props; + let itemRef = useDOMRef(ref); + + return ( +
  • + + linkStyles({size, ...renderProps})}> + {children} + +
  • + ); +}); + +interface NumberBadgeStyleProps { + /** + * The size of the number badge. + * + * @default 'S' + */ + size?: 'S' | 'M' | 'L' | 'XL'; +} + +export interface NumberBadgeProps + extends DOMProps, AriaLabelingProps, StyleProps, NumberBadgeStyleProps, SlotProps { + /** + * The value to be displayed in the notification badge. + */ + value: number; +} + +interface NumberBadgeContextProps extends Partial {} +export const NumberBadgeContext = + createContext, DOMRefValue>>(null); + +const badge = style( + { + display: 'flex', + color: 'gray-900', + font: { + size: { + S: 'ui-xs', + M: 'ui-sm', + L: 'ui', + XL: 'ui-lg' + } + }, + borderStyle: { + forcedColors: 'solid' + }, + borderWidth: { + forcedColors: '[1px]' + }, + borderColor: { + forcedColors: 'ButtonBorder' + }, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'gray-200', + // These are arbitrary sizes since there are no designs for them + width: { + size: { + S: 14, + M: 16, + L: 18, + XL: 20 + } + }, + height: { + size: { + S: 18, + M: 20, + L: 22, + XL: 24 + } + }, + borderRadius: 'sm' + }, + getAllowedOverrides() +); + +/** + * A small visual indicator showing a count or position. + */ +export const NumberBadge = forwardRef(function NumberBadge( + props: NumberBadgeProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, NumberBadgeContext); + let {size = 'S', value, ...otherProps} = props; + let domRef = useDOMRef(ref); + let {locale} = useLocale(); + let formattedValue = ''; + + if (value <= 0 && process.env.NODE_ENV !== 'production') { + console.warn('Value must be a positive integer'); + } else { + formattedValue = new NumberFormatter(locale).format(value); + } + + // let ariaLabel = props['aria-label'] || undefined; + return ( + + {formattedValue} + + ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/MessageSuggestion.tsx b/packages/@react-spectrum/s2-ai/src/MessageSuggestion.tsx new file mode 100644 index 00000000000..ec2ab29c62f --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/MessageSuggestion.tsx @@ -0,0 +1,199 @@ +/* + * 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 {AriaLabelingProps, DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; +import ArrowCurved from '@react-spectrum/s2/icons/ArrowCurved'; +import {ButtonProps, Button as RACButton} from 'react-aria-components/Button'; +import {centerBaseline} from './CenterBaseline'; +import {centerPadding, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ContextValue, Provider, SlotProps} from 'react-aria-components/slots'; +import {controlSize, getAllowedOverrides} from './style-utils-copy' with {type: 'macro'}; +import {createContext, forwardRef, ReactNode} from 'react'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {IconContext} from '@react-spectrum/s2/Icon'; +import {pressScale} from '@react-spectrum/s2'; +import type {StyleProps} from './style-utils-copy'; +import {useDOMRef} from './useDOMRef'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface MessageSuggestionProps + extends Omit, StyleProps { + /** The text content of the suggestion. */ + children: ReactNode; + /** The size of the MessageSuggestion. */ + size?: 'S' | 'M' | 'L' | 'XL'; +} + +export const MessageSuggestionContext = + createContext, DOMRefValue>>( + null + ); + +const suggestionStyles = style<{ + isHovered: boolean; + isPressed: boolean; + isDisabled: boolean; + isFocusVisible: boolean; + isFocused: boolean; + size: 'S' | 'M' | 'L' | 'XL'; +}>( + { + display: 'flex', + flexDirection: 'row', + alignItems: 'baseline', + gap: 'text-to-visual', + paddingX: 'pill', + paddingY: 0, + '--labelPadding': { + type: 'paddingTop', + value: centerPadding() + }, + minHeight: controlSize(), + borderRadius: 'pill', + font: { + size: { + S: 'body-sm', + M: 'body', + L: 'body-lg', + XL: 'body-xl' + } + }, + backgroundColor: { + default: 'gray-100', + isHovered: 'gray-200', + isPressed: 'gray-300' + }, + color: 'neutral', + borderStyle: 'none', + disableTapHighlight: true, + ...focusRing() + }, + getAllowedOverrides() +); + +/** + * MessageSuggestion renders a single pressable suggestion in a conversation. + */ +export const MessageSuggestion = forwardRef(function MessageSuggestion( + props: MessageSuggestionProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, MessageSuggestionContext); + let domRef = useDOMRef(ref); + let {children, UNSAFE_className = '', UNSAFE_style, styles, size = 'M', ...otherProps} = props; + let {direction} = useLocale(); + let isRTL = direction === 'rtl'; + + return ( + + UNSAFE_className + '' + suggestionStyles({...renderProps, size}, styles) + }> + + + {children} + + + ); +}); + +export interface MessageSuggestionListProps + extends DOMProps, AriaLabelingProps, StyleProps, SlotProps { + /** The MessageSuggestion children to display. */ + children: ReactNode; + /** Heading displayed above the suggestions. */ + title: string; + /** The size of hte Buttons within the MessageSuggestionList. */ + size?: 'S' | 'M' | 'L' | 'XL'; +} + +export const MessageSuggestionListContext = + createContext, DOMRefValue>>( + null + ); + +const listStyles = style( + { + display: 'flex', + flexDirection: 'column', + gap: 8 + }, + getAllowedOverrides() +); + +const titleStyles = style({ + font: { + size: { + S: 'title-sm', + M: 'title', + L: 'title-lg', + XL: 'title-xl' + } + }, + color: 'neutral', + margin: 0, + padding: 0 +}); + +const responseStyles = style({ + display: 'flex', + flexWrap: 'wrap', + gap: 8 +}); + +/** + * MessageSuggestionList renders a group of suggestion responses with a title heading. + */ +export const MessageSuggestionList = forwardRef(function MessageSuggestionList( + props: MessageSuggestionListProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, MessageSuggestionListContext); + let domRef = useDOMRef(ref); + let {children, title, UNSAFE_className = '', UNSAFE_style, styles, size = 'M'} = props; + + return ( +
    +

    {title}

    + +
    {children}
    +
    +
    + ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/ResponseStatus.tsx b/packages/@react-spectrum/s2-ai/src/ResponseStatus.tsx new file mode 100644 index 00000000000..eb5ad374a39 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/ResponseStatus.tsx @@ -0,0 +1,399 @@ +/* + * 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 { + AriaLabelingProps, + DOMProps, + DOMRef, + DOMRefValue, + GlobalDOMAttributes +} from '@react-types/shared'; +import { + baseColor, + focusRing, + iconStyle, + space, + style +} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Button} from 'react-aria-components/Button'; +import {CenterBaseline, centerBaseline} from './CenterBaseline'; +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; +import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; +import {ContextValue, Provider, useSlottedContext} from 'react-aria-components/slots'; +import { + DisclosureStateContext, + Disclosure as RACDisclosure, + DisclosurePanel as RACDisclosurePanel, + DisclosurePanelProps as RACDisclosurePanelProps, + DisclosureProps as RACDisclosureProps +} from 'react-aria-components/Disclosure'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import { + getAllowedOverrides, + StyleProps, + StylesPropWithFont, + UnsafeStyles +} from './style-utils-copy' with {type: 'macro'}; +import {Heading} from 'react-aria-components/Heading'; +import {IconContext} from '@react-spectrum/s2/Icon'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import React, {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {useDOMRef} from './useDOMRef'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface ResponseStatusProps + extends + Omit< + RACDisclosureProps, + 'className' | 'style' | 'render' | 'children' | keyof GlobalDOMAttributes + >, + StyleProps { + /** + * The size of the response status. + * + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL'; + /** + * The amount of space between stacked response statuses. + * + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious'; + /** + * Whether the response is still being generated. When true, a ProgressCircle replaces + * the chevron and the panel cannot be expanded. The trigger remains focusable. + */ + isLoading?: boolean; + /** + * The contents of the response status, consisting of a ResponseStatusTitle and + * ResponseStatusPanel. + */ + children: ReactNode; +} + +export const ResponseStatusContext = + createContext, DOMRefValue>>(null); + +const responseStatus = style( + { + color: 'heading', + minWidth: 200 + }, + getAllowedOverrides() +); + +/** + * A ResponseStatus indicates the progress of a system response while it is begin generated and when + * it is complete. + */ +export const ResponseStatus = forwardRef(function ResponseStatus( + props: ResponseStatusProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, ResponseStatusContext); + let {size = 'M', density = 'regular', isLoading, UNSAFE_style, UNSAFE_className = ''} = props; + let domRef = useDOMRef(ref); + + let disclosureProps: Partial = {}; + if (isLoading) { + disclosureProps.isExpanded = false; + disclosureProps.onExpandedChange = () => {}; + } + + return ( + + + {props.children} + + + ); +}); + +export interface ResponseStatusTitleProps extends UnsafeStyles, DOMProps { + /** + * The heading level of the response status header. + * + * @default 3 + */ + level?: number; + /** The contents of the response status header. */ + children: React.ReactNode; + /** + * Spectrum-defined styles, returned by the `style()` macro. Only allows overriding + * `font`, `fontFamily`, `fontWeight`, `fontSize`, and `lineHeight`. + */ + styles?: StylesPropWithFont; +} + +const headingStyle = style({ + margin: 0, + flexGrow: 1, + display: 'flex', + flexShrink: 1, + minWidth: 0 +}); + +const buttonStyles = style( + { + ...focusRing(), + outlineOffset: -2, + font: { + size: { + S: 'body-sm', + M: 'body', + L: 'body-lg', + XL: 'body-xl' + } + }, + color: { + default: baseColor('neutral'), + forcedColors: 'ButtonText', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + display: 'flex', + flexGrow: 1, + alignItems: 'center', + paddingX: 'calc(self(minHeight) * 3/8 - 1px)', + gap: 'calc(self(minHeight) * 3/8 - 1px)', + minHeight: { + size: { + S: { + density: { + compact: 18, + regular: 24, + spacious: 32 + } + }, + M: { + density: { + compact: 24, + regular: 32, + spacious: 40 + } + }, + L: { + density: { + compact: 32, + regular: 40, + spacious: 48 + } + }, + XL: { + density: { + compact: 40, + regular: 48, + spacious: 56 + } + } + } + }, + width: 'full', + backgroundColor: 'transparent', + transition: 'default', + borderWidth: 0, + borderRadius: 'default', + textAlign: 'start', + disableTapHighlight: true + }, + getAllowedOverrides({font: true}) +); + +const chevronStyles = style({ + rotate: { + isRTL: 180, + isExpanded: 90 + }, + transition: 'default', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + flexShrink: 0 +}); + +const progressCircleStyles = style({ + width: { + size: { + S: 16, + M: 18, + L: 20, + XL: 22 + } + }, + height: { + size: { + S: 16, + M: 18, + L: 20, + XL: 22 + } + } +}); + +/** + * A response status title consisting of a heading and a trigger button. The leading icon is + * a progress circle while loading and a chevron once complete; a checkmark is rendered at + * the trailing edge of the row when not loading. + */ +export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( + props: ResponseStatusTitleProps, + ref: DOMRef +) { + let {level = 3, UNSAFE_style, UNSAFE_className = '', styles, ...otherProps} = props; + let domRef = useDOMRef(ref); + const domProps = filterDOMProps(otherProps); + let {direction} = useLocale(); + let {isExpanded} = useContext(DisclosureStateContext)!; + let {size = 'M', density, isLoading} = useSlottedContext(ResponseStatusContext)!; + let isRTL = direction === 'rtl'; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + + return ( + + + + ); +}); + +export interface ResponseStatusPanelProps + extends + Omit, + UnsafeStyles, + DOMProps, + AriaLabelingProps { + children: React.ReactNode; +} + +const panelStyles = style({ + font: 'body', + height: '--disclosure-panel-height', + overflow: 'clip', + transition: { + default: '[height]', + '@media (prefers-reduced-motion: reduce)': 'none' + } +}); + +const panelInner = style({ + paddingTop: 8, + paddingBottom: 16, + paddingX: { + size: { + S: 8, + M: space(9), + L: 12, + XL: space(15) + } + } +}); + +/** + * A response status panel is a collapsible section of content that is hidden until the + * response status is expanded. The panel cannot be expanded while `isLoading` is true. + */ +export const ResponseStatusPanel = forwardRef(function ResponseStatusPanel( + props: ResponseStatusPanelProps, + ref: DOMRef +) { + let {UNSAFE_style, UNSAFE_className = '', ...otherProps} = props; + let {size = 'M'} = useSlottedContext(ResponseStatusContext)!; + const domProps = filterDOMProps(otherProps); + let panelRef = useDOMRef(ref); + return ( + +
    {props.children}
    +
    + ); +}); + +// Ideally I would use iconStyle but since it must be fully evaluated at compile time — passing a runtime variable (size prop) to it is not allowed. +// TODO: Should iconStyle also return a runtime function? +function Chevron({size}) { + switch (size) { + case 'S': + return ; + case 'M': + return ; + case 'L': + return ; + case 'XL': + return ; + } +} diff --git a/packages/@react-spectrum/s2-ai/src/Thread.tsx b/packages/@react-spectrum/s2-ai/src/Thread.tsx new file mode 100644 index 00000000000..f2beefb9c81 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/Thread.tsx @@ -0,0 +1,314 @@ +/* + * 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 {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import {ButtonContext} from 'react-aria-components/Button'; +import { + createContext, + forwardRef, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import type {CSSProperties} from 'react'; +import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import {DOMRef, forwardRefType} from '@react-types/shared'; +import { + GridList, + GridListItem, + GridListItemProps, + GridListProps +} from 'react-aria-components/GridList'; +import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {TextFieldContext} from 'react-aria-components/TextField'; +import {useDOMRef} from './useDOMRef'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; + +interface InternalThreadContextValue { + announceItem: (text: string) => void; + setGridListFocused: (isFocused: boolean) => void; + setIsNearBottom: (isNear: boolean) => void; + setScrollElement: (element: HTMLElement | null) => void; +} + +const InternalThreadContext = createContext({ + announceItem: text => announce(text, 'polite'), + setGridListFocused: () => {}, + setIsNearBottom: () => {}, + setScrollElement: () => {} +}); + +interface ThreadScrollButtonContextValue { + isNearBottom: boolean; + scrollToBottom: () => void; +} + +const ThreadScrollButtonContext = createContext({ + isNearBottom: true, + scrollToBottom: () => {} +}); + +// TODO: make this more RAC like (aka default class name and other RAC prop) +interface ThreadProps { + className?: string; + style?: CSSProperties; + children?: ReactNode; +} + +// TODO: tabbing is a bit broken as well since we hit the child elements of the gridlist rows in opposite order... This seems to be due to the +// tabIndex = 0 of the ToggleButtons in the ToggleButtonGroup +export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thread( + props: ThreadProps, + ref: DOMRef +) { + let {children, className, style} = props; + let domRef = useDOMRef(ref); + let isGridListFocusedRef = useRef(false); + let isFieldFocusedRef = useRef(false); + let hasNewMessagesRef = useRef(false); + let timeout = useRef | null>(null); + + let scrollRef = useRef(null); + let scrollToBottom = useCallback(() => { + scrollRef.current?.scrollTo({top: 0, behavior: 'smooth'}); + }, []); + let [isNearBottom, setIsNearBottom] = useState(true); + + // only announce new items if user is in the prompt field, otherwise if they + // are in the thread only announce there are new responses. If not in thread, don't announce + let announceItem = useCallback((text: string) => { + if (isGridListFocusedRef.current) { + // TODO: ideally announce number of new messages, but only count system messages? maybe threaditem needs + // to have a "type" prop + if (!hasNewMessagesRef.current) { + hasNewMessagesRef.current = true; + announce('New message', 'polite'); + // TODO: arbirary amount of time to wait before announcing new message, maybe we don't clear until + // we detect they scroll down? Or maybe when we do the message count we do it after a certain number of messages? + // or maybe this is fine + timeout.current = setTimeout(() => { + hasNewMessagesRef.current = false; + timeout.current = null; + }, 5000); + } + return; + } + + if (isFieldFocusedRef.current) { + announce(text, 'polite'); + } + }, []); + + let setGridListFocused = useCallback((isFocused: boolean) => { + isGridListFocusedRef.current = isFocused; + }, []); + + let setScrollElement = useCallback((el: HTMLElement | null) => { + scrollRef.current = el; + }, []); + + useEffect(() => { + return () => { + if (timeout.current !== null) { + clearTimeout(timeout.current); + } + }; + }, []); + + return ( + { + isFieldFocusedRef.current = focused; + } + } + } + } + ] + ]}> +
    + {children} +
    +
    + ); +}); + +// TODO: update the items/className/children/etc type to reflect a thread specific classname once we finalize API +interface ThreadListProps extends Pick< + GridListProps, + 'items' | 'children' | 'focusOnEntry' | 'aria-label' | 'aria-labelledby' | 'className' +> {} + +export function ThreadList(props: ThreadListProps) { + let { + items, + children, + className, + focusOnEntry, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby + } = props; + + let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalThreadContext); + let isNearBottomRef = useRef(true); + let gridListRef = useRef(null); + + let callbackRef = useCallback( + (el: HTMLDivElement | null) => { + gridListRef.current = el; + setScrollElement(el); + }, + [setScrollElement] + ); + + // TODO: gridlist doesn't have onFocus/onBlur + useEffect(() => { + let el = gridListRef.current; + if (!el) { + return; + } + + let onFocusIn = () => setGridListFocused(true); + let onFocusOut = (e: FocusEvent) => { + if (!nodeContains(el, e.relatedTarget as Node)) { + setGridListFocused(false); + } + }; + + el.addEventListener('focusin', onFocusIn); + el.addEventListener('focusout', onFocusOut); + return () => { + el.removeEventListener('focusin', onFocusIn); + el.removeEventListener('focusout', onFocusOut); + }; + }, [setGridListFocused]); + + let handleScroll = useCallback(() => { + let el = gridListRef.current; + if (!el) { + return; + } + + // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up + let nearBottom = el.scrollTop > -100; + isNearBottomRef.current = nearBottom; + setIsNearBottom(nearBottom); + }, [setIsNearBottom]); + + useEffect(() => { + // scrolls to bottom on first render cuz we initialize isNearBottomRef to true, + // otherwise handles scrolling new prompts/etc into view unless you are scrolled up above + // 100px + if (isNearBottomRef.current) { + requestAnimationFrame(() => { + if (gridListRef.current) { + gridListRef.current.scrollTop = 0; + } + }); + } + }, [items]); + + return ( + + {children} + + ); +} + +interface ThreadScrollButtonProps { + children?: ReactNode; +} + +// TODO: wrapper so we can do the "if isNearBottom then hide" logic, could do this via inline styles perhaps +// and ditch the wrapper? +export function ThreadScrollButton({children}: ThreadScrollButtonProps) { + let {isNearBottom, scrollToBottom} = useContext(ThreadScrollButtonContext); + + if (isNearBottom) { + return null; + } + + return ( + + {children} + + ); +} + +// TODO: update the className type to reflect a thread specific classname once we finalize API +interface ThreadItemProps extends Pick { + /** Whether or not the item's content is currently being streamed in. */ + isStreaming?: boolean; + /** Announce textValue on mount even when isStreaming is provided. */ + shouldAnnounceOnMount?: boolean; +} + +export function ThreadItem(props: ThreadItemProps) { + let {className, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props; + let {announceItem} = useContext(InternalThreadContext); + + // TODO: using aria-live on the gridlist item was pretty chatty and the streaming causes the text announcement + // to constantly reset. If we used a live region and updated its contents when streaming finished that worked decently + // but still feels quite verbose. Stick with this and get feedback + useLayoutEffect(() => { + if ((isStreaming === undefined || shouldAnnounceOnMount) && textValue && textValue !== ' ') { + announceItem(textValue); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + let isStreamingNow = isStreaming ?? false; + let prevStreamingRef = useRef(isStreamingNow); + useLayoutEffect(() => { + if (isStreaming === undefined) { + return; + } + let wasStreaming = prevStreamingRef.current; + prevStreamingRef.current = isStreamingNow; + if (wasStreaming && !isStreamingNow && textValue && textValue !== ' ') { + announceItem(textValue); + } + }, [isStreaming, isStreamingNow, textValue, announceItem]); + + return ( + + {children} + + ); +} diff --git a/packages/@react-spectrum/s2-ai/src/UserMessage.tsx b/packages/@react-spectrum/s2-ai/src/UserMessage.tsx new file mode 100644 index 00000000000..024a5a9f8dd --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/UserMessage.tsx @@ -0,0 +1,107 @@ +/* + * 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 {AriaLabelingProps, DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; +import {ContextValue, DEFAULT_SLOT, Provider, SlotProps} from 'react-aria-components/slots'; +import {createContext, forwardRef, ReactNode} from 'react'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getAllowedOverrides} from './style-utils-copy' with {type: 'macro'}; +import {ImageContext} from '@react-spectrum/s2/Image'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import type {StyleProps} from './style-utils-copy'; +import {useDOMRef} from './useDOMRef'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface UserMessageProps extends DOMProps, AriaLabelingProps, StyleProps, SlotProps { + /** The contents of the user message bubble. */ + children: ReactNode; +} + +export const UserMessageContext = + createContext, DOMRefValue>>(null); + +const previewImage = style({ + width: 'full', + height: 132, + borderRadius: 'sm', + backgroundColor: 'transparent', + objectFit: 'cover' +}); + +// TODO: revisit whether 75% is the right, or if there should be different modalities +const bubble = style( + { + display: 'flex', + flexDirection: { + default: 'row', + ':has([slot=image])': 'column' + }, + alignItems: { + default: 'center', + ':has([slot=image])': 'stretch' + }, + gap: 8, + paddingY: 8, + paddingX: { + default: 16, + ':has(img)': 8 + }, + backgroundColor: 'gray-50', + color: 'neutral', + borderRadius: 'lg', + font: 'body', + boxSizing: 'border-box', + alignSelf: 'end', + maxWidth: '75%', + width: { + default: 'fit', + ':has([slot=image])': '75%' + } + }, + getAllowedOverrides() +); + +/** + * UserMessage renders a single user-authored message in a conversational AI thread. + * Pass `slot="image"` on an Image child to switch to a vertical layout with a full-width preview. + */ +export const UserMessage = forwardRef(function UserMessage( + props: UserMessageProps, + ref: DOMRef +) { + [props, ref] = useSpectrumContextProps(props, ref, UserMessageContext); + let domRef = useDOMRef(ref); + let {children, UNSAFE_className = '', UNSAFE_style, styles} = props; + + return ( +
    + + {children} + +
    + ); +}); diff --git a/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts b/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts new file mode 100644 index 00000000000..0b234b107ac --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts @@ -0,0 +1,106 @@ +import {CSSProperties} from 'react'; +import {StyleString} from './types'; + +export const controlSize = (size: 'sm' | 'md' = 'md'): typeof controlSizeM | typeof controlSizeS => + size === 'sm' ? controlSizeS : controlSizeM; + +const controlSizeM = { + default: 32, + size: { + XS: 20, + S: 24, + L: 40, + XL: 48 + } +} as const; + +const controlSizeS = { + default: 16, + size: { + S: 14, + L: 18, + XL: 20 + } +} as const; + +const allowedOverrides = [ + 'margin', + 'marginStart', + 'marginEnd', + 'marginTop', + 'marginBottom', + 'marginX', + 'marginY', + 'flexGrow', + 'flexShrink', + 'flexBasis', + 'justifySelf', + 'alignSelf', + 'order', + 'gridArea', + 'gridRowStart', + 'gridRowEnd', + 'gridColumnStart', + 'gridColumnEnd', + 'position', + 'zIndex', + 'top', + 'bottom', + 'inset', + 'insetX', + 'insetY', + 'insetStart', + 'insetEnd', + 'visibility' +] as const; + +export const widthProperties = ['width', 'minWidth', 'maxWidth'] as const; + +export const heightProperties = ['size', 'height', 'minHeight', 'maxHeight'] as const; + +export const fontProperties = [ + 'font', + 'fontFamily', + 'fontWeight', + 'lineHeight', + 'fontSize' +] as const; + +export type StylesProp = StyleString< + (typeof allowedOverrides)[number] | (typeof widthProperties)[number] +>; +export type StylesPropWithHeight = StyleString< + | (typeof allowedOverrides)[number] + | (typeof widthProperties)[number] + | (typeof heightProperties)[number] +>; +export type StylesPropWithoutWidth = StyleString<(typeof allowedOverrides)[number]>; +export type UnsafeClassName = string & {properties?: never}; +export type StylesPropWithFont = StyleString<(typeof fontProperties)[number]>; + +export interface UnsafeStyles { + /** + * Sets the CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) + * for the element. Only use as a **last resort**. Use the `style` macro via the `styles` prop + * instead. + */ + UNSAFE_className?: UnsafeClassName; + /** + * Sets inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for + * the element. Only use as a **last resort**. Use the `style` macro via the `styles` prop + * instead. + */ + UNSAFE_style?: CSSProperties; +} + +export interface StyleProps extends UnsafeStyles { + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesProp; +} + +export function getAllowedOverrides({width = true, height = false, font = false} = {}): string[] { + return (allowedOverrides as unknown as string[]) + .concat(width ? widthProperties : []) + .concat(height ? heightProperties : []) + .concat(font ? ['fontFamily', 'fontWeight', 'lineHeight', 'fontSize'] : []); +} diff --git a/packages/@react-spectrum/s2-ai/src/types.ts b/packages/@react-spectrum/s2-ai/src/types.ts new file mode 100644 index 00000000000..a2d5a52d98b --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/types.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2024 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 type * as CSS from 'csstype'; + +export type CSSValue = string | number; +export type CustomValue = string | number | boolean; +export type Value = CustomValue | readonly any[]; +export type PropertyValueDefinition = T | {[condition: string]: PropertyValueDefinition}; +export type PropertyValueMap = { + [name in T]: PropertyValueDefinition; +}; + +export type CustomProperty = `--${string}`; +export type CSSProperties = CSS.Properties & { + [k: CustomProperty]: CSSValue; +}; + +export interface Property { + cssProperties: string[]; + toCSSValue(value: T): PropertyValueDefinition; + toCSSProperties( + customProperty: string | null, + value: PropertyValueDefinition + ): PropertyValueDefinition<[CSSProperties]>; +} + +export type ShorthandProperty = (value: T) => {[name: string]: Value}; + +export interface Theme { + properties: { + [name: string]: PropertyValueMap | Property | string[]; + }; + conditions: { + [name: string]: string | string[]; + }; + shorthands: { + [name: string]: string[] | ShorthandProperty; + }; +} + +type PropertyValue = + T extends Property + ? P + : T extends PropertyValueMap + ? P + : T extends string[] + ? T[number] + : never; + +type FunctionName = + | 'var' + | 'calc' + | 'min' + | 'max' + | 'clamp' + | 'round' + | 'mod' + | 'rem' + | 'sin' + | 'cos' + | 'tan' + | 'asin' + | 'acos' + | 'atan' + | 'atan2' + | 'pow' + | 'sqrt' + | 'hypot' + | 'log' + | 'exp' + | 'abs' + | 'sign'; +type CSSWideKeyword = 'inherit' | 'initial' | 'unset'; +export type ArbitraryValue = + | CustomProperty + | `[${string}]` + | `${FunctionName}(${string})` + | CSSWideKeyword; +type PropertyValue2 = PropertyValue | ArbitraryValue; +type Merge = T extends any ? T : never; +type ShorthandValue = P extends string[] + ? PropertyValue2 + : P extends ShorthandProperty + ? V + : never; + +// Pre-compute value types for all theme properties ahead of time. +export type ThemeProperties = Merge<{ + [K in keyof T['properties'] | keyof T['shorthands']]: K extends keyof T['properties'] + ? Merge> + : K extends keyof T['shorthands'] + ? Merge> + : never; +}>; + +type Style< + T extends ThemeProperties, + C extends string, + R extends RenderProps +> = StaticProperties & CustomProperties; + +type StaticProperties< + T extends ThemeProperties, + C extends string, + R extends RenderProps +> = { + [Name in keyof T]?: StyleValue; +}; + +type CustomProperties< + T extends ThemeProperties, + C extends string, + R extends RenderProps +> = { + [key: CustomProperty]: CustomPropertyValue; +}; + +// Infer the value type of custom property values from the `type` key, which references a theme property. +type CustomPropertyValue< + T extends ThemeProperties, + P extends keyof T, + C extends string, + R extends RenderProps +> = P extends any ? {type: P; value: StyleValue} : never; + +export type RenderProps = { + [key in K]: any; +}; + +export type StyleValue> = + | V + | Conditional; +export type Condition = 'default' | Extract; +type Conditional> = CSSConditions< + V, + C, + R +> & + RuntimeConditions; + +type ArbitraryCondition = `:${string}` | `@${string}`; +type CSSConditions> = { + [name in C]?: StyleValue; +}; + +// If render props are unknown, allow any custom conditions to be inferred. +// Unfortunately this breaks "may only specify known properties" errors. +type RuntimeConditions> = [ + R +] extends [never] + ? UnknownConditions + : RenderPropConditions; + +type UnknownConditions = { + [name: string]: StyleValue | VariantMap; +}; + +type BooleanConditionName = `is${Capitalize}` | `allows${Capitalize}`; +type RenderPropConditions> = { + [K in keyof R]?: K extends BooleanConditionName ? StyleValue : VariantMap; +}; + +type Values = { + [k in K]: T[k]; +}[K]; + +export type VariantMap< + K extends CSSValue, + V extends Value, + C extends string, + R extends RenderProps +> = { + [k in K]?: StyleValue; +}; + +// These types are used to recursively extract all runtime conditions/variants in case an +// explicit render prop generic type is not provided/inferred. This allows the returned function +// to automatically accept the correct arguments based on the style definition. +type ExtractConditionalValue = V extends Value + ? never + : + // Add the keys from this level for boolean conditions not in the theme. + | RuntimeConditionObject, boolean> + // Add variant values for non-boolean named keys. + | Variants> + // Recursively include conditions from the next level. + | ExtractConditionalValue< + C, + | Values> + // And skip over variants to get to the values. + | Values>> + >; + +type RuntimeConditionObject = K extends keyof any ? {[P in K]?: V} : never; + +type Variants = K extends any + ? { + [k in K]?: keyof T[k]; + } + : never; + +type InferCustomPropertyValue = T extends {value: infer V} ? V : never; + +// https://stackoverflow.com/questions/49401866/all-possible-keys-of-an-union-type +type KeysOfUnion = T extends T ? keyof T : never; +type KeyValue> = T extends {[k in K]?: any} ? T[K] : never; +type MergeUnion = {[K in KeysOfUnion]: KeyValue}; + +type RuntimeConditionsObject> = MergeUnion< + ExtractConditionalValue< + C, + | Values> + // Skip top-level object for custom properties and go straight to value. + | InferCustomPropertyValue>> + > +>; + +// Return an intersection between string and the used style props so we can prevent passing certain properties to components. +export type StyleString

    = string & {properties: P}; +type Keys = [R] extends [never] ? never : keyof R; +export type RuntimeStyleFunction = + Keys extends never ? () => StyleString : (props: R) => StyleString; + +// If a render prop type was provided, use that so that we get autocomplete for conditions. +// Otherwise, fall back to inferring the render props from the style definition itself. +type InferProps> = [R] extends [never] + ? AllowOthers> + : R; +type AllowOthers = Keys extends never ? never : R | {[x: string]: any}; +type StyleFunctionResult = + Keys extends never ? StyleString : (props: R) => StyleString; +type StyleFunctionResultWithOverrides = ( + props: Keys extends never ? null : R, + overrides?: StyleString | null +) => StyleString; +export type StyleFunction, C extends string> = { + < + R extends RenderProps = never, + S extends Style = Style + >( + style: S + ): StyleFunctionResult>; + < + R extends RenderProps = never, + S extends Style = Style + >( + style: S, + allowedProperties: readonly string[] + ): StyleFunctionResultWithOverrides>; +}; diff --git a/packages/@react-spectrum/s2-ai/src/useDOMRef.ts b/packages/@react-spectrum/s2-ai/src/useDOMRef.ts new file mode 100644 index 00000000000..8b3afaf7881 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/useDOMRef.ts @@ -0,0 +1,32 @@ +/* + * 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 {DOMRef, DOMRefValue, RefObject} from '@react-types/shared'; +import {useImperativeHandle, useRef} from 'react'; + +export function createDOMRef( + ref: RefObject +): DOMRefValue { + return { + UNSAFE_getDOMNode() { + return ref.current; + } + }; +} + +export function useDOMRef( + ref: DOMRef +): RefObject { + let domRef = useRef(null); + useImperativeHandle(ref, () => createDOMRef(domRef)); + return domRef; +} diff --git a/packages/@react-spectrum/s2-ai/src/useSpectrumContextProps.ts b/packages/@react-spectrum/s2-ai/src/useSpectrumContextProps.ts new file mode 100644 index 00000000000..fe26b86301a --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/useSpectrumContextProps.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 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 {Context, ForwardedRef, useMemo} from 'react'; +import {ContextValue, SlotProps, useSlottedContext} from 'react-aria-components/slots'; +import {mergeProps} from 'react-aria/mergeProps'; +import {mergeRefs} from 'react-aria/mergeRefs'; +import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; +import {RefObject} from '@react-types/shared'; +import {useObjectRef} from 'react-aria/useObjectRef'; + +export function useSpectrumContextProps( + props: T & SlotProps, + ref: ForwardedRef, + context: Context> +): [T, RefObject] { + let ctx = useSlottedContext(context, props.slot) || {}; + let {ref: contextRef, ...contextProps} = ctx as any; + let mergedRef = useObjectRef(useMemo(() => mergeRefs(ref, contextRef), [ref, contextRef])); + let mergedProps = mergeProps(contextProps, props) as unknown as T; + + // mergeProps does not merge `UNSAFE_style` + if ( + 'UNSAFE_style' in contextProps && + contextProps.UNSAFE_style && + 'UNSAFE_style' in props && + props.UNSAFE_style + ) { + // @ts-ignore + mergedProps.UNSAFE_style = {...contextProps.UNSAFE_style, ...props.UNSAFE_style}; + } + + // Merge macro styles. + if ('styles' in contextProps && contextProps.styles && 'styles' in props && props.styles) { + // @ts-ignore + mergedProps.styles = mergeStyles(contextProps.styles, props.styles); + } + + return [mergedProps, mergedRef]; +} diff --git a/packages/@react-spectrum/s2-ai/stories/HorizontalCard.stories.tsx b/packages/@react-spectrum/s2-ai/stories/HorizontalCard.stories.tsx new file mode 100644 index 00000000000..32495b3ed35 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/HorizontalCard.stories.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2024 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 {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; +import { + Attachment as AttachmentComponent, + AttachmentList +} from '/packages/@react-spectrum/s2-ai/exports/AttachmentList'; +import { + BasicHorizontalCard, + CardPreview, + HorizontalCard +} from '@react-spectrum/s2-ai/HorizontalCard'; +import {Card, CardProps} from '@react-spectrum/s2/Card'; +import ChevronRight from '@react-spectrum/s2/icons/ChevronRight'; +import {Content} from '@react-spectrum/s2/Content'; +import {Footer} from '@react-spectrum/s2/Footer'; +import {Image} from '@react-spectrum/s2/Image'; +import {MenuItem} from '@react-spectrum/s2/Menu'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Skeleton} from '@react-spectrum/s2/Skeleton'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Text} from '@react-spectrum/s2/Text'; + +const meta: Meta = { + component: HorizontalCard, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: { + isLoading: false + }, + argTypes: { + href: {table: {disable: true}}, + download: {table: {disable: true}}, + hrefLang: {table: {disable: true}}, + referrerPolicy: {table: {disable: true}}, + rel: {table: {disable: true}}, + routerOptions: {table: {disable: true}}, + ping: {table: {disable: true}}, + target: {table: {disable: true}}, + value: {table: {disable: true}}, + textValue: {table: {disable: true}}, + onAction: {table: {disable: true}}, + isDisabled: {table: {disable: true}}, + children: {table: {disable: true}} + }, + decorators: (children, {args}) => ( + {children(args)} + ), + title: 'S2-AI/HorizontalCard' +}; + +export default meta; + +type Story = StoryObj; + +export const Horizontal: Story = { + render: args => ( +

    + + + + + + + Card title + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + + + + + + + + + + + Card title + + + Card description. Give a concise overview of the context or functionality that's + mentioned in the card title. + + + + + + + Card title + Card description. + +
    + + Test + +
    +
    + + + + Card title + Card description. + +
    + + + +
    +
    + + + + Card title + Card description. + + + + + + Card title + Card description. + + + + + +
    + ) +}; + +export const AIAttachmentList: Story = { + render: args => ( + + + + + + + + + + + + + + Card title + Card description. + + + + ) +}; diff --git a/packages/@react-spectrum/s2-ai/stories/MessageFeedback.stories.tsx b/packages/@react-spectrum/s2-ai/stories/MessageFeedback.stories.tsx new file mode 100644 index 00000000000..86f14f8b88c --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/MessageFeedback.stories.tsx @@ -0,0 +1,65 @@ +/* + * 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 {ComponentProps, ReactElement, useState} from 'react'; +import {fn} from 'storybook/test'; +import {MessageFeedback} from '@react-spectrum/s2-ai/MessageFeedback'; +import type {Meta, StoryObj} from '@storybook/react'; + +const meta: Meta = { + component: MessageFeedback, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + onChange: {table: {category: 'Events'}}, + value: {table: {disable: true}}, + defaultValue: {table: {disable: true}} + }, + args: { + 'aria-label': 'Rate this response', + onChange: fn() + }, + title: 'S2-AI/MessageFeedback' +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = {}; + +export const InitiallyUp: Story = { + args: {defaultValue: 'up'} +}; + +export const InitiallyDown: Story = { + args: {defaultValue: 'down'} +}; + +export const Disabled: Story = { + args: {isDisabled: true} +}; + +function ControlledExample(args: ComponentProps): ReactElement { + let [value, setValue] = useState<'up' | 'down' | null>(null); + return ( +
    + + Current value: {String(value)} +
    + ); +} + +export const Controlled: Story = { + render: args => +}; diff --git a/packages/@react-spectrum/s2-ai/stories/MessageSource.stories.tsx b/packages/@react-spectrum/s2-ai/stories/MessageSource.stories.tsx new file mode 100644 index 00000000000..f66a165fdc5 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/MessageSource.stories.tsx @@ -0,0 +1,61 @@ +/* + * 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 {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; +import type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const meta: Meta = { + component: MessageSource, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'radio', + options: ['S', 'M', 'L', 'XL'] + }, + density: { + control: 'radio', + options: ['compact', 'regular', 'spacious'] + }, + isDisabled: { + control: {type: 'boolean'} + }, + children: {table: {disable: true}} + }, + title: 'S2-AI/MessageSource' +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: 'Sources' + }, + render: args => { + return ( +
    + + + Hilton email + Market research + User research + + +
    + ); + } +}; diff --git a/packages/@react-spectrum/s2-ai/stories/MessageSuggestion.stories.tsx b/packages/@react-spectrum/s2-ai/stories/MessageSuggestion.stories.tsx new file mode 100644 index 00000000000..7ce30db95c0 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/MessageSuggestion.stories.tsx @@ -0,0 +1,54 @@ +/* + * 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 {getActionArgs} from '../../s2/stories/utils'; +import {MessageSuggestion, MessageSuggestionList} from '@react-spectrum/s2-ai/MessageSuggestion'; +import type {Meta, StoryObj} from '@storybook/react'; + +const events = ['onPress']; + +const meta: Meta = { + component: MessageSuggestionList, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + args: { + ...getActionArgs(events) + }, + title: 'S2-AI/MessageSuggestionList' +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + title: 'What would you like to do next?' + }, + render: args => ( + + + Create a year-over-year growth chart for the next decade + + Generate a congratulatory poster + Summarize development pipeline + + ), + decorators: [ + Story => ( +
    + +
    + ) + ] +}; diff --git a/packages/@react-spectrum/s2-ai/stories/ResponseStatus.stories.tsx b/packages/@react-spectrum/s2-ai/stories/ResponseStatus.stories.tsx new file mode 100644 index 00000000000..542e85b7ea1 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/ResponseStatus.stories.tsx @@ -0,0 +1,61 @@ +/* + * 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 type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import {ResponseStatus, ResponseStatusPanel, ResponseStatusTitle} from '../src/ResponseStatus'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const meta: Meta = { + component: ResponseStatus, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'radio', + options: ['S', 'M', 'L', 'XL'] + }, + density: { + control: 'radio', + options: ['compact', 'regular', 'spacious'] + }, + isLoading: { + control: {type: 'boolean'} + }, + children: {table: {disable: true}} + }, + args: { + isLoading: true + }, + title: 'S2-AI/ResponseStatus' +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: args => ( +
    + + + {args.isLoading ? 'Generating response' : 'Response generated'} + + + Here is the generated response content. This area is hidden until the disclosure is + expanded, and cannot be expanded while loading. + + +
    + ) +}; diff --git a/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx new file mode 100644 index 00000000000..faefe2e288c --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx @@ -0,0 +1,804 @@ +/* + * 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 {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; +import {AssetCard, Card, CardPreview} from '@react-spectrum/s2/Card'; +import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Button} from '@react-spectrum/s2/Button'; +import { + ButtonContext, + GridList, + Group, + isFileDropItem, + Label, + Tag, + TagGroup, + TagList, + TextArea, + TextField, + useDrop +} from 'react-aria-components'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import {CloseButton} from '@react-spectrum/s2/CloseButton'; +import {Content} from '@react-spectrum/s2/Content'; +import {Image} from '@react-spectrum/s2/Image'; +import {Link} from '@react-spectrum/s2/Link'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import {MenuItem} from '@react-spectrum/s2/Menu'; +import {MessageFeedback} from '../src/MessageFeedback'; +import {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; +import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; +import type {Meta} from '@storybook/react'; +import Plus from '@react-spectrum/s2/icons/Add'; +import {ReactNode, useRef, useState} from 'react'; +import {ResponseStatus, ResponseStatusPanel, ResponseStatusTitle} from '../src/ResponseStatus'; +import Send from '@react-spectrum/s2/icons/ArrowUpSend'; +import {Text} from '@react-spectrum/s2/Text'; +import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; +import {UserMessage} from '../src/UserMessage'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; + +const meta: Meta = { + component: Thread, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'S2-AI/Thread', + decorators: [ + Story => ( +
    + +
    + ) + ] +}; + +export default meta; + +let dummyResponses = [ + "Sure! Here's a summary of the key points based on the assets you shared. The main themes revolve around brand consistency, audience engagement, and clear calls to action across all touchpoints.", + 'Great question. Based on the context provided, I recommend focusing on the narrative arc first, then layering in supporting visuals and data to reinforce the core message.', + "I've analyzed the content and identified three main opportunities: improving visual hierarchy, strengthening the headline, and adding a clearer value proposition in the opening section." +]; + +type Message = + | {id: number; type: 'user' | 'system'; content: string} + | {id: number; type: 'status'; status: 'pending' | 'complete'}; + +let initialResponses = [ + {id: 0, type: 'user', content: 'prompt 1'}, + {id: 1, type: 'system', content: dummyResponses[0]}, + {id: 2, type: 'user', content: 'prompt 2'}, + {id: 3, type: 'system', content: dummyResponses[1]}, + {id: 4, type: 'user', content: 'prompt 3'}, + {id: 5, type: 'system', content: dummyResponses[2]}, + {id: 6, type: 'user', content: 'prompt 4'}, + {id: 7, type: 'system', content: dummyResponses[0]}, + {id: 8, type: 'user', content: 'prompt 5'}, + {id: 9, type: 'system', content: dummyResponses[1]}, + {id: 10, type: 'user', content: 'prompt 6'}, + {id: 11, type: 'system', content: dummyResponses[2]} +] as Message[]; + +type StreamingMessage = + | {id: number; type: 'user'; content: string} + | {id: number; type: 'system'; content: string; isStreaming?: boolean; sources?: string[]} + | { + id: number; + type: 'status'; + label: string; + isStreaming: boolean; + details: string; + } + | {id: number; type: 'card'; title: string; description: string; imageUrl: string} + | {id: number; type: 'suggestions'; title: string; suggestions: string[]}; + +let MOCK_SOURCES = [ + 'Hilton brand email — Q1 campaign 2026', + 'Market research — hospitality trends 2025', + 'User research — loyalty programme survey' +]; + +let MOCK_SUGGESTIONS = [ + 'Suggest a presentation structure', + 'What other assets might be relevant?', + 'Summarize the key themes' +]; + +let MOCK_CARD = { + title: 'Desert Sunset', + description: 'PNG • 2/3/2024', + imageUrl: + 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' +}; + +function CardMessage({ + title, + description, + imageUrl +}: { + title: string; + description: string; + imageUrl: string; +}) { + return ( + + + + + + + {title} + + Edit + Share + Delete + + {description} + + + + ); +} + +export function StreamingThread() { + let [messages, setMessages] = useState( + initialResponses as StreamingMessage[] + ); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isDisabled = + (lastMessage?.type === 'status' && lastMessage.isStreaming) || + (lastMessage?.type === 'system' && lastMessage.isStreaming); + + function handleSend(text: string) { + if (!text.trim()) { + return; + } + + // user message added first so its announcement plays before + setMessages(prev => [...prev, {id: nextId.current++, type: 'user', content: text}]); + + function addTool(label: string, replaceStatus = false) { + setMessages(prev => + replaceStatus + ? [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + : [ + ...prev, + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + ); + } + + function completeTool(details: string) { + setMessages(prev => + prev.map(m => + m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m + ) + ); + } + + function streamText(content: string, sources?: string[]) { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = content.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + setTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? { + ...m, + content: accumulated, + isStreaming: !isLastToken, + ...(isLastToken && sources ? {sources} : {}) + } + : m + ) + ); + }, i * 80); + }); + } + + // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be + // faster and thus the announcements will get cut off even with polite... + // first batch, does tool calls with text response + let timestamp = 0; + let toolCallDuration = 4000; + // Status added after short delay so user message announcement plays first + setTimeout( + () => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]); + }, + (timestamp += 1000) + ); + setTimeout(() => addTool('Thinking', true), (timestamp += 1000)); + setTimeout( + () => + completeTool( + 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' + ), + (timestamp += toolCallDuration) + ); + setTimeout(() => addTool('Loading tool'), (timestamp += 1000)); + setTimeout( + () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + (timestamp += toolCallDuration) + ); + setTimeout(() => addTool('Searching'), (timestamp += 1000)); + setTimeout( + () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + (timestamp += toolCallDuration) + ); + setTimeout( + () => + streamText( + 'I found some relevant assets that match your request. Let me pull up the details.' + ), + (timestamp += 1000) + ); + + // then does searching, streaming more text, returning a card and sources + setTimeout(() => addTool('Searching'), (timestamp += 4000)); + setTimeout( + () => + completeTool('Identified additional brand materials related to the presentation context.'), + (timestamp += toolCallDuration) + ); + setTimeout(() => addTool('Querying database'), (timestamp += 1000)); + setTimeout( + () => + completeTool( + 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' + ), + (timestamp += toolCallDuration) + ); + setTimeout( + () => + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]), + (timestamp += 500) + ); + setTimeout( + () => + setMessages(prev => [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label: 'Response generated', + isStreaming: false, + details: + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' + } + ]), + (timestamp += 2000) + ); + setTimeout( + () => + streamText( + 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + + 'revolve around brand consistency, audience engagement, and clear calls to action.', + MOCK_SOURCES + ), + (timestamp += 1000) + ); + + let streamEndTimestamp = timestamp + 8000; + setTimeout(() => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); + }, streamEndTimestamp); + setTimeout(() => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'suggestions', + title: 'Suggested follow-ups', + suggestions: MOCK_SUGGESTIONS + } + ]); + }, streamEndTimestamp + 1000); + } + + return ( + // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? + // of particular note is the scroll button. Same for the other styles +
    + +
    +
    + + + + + +
    + + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + // TODO: probably want ThreadItem to be a part of UserMessage? + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; + let title = msg.isStreaming ? `${msg.label}…` : msg.label; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + {msg.details && ( +

    {msg.details}

    + )} +
    +
    +
    + ); + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well + // but this one I could see perhaps being a standalone component to be used outside of thread + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } + return ( + +
    +

    {msg.content || ''}

    +
    + {!msg.isStreaming && } +
    + ); + }} +
    +
    + +
    +
    + ); +} + +// Ignore this story, just here for local testing +export function VirtualizedThread() { + let [messages, setMessages] = useState(initialResponses); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; + function handleSend(text: string) { + if (!text.trim()) { + return; + } + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: text}, + {id: nextId.current++, type: 'status', status: 'pending'} + ]); + setTimeout(() => { + let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; + setMessages(prev => [ + ...prev.slice(0, -1), + {id: nextId.current++, type: 'system', content: response} + ]); + }, 1500); + } + + return ( +
    + + + {msg => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let isPending = msg.status === 'pending'; + let message = isPending ? 'Generating response' : 'Response generated'; + + return ( + + + {message} + + + ); + } + return ( + +
    +

    {msg.content}

    +
    + +
    + ); + }} +
    +
    + +
    + ); +} + +// TODO: all of the below was copied from rsp-prototypes, just filler for now +// some modifications for streaming and what not +function PromptField({ + onSend, + isDisabled +}: { + onSend?: (text: string) => void; + isDisabled?: boolean; +}) { + let [text, setText] = useState(''); + let [attachments, setAttachments] = useState([ + { + image: 'https://react-spectrum.adobe.com/preview.c3b340d3.png', + title: 'Hilton assets', + description: '2026' + } + ]); + + // Not using RAC DropZone because it adds its own focusable button, + // and we want to avoid an extra tab stop by attaching to the input. + // TODO: support clipboard too (without messing up pasting text) + let inputRef = useRef(null); + let {dropProps, dropButtonProps, isDropTarget} = useDrop({ + ref: inputRef, + hasDropButton: true, + async onDrop(e) { + let files = await Promise.all( + e.items.filter(isFileDropItem).map(async item => ({ + image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '', + title: item.name, + description: item.type + })) + ); + setAttachments(attachments => [...attachments, ...files]); + } + }); + + return ( +
    + + style({ + ...focusRing(), + padding: 16, + boxShadow: 'emphasized', + backgroundColor: { + default: 'elevated', + isDropTarget: 'blue-200' + }, + borderRadius: 'lg', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isFocusWithin: 'gray-900', + isDropTarget: 'blue-800' + } + })({...renderProps, isDropTarget}) + }> + + {attachments.map((attachment, i) => ( + { + setAttachments(attachments.slice(0, i).concat(attachments.slice(i + 1))); + }} + /> + ))} + + setText(value)} slot="prompt"> + +