diff --git a/src/components/Schedule/Event.jsx b/src/components/Schedule/Event.jsx index e88d38c2..0fba9de1 100644 --- a/src/components/Schedule/Event.jsx +++ b/src/components/Schedule/Event.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react' import styled from 'styled-components' import { P, H3 } from '../Typography' import { Card, ScrollbarLike } from '../Common' @@ -15,10 +15,10 @@ const EventDescription = styled(P)` display: -webkit-box; -webkit-line-clamp: ${props => (props.expanded ? 'unset' : '3')}; -webkit-box-orient: vertical; - max-height: ${props => (props.expanded ? 'none' : '4.5em')}; + max-height: ${props => (props.expanded ? `${props.maxHeight}px` : '4.5em')}; transition: max-height 0.3s ease; ${p => p.theme.mediaQueries.mobile} { - overflow-y: scroll; + overflow-y: auto; ${ScrollbarLike} } ` @@ -35,19 +35,25 @@ const Points = styled(P)` ` const ToggleButton = styled.button` - background-image: url(${expandButton}); - background-color: transparent; + background: transparent; border: none; position: absolute; cursor: pointer; - width: 15px; - height: 15px; - background-size: contain; - background-repeat: no-repeat; - transform: ${props => (props.expanded ? 'rotate(180deg)' : 'rotate(0deg)')}; - transition: transform 0.3s ease; + width: 28px; + height: 28px; right: 15px; bottom: 15px; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + & > img { + width: 15px; + height: 15px; + transform: ${props => (props.expanded ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.3s ease; + display: block; + } ${p => p.theme.mediaQueries.mobile} { display: none; } @@ -121,19 +127,81 @@ const Event = ({ event }) => { const descriptionRef = useRef(null) const theme = useTheme() - useEffect(() => { - if (descriptionRef.current) { - const isOverflowing = - descriptionRef.current.scrollHeight > descriptionRef.current.clientHeight - setShowToggleButton(isOverflowing) + useLayoutEffect(() => { + const el = descriptionRef.current + if (!el) return + + const clampLines = 3 + + const measureFullHeight = node => { + const clone = node.cloneNode(true) + // keep same wrapping so measurement matches on-card layout + clone.style.width = `${node.clientWidth}px` + clone.style.position = 'absolute' + clone.style.visibility = 'hidden' + clone.style.pointerEvents = 'none' + clone.style.maxHeight = 'none' + clone.style.webkitLineClamp = 'unset' + clone.style.display = 'block' + clone.style.boxSizing = 'border-box' + document.body.appendChild(clone) + const h = clone.scrollHeight + document.body.removeChild(clone) + return h } - }, [descriptionRef, event.description]) - const toggleExpanded = () => { - setExpanded(!expanded) - if (!expanded) { - setMaxHeight(descriptionRef.current.scrollHeight) + const checkOverflow = () => { + const style = getComputedStyle(el) + const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 + const allowedHeight = lineHeight * clampLines + const fullHeight = measureFullHeight(el) + const isOverflowed = fullHeight >= Math.ceil(allowedHeight) + setShowToggleButton(isOverflowed) + } + + // initial check on next paint + const rafId = requestAnimationFrame(checkOverflow) + + // re-check when the element or its parent resizes (fonts, layout, CSS) + let ro + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(checkOverflow) + ro.observe(el) + if (el.parentElement) ro.observe(el.parentElement) + } + + // re-check after fonts load (if supported) + if (document?.fonts && document.fonts.ready) { + document.fonts.ready.then(checkOverflow).catch(() => {}) } + + // when expanded, measure and set the full pixel height (with a small buffer) + // to ensure the animated max-height is never slightly short of the content. + const HEIGHT_BUFFER = 6 // extra pixels to avoid 1-2px clipping on some browsers + const setMeasuredHeightWhenExpanded = () => { + if (!expanded) return + // measure via clone to avoid mutating the live node + const fullHeight = measureFullHeight(el) + // add a small buffer so rounding doesn't clip the last line + setMaxHeight(fullHeight + HEIGHT_BUFFER) + } + + // set measured height initially if already expanded + const rafSetHeight = requestAnimationFrame(setMeasuredHeightWhenExpanded) + + const onResize = () => checkOverflow() + window.addEventListener('resize', onResize) + + return () => { + cancelAnimationFrame(rafId) + cancelAnimationFrame(rafSetHeight) + if (ro) ro.disconnect() + window.removeEventListener('resize', onResize) + } + }, [event.description, expanded]) + + const toggleExpanded = () => { + setExpanded(prev => !prev) } return ( @@ -155,11 +223,15 @@ const Event = ({ event }) => { {formatTime(event.startTime)} - {formatTime(event.endTime)} {event.location} - {event.points && `Points: ${event.points}`} + {event.points > 0 && Points: ${event.points}} {event.description} - {showToggleButton && } + {showToggleButton && ( + + expand + + )} ) }