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 && (