From a40845c7457145c5e40197f81c11f5fa6989fb90 Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Thu, 10 Sep 2026 09:59:01 -0700 Subject: [PATCH 1/3] add: AttachmentBadge for large thumnail AttachmentList variant --- .../@react-spectrum/ai/src/AttachmentList.tsx | 39 +++++++++++++++--- .../@react-spectrum/ai/src/PromptField.tsx | 2 +- .../ai/test/AttachmentList.test.tsx | 41 ++++++++++++++++++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 41ef362819b..51595e5c020 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -19,6 +19,7 @@ import { GlobalDOMAttributes } from '@react-types/shared'; import AudioWave from '@react-spectrum/s2/icons/AudioWave'; +import {Badge} from '@react-spectrum/s2/Badge'; import { baseColor, css, @@ -42,6 +43,7 @@ import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; import ImageIcon from '@react-spectrum/s2/icons/Image'; import intlMessages from '../intl/*.json'; import {keyframes, scrollFade} from './tokens.macro' with {type: 'macro'}; +import {matchMimeType} from './PromptField'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import Play from '@react-spectrum/s2/icons/Play'; import {pressScale} from '@react-spectrum/s2/pressScale'; @@ -697,10 +699,16 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { } if (otherProps.src) { - return ; + //only the large thumnail variant should display the MIME type badge + return ( + <> + + {size === 'L' && } + + ); } - if (mimeType.startsWith('audio/')) { + if (matchMimeType(mimeType, ['audio/*'])) { return (
@@ -708,7 +716,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { ); } - if (mimeType.startsWith('video/')) { + if (matchMimeType(mimeType, ['video/*'])) { return (
@@ -716,7 +724,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { ); } - if (mimeType.startsWith('image/')) { + if (matchMimeType(mimeType, ['image/*'])) { return (
@@ -724,7 +732,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { ); } - if (mimeType.startsWith('text/')) { + if (matchMimeType(mimeType, ['text/*'])) { return (
@@ -739,6 +747,27 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { ); } +const attachmentBadgeStyles = style({ + position: 'absolute', + bottom: 4, + insetStart: 4, + maxWidth: 64 +}); + +function AttachmentBadge({mimeType}: {mimeType: string}) { + let label = matchMimeType(mimeType, ['application/pdf']) ? 'PDF' : 'FILE'; + return ( + + {label} + + ); +} + function AlertTriangleIcon({size}) { switch (size) { case 'XS': diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index cb2ef79305b..6dffcc22c2a 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -256,7 +256,7 @@ const PromptFieldContext = createContext({ // aka the difference between a slash command and using the + menu which won't have filter text const PromptCompletionAnchorContext = createContext(null); -function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { +export function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { return acceptedMimeTypes.some(type => { if (type === '*/*') { return true; diff --git a/packages/@react-spectrum/ai/test/AttachmentList.test.tsx b/packages/@react-spectrum/ai/test/AttachmentList.test.tsx index 5e428d1f9ff..90f232371ce 100644 --- a/packages/@react-spectrum/ai/test/AttachmentList.test.tsx +++ b/packages/@react-spectrum/ai/test/AttachmentList.test.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Attachment, AttachmentList} from '@react-spectrum/ai'; +import {Attachment, AttachmentList, AttachmentPreview} from '@react-spectrum/ai'; import {Image} from '@react-spectrum/s2/Image'; import React from 'react'; import {render} from '@react-spectrum/test-utils-internal'; @@ -55,4 +55,43 @@ describeOrSkip('AttachmentList', () => { offsetWidthSpy.mockRestore(); scrollWidthSpy.mockRestore(); }); + + it('should automatically show a mime-type badge on the large thumbnail variant', () => { + let {getByText} = render( + + + + + + ); + expect(getByText('PDF')).toBeInTheDocument(); + }); + + it('should not show a badge for other sizes or when no thumbnail image is provided', () => { + let {queryByText, rerender} = render( + + + + + + ); + expect(queryByText('PDF')).not.toBeInTheDocument(); + + rerender( + + + + + + ); + expect(queryByText('PDF')).not.toBeInTheDocument(); + }); }); From f74a2e5244cfe364928a26f7d728c7242b5c914f Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Fri, 11 Sep 2026 19:48:22 -0700 Subject: [PATCH 2/3] feat: isDisabled + accompanying badge changes --- .../@react-spectrum/ai/src/AttachmentList.tsx | 135 ++++++++++++------ .../@react-spectrum/ai/src/PromptField.tsx | 6 +- .../ai/stories/AttachmentList.stories.tsx | 30 +++- 3 files changed, 122 insertions(+), 49 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 51595e5c020..27684490351 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -134,6 +134,7 @@ const attachmentCard = style({ outlineColor: { default: lightDark('black/3', 'white/3'), isLoading: lightDark('black/2', 'white/2'), + isDisabled: lightDark('black/2', 'white/2'), forcedColors: 'ButtonBorder', isInvalid: { default: 'negative-900', @@ -212,6 +213,13 @@ const attachmentCard = style({ default: '[3px]', [onlyPreview]: 'lg' } + }, + '--badge-visibility': { + type: 'visibility', + value: { + default: 'hidden', + [onlyPreview]: 'visible' + } } }); @@ -264,6 +272,25 @@ const attachmentContent = style({ } }); +const imageThumbnailStyles = style({ + position: 'relative', + alignSelf: 'center', + flexShrink: 0, + pointerEvents: 'none', + userSelect: 'none', + size: '--image-size', + borderRadius: '--image-border-radius', + objectFit: 'cover', + outlineStyle: 'solid', + outlineWidth: 1, + outlineColor: 'gray-800/10', + outlineOffset: -1, + opacity: { + default: 1, + isDisabled: 0.3 + } +}); + const CloseButton = function CloseButton(props) { let ref = useRef(null); // oxlint-disable react/react-compiler @@ -514,7 +541,7 @@ export interface AttachmentProps 'styles' | 'UNSAFE_className' | 'UNSAFE_style' | 'allowsArrowNavigation' | 'focusMode' >, AriaLabelingProps, - Pick { + Pick { /** The children of the Attachment. */ children: ReactNode; uploadProgress?: number; @@ -539,6 +566,7 @@ interface AttachmentCardProps { size?: 'XS' | 'S' | 'M' | 'L' | 'XL'; isInvalid?: boolean; isLoading?: boolean; + isDisabled?: boolean; children: ReactNode; } @@ -546,12 +574,14 @@ function AttachmentCard({ size = 'M', isInvalid = false, isLoading = false, + isDisabled = false, children }: AttachmentCardProps) { return (
+ aria-disabled={isDisabled || undefined} + className={attachmentCard({size, isInvalid, isLoading, isDisabled})}> mergeStyles(tagStyles({...renderProps}), styles)}> - + + value={{ + isInvalid: !!isInvalid, + isDisabled: !!isDisabled, + uploadProgress: isDisabled ? 100 : (props.uploadProgress ?? 100), + size + }}> {children} @@ -646,7 +675,7 @@ export const Attachment = forwardRef(function Attachment( insetEnd: 0, transform: 'translate(30%, -30%)' })}> - +
); @@ -657,17 +686,26 @@ const attachmentPreviewWrapper = style({ height: 32, display: 'flex', alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', + opacity: { + default: 1, + isDisabled: 0.3 + } }); const AttachmentPreviewContext = createContext({ isInvalid: false, + isDisabled: false, uploadProgress: 100, size: 'S' as 'XS' | 'S' | 'M' | 'L' | 'XL' }); export interface AttachmentPreviewProps extends ImageProps { - mimeType: string; + /** + * The MIME type of the attachment. Determines the fallback icon, and enables the file-type badge + * on the large thumbnail variant. + */ + mimeType?: string; } /** @@ -675,12 +713,12 @@ export interface AttachmentPreviewProps extends ImageProps { */ export function AttachmentPreview(props: AttachmentPreviewProps) { let {mimeType, ...otherProps} = props; - let {isInvalid, uploadProgress, size} = useContext(AttachmentPreviewContext)!; + let {isInvalid, isDisabled, uploadProgress, size} = useContext(AttachmentPreviewContext)!; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); if (isInvalid) { return ( -
+
); @@ -688,7 +726,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { if (uploadProgress < 100) { return ( -
+
- {size === 'L' && } + {size === 'L' && mimeType && ( + + )} ); } if (matchMimeType(mimeType, ['audio/*'])) { return ( -
+
); @@ -718,7 +759,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { if (matchMimeType(mimeType, ['video/*'])) { return ( -
+
); @@ -726,7 +767,7 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { if (matchMimeType(mimeType, ['image/*'])) { return ( -
+
); @@ -734,14 +775,14 @@ export function AttachmentPreview(props: AttachmentPreviewProps) { if (matchMimeType(mimeType, ['text/*'])) { return ( -
+
); } return ( -
+
); @@ -751,20 +792,30 @@ const attachmentBadgeStyles = style({ position: 'absolute', bottom: 4, insetStart: 4, - maxWidth: 64 + maxWidth: 64, + visibility: '--badge-visibility' }); -function AttachmentBadge({mimeType}: {mimeType: string}) { +const attachmentBadgeWrapper = style({ + opacity: { + default: 0.9, + isDisabled: 0.7 + } +}); + +function AttachmentBadge({mimeType, isDisabled}: {mimeType: string; isDisabled?: boolean}) { let label = matchMimeType(mimeType, ['application/pdf']) ? 'PDF' : 'FILE'; return ( - - {label} - +
+ + {label} + +
); } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 6dffcc22c2a..6661a9f6b17 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -256,7 +256,11 @@ const PromptFieldContext = createContext({ // aka the difference between a slash command and using the + menu which won't have filter text const PromptCompletionAnchorContext = createContext(null); -export function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { +export function matchMimeType(mimeType: string | undefined, acceptedMimeTypes: string[]): boolean { + if (!mimeType) { + return false; + } + return acceptedMimeTypes.some(type => { if (type === '*/*') { return true; diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index c1a311525f9..6baf79bba43 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -33,13 +33,14 @@ const meta: Meta = { ...categorizeArgTypes('Events', events), children: {table: {disable: true}}, isInvalid: {control: 'boolean'}, + isDisabled: {control: 'boolean'}, uploadProgress: {control: 'number', min: 0, max: 100}, size: { control: 'radio', options: ['XS', 'S', 'M', 'L', 'XL'] } }, - args: {isInvalid: false, size: 'M', ...getActionArgs(events)}, + args: {isInvalid: false, isDisabled: false, size: 'M', ...getActionArgs(events)}, title: 'AI/AttachmentList' }; @@ -48,12 +49,13 @@ export default meta; type Story = StoryObj; function AttachmentListRender(args) { - let {isInvalid, size, uploadProgress, ...listArgs} = args; + let {isInvalid, isDisabled, size, uploadProgress, ...listArgs} = args; return ( @@ -121,6 +127,7 @@ function NonImageAttachmentListRender(args) { @@ -132,6 +139,7 @@ function NonImageAttachmentListRender(args) { @@ -172,7 +180,7 @@ export const LongContents: Story = { }; function MixedAttachments(args) { - let {isInvalid, size, uploadProgress, ...listArgs} = args; + let {isInvalid, isDisabled, size, uploadProgress, ...listArgs} = args; return (
@@ -180,6 +188,7 @@ function MixedAttachments(args) { @@ -198,6 +208,7 @@ function MixedAttachments(args) { @@ -205,6 +216,7 @@ function MixedAttachments(args) { @@ -214,6 +226,7 @@ function MixedAttachments(args) { @@ -240,6 +254,7 @@ function MixedAttachments(args) { @@ -251,6 +266,7 @@ function MixedAttachments(args) { @@ -270,7 +286,7 @@ export const Mixed: Story = { }; function CarouselRender(args) { - let {isInvalid, size, uploadProgress, ...listArgs} = args; + let {isInvalid, isDisabled, size, uploadProgress, ...listArgs} = args; return ( {Array.from({length: 8}, (_, i) => ( @@ -278,6 +294,7 @@ function CarouselRender(args) { key={i} uploadProgress={uploadProgress} isInvalid={isInvalid} + isDisabled={isDisabled} size={size} aria-label={`file-${i + 1}.pdf`}> {Array.from({length: 6}, (_, i) => ( @@ -305,6 +322,7 @@ function CarouselCardsRender(args) { key={i} uploadProgress={uploadProgress} isInvalid={isInvalid} + isDisabled={isDisabled} size={size} aria-label={`Card_file_${i + 1}.pdf`}> Date: Mon, 14 Sep 2026 11:40:46 -0700 Subject: [PATCH 3/3] chore: export scroll fade from style macro file --- .../@react-spectrum/ai/src/AttachmentList.tsx | 3 +- .../@react-spectrum/ai/src/PromptField.tsx | 10 +- .../ai/src/PromptFieldContainer.tsx | 9 +- .../@react-spectrum/ai/src/ResponseStatus.tsx | 3 +- .../@react-spectrum/ai/src/tokens.macro.ts | 204 +----------------- packages/@react-spectrum/s2/style/index.ts | 3 +- .../@react-spectrum/s2/style/style-macro.ts | 150 +++++++++++++ 7 files changed, 172 insertions(+), 210 deletions(-) diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 27684490351..c0590106d2c 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -25,7 +25,9 @@ import { css, focusRing, iconStyle, + keyframes, lightDark, + scrollFade, style } from '@react-spectrum/s2/style' with {type: 'macro'}; import {Button, ButtonProps} from 'react-aria-components/Button'; @@ -42,7 +44,6 @@ import {Image, ImageContext, ImageProps} from '@react-spectrum/s2/Image'; import {ImageCoordinator} from '@react-spectrum/s2/ImageCoordinator'; import ImageIcon from '@react-spectrum/s2/icons/Image'; import intlMessages from '../intl/*.json'; -import {keyframes, scrollFade} from './tokens.macro' with {type: 'macro'}; import {matchMimeType} from './PromptField'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import Play from '@react-spectrum/s2/icons/Play'; diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 6661a9f6b17..0a04cf99d7e 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -17,7 +17,14 @@ import {Autocomplete} from 'react-aria-components/Autocomplete'; import {Button, ButtonContext} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; -import {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +import { + color, + css, + scrollFade, + space, + style, + StyleString +} from '@react-spectrum/s2/style' with {type: 'macro'}; import { createContext, createRef, @@ -55,7 +62,6 @@ import { import {PromptFieldContainer} from './PromptFieldContainer'; import {PromptFocusContext} from './Chat'; import {Provider} from 'react-aria-components/slots'; -import {scrollFade} from './tokens.macro' with {type: 'macro'}; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import {setTokenFieldSelection} from 'react-aria/useTokenField'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; diff --git a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx index a82500f19e1..0120f82f82e 100644 --- a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx +++ b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx @@ -3,12 +3,17 @@ import { convertColor, defaultBrand, defineProperties, - keyframes, outerBorderStops, stops, token } from './tokens.macro' with {type: 'macro'}; -import {color, css, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; +import { + color, + css, + keyframes, + style, + StyleString +} from '@react-spectrum/s2/style' with {type: 'macro'}; import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group, GroupProps} from 'react-aria-components/Group'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index aa7c82b066e..6b6a7473991 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -17,6 +17,8 @@ import { css, focusRing, iconStyle, + keyframes, + scrollFade, size, space, style @@ -39,7 +41,6 @@ import {Heading} from 'react-aria-components/Heading'; import {IconContext} from '@react-spectrum/s2/Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {keyframes, scrollFade} from './tokens.macro' with {type: 'macro'}; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {PixelLoader} from '../exports'; import {Provider} from 'react-aria-components/slots'; diff --git a/packages/@react-spectrum/ai/src/tokens.macro.ts b/packages/@react-spectrum/ai/src/tokens.macro.ts index 98ca80dab44..5548cae5b61 100644 --- a/packages/@react-spectrum/ai/src/tokens.macro.ts +++ b/packages/@react-spectrum/ai/src/tokens.macro.ts @@ -1,4 +1,4 @@ -import {color, css} from '@react-spectrum/s2/style'; +import {color} from '@react-spectrum/s2/style'; import tokens from './tokens.merged.json'; // Colors below this OKLCH chroma are treated as neutral (white/black/gray) and @@ -170,205 +170,3 @@ function outerBorderStop(stop: number, variant: string, colorScheme: string, div } return `oklch(from ${token(`outer-border.gradient.ob-hue.stop-${stop}.${colorScheme}`)} l c h / ${opacity}%)`; } - -export function keyframes(this: any | 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; -} - -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; -} - -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)); -} - -// 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; -} - -interface ScrollFadeOptions { - top?: number; - bottom?: number; - start?: number; - end?: number; - x?: number; - y?: number; - inset?: number; -} - -export function scrollFade(this: any | void, options: ScrollFadeOptions) { - let {x = 0, y = 0, top = y, bottom = y, start = x, end = x, inset = 0} = options; - - let blockMask = '', - inlineMask = ''; - - if (top || bottom) { - blockMask = `linear-gradient( - to bottom, - transparent 0px ${inset > 0 ? `calc(var(--scroll-fade-top, ${inset}px) - ${top}px)` : ''}, - black var(--scroll-fade-top, ${inset}px), - black var(--scroll-fade-bottom, calc(100% - ${bottom}px)), - transparent ${inset > 0 ? `calc(var(--scroll-fade-bottom, 100%) + ${inset}px)` : ''} 100% - )`; - } - - if (start || end) { - // TODO: rtl - inlineMask = `linear-gradient( - to right, - transparent 0px ${inset > 0 ? `calc(var(--scroll-fade-left, ${inset}px) - ${start}px)` : ''}, - black var(--scroll-fade-left, ${inset}px), - black var(--scroll-fade-right, calc(100% - ${end}px)), - transparent ${inset > 0 ? `calc(var(--scroll-fade-right, 100%) + ${end}px)` : ''} 100% - )`; - } - - let topAnimation = scrollFadeKeyframes.call( - this, - 'top', - '0px', - top ? `${top + inset}px` : '', - '0px' - ); - let bottomAnimation = scrollFadeKeyframes.call( - this, - 'bottom', - bottom ? `calc(100% - ${bottom + inset}px)` : '', - '100%', - '100%' - ); - let leftAnimation = scrollFadeKeyframes.call( - this, - 'left', - '0px', - start ? `${start + inset}px` : '', - '0px' - ); - let rightAnimation = scrollFadeKeyframes.call( - this, - 'right', - end ? `calc(100% - ${end + inset}px)` : '', - '100%', - '100%' - ); - let animations = [topAnimation, bottomAnimation, leftAnimation, rightAnimation]; - let timeline = ['scroll(self y)', 'scroll(self y)', 'scroll(self x)', 'scroll(self x)'] - .filter((_, i) => animations[i]) - .join(', '); - let range = [ - `0px ${top}px`, - `calc(100% - ${bottom}px) 100%`, - `0px ${start}px`, - `calc(100% - ${end}px) 100%` - ] - .filter((_, i) => animations[i]) - .join(', '); - - if (blockMask || inlineMask) { - return css.call( - this, - ` - mask-image: ${[blockMask, inlineMask].filter(Boolean).join(', ')}; - mask-composite: intersect; - mask-repeat: no-repeat; - - @supports (animation-timeline: scroll()) { - animation: ${animations.filter(Boolean).join(', ')}; - animation-duration: 1ms; - animation-timing-function: ease-in-out; - animation-timeline: ${timeline}; - animation-range: ${range}; - animation-fill-mode: both; - } - ` - ); - } - - return ''; -} - -function scrollFadeKeyframes(this: any, name: string, start: string, end: string, initial: string) { - if (!start || !end) { - return ''; - } - - if (this && typeof this.addAsset === 'function') { - this.addAsset({ - type: 'css', - content: ` - @property --scroll-fade-${name} { - syntax: ""; - inherits: false; - initial-value: ${initial}; - } - ` - }); - } - - return keyframes.call( - this, - ` - from { --scroll-fade-${name}: ${start}; } - to { --scroll-fade-${name}: ${end}; } - ` - ); -} diff --git a/packages/@react-spectrum/s2/style/index.ts b/packages/@react-spectrum/s2/style/index.ts index 2685ce16742..5013ef881c1 100644 --- a/packages/@react-spectrum/s2/style/index.ts +++ b/packages/@react-spectrum/s2/style/index.ts @@ -22,7 +22,8 @@ import type {MacroContext} from '@parcel/macros'; import {StyleString} from './types'; export {baseColor, color, lightDark, colorMix, size, style} from './spectrum-theme'; -export {css} from './style-macro'; +export {css, keyframes, scrollFade} from './style-macro'; +export type {ScrollFadeOptions} from './style-macro'; export {centerPadding, setColorScheme} from '../src/style-utils'; export type {StyleString} from './types'; diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index e7f3900c0ad..770c44c4999 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -1153,3 +1153,153 @@ export function keyframes(this: MacroContext | void, css: string): string { } return name; } + +export interface ScrollFadeOptions { + top?: number; + bottom?: number; + start?: number; + end?: number; + x?: number; + y?: number; + inset?: number; +} + +/** + * Generates a `mask-image` that fades out the edges of a scrollable element, animated via + * `animation-timeline: scroll()` so the fade only appears where there's more content to scroll to. + * Must be imported with `{type: 'macro'}`. + * + * @example + * import {scrollFade, style} from '@react-spectrum/s2/style' with {type: 'macro'}; + * + * const styles = style({ + * overflow: 'auto', + * ...scrollFade({y: 24}) + * }); + * + * @param options - Fade sizes (in px) per edge, or `x`/`y` shorthands for both edges on an axis. + * @returns Style declarations to spread into a `style()` call. + */ +export function scrollFade(this: MacroContext | void, options: ScrollFadeOptions) { + let {x = 0, y = 0, top = y, bottom = y, start = x, end = x, inset = 0} = options; + + let blockMask = '', + inlineMask = ''; + + if (top || bottom) { + blockMask = `linear-gradient( + to bottom, + transparent 0px ${inset > 0 ? `calc(var(--scroll-fade-top, ${inset}px) - ${top}px)` : ''}, + black var(--scroll-fade-top, ${inset}px), + black var(--scroll-fade-bottom, calc(100% - ${bottom}px)), + transparent ${inset > 0 ? `calc(var(--scroll-fade-bottom, 100%) + ${inset}px)` : ''} 100% + )`; + } + + if (start || end) { + // TODO: rtl + inlineMask = `linear-gradient( + to right, + transparent 0px ${inset > 0 ? `calc(var(--scroll-fade-left, ${inset}px) - ${start}px)` : ''}, + black var(--scroll-fade-left, ${inset}px), + black var(--scroll-fade-right, calc(100% - ${end}px)), + transparent ${inset > 0 ? `calc(var(--scroll-fade-right, 100%) + ${end}px)` : ''} 100% + )`; + } + + let topAnimation = scrollFadeKeyframes.call( + this, + 'top', + '0px', + top ? `${top + inset}px` : '', + '0px' + ); + let bottomAnimation = scrollFadeKeyframes.call( + this, + 'bottom', + bottom ? `calc(100% - ${bottom + inset}px)` : '', + '100%', + '100%' + ); + let leftAnimation = scrollFadeKeyframes.call( + this, + 'left', + '0px', + start ? `${start + inset}px` : '', + '0px' + ); + let rightAnimation = scrollFadeKeyframes.call( + this, + 'right', + end ? `calc(100% - ${end + inset}px)` : '', + '100%', + '100%' + ); + let animations = [topAnimation, bottomAnimation, leftAnimation, rightAnimation]; + let timeline = ['scroll(self y)', 'scroll(self y)', 'scroll(self x)', 'scroll(self x)'] + .filter((_, i) => animations[i]) + .join(', '); + let range = [ + `0px ${top}px`, + `calc(100% - ${bottom}px) 100%`, + `0px ${start}px`, + `calc(100% - ${end}px) 100%` + ] + .filter((_, i) => animations[i]) + .join(', '); + + if (blockMask || inlineMask) { + return css.call( + this, + ` + mask-image: ${[blockMask, inlineMask].filter(Boolean).join(', ')}; + mask-composite: intersect; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: ${animations.filter(Boolean).join(', ')}; + animation-duration: 1ms; + animation-timing-function: ease-in-out; + animation-timeline: ${timeline}; + animation-range: ${range}; + animation-fill-mode: both; + } + ` + ); + } + + return ''; +} + +function scrollFadeKeyframes( + this: MacroContext | void, + name: string, + start: string, + end: string, + initial: string +) { + if (!start || !end) { + return ''; + } + + if (this && typeof this.addAsset === 'function') { + this.addAsset({ + type: 'css', + content: ` + @property --scroll-fade-${name} { + syntax: ""; + inherits: false; + initial-value: ${initial}; + } + ` + }); + } + + return keyframes.call( + this, + ` + from { --scroll-fade-${name}: ${start}; } + to { --scroll-fade-${name}: ${end}; } + ` + ); +}