Skip to content
Open
15 changes: 15 additions & 0 deletions packages/@react-spectrum/s2-ai/exports/MessageSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export {
MessageSource,
MessageSourceContext,
SourceList,
SourceListItem,
NumberBadge,
NumberBadgeContext
} from '../src/MessageSource';

export type {
MessageSourceProps,
SourceListProps,
SourceListItemProps,
NumberBadgeProps
} from '../src/MessageSource';
12 changes: 12 additions & 0 deletions packages/@react-spectrum/s2-ai/exports/ResponseStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export {
ResponseStatus,
ResponseStatusContext,
ResponseStatusTitle,
ResponseStatusPanel
} from '../src/ResponseStatus';

export type {
ResponseStatusProps,
ResponseStatusTitleProps,
ResponseStatusPanelProps
} from '../src/ResponseStatus';
3 changes: 3 additions & 0 deletions packages/@react-spectrum/s2-ai/intl/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"responsestatus.loading": "Loading"
}
54 changes: 54 additions & 0 deletions packages/@react-spectrum/s2-ai/src/CenterBaseline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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} from './style-macro' with {type: 'macro'};
import {CSSProperties, ReactNode} from 'react';
import {DOMAttributes} from '@react-types/shared';
import {filterDOMProps} from 'react-aria/filterDOMProps';
import {mergeStyles} from './mergeStyles';
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
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 (
<div
{...domProps}
slot={props.slot}
style={props.style}
className={mergeStyles(styles, props.styles) + ' ' + centerBaselineBefore}>
{props.children}
</div>
);
}

export const centerBaselineBefore = css(
'&::before { content: "\u00a0"; width: 0; visibility: hidden }'
);

export function centerBaseline(
props: Omit<CenterBaselineProps, 'children'> = {}
): (icon: ReactNode) => ReactNode {
return (icon: ReactNode) => <CenterBaseline {...props}>{icon}</CenterBaseline>;
}
268 changes: 268 additions & 0 deletions packages/@react-spectrum/s2-ai/src/MessageSource.tsx
Original file line number Diff line number Diff line change
@@ -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<DisclosureProps, 'isQuiet'> {
label: string;
}

export const MessageSourceContext =
createContext<ContextValue<Partial<MessageSourceProps>, DOMRefValue<HTMLDivElement>>>(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<HTMLDivElement>
) {
[props, ref] = useSpectrumContextProps(props, ref, MessageSourceContext);
let {label, children, size = 'M', ...otherProps} = props;

return (
<MessageSourceInternalContext.Provider value={{size}}>
<NumberBadgeContext.Provider value={{size}}>
<Disclosure {...otherProps} size={size} ref={ref} isQuiet>
<DisclosureTitle>{label}</DisclosureTitle>
{children}
</Disclosure>
</NumberBadgeContext.Provider>
</MessageSourceInternalContext.Provider>
);
});

// SourceList injects a 1-based index so SourceListItem
// can render it without needing an explicit prop.
const SourceListIndexContext = createContext<number>(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<HTMLDivElement>
) {
let {children, ...otherProps} = props;

let numberedChildren = React.Children.map(children, (child, i) => (
<SourceListIndexContext.Provider value={i + 1}>{child}</SourceListIndexContext.Provider>
));

return (
<DisclosurePanel {...otherProps} ref={ref}>
<ol className={listStyles}>{numberedChildren}</ol>
</DisclosurePanel>
);
});

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<LinkProps, 'className' | 'style'>, 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<HTMLLIElement>
) {
let index = useContext(SourceListIndexContext);
let {size} = useContext(MessageSourceInternalContext);
let {children, UNSAFE_style, UNSAFE_className = '', ...otherProps} = props;
let itemRef = useDOMRef(ref);

return (
<li ref={itemRef} style={UNSAFE_style} className={(UNSAFE_className ?? '') + itemStyles}>
<NumberBadge value={index} />
<Link {...otherProps} className={linkStyles({size})}>
{children}
</Link>
</li>
);
});

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<NumberBadgeProps> {}
export const NumberBadgeContext =
createContext<ContextValue<Partial<NumberBadgeContextProps>, DOMRefValue<HTMLSpanElement>>>(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<HTMLSpanElement>
) {
[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 (
<span
{...filterDOMProps(otherProps)}
// role={ariaLabel && 'img'}
// aria-label={ariaLabel}
// We set aria-hidden to true to prevent screenreader from announcing the value of the badge by itself which is not very meaningful.
aria-hidden="true"
className={
(props.UNSAFE_className || '') +
badge(
{
size
},
props.styles
)
}
style={props.UNSAFE_style}
ref={domRef}>
{formattedValue}
</span>
);
});
Loading