UNPKG

@carbon/react

Version:

React components for the Carbon Design System

412 lines (410 loc) 16.5 kB
/** * 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 };