-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add adaptive shortener with dynamically content
With this, we can adaptively truncate any content based on the available space. When truncated, we can display the full version in a tooltip. The shortened version is generated using a user-specified function.
- Loading branch information
Showing
1 changed file
with
151 additions
and
0 deletions.
There are no files selected for viewing
151 changes: 151 additions & 0 deletions
151
src/app/components/AdaptiveTrimmer/AdaptiveDynamicTrimmer.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react' | ||
import Box from '@mui/material/Box' | ||
import InfoIcon from '@mui/icons-material/Info' | ||
import { MaybeWithTooltip } from './MaybeWithTooltip' | ||
|
||
type AdaptiveDynamicTrimmerProps = { | ||
getFullContent: () => { | ||
content: ReactNode | ||
length: number | ||
} | ||
getShortenedContent: (wantedLength: number) => ReactNode | ||
extraTooltip: ReactNode | ||
} | ||
|
||
/** | ||
* Display content, potentially shortened as needed. | ||
* | ||
* This component will do automatic detection of available space, | ||
* and determine the best way to display content accordingly. | ||
* | ||
* The difference compared to AdaptiveTrimmer is that this component | ||
* expects a function to provide a shortened version of the components. | ||
*/ | ||
export const AdaptiveDynamicTrimmer: FC<AdaptiveDynamicTrimmerProps> = ({ | ||
getFullContent, | ||
getShortenedContent, | ||
extraTooltip, | ||
}) => { | ||
// Initial setup | ||
const textRef = useRef<HTMLDivElement | null>(null) | ||
const { content: fullContent, length: fullLength } = getFullContent() | ||
|
||
// Data about the currently rendered version | ||
const [currentContent, setCurrentContent] = useState<ReactNode>() | ||
const [currentLength, setCurrentLength] = useState(0) | ||
|
||
// Known good - this fits | ||
const [largestKnownGood, setLargestKnownGood] = useState(0) | ||
|
||
// Known bad - this doesn't fit | ||
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1) | ||
|
||
// Are we exploring our possibilities now? | ||
const [inDiscovery, setInDiscovery] = useState(false) | ||
|
||
const attemptContent = useCallback((content: ReactNode, length: number) => { | ||
setCurrentContent(content) | ||
setCurrentLength(length) | ||
}, []) | ||
|
||
const attemptShortenedContent = useCallback( | ||
(wantedLength: number) => attemptContent(getShortenedContent(wantedLength), wantedLength), | ||
[attemptContent, getShortenedContent], | ||
) | ||
|
||
const initDiscovery = useCallback(() => { | ||
setLargestKnownGood(0) | ||
setSmallestKnownBad(fullLength + 1) | ||
attemptContent(fullContent, fullLength) | ||
setInDiscovery(true) | ||
}, [fullContent, fullLength, attemptContent]) | ||
|
||
useEffect(() => { | ||
initDiscovery() | ||
const handleResize = () => { | ||
initDiscovery() | ||
} | ||
|
||
window.addEventListener('resize', handleResize) | ||
return () => window.removeEventListener('resize', handleResize) | ||
}, [initDiscovery]) | ||
|
||
useEffect(() => { | ||
if (inDiscovery) { | ||
if (!textRef.current) { | ||
return | ||
} | ||
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth | ||
// log('Overflow?', isOverflow) | ||
|
||
if (isOverflow) { | ||
// This is too much | ||
|
||
// Update known bad length | ||
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad) | ||
setSmallestKnownBad(newSmallestKnownBad) | ||
|
||
// We should try something smaller | ||
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2)) | ||
} else { | ||
// This is OK | ||
|
||
// Update known good length | ||
const newLargestKnownGood = Math.max(currentLength, largestKnownGood) | ||
setLargestKnownGood(currentLength) | ||
|
||
if (currentLength === fullLength) { | ||
// The whole thing fits, so we are good. | ||
setInDiscovery(false) | ||
} else { | ||
if (currentLength + 1 === smallestKnownBad) { | ||
// This the best we can do, for now | ||
setInDiscovery(false) | ||
} else { | ||
// So far, so good, but we should try something longer | ||
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2)) | ||
} | ||
} | ||
} | ||
} | ||
}, [ | ||
attemptShortenedContent, | ||
currentLength, | ||
fullContent, | ||
fullLength, | ||
inDiscovery, | ||
initDiscovery, | ||
largestKnownGood, | ||
smallestKnownBad, | ||
]) | ||
|
||
const title = | ||
currentLength !== fullLength ? ( | ||
<Box> | ||
<Box>{fullContent}</Box> | ||
{extraTooltip && ( | ||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}> | ||
<InfoIcon /> | ||
{extraTooltip} | ||
</Box> | ||
)} | ||
</Box> | ||
) : ( | ||
extraTooltip | ||
) | ||
|
||
return ( | ||
<Box | ||
ref={textRef} | ||
sx={{ | ||
overflow: 'hidden', | ||
maxWidth: '100%', | ||
textWrap: 'nowrap', | ||
}} | ||
> | ||
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}> | ||
{currentContent} | ||
</MaybeWithTooltip> | ||
</Box> | ||
) | ||
} |