diff --git a/app/javascript/mastodon/components/mini_card/index.tsx b/app/javascript/mastodon/components/mini_card/index.tsx new file mode 100644 index 00000000000..a619bb214a0 --- /dev/null +++ b/app/javascript/mastodon/components/mini_card/index.tsx @@ -0,0 +1,33 @@ +import type { FC, ReactNode } from 'react'; + +import classNames from 'classnames'; + +import classes from './styles.module.css'; + +export interface MiniCardProps { + label: ReactNode; + value: ReactNode; + className?: string; + hidden?: boolean; +} + +export const MiniCard: FC = ({ + label, + value, + className, + hidden, +}) => { + if (!label) { + return null; + } + + return ( +
+
{label}
+
{value}
+
+ ); +}; diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx new file mode 100644 index 00000000000..b5b8fbc2c80 --- /dev/null +++ b/app/javascript/mastodon/components/mini_card/list.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { FC, Key, MouseEventHandler } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { MiniCard } from '.'; +import type { MiniCardProps } from '.'; +import classes from './styles.module.css'; + +interface MiniCardListProps { + cards?: (Pick & { key?: Key })[]; + onOverflowClick?: MouseEventHandler; +} + +export const MiniCardList: FC = ({ + cards = [], + onOverflowClick, +}) => { + const { + wrapperRef, + listRef, + hiddenCount, + hasOverflow, + hiddenIndex, + maxWidth, + } = useOverflow(); + + return ( +
+
+ {cards.map((card, index) => ( +
+ +
+ ); +}; + +function useOverflow() { + const [hiddenIndex, setHiddenIndex] = useState(-1); + const [hiddenCount, setHiddenCount] = useState(0); + const [maxWidth, setMaxWidth] = useState('none'); + + // This is the item container element. + const listRef = useRef(null); + + // The main recalculation function. + const handleRecalculate = useCallback(() => { + const listEle = listRef.current; + if (!listEle) return; + + const reset = () => { + setHiddenIndex(-1); + setHiddenCount(0); + setMaxWidth('none'); + }; + + // Calculate the width via the parent element, minus the more button, minus the padding. + const maxWidth = + (listEle.parentElement?.offsetWidth ?? 0) - + (listEle.nextElementSibling?.scrollWidth ?? 0) - + 4; + if (maxWidth <= 0) { + reset(); + return; + } + + // Iterate through children until we exceed max width. + let visible = 0; + let index = 0; + let totalWidth = 0; + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + const rightOffset = child.offsetLeft + child.offsetWidth; + if (rightOffset <= maxWidth) { + visible += 1; + totalWidth = rightOffset; + } else { + break; + } + } + index++; + } + + // All are visible, so remove max-width restriction. + if (visible === listEle.children.length) { + reset(); + return; + } + + // Set the width to avoid wrapping, and set hidden count. + setHiddenIndex(index); + setHiddenCount(listEle.children.length - visible); + setMaxWidth(totalWidth); + }, []); + + // Set up observers to watch for size and content changes. + const resizeObserverRef = useRef(null); + const mutationObserverRef = useRef(null); + + // Helper to get or create the resize observer. + const resizeObserver = useCallback(() => { + const observer = (resizeObserverRef.current ??= new ResizeObserver( + handleRecalculate, + )); + return observer; + }, [handleRecalculate]); + + // Iterate through children and observe them for size changes. + const handleChildrenChange = useCallback(() => { + const listEle = listRef.current; + const observer = resizeObserver(); + + if (listEle) { + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + observer.observe(child); + } + } + } + handleRecalculate(); + }, [handleRecalculate, resizeObserver]); + + // Helper to get or create the mutation observer. + const mutationObserver = useCallback(() => { + const observer = (mutationObserverRef.current ??= new MutationObserver( + handleChildrenChange, + )); + return observer; + }, [handleChildrenChange]); + + // Set up observers. + const handleObserve = useCallback(() => { + if (wrapperRef.current) { + resizeObserver().observe(wrapperRef.current); + } + if (listRef.current) { + mutationObserver().observe(listRef.current, { childList: true }); + handleChildrenChange(); + } + }, [handleChildrenChange, mutationObserver, resizeObserver]); + + // Watch the wrapper for size changes, and recalculate when it resizes. + const wrapperRef = useRef(null); + const wrapperRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + wrapperRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + // If there are changes to the children, recalculate which are visible. + const listRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + listRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + useEffect(() => { + handleObserve(); + + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + } + }; + }, [handleObserve]); + + return { + hiddenCount, + hasOverflow: hiddenCount > 0, + wrapperRef: wrapperRefCallback, + hiddenIndex, + maxWidth, + listRef: listRefCallback, + recalculate: handleRecalculate, + }; +} diff --git a/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx b/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx new file mode 100644 index 00000000000..ada76011b27 --- /dev/null +++ b/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { MiniCardList } from './list'; + +const meta = { + title: 'Components/MiniCard', + component: MiniCardList, + args: { + cards: [ + { label: 'Pronouns', value: 'they/them' }, + { + label: 'Website', + value: bowie-the-db.meow, + }, + { + label: 'Free playlists', + value: soundcloud.com, + }, + { label: 'Location', value: 'Purris, France' }, + ], + onOverflowClick: action('Overflow clicked'), + }, + render(args) { + return ( +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongValue: Story = { + args: { + cards: [ + { + label: 'Username', + value: 'bowie-the-dj', + }, + { + label: 'Bio', + value: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + ], + }, +}; diff --git a/app/javascript/mastodon/components/mini_card/styles.module.css b/app/javascript/mastodon/components/mini_card/styles.module.css new file mode 100644 index 00000000000..d912f1e5cf5 --- /dev/null +++ b/app/javascript/mastodon/components/mini_card/styles.module.css @@ -0,0 +1,55 @@ +.wrapper { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + gap: 4px; +} + +.list { + min-width: 0; + display: flex; + gap: 4px; + overflow: hidden; + position: relative; +} + +.card, +.more { + border: 1px solid var(--color-border-primary); + padding: 8px; + border-radius: 8px; + flex-shrink: 0; +} + +.card { + max-width: 20vw; + overflow: hidden; +} + +.more { + color: var(--color-text-secondary); + font-weight: 600; + appearance: none; + background: none; +} + +.hidden { + display: none; +} + +.label { + color: var(--color-text-secondary); + margin-bottom: 2px; +} + +.value { + color: var(--color-text-primary); + font-weight: 600; +} + +.label, +.value { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index f1ab23570af..77dd9538871 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -589,6 +589,7 @@ "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading…", "media_gallery.hide": "Hide", + "minicard.more_items": "+ {count} more", "moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.", "mute_modal.hide_from_notifications": "Hide from notifications", "mute_modal.hide_options": "Hide options",