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/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/intl/en-US.json b/packages/@react-spectrum/s2-ai/intl/en-US.json
new file mode 100644
index 00000000000..8ac04433931
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/intl/en-US.json
@@ -0,0 +1,3 @@
+{
+ "responsestatus.loading": "Loading"
+}
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..c7db30f06e5
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/src/CenterBaseline.tsx
@@ -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 (
+
+ {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/MessageSource.tsx b/packages/@react-spectrum/s2-ai/src/MessageSource.tsx
new file mode 100644
index 00000000000..38ccdb67f3d
--- /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 (
+
+
+
+ {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/ResponseStatus.tsx b/packages/@react-spectrum/s2-ai/src/ResponseStatus.tsx
new file mode 100644
index 00000000000..db566e0571f
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/src/ResponseStatus.tsx
@@ -0,0 +1,382 @@
+/*
+ * 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, 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 Chevron from '../ui-icons/Chevron';
+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 (
+
+
,
+ parentPriority: number,
+ currentConditions: Set,
+ skipConditions: Set,
+ fn: (
+ value: P,
+ priority: number,
+ conditions: Set,
+ skipConditions: Set
+ ) => [number, Rule[]]
+ ): [number, Rule[]] {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ let rules: Rule[] = [];
+
+ // Later conditions in parent rules override conditions in child rules.
+ let subSkipConditions = new Set([...skipConditions, ...Object.keys(value)]);
+
+ // Skip the default condition if we're already filtering by one of the other possible conditions.
+ // For example, if someone specifies `dark: 'gray-400'`, only include the dark version of `gray-400` from the theme.
+ let skipDefault = Object.keys(value).some(k => currentConditions.has(k));
+ let wasCSSCondition = false;
+ let priority = parentPriority;
+
+ for (let condition in value) {
+ if (skipConditions.has(condition) || (condition === 'default' && skipDefault)) {
+ continue;
+ }
+ subSkipConditions.delete(condition);
+
+ let val = value[condition];
+
+ // If a theme condition comes after runtime conditions, create a new grouping.
+ // This makes the CSS class unconditional so it appears outside the `else` block in the JS.
+ // The @layer order in the generated CSS will ensure that it overrides classes applied by runtime conditions.
+ let isCSSCondition = condition in theme.conditions || /^[@:]/.test(condition);
+ if (!wasCSSCondition && isCSSCondition && rules.length) {
+ rules = [new GroupRule(rules)];
+ }
+ wasCSSCondition = isCSSCondition;
+
+ // Increment the current priority whenever we see a new CSS condition.
+ if (isCSSCondition) {
+ priority++;
+ }
+
+ // If this is a runtime condition, inherit the priority from the parent rule.
+ // Otherwise, use the current maximum of the parent and current priorities.
+ let rulePriority = isCSSCondition ? priority : parentPriority;
+
+ if (
+ condition === 'default' ||
+ isCSSCondition ||
+ /^is[A-Z]/.test(condition) ||
+ /^allows[A-Z]/.test(condition)
+ ) {
+ let subConditions = currentConditions;
+ if (isCSSCondition) {
+ subConditions = new Set([...currentConditions, condition]);
+ }
+ let [subPriority, subRules] = conditionalToRules(
+ val,
+ rulePriority,
+ subConditions,
+ subSkipConditions,
+ fn
+ );
+ rules.push(...compileCondition(currentConditions, condition, priority, subRules));
+ priority = Math.max(priority, subPriority);
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
+ for (let key in val) {
+ let branchValue = val[key];
+ // If this branch has no default, inherit the parent's default so e.g. forcedColors.default
+ // applies when selectionStyle.highlight doesn't define its own default.
+ // eslint-disable-next-line max-depth
+ if (
+ value.default !== undefined &&
+ branchValue &&
+ typeof branchValue === 'object' &&
+ !Array.isArray(branchValue) &&
+ !('default' in branchValue)
+ ) {
+ branchValue = {default: value.default, ...branchValue};
+ }
+ let [subPriority, subRules] = conditionalToRules(
+ branchValue,
+ rulePriority,
+ currentConditions,
+ subSkipConditions,
+ fn
+ );
+ rules.push(
+ ...compileCondition(
+ currentConditions,
+ `${condition} === ${JSON.stringify(key)}`,
+ priority,
+ subRules
+ )
+ );
+ priority = Math.max(priority, subPriority);
+ }
+ }
+ }
+ return [priority, rules];
+ } else {
+ // @ts-ignore - broken in non-strict?
+ return fn(value, parentPriority, currentConditions, skipConditions);
+ }
+ }
+
+ function compileCondition(
+ conditions: Set,
+ condition: string,
+ priority: number,
+ rules: Rule[]
+ ): Rule[] {
+ if (condition === 'default' || conditions.has(condition)) {
+ return [new GroupRule(rules)];
+ }
+
+ if (condition in theme.conditions || /^[@:]/.test(condition)) {
+ // Conditions starting with : are CSS pseudo classes. Nest them inside the parent rule.
+ let prelude = theme.conditions[condition] || condition;
+ let preludes = Array.isArray(prelude) ? prelude : [prelude];
+ return preludes.map(prelude => {
+ if (prelude.startsWith(':')) {
+ let rulesWithPseudo = rules.map(rule => {
+ rule = rule.copy();
+ rule.addPseudo(prelude);
+ return rule;
+ });
+
+ return new GroupRule(rulesWithPseudo, generateName(priority, true));
+ }
+
+ // Otherwise, wrap the rule in the condition (e.g. @media).
+ // Top level layer is based on the priority of the rule, not the condition.
+ // Also group in a sub-layer based on the condition so that lightningcss can more effectively deduplicate rules.
+ let layer = `${generateName(priority, true)}.${propertyInfo.conditions[prelude] || generateArbitraryValueSelector(condition, true)}`;
+ return new AtRule(rules, prelude, layer, condition);
+ });
+ }
+
+ hasConditions = true;
+ return [new ConditionalRule(rules, condition)];
+ }
+
+ function compileRule(
+ property: string,
+ themeProperty: string,
+ value: Value,
+ priority: number,
+ conditions: Set,
+ skipConditions: Set
+ ): [number, Rule[]] {
+ let propertyFunction = properties.get(themeProperty);
+ if (propertyFunction) {
+ // Expand value to conditional CSS values, and then to rules.
+ let propertyValue = value;
+ let arbitrary = parseArbitraryValue(value);
+ let cssValue = arbitrary ? arbitrary : propertyFunction.toCSSValue(value);
+ let cssProperties = propertyFunction.toCSSProperties(
+ property.startsWith('--') ? property : null,
+ cssValue
+ );
+
+ return conditionalToRules(
+ cssProperties,
+ priority,
+ conditions,
+ skipConditions,
+ (value, priority, conditions) => {
+ let [obj] = value;
+ let rules: Rule[] = [];
+ for (let key in obj) {
+ let k = key as any;
+ let value = obj[k];
+ if (value === undefined) {
+ continue;
+ }
+ if (typeof value === 'string') {
+ // Replace self() references with variables and track the dependencies.
+ value = value.replace(/self\(([a-zA-Z]+)/g, (_, v) => {
+ let prop = properties.get(v);
+ if (!prop) {
+ throw new Error(`self(${v}) is invalid. ${v} is not a known property.`);
+ }
+ let cssProperties = prop.cssProperties;
+ if (cssProperties.length !== 1) {
+ throw new Error(
+ `self(${v}) is not supported. ${v} expands to multiple CSS properties.`
+ );
+ }
+ dependencies.add(v);
+ return `var(--${shortCSSPropertyName(cssProperties[0])}`;
+ });
+ }
+
+ // Generate selector. This consists of three parts: property, conditions, value.
+ let cssProperty = key;
+ if (property.startsWith('--')) {
+ cssProperty = propertyFunction.cssProperties[0];
+ }
+
+ let className = classNamePrefix(key, cssProperty);
+ if (conditions.size > 0) {
+ for (let condition of conditions) {
+ let prelude = theme.conditions[condition] || condition;
+ let preludes = Array.isArray(prelude) ? prelude : [prelude];
+ for (let prelude of preludes) {
+ className +=
+ propertyInfo.conditions[prelude] || generateArbitraryValueSelector(condition);
+ }
+ }
+ }
+
+ if (cssProperty !== key) {
+ className += shortCSSPropertyName(cssProperty);
+ }
+
+ className +=
+ propertyInfo.values[cssProperty]?.[String(value)] ??
+ generateArbitraryValueSelector(String(value));
+ className += POSTFIX;
+ rules.push(
+ new StyleRule(
+ className,
+ key,
+ String(value),
+ isCompilingDependencies ? themeProperty : property,
+ propertyValue
+ )
+ );
+ }
+
+ return [0, rules];
+ }
+ );
+ } else {
+ throw new Error('Unknown property ' + themeProperty);
+ }
+ }
+}
+
+function kebab(property: string) {
+ if (property.startsWith('--')) {
+ return property;
+ }
+ return property.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`);
+}
+
+// Generate a class name from a number, e.g. index within the theme.
+// This maps to an alphabet containing lower case letters, upper case letters, and numbers.
+// For numbers larger than 62, an underscore is prepended.
+// This encoding allows easy parsing to enable runtime merging by property.
+function generateName(index: number, atStart = false): string {
+ if (index < 26) {
+ // lower case letters
+ return String.fromCharCode(index + 97);
+ }
+
+ if (index < 52) {
+ // upper case letters
+ return String.fromCharCode(index - 26 + 65);
+ }
+
+ if (index < 62 && !atStart) {
+ // numbers
+ return String.fromCharCode(index - 52 + 48);
+ }
+
+ return '_' + generateName(index - (atStart ? 52 : 62));
+}
+
+// For arbitrary values, we use a hash of the string to generate the class name.
+function generateArbitraryValueSelector(v: string, atStart = false) {
+ let c = toBase62(hash(v));
+ if (atStart && /^[0-9]/.test(c)) {
+ c = `_${c}`;
+ }
+ return c;
+}
+
+function toBase62(value: number) {
+ if (value === 0) {
+ return generateName(value);
+ }
+
+ let res = '';
+ while (value) {
+ let remainder = value % 62;
+ res += generateName(remainder);
+ value = Math.floor((value - remainder) / 62);
+ }
+
+ return res;
+}
+
+// djb2 hash function.
+// http://www.cse.yorku.ca/~oz/hash.html
+function hash(v: string) {
+ let hash = 5381;
+ for (let i = 0; i < v.length; i++) {
+ hash = ((hash << 5) + hash + v.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+function layerName(name: string) {
+ // All of our layers should be sub-layers of a single parent layer, so that
+ // the unsafe overrides layer always comes after.
+ return `_.${name}`;
+}
+
+interface Rule {
+ addPseudo(prelude: string): void;
+ getStaticClassName(): string;
+ toCSS(rulesByLayer: Map, preludes?: string[], layer?: string): void;
+ toJS(allowedOverridesSet: Set, indent?: string): string;
+ copy(): Rule;
+}
+
+let conditionStack: string[] = [];
+
+/** A CSS style rule. */
+class StyleRule implements Rule {
+ className: string;
+ pseudos: string;
+ property: string;
+ value: string;
+ themeProperty: string | undefined;
+ themeValue: Value | undefined;
+
+ constructor(
+ className: string,
+ property: string,
+ value: string,
+ themeProperty: string | undefined,
+ themeValue: Value | undefined
+ ) {
+ this.className = className;
+ this.pseudos = '';
+ this.property = property;
+ this.value = value;
+ if (process.env.NODE_ENV !== 'production' && isCompilingDependencies !== null) {
+ this.themeProperty = themeProperty;
+ this.themeValue = themeValue;
+ }
+ }
+
+ copy(): Rule {
+ let rule = new StyleRule(
+ this.className,
+ this.property,
+ this.value,
+ this.themeProperty,
+ this.themeValue
+ );
+ rule.pseudos = this.pseudos;
+ return rule;
+ }
+
+ addPseudo(prelude: string) {
+ this.pseudos += prelude;
+ }
+
+ getStaticClassName(): string {
+ return ' ' + this.className;
+ }
+
+ toCSS(rulesByLayer: Map, preludes: string[] = [], layer = 'a') {
+ let prelude = `.${this.className}${this.pseudos}`;
+ preludes.push(prelude);
+
+ // Nest rule in our stack of preludes (e.g. media queries/selectors).
+ let content = ' ';
+ preludes.forEach((p, i) => {
+ content += `${p} {\n${' '.repeat((i + 2) * 2)}`;
+ });
+ content += `${kebab(this.property)}: ${this.value};\n`;
+ preludes.map((_, i) => {
+ content += `${' '.repeat((preludes.length - i) * 2)}}\n`;
+ });
+
+ // Group rule into the appropriate layer.
+ let rules = rulesByLayer.get(layer);
+ if (!rules) {
+ rules = [];
+ rulesByLayer.set(layer, rules);
+ }
+ rules.push(content);
+ preludes.pop();
+ }
+
+ toJS(allowedOverridesSet: Set, indent = ''): string {
+ let res = '';
+ if (allowedOverridesSet.has(this.property)) {
+ res += `${indent}if (!${this.property.replace('--', '__')}) `;
+ }
+ res += `${indent}rules += ' ${this.className}';`;
+ if (process.env.NODE_ENV !== 'production' && this.themeProperty) {
+ let name = this.themeProperty;
+ if (this.pseudos) {
+ conditionStack.push(this.pseudos);
+ }
+ let propertyName = JSON.stringify(name);
+ let valueJson = JSON.stringify(this.themeValue);
+ if (conditionStack.length) {
+ // name += ` (${conditionStack.join(', ')})`;
+ let conditionKey = JSON.stringify(conditionStack.join(' && '));
+ // Ensure currentRules[name] is an object, converting from simple value if needed
+ res += ` currentRules[${propertyName}] = typeof currentRules[${propertyName}] === 'object' ? currentRules[${propertyName}] : {"default": currentRules[${propertyName}]};`;
+ // Set the value for this specific condition
+ res += ` currentRules[${propertyName}][${conditionKey}] = ${valueJson};`;
+ } else {
+ res += ` currentRules[${propertyName}] = ${valueJson};`;
+ }
+ if (this.pseudos) {
+ conditionStack.pop();
+ }
+ }
+ return res;
+ }
+}
+
+/** Base class for rules that contain other rules. */
+class GroupRule implements Rule {
+ rules: Rule[];
+ layer: string | null;
+
+ constructor(rules: Rule[], layer?: string | null) {
+ this.rules = rules;
+ this.layer = layer ?? null;
+ }
+
+ copy(): Rule {
+ return new GroupRule(
+ this.rules.map(rule => rule.copy()),
+ this.layer
+ );
+ }
+
+ addPseudo(prelude: string) {
+ for (let rule of this.rules) {
+ rule.addPseudo(prelude);
+ }
+ }
+
+ getStaticClassName(): string {
+ return this.rules.map(rule => rule.getStaticClassName()).join('');
+ }
+
+ toCSS(rulesByLayer: Map, preludes?: string[], layer?: string) {
+ for (let rule of this.rules) {
+ rule.toCSS(rulesByLayer, preludes, this.layer || layer);
+ }
+ }
+
+ toJS(allowedOverridesSet: Set, indent = ''): string {
+ let rules = this.rules.slice();
+ let conditional = rules
+ .filter(rule => rule instanceof ConditionalRule)
+ .reverse()
+ .map((rule, i) => {
+ return `${i > 0 ? ' else ' : ''}${rule.toJS(allowedOverridesSet, indent)}`;
+ });
+
+ let elseCases = rules
+ .filter(rule => !(rule instanceof ConditionalRule))
+ .map(rule => rule.toJS(allowedOverridesSet, indent));
+ if (conditional.length && elseCases.length) {
+ return `${conditional.join('')} else {\n${indent} ${elseCases.join('\n' + indent + ' ')}\n${indent}}`;
+ }
+
+ if (conditional.length) {
+ return conditional.join('');
+ }
+
+ return elseCases.join('\n' + indent);
+ }
+}
+
+/** A rule that applies conditionally in CSS (e.g. @media). */
+class AtRule extends GroupRule {
+ prelude: string;
+ themeCondition: string | null;
+
+ constructor(rules: Rule[], prelude: string, layer: string | null, themeCondition: string | null) {
+ super(rules, layer);
+ this.prelude = prelude;
+ this.themeCondition = themeCondition;
+ }
+
+ copy(): Rule {
+ return new AtRule(
+ this.rules.map(rule => rule.copy()),
+ this.prelude,
+ this.layer,
+ this.themeCondition
+ );
+ }
+
+ toCSS(rulesByLayer: Map, preludes: string[] = [], layer?: string): void {
+ preludes.push(this.prelude);
+ super.toCSS(rulesByLayer, preludes, layer);
+ preludes?.pop();
+ }
+
+ toJS(allowedOverridesSet: Set, indent?: string): string {
+ conditionStack.push(this.themeCondition || this.prelude);
+ let res = super.toJS(allowedOverridesSet, indent);
+ conditionStack.pop();
+ return res;
+ }
+}
+
+/** A rule that applies conditionally at runtime. */
+class ConditionalRule extends GroupRule {
+ condition: string;
+
+ constructor(rules: Rule[], condition: string) {
+ super(rules);
+ this.condition = condition;
+ }
+
+ copy(): Rule {
+ return new ConditionalRule(
+ this.rules.map(rule => rule.copy()),
+ this.condition
+ );
+ }
+
+ getStaticClassName(): string {
+ throw new Error('Conditional rules cannot be compiled to a static class name. This is a bug.');
+ }
+
+ toJS(allowedOverridesSet: Set, indent = ''): string {
+ conditionStack.push(this.condition);
+ let res = `${indent}if (props.${this.condition}) {\n${super.toJS(allowedOverridesSet, indent + ' ')}\n${indent}}`;
+ conditionStack.pop();
+ return res;
+ }
+}
+
+/**
+ * Injects a raw CSS string into the style system. The CSS is wrapped in a generated
+ * class name and placed within the specified `@layer`. Returns the generated class name.
+ * This is an escape hatch for advanced cases (e.g. pseudo selectors or features not yet
+ * available in the style macro API), and should be used sparingly.
+ * Must be imported with `{type: 'macro'}`.
+ *
+ * @example
+ * import {css} from '@react-spectrum/s2/style' with {type: 'macro'};
+ *
+ * const styles = css(`
+ * backdrop-filter: blur(8px);
+ * `);
+ *
+ * @param content - The CSS declarations to inject.
+ * @param layer - The CSS `@layer` to place the styles in. Defaults to `'_.a'`.
+ * @returns The generated class name that applies the styles.
+ */
+export function css(this: MacroContext | void, content: string, layer = '_.a'): string {
+ // Check if `this` is undefined, which means style was not called as a macro but as a normal function.
+ // We also check if this is globalThis, which happens in non-strict mode bundles.
+ // Also allow style to be called as a normal function in tests.
+ // @ts-ignore
+
+ if ((this == null || this === globalThis) && process.env.NODE_ENV !== 'test') {
+ throw new Error('The css macro must be imported with {type: "macro"}.');
+ }
+ let className = generateArbitraryValueSelector(content, true);
+ content = `@layer ${layer} {
+ .${className} {
+ ${content}
+ }
+}`;
+
+ // Ensure layer is always declared after the _ layer used by style macro.
+ if (!layer.startsWith('_.')) {
+ content = `@layer _, ${layer};\n` + content;
+ }
+
+ if (this && typeof this.addAsset === 'function') {
+ this.addAsset({
+ type: 'css',
+ content
+ });
+ }
+ return className;
+}
+
+export function keyframes(this: MacroContext | void, css: string): string {
+ // Check if `this` is undefined, which means style was not called as a macro but as a normal function.
+ // We also check if this is globalThis, which happens in non-strict mode bundles.
+ // Also allow style to be called as a normal function in tests.
+ // @ts-ignore
+
+ if ((this == null || this === globalThis) && process.env.NODE_ENV !== 'test') {
+ throw new Error('The keyframes macro must be imported with {type: "macro"}.');
+ }
+ let name = generateArbitraryValueSelector(css, true);
+ css = `@keyframes ${name} {
+ ${css}
+}`;
+ if (this && typeof this.addAsset === 'function') {
+ this.addAsset({
+ type: 'css',
+ content: css
+ });
+ }
+ return name;
+}
diff --git a/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts b/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts
index fa4bb418230..0b234b107ac 100644
--- a/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts
+++ b/packages/@react-spectrum/s2-ai/src/style-utils-copy.ts
@@ -58,6 +58,14 @@ 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]
>;
@@ -68,6 +76,8 @@ export type StylesPropWithHeight = StyleString<
>;
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)
@@ -88,8 +98,9 @@ export interface StyleProps extends UnsafeStyles {
styles?: StylesProp;
}
-export function getAllowedOverrides({width = true, height = false} = {}): string[] {
+export function getAllowedOverrides({width = true, height = false, font = false} = {}): string[] {
return (allowedOverrides as unknown as string[])
.concat(width ? widthProperties : [])
- .concat(height ? heightProperties : []);
+ .concat(height ? heightProperties : [])
+ .concat(font ? ['fontFamily', 'fontWeight', 'lineHeight', 'fontSize'] : []);
}
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/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/ui-icons/Chevron.tsx b/packages/@react-spectrum/s2-ai/ui-icons/Chevron.tsx
new file mode 100644
index 00000000000..1db397ac23e
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/Chevron.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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 Chevron_L from './S2_ChevronSize200.svg';
+import Chevron_M from './S2_ChevronSize100.svg';
+import Chevron_S from './S2_ChevronSize75.svg';
+import Chevron_XL from './S2_ChevronSize300.svg';
+import Chevron_XS from './S2_ChevronSize50.svg';
+import Chevron_XXL from './S2_ChevronSize400.svg';
+import {ReactNode, SVGProps} from 'react';
+import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
+
+let styles = style({
+ width: {
+ size: {
+ M: 10,
+ L: 12,
+ XL: 14,
+ XXL: 16,
+ XS: 6,
+ S: 10
+ }
+ },
+ height: {
+ size: {
+ M: 10,
+ L: 12,
+ XL: 14,
+ XXL: 16,
+ XS: 6,
+ S: 10
+ }
+ }
+});
+
+export default function Chevron(
+ props: SVGProps & {size?: 'M' | 'L' | 'XL' | 'XXL' | 'XS' | 'S'}
+): ReactNode {
+ let {size = 'M', ...otherProps} = props;
+ switch (size) {
+ case 'M':
+ return (
+
+ );
+ case 'L':
+ return (
+
+ );
+ case 'XL':
+ return (
+
+ );
+ case 'XXL':
+ return (
+
+ );
+ case 'XS':
+ return (
+
+ );
+ case 'S':
+ return (
+
+ );
+ }
+}
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize100.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize100.svg
new file mode 100644
index 00000000000..2c9651d4a76
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize100.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize200.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize200.svg
new file mode 100644
index 00000000000..432a261200a
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize200.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize300.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize300.svg
new file mode 100644
index 00000000000..d73f848a1e7
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize300.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize400.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize400.svg
new file mode 100644
index 00000000000..6ec8b418664
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize400.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize50.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize50.svg
new file mode 100644
index 00000000000..aca7a74e41a
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize50.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize75.svg b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize75.svg
new file mode 100644
index 00000000000..ab538918da1
--- /dev/null
+++ b/packages/@react-spectrum/s2-ai/ui-icons/S2_ChevronSize75.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/@react-spectrum/s2/src/Disclosure.tsx b/packages/@react-spectrum/s2/src/Disclosure.tsx
index 21f510d8d46..1ea0a0baa0c 100644
--- a/packages/@react-spectrum/s2/src/Disclosure.tsx
+++ b/packages/@react-spectrum/s2/src/Disclosure.tsx
@@ -41,7 +41,12 @@ import {
DisclosureProps as RACDisclosureProps
} from 'react-aria-components/Disclosure';
import {filterDOMProps} from 'react-aria/filterDOMProps';
-import {getAllowedOverrides, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'};
+import {
+ getAllowedOverrides,
+ StyleProps,
+ StylesPropWithFont,
+ UnsafeStyles
+} from './style-utils' with {type: 'macro'};
import {Heading} from 'react-aria-components/Heading';
import React, {createContext, forwardRef, ReactNode, useContext} from 'react';
import {useDOMRef} from './useDOMRef';
@@ -139,6 +144,11 @@ export interface DisclosureTitleProps extends UnsafeStyles, DOMProps {
level?: number;
/** The contents of the disclosure header. */
children: React.ReactNode;
+ /**
+ * Spectrum-defined styles, returned by the `style()` macro. Only allows overriding
+ * `font`, `fontFamily`, `fontWeight`, `fontSize`, and `lineHeight`.
+ */
+ styles?: StylesPropWithFont;
}
interface DisclosureHeaderProps extends UnsafeStyles, DOMProps {
@@ -153,85 +163,88 @@ const headingStyle = style({
minWidth: 0
});
-const buttonStyles = style({
- ...focusRing(),
- outlineOffset: -2,
- font: 'heading',
- color: {
- default: baseColor('neutral'),
- forcedColors: 'ButtonText',
- isDisabled: {
- default: 'disabled',
- forcedColors: 'GrayText'
- }
- },
- fontWeight: 'bold',
- fontSize: {
- size: {
- S: 'title-sm',
- M: 'title',
- L: 'title-lg',
- XL: 'title-xl'
- }
- },
- lineHeight: 'ui',
- display: 'flex',
- flexGrow: 1,
- alignItems: 'baseline',
- paddingX: 'calc(self(minHeight) * 3/8 - 1px)',
- paddingY: centerPadding(),
- gap: 'calc(self(minHeight) * 3/8 - 1px)',
- minHeight: {
- // compact is equivalent to 'control', but other densities have more padding.
- 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
+const buttonStyles = style(
+ {
+ ...focusRing(),
+ outlineOffset: -2,
+ font: 'heading',
+ color: {
+ default: baseColor('neutral'),
+ forcedColors: 'ButtonText',
+ isDisabled: {
+ default: 'disabled',
+ forcedColors: 'GrayText'
+ }
+ },
+ fontWeight: 'bold',
+ fontSize: {
+ size: {
+ S: 'title-sm',
+ M: 'title',
+ L: 'title-lg',
+ XL: 'title-xl'
+ }
+ },
+ lineHeight: 'ui',
+ display: 'flex',
+ flexGrow: 1,
+ alignItems: 'baseline',
+ paddingX: 'calc(self(minHeight) * 3/8 - 1px)',
+ paddingY: centerPadding(),
+ gap: 'calc(self(minHeight) * 3/8 - 1px)',
+ minHeight: {
+ // compact is equivalent to 'control', but other densities have more padding.
+ 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: {
- default: 'transparent',
- isFocusVisible: lightDark('transparent-black-100', 'transparent-white-100'),
- isHovered: lightDark('transparent-black-100', 'transparent-white-100'),
- isPressed: lightDark('transparent-black-300', 'transparent-white-300')
- },
- transition: 'default',
- borderWidth: 0,
- borderRadius: {
- // Only rounded for keyboard focus and quiet.
- default: 'none',
- isFocusVisible: 'default',
- isQuiet: 'default'
+ },
+ width: 'full',
+ backgroundColor: {
+ default: 'transparent',
+ isFocusVisible: lightDark('transparent-black-100', 'transparent-white-100'),
+ isHovered: lightDark('transparent-black-100', 'transparent-white-100'),
+ isPressed: lightDark('transparent-black-300', 'transparent-white-300')
+ },
+ transition: 'default',
+ borderWidth: 0,
+ borderRadius: {
+ // Only rounded for keyboard focus and quiet.
+ default: 'none',
+ isFocusVisible: 'default',
+ isQuiet: 'default'
+ },
+ textAlign: 'start',
+ disableTapHighlight: true
},
- textAlign: 'start',
- disableTapHighlight: true
-});
+ getAllowedOverrides({font: true})
+);
const chevronStyles = style({
rotate: {
@@ -293,7 +306,7 @@ export const DisclosureTitle = forwardRef(function DisclosureTitle(
props: DisclosureTitleProps,
ref: DOMRef
) {
- let {level = 3, UNSAFE_style, UNSAFE_className = '', ...otherProps} = props;
+ let {level = 3, UNSAFE_style, UNSAFE_className = '', styles, ...otherProps} = props;
let domRef = useDOMRef(ref);
const domProps = filterDOMProps(otherProps);
let {direction} = useLocale();
@@ -309,7 +322,7 @@ export const DisclosureTitle = forwardRef(function DisclosureTitle(
style={UNSAFE_style}
className={(UNSAFE_className ?? '') + headingStyle}>