diff --git a/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx b/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx index 3da45e149a498..edb5839bc9345 100644 --- a/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx +++ b/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx @@ -14,8 +14,14 @@ * limitations under the License. */ -import React, { useCallback, useState, useLayoutEffect, useRef } from 'react'; -import styled from 'styled-components'; +import React, { + useCallback, + useState, + useEffect, + useLayoutEffect, + useRef, +} from 'react'; +import styled, { css } from 'styled-components'; import { Box, @@ -75,14 +81,16 @@ export function ResourceCard({ resource, onLabelClick }: Props) { const ResTypeIcon = resourceTypeIcon(resource.kind); const description = resourceDescription(resource); - const labelsInnerContainer = useRef(null); - const [showMoreLabelsButton, setShowMoreLabelsButton] = useState(false); const [showAllLabels, setShowAllLabels] = useState(false); const [numMoreLabels, setNumMoreLabels] = useState(0); - - const nameTextRef = useRef(null); const [isNameOverflowed, setIsNameOverflowed] = useState(false); + + const innerContainer = useRef(null); + const labelsInnerContainer = useRef(null); + const nameText = useRef(null); + const collapseTimeout = useRef>(null); + // This effect installs a resize observer whose purpose is to detect the size // of the component that contains all the labels. If this component is taller // than the height of a single label row, we show a "+x more" button. @@ -93,8 +101,8 @@ export function ResourceCard({ resource, onLabelClick }: Props) { // This check will let us know if the name text has overflowed. We do this // to conditionally render a tooltip for only overflowed names if ( - nameTextRef.current?.scrollWidth > - nameTextRef.current?.parentElement.offsetWidth + nameText.current?.scrollWidth > + nameText.current?.parentElement.offsetWidth ) { setIsNameOverflowed(true); } else { @@ -132,17 +140,44 @@ export function ResourceCard({ resource, onLabelClick }: Props) { }; }); + // Clear the timeout on unmount to prevent changing a state of an unmounted + // component. + useEffect(() => () => clearTimeout(collapseTimeout.current), []); + const onMoreLabelsClick = () => { setShowAllLabels(true); }; + const onMouseLeave = () => { + // If the user expanded the labels and then scrolled down enough to hide the + // top of the card, we scroll back up and collapse the labels with a small + // delay to keep the user from losing focus on the card that they were + // looking at. The delay is picked by hand, since there's no (easy) way to + // know when the animation ends. + if ( + showAllLabels && + (innerContainer.current?.getBoundingClientRect().top ?? 0) < 0 + ) { + innerContainer.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + clearTimeout(collapseTimeout.current); + collapseTimeout.current = setTimeout(() => setShowAllLabels(false), 700); + } else { + // Otherwise, we just collapse the labels immediately. + setShowAllLabels(false); + } + }; + return ( setShowAllLabels(false)} + onMouseLeave={onMouseLeave} > {/* MinWidth is important to prevent descriptions from overflowing. */} @@ -151,12 +186,12 @@ export function ResourceCard({ resource, onLabelClick }: Props) { {isNameOverflowed ? ( {name}}> - + {name} ) : ( - + {name} )} @@ -345,6 +380,12 @@ const CardContainer = styled(Box)` position: relative; `; +const elevatedCardMixin = css` + background-color: ${props => props.theme.colors.levels.elevated}; + border-color: ${props => props.theme.colors.levels.elevated}; + box-shadow: ${props => props.theme.boxShadow[1]}; +`; + /** * The inner container that normally holds a regular layout of the card, and is * fully contained inside the outer container. Once the user clicks the "more" @@ -359,16 +400,19 @@ const CardInnerContainer = styled(Flex)` background-color: transparent; ${props => - props.showAllLabels - ? 'position: absolute; left: 0; right: 0; z-index: 1;' - : ''} + props.showAllLabels && + css` + position: absolute; + left: 0; + right: 0; + z-index: 1; + ${elevatedCardMixin} + `} transition: all 150ms; ${CardContainer}:hover & { - background-color: ${props => props.theme.colors.levels.elevated}; - border-color: ${props => props.theme.colors.levels.elevated}; - box-shadow: ${props => props.theme.boxShadow[1]}; + ${elevatedCardMixin} } @media (min-width: ${props => props.theme.breakpoints.tablet}px) { diff --git a/web/packages/teleport/src/UnifiedResources/Resources.story.tsx b/web/packages/teleport/src/UnifiedResources/Resources.story.tsx index 8d91db0a95c42..bd939e7373655 100644 --- a/web/packages/teleport/src/UnifiedResources/Resources.story.tsx +++ b/web/packages/teleport/src/UnifiedResources/Resources.story.tsx @@ -39,8 +39,17 @@ export default { loaders: [mswLoader], }; +const aLotOfLabels = { + ...databases[0], + name: 'A DB with a lot of labels', + labels: Array(300) + .fill(0) + .map((_, i) => ({ name: `label-${i}`, value: `value ${i}` })), +}; + const allResources = [ ...apps, + aLotOfLabels, ...databases, ...kubes, ...desktops,