Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/file-token-group/__tests__/file-token-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});

Expand Down
154 changes: 82 additions & 72 deletions src/file-token-group/file-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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 && (
<div
className={clsx(styles['file-loading-overlay'], {
[styles['file-loading-overlay-single-row']]: loading && fileIsSingleRow,
})}
>
<InternalSpinner variant="normal" size="normal" />
</div>
)}
<InternalBox className={styles['file-option']}>
{showFileThumbnail && isImage && <FileOptionThumbnail file={file} />}

<div
className={clsx(styles['file-option-metadata'], {
[styles['with-image']]: showFileThumbnail && isImage,
[styles['single-row-loading']]: loading && fileIsSingleRow,
})}
>
<InternalSpaceBetween direction="vertical" size="xxxs">
<div
className={styles['file-name-container']}
onMouseOver={() => 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}
>
<InternalBox
fontWeight="normal"
className={clsx(styles['file-option-name'], testUtilStyles['file-option-name'], {
[testUtilStyles['ellipsis-active']]: isTruncated,
})}
>
<span ref={fileNameRef}>{file.name}</span>
</InternalBox>
</div>

{showFileSize && file.size ? (
<InternalBox
fontSize="body-s"
color={'text-body-secondary'}
className={clsx(styles['file-option-size'], testUtilStyles['file-option-size'])}
>
{formatFileSize(file.size)}
</InternalBox>
) : null}

{showFileLastModified && file.lastModified ? (
<InternalBox
fontSize="body-s"
color={'text-body-secondary'}
className={clsx(styles['file-option-last-modified'], testUtilStyles['file-option-last-modified'])}
>
{formatFileLastModified(new Date(file.lastModified))}
</InternalBox>
) : null}
</InternalSpaceBetween>
</div>
</InternalBox>
</>
);

return (
<div
ref={containerRef}
Expand All @@ -109,82 +177,24 @@ function InternalFileToken({
role="group"
aria-label={file.name}
aria-describedby={errorText ? errorId : warningText ? warningId : undefined}
aria-disabled={loading}
aria-disabled={loading || undefined}
data-index={index}
>
<div
className={clsx(styles['token-box'], {
<InternalToken
// The outer wrapper above is the accessibility group (role="group" + aria-label). The
// token itself is presentation-only so screen readers don't see two nested groups.
role="presentation"
__customContent={fileContent}
onDismiss={readOnly ? undefined : onDismiss}
dismissLabel={getDismissLabel(index)}
__tokenBoxClassName={clsx(styles['token-box'], {
[styles.loading]: loading,
[styles.error]: errorText,
[styles.warning]: showWarning,
[styles.horizontal]: alignment === 'horizontal',
[styles['read-only']]: readOnly,
})}
>
{loading && (
<div
className={clsx(styles['file-loading-overlay'], {
[styles['file-loading-overlay-single-row']]: loading && fileIsSingleRow,
})}
>
<InternalSpinner variant="normal" size="normal" />
</div>
)}
<InternalBox className={styles['file-option']}>
{showFileThumbnail && isImage && <FileOptionThumbnail file={file} />}

<div
className={clsx(styles['file-option-metadata'], {
[styles['with-image']]: showFileThumbnail && isImage,
[styles['single-row-loading']]: loading && fileIsSingleRow,
})}
>
<InternalSpaceBetween direction="vertical" size="xxxs">
<div
className={styles['file-name-container']}
onMouseOver={() => 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}
>
<InternalBox
fontWeight="normal"
className={clsx(styles['file-option-name'], testUtilStyles['file-option-name'], {
[testUtilStyles['ellipsis-active']]: isTruncated,
})}
>
<span ref={fileNameRef}>{file.name}</span>
</InternalBox>
</div>

{showFileSize && file.size ? (
<InternalBox
fontSize="body-s"
color={'text-body-secondary'}
className={clsx(styles['file-option-size'], testUtilStyles['file-option-size'])}
>
{formatFileSize(file.size)}
</InternalBox>
) : null}

{showFileLastModified && file.lastModified ? (
<InternalBox
fontSize="body-s"
color={'text-body-secondary'}
className={clsx(styles['file-option-last-modified'], testUtilStyles['file-option-last-modified'])}
>
{formatFileLastModified(new Date(file.lastModified))}
</InternalBox>
) : null}
</InternalSpaceBetween>
</div>
</InternalBox>
{onDismiss && !readOnly && <DismissButton dismissLabel={getDismissLabel(index)} onDismiss={onDismiss} />}
</div>
/>
{errorText && (
<FormFieldError id={errorId} errorIconAriaLabel={i18nStrings?.errorIconAriaLabel}>
{errorText}
Expand Down
50 changes: 40 additions & 10 deletions src/token/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenProps, 'label'> &
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({
Expand All @@ -52,6 +67,8 @@ function InternalToken({
// Internal
role,
disableInnerPadding,
__tokenBoxClassName,
__customContent,

// Base
__internalRootRef,
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -131,31 +151,40 @@ 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}
>
<SpanOrDivTag
className={clsx(
!isInline ? styles['token-box'] : styles['token-box-inline'],
disabled && styles['token-box-disabled'],
readOnly && styles['token-box-readonly'],
!isInline && !onDismiss && styles['token-box-without-dismiss'],
disableInnerPadding && styles['disable-padding']
disableInnerPadding && styles['disable-padding'],
__tokenBoxClassName
)}
style={tokenRootStyleProps}
>
Expand All @@ -167,6 +196,7 @@ function InternalToken({
labelContainerRef={labelContainerRef}
labelRef={labelRef}
labelId={ariaLabelledbyId}
customContent={__customContent}
/>
{onDismiss && (
<DismissButton
Expand Down
Loading