diff --git a/src/file-token-group/__tests__/file-token-group.test.tsx b/src/file-token-group/__tests__/file-token-group.test.tsx index 22284f31ae..44349df3a0 100644 --- a/src/file-token-group/__tests__/file-token-group.test.tsx +++ b/src/file-token-group/__tests__/file-token-group.test.tsx @@ -212,8 +212,11 @@ describe('File loading', () => { test('Spinner added when loading', () => { const wrapper = render({ items: [{ file: file1, loading: true }, { file: file2 }] }); - expect(wrapper.findFileToken(1)?.getElement().firstChild).toHaveClass(styles.loading); - expect(wrapper.findFileToken(2)?.getElement().firstChild).not.toHaveClass(styles.loading); + // Reach into the token-box since the file-token now composes InternalToken. + expect(wrapper.findFileToken(1)?.getElement().querySelector(`.${styles['token-box']}`)).toHaveClass(styles.loading); + expect(wrapper.findFileToken(2)?.getElement().querySelector(`.${styles['token-box']}`)).not.toHaveClass( + styles.loading + ); }); }); diff --git a/src/file-token-group/file-token.tsx b/src/file-token-group/file-token.tsx index e2b1e6a904..d83e2befdd 100644 --- a/src/file-token-group/file-token.tsx +++ b/src/file-token-group/file-token.tsx @@ -11,7 +11,7 @@ import { FormFieldError, FormFieldWarning } from '../form-field/internal'; import { BaseComponentProps } from '../internal/base-component/index.js'; import InternalSpaceBetween from '../space-between/internal.js'; import InternalSpinner from '../spinner/internal.js'; -import DismissButton from '../token/dismiss-button.js'; +import InternalToken from '../token/internal.js'; import { TokenGroupProps } from '../token-group/interfaces.js'; import Tooltip from '../tooltip/internal.js'; import * as defaultFormatters from './default-formatters.js'; @@ -77,9 +77,7 @@ function InternalFileToken({ const [showTooltip, setShowTooltip] = useState(false); const [isTruncated, setIsTruncated] = useState(false); - const getDismissLabel = (fileIndex: number) => { - return i18nStrings?.removeFileAriaLabel?.(fileIndex, file.name); - }; + const getDismissLabel = (fileIndex: number) => i18nStrings?.removeFileAriaLabel?.(fileIndex, file.name); function isEllipsisActive() { const span = fileNameRef.current; @@ -99,6 +97,76 @@ function InternalFileToken({ const fileIsSingleRow = !showFileLastModified && !showFileSize && (!groupContainsImage || (groupContainsImage && !showFileThumbnail)); + // The full body of the token. Rendered through InternalToken's customContent slot so this + // component owns the inner layout (thumbnail + metadata column + loading overlay), while + // InternalToken handles the token-box and the dismiss button. + const fileContent = ( + <> + {loading && ( +
+ +
+ )} + + {showFileThumbnail && isImage && } + +
+ +
setShowTooltip(true)} + onMouseOut={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + role={isTruncated ? 'button' : undefined} + aria-expanded={isTruncated ? showTooltip : undefined} + tabIndex={isTruncated ? 0 : -1} + ref={fileNameContainerRef} + > + + {file.name} + +
+ + {showFileSize && file.size ? ( + + {formatFileSize(file.size)} + + ) : null} + + {showFileLastModified && file.lastModified ? ( + + {formatFileLastModified(new Date(file.lastModified))} + + ) : null} +
+
+
+ + ); + return (
-
- {loading && ( -
- -
- )} - - {showFileThumbnail && isImage && } - -
- -
setShowTooltip(true)} - onMouseOut={() => setShowTooltip(false)} - onFocus={() => setShowTooltip(true)} - onBlur={() => setShowTooltip(false)} - role={isTruncated ? 'button' : undefined} - aria-expanded={isTruncated ? showTooltip : undefined} - tabIndex={isTruncated ? 0 : -1} - ref={fileNameContainerRef} - > - - {file.name} - -
- - {showFileSize && file.size ? ( - - {formatFileSize(file.size)} - - ) : null} - - {showFileLastModified && file.lastModified ? ( - - {formatFileLastModified(new Date(file.lastModified))} - - ) : null} -
-
-
- {onDismiss && !readOnly && } -
+ /> {errorText && ( {errorText} diff --git a/src/token/internal.tsx b/src/token/internal.tsx index 8d7bede72e..e04170430f 100644 --- a/src/token/internal.tsx +++ b/src/token/internal.tsx @@ -28,10 +28,25 @@ import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; -type InternalTokenProps = TokenProps & +type InternalTokenProps = Omit & InternalBaseComponentProps & { + /** Token label. Required unless `__customContent` replaces the option layout entirely. */ + label?: React.ReactNode; + /** + * Overrides the default `role="group"` on the token root. Set to `"presentation"` when a + * parent element provides grouping semantics; this also strips ARIA attributes + * (aria-label, aria-labelledby, aria-disabled), focus/mouse handlers, and the tab stop so the + * token doesn't expose a redundant nested group to assistive tech. + */ role?: string; disableInnerPadding?: boolean; + /** Extra class on the token-box element */ + __tokenBoxClassName?: string; + /** + * Renders content inside the token-box, replacing the standard option layout + * (label, labelTag, description, tags). The dismiss button is still rendered as a sibling. + */ + __customContent?: React.ReactNode; }; function InternalToken({ @@ -52,6 +67,8 @@ function InternalToken({ // Internal role, disableInnerPadding, + __tokenBoxClassName, + __customContent, // Base __internalRootRef, @@ -65,6 +82,9 @@ function InternalToken({ const [isEllipsisActive, setIsEllipsisActive] = useState(false); const isInline = variant === 'inline'; const isOneTheme = isThemeActive(Theme.OneTheme); + // Consumers with their own grouping semantics can pass role="presentation" to treat the root + // as a pure styling wrapper (strips ARIA and focus/mouse handlers). + const isPresentation = role === 'presentation'; const ariaLabelledbyId = useUniqueId(); const isLabelOverflowing = () => { @@ -131,23 +151,31 @@ function InternalToken({ analyticsSelectors.token, baseProps.className )} - aria-label={ariaLabel} - aria-labelledby={!ariaLabel ? ariaLabelledbyId : undefined} - aria-disabled={!!disabled} + aria-label={isPresentation ? undefined : ariaLabel} + aria-labelledby={isPresentation || ariaLabel ? undefined : ariaLabelledbyId} + aria-disabled={isPresentation ? undefined : !!disabled} role={role ?? 'group'} onFocus={() => { - setShowTooltip(true); + if (!isPresentation) { + setShowTooltip(true); + } }} onBlur={() => { - setShowTooltip(false); + if (!isPresentation) { + setShowTooltip(false); + } }} onMouseEnter={() => { - setShowTooltip(true); + if (!isPresentation) { + setShowTooltip(true); + } }} onMouseLeave={() => { - setShowTooltip(false); + if (!isPresentation) { + setShowTooltip(false); + } }} - tabIndex={!!tooltipContent && isInline && isEllipsisActive ? 0 : undefined} + tabIndex={!isPresentation && !!tooltipContent && isInline && isEllipsisActive ? 0 : undefined} > @@ -167,6 +196,7 @@ function InternalToken({ labelContainerRef={labelContainerRef} labelRef={labelRef} labelId={ariaLabelledbyId} + customContent={__customContent} /> {onDismiss && (