@carbon/react
Version:
React components for the Carbon Design System
412 lines (410 loc) • 16.5 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { usePrefix } from "../../internal/usePrefix.js";
import { Text } from "../Text/Text.js";
import { Enter, Space } from "../../internal/keyboard/keys.js";
import { matches } from "../../internal/keyboard/match.js";
import useIsomorphicEffect from "../../internal/useIsomorphicEffect.js";
import { useId } from "../../internal/useId.js";
import { deprecate } from "../../prop-types/deprecate.js";
import Link_default from "../Link/index.js";
import { isComponentElement } from "../../internal/utils.js";
import { useMergedRefs } from "../../internal/useMergedRefs.js";
import { useFeatureFlag } from "../FeatureFlags/index.js";
import { getInteractiveContent, getRoleContent } from "../../internal/useNoInteractiveChildren.js";
import { AILabel } from "../AILabel/index.js";
import { composeEventHandlers } from "../../tools/events.js";
import classNames from "classnames";
import React, { Children, cloneElement, forwardRef, useCallback, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { jsx, jsxs } from "react/jsx-runtime";
import { AiLabel, ArrowRight, Checkbox, CheckboxCheckedFilled, ChevronDown, Error } from "@carbon/icons-react";
//#region src/components/Tile/Tile.tsx
/**
* Copyright IBM Corp. 2019, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const Tile = React.forwardRef(({ children, className, decorator, light = false, slug, hasRoundedCorners = false, ...rest }, ref) => {
const prefix = usePrefix();
return /* @__PURE__ */ jsxs("div", {
className: classNames(`${prefix}--tile`, {
[`${prefix}--tile--light`]: light,
[`${prefix}--tile--slug`]: slug,
[`${prefix}--tile--slug-rounded`]: slug && hasRoundedCorners,
[`${prefix}--tile--decorator`]: decorator,
[`${prefix}--tile--decorator-rounded`]: decorator && hasRoundedCorners
}, className),
ref,
...rest,
children: [
children,
slug,
decorator && /* @__PURE__ */ jsx("div", {
className: `${prefix}--tile--inner-decorator`,
children: decorator
})
]
});
});
Tile.displayName = "Tile";
Tile.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
decorator: PropTypes.node,
hasRoundedCorners: PropTypes.bool,
light: deprecate(PropTypes.bool, "The `light` prop for `Tile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead."),
slug: deprecate(PropTypes.node, "The `slug` prop for `Tile` has been deprecated in favor of the new `decorator` prop. It will be removed in the next major release.")
};
const ClickableTile = React.forwardRef(({ children, className, clicked = false, decorator, disabled, href, light, onClick = () => {}, onKeyDown = () => {}, renderIcon: Icon, hasRoundedCorners, slug, ...rest }, ref) => {
const prefix = usePrefix();
const classes = classNames(`${prefix}--tile`, `${prefix}--tile--clickable`, {
[`${prefix}--tile--is-clicked`]: clicked,
[`${prefix}--tile--light`]: light,
[`${prefix}--tile--slug`]: slug,
[`${prefix}--tile--slug-rounded`]: slug && hasRoundedCorners,
[`${prefix}--tile--decorator`]: decorator,
[`${prefix}--tile--decorator-rounded`]: decorator && hasRoundedCorners
}, className);
function handleOnClick(evt) {
evt?.persist?.();
onClick(evt);
}
function handleOnKeyDown(evt) {
evt?.persist?.();
if (!href && matches(evt, [Enter, Space])) {
evt.preventDefault();
onClick(evt);
}
onKeyDown(evt);
}
const v12DefaultIcons = useFeatureFlag("enable-v12-tile-default-icons");
if (v12DefaultIcons) {
if (!Icon) Icon = ArrowRight;
if (disabled) Icon = Error;
}
const iconClasses = classNames({
[`${prefix}--tile--icon`]: !v12DefaultIcons || v12DefaultIcons && !disabled,
[`${prefix}--tile--disabled-icon`]: v12DefaultIcons && disabled
});
return /* @__PURE__ */ jsxs(Link_default, {
className: classes,
href,
tabIndex: !href && !disabled ? 0 : void 0,
onClick: !disabled ? handleOnClick : void 0,
onKeyDown: handleOnKeyDown,
ref,
disabled,
...rest,
children: [
slug || decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--tile-content`,
children
}) : children,
(slug === true || decorator === true) && /* @__PURE__ */ jsx(AiLabel, {
size: "24",
className: `${prefix}--tile--ai-label-icon`
}),
React.isValidElement(decorator) && /* @__PURE__ */ jsx("div", {
className: `${prefix}--tile--inner-decorator`,
children: decorator
}),
Icon && /* @__PURE__ */ jsx(Icon, {
className: iconClasses,
"aria-hidden": "true"
})
]
});
});
ClickableTile.displayName = "ClickableTile";
ClickableTile.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
clicked: PropTypes.bool,
decorator: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]),
disabled: PropTypes.bool,
hasRoundedCorners: PropTypes.bool,
href: PropTypes.string,
light: deprecate(PropTypes.bool, "The `light` prop for `ClickableTile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead."),
onClick: PropTypes.func,
onKeyDown: PropTypes.func,
rel: PropTypes.string,
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object])
};
const SelectableTile = React.forwardRef(({ children, className, decorator, disabled, id, light, onClick = () => {}, onChange = () => {}, onKeyDown = () => {}, selected = false, tabIndex = 0, title = "title", slug, hasRoundedCorners, ...rest }, ref) => {
const prefix = usePrefix();
const clickHandler = onClick;
const keyDownHandler = onKeyDown;
const [isSelected, setIsSelected] = useState(selected);
useEffect(() => {
setIsSelected(selected);
}, [selected]);
const classes = classNames(`${prefix}--tile`, `${prefix}--tile--selectable`, {
[`${prefix}--tile--is-selected`]: isSelected,
[`${prefix}--tile--light`]: light,
[`${prefix}--tile--disabled`]: disabled,
[`${prefix}--tile--slug`]: slug,
[`${prefix}--tile--slug-rounded`]: slug && hasRoundedCorners,
[`${prefix}--tile--decorator`]: decorator,
[`${prefix}--tile--decorator-rounded`]: decorator && hasRoundedCorners
}, className);
const handleSelectionChange = useCallback((evt, newSelected) => {
setIsSelected(newSelected);
onChange(evt, newSelected, id);
}, [onChange, id]);
function handleClick(evt) {
evt.preventDefault();
evt?.persist?.();
if (normalizedDecorator && decoratorRef.current && evt.target instanceof Node && decoratorRef.current.contains(evt.target)) return;
handleSelectionChange(evt, !isSelected);
clickHandler(evt);
}
function handleKeyDown(evt) {
evt?.persist?.();
if (matches(evt, [Enter, Space])) {
evt.preventDefault();
handleSelectionChange(evt, !isSelected);
}
keyDownHandler(evt);
}
const decoratorRef = useRef(null);
const candidate = slug ?? decorator;
const normalizedDecorator = isComponentElement(candidate, AILabel) ? cloneElement(candidate, {
size: "xs",
ref: decoratorRef
}) : candidate;
return /* @__PURE__ */ jsxs("div", {
className: classes,
onClick: !disabled ? handleClick : void 0,
role: "checkbox",
"aria-checked": isSelected,
onKeyDown: !disabled ? handleKeyDown : void 0,
tabIndex: !disabled ? tabIndex : void 0,
ref,
id,
title,
...rest,
children: [
/* @__PURE__ */ jsx("span", {
className: `${prefix}--tile__checkmark ${prefix}--tile__checkmark--persistent`,
children: isSelected ? /* @__PURE__ */ jsx(CheckboxCheckedFilled, {}) : /* @__PURE__ */ jsx(Checkbox, {})
}),
/* @__PURE__ */ jsx(Text, {
as: "label",
htmlFor: id,
className: `${prefix}--tile-content`,
children
}),
slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--tile--inner-decorator`,
children: normalizedDecorator
}) : ""
]
});
});
SelectableTile.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
decorator: PropTypes.node,
disabled: PropTypes.bool,
hasRoundedCorners: PropTypes.bool,
id: PropTypes.string,
light: deprecate(PropTypes.bool, "The `light` prop for `SelectableTile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead."),
name: deprecate(PropTypes.string, "The `name` property is no longer used. It will be removed in the next major release."),
onChange: PropTypes.func,
onClick: PropTypes.func,
onKeyDown: PropTypes.func,
selected: PropTypes.bool,
slug: deprecate(PropTypes.node, "The `slug` prop for `SelectableTile` has been deprecated in favor of the new `decorator` prop. It will be removed in the next major release."),
tabIndex: PropTypes.number,
title: PropTypes.string,
value: deprecate(PropTypes.oneOfType([PropTypes.string, PropTypes.number]), "The `value` property is no longer used. It will be removed in the next major release.`")
};
const ExpandableTile = forwardRef(({ tabIndex = 0, className, children, decorator, expanded = false, tileMaxHeight = 0, tilePadding = 0, onClick, onKeyUp, tileCollapsedIconText = "Interact to expand Tile", tileExpandedIconText = "Interact to collapse Tile", tileCollapsedLabel, tileExpandedLabel, light, slug, hasRoundedCorners, ...rest }, forwardRef) => {
const [measuredAboveHeight, setMeasuredAboveHeight] = useState(0);
const [measuredPadding, setMeasuredPadding] = useState(0);
const [isExpanded, setIsExpanded] = useState(expanded);
const [interactive, setInteractive] = useState(true);
const aboveTheFold = useRef(null);
const belowTheFold = useRef(null);
const chevronInteractiveRef = useRef(null);
const tileContent = useRef(null);
const tile = useRef(null);
const ref = useMergedRefs([forwardRef, tile]);
const prefix = usePrefix();
useEffect(() => {
setIsExpanded(expanded);
}, [expanded]);
const handleClick = () => {
setIsExpanded((prev) => !prev);
};
const handleKeyUp = (evt) => {
if (evt.target !== tile.current && evt.target !== chevronInteractiveRef.current) {
if (matches(evt, [Enter, Space])) evt.preventDefault();
}
};
const classNames$1 = classNames(`${prefix}--tile`, `${prefix}--tile--expandable`, {
[`${prefix}--tile--is-expanded`]: isExpanded,
[`${prefix}--tile--light`]: light
}, className);
const interactiveClassNames = classNames(classNames$1, `${prefix}--tile--expandable--interactive`, {
[`${prefix}--tile--slug`]: slug,
[`${prefix}--tile--slug-rounded`]: slug && hasRoundedCorners,
[`${prefix}--tile--decorator`]: decorator,
[`${prefix}--tile--decorator-rounded`]: decorator && hasRoundedCorners
});
const chevronInteractiveClassNames = classNames(`${prefix}--tile__chevron`, `${prefix}--tile__chevron--interactive`);
const childrenAsArray = Children.toArray(children);
useIsomorphicEffect(() => {
if (!tile.current || !aboveTheFold.current) return;
const style = window.getComputedStyle(tile.current);
setMeasuredPadding((parseInt(style.getPropertyValue("padding-top"), 10) || 0) + (parseInt(style.getPropertyValue("padding-bottom"), 10) || 0));
setMeasuredAboveHeight(aboveTheFold.current.scrollHeight);
}, []);
useIsomorphicEffect(() => {
if (!aboveTheFold.current || !belowTheFold.current) return;
setInteractive(Boolean(getInteractiveContent(aboveTheFold.current)) || Boolean(getRoleContent(aboveTheFold.current)) || Boolean(getInteractiveContent(belowTheFold.current)) || Boolean(getRoleContent(belowTheFold.current)) || Boolean(slug || decorator));
}, [
slug,
decorator,
children
]);
useIsomorphicEffect(() => {
if (!tile.current) return;
if (isExpanded) {
tile.current.style.maxHeight = "";
return;
}
const measured = measuredAboveHeight || aboveTheFold.current?.scrollHeight || 0;
const baseHeight = tileMaxHeight > 0 ? tileMaxHeight : measured;
const pad = tilePadding > 0 ? tilePadding : measuredPadding;
tile.current.style.maxHeight = `${baseHeight + pad}px`;
}, [
isExpanded,
tileMaxHeight,
tilePadding,
measuredAboveHeight,
measuredPadding
]);
useEffect(() => {
if (!aboveTheFold.current) return;
const resizeObserver = new ResizeObserver(() => {
if (aboveTheFold.current) setMeasuredAboveHeight(aboveTheFold.current.scrollHeight);
});
resizeObserver.observe(aboveTheFold.current);
return () => resizeObserver.disconnect();
}, []);
const belowTheFoldId = useId(interactive ? "expandable-tile-interactive" : "expandable-tile");
const candidate = slug ?? decorator;
const normalizedDecorator = isComponentElement(candidate, AILabel) ? cloneElement(candidate, { size: "xs" }) : candidate;
return interactive ? /* @__PURE__ */ jsx("div", {
ref,
className: interactiveClassNames,
...rest,
children: /* @__PURE__ */ jsxs("div", {
ref: tileContent,
children: [
slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--tile--inner-decorator`,
children: normalizedDecorator
}) : "",
/* @__PURE__ */ jsx("div", {
ref: aboveTheFold,
className: `${prefix}--tile-content`,
children: childrenAsArray[0]
}),
/* @__PURE__ */ jsx("button", {
type: "button",
"aria-expanded": isExpanded,
"aria-controls": belowTheFoldId,
onKeyUp: composeEventHandlers([onKeyUp, handleKeyUp]),
onClick: composeEventHandlers([onClick, handleClick]),
"aria-label": isExpanded ? tileExpandedIconText : tileCollapsedIconText,
ref: chevronInteractiveRef,
className: chevronInteractiveClassNames,
children: /* @__PURE__ */ jsx(ChevronDown, {})
}),
/* @__PURE__ */ jsx("div", {
ref: belowTheFold,
className: `${prefix}--tile-content`,
id: belowTheFoldId,
children: childrenAsArray[1]
})
]
})
}) : /* @__PURE__ */ jsx("button", {
type: "button",
ref,
className: classNames$1,
"aria-controls": belowTheFoldId,
"aria-expanded": isExpanded,
title: isExpanded ? tileExpandedIconText : tileCollapsedIconText,
...rest,
onKeyUp: composeEventHandlers([onKeyUp, handleKeyUp]),
onClick: composeEventHandlers([onClick, handleClick]),
tabIndex,
children: /* @__PURE__ */ jsxs("div", {
ref: tileContent,
children: [
/* @__PURE__ */ jsx("div", {
ref: aboveTheFold,
className: `${prefix}--tile-content`,
children: childrenAsArray[0]
}),
/* @__PURE__ */ jsxs("div", {
className: `${prefix}--tile__chevron`,
children: [/* @__PURE__ */ jsx("span", { children: isExpanded ? tileExpandedLabel : tileCollapsedLabel }), /* @__PURE__ */ jsx(ChevronDown, {})]
}),
/* @__PURE__ */ jsx("div", {
ref: belowTheFold,
id: belowTheFoldId,
className: `${prefix}--tile-content`,
children: childrenAsArray[1]
})
]
})
});
});
ExpandableTile.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
decorator: PropTypes.node,
expanded: PropTypes.bool,
hasRoundedCorners: PropTypes.bool,
id: PropTypes.string,
light: deprecate(PropTypes.bool, "The `light` prop for `ExpandableTile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead."),
onClick: PropTypes.func,
onKeyUp: PropTypes.func,
slug: deprecate(PropTypes.node, "The `slug` prop for `ExpandableTile` has been deprecated in favor of the new `decorator` prop. It will be removed in the next major release."),
tabIndex: PropTypes.number,
tileCollapsedIconText: PropTypes.string,
tileCollapsedLabel: PropTypes.string,
tileExpandedIconText: PropTypes.string,
tileExpandedLabel: PropTypes.string
};
ExpandableTile.displayName = "ExpandableTile";
const TileAboveTheFoldContent = React.forwardRef(({ children }, ref) => {
return /* @__PURE__ */ jsx("div", {
ref,
className: `${usePrefix()}--tile-content__above-the-fold`,
children
});
});
TileAboveTheFoldContent.propTypes = { children: PropTypes.node };
TileAboveTheFoldContent.displayName = "TileAboveTheFoldContent";
const TileBelowTheFoldContent = React.forwardRef(({ children }, ref) => {
return /* @__PURE__ */ jsx("div", {
ref,
className: `${usePrefix()}--tile-content__below-the-fold`,
children
});
});
TileBelowTheFoldContent.propTypes = { children: PropTypes.node };
TileBelowTheFoldContent.displayName = "TileBelowTheFoldContent";
//#endregion
export { ClickableTile, ExpandableTile, SelectableTile, Tile, TileAboveTheFoldContent, TileBelowTheFoldContent };