MiniCard and MiniCardList components (#37479)

This commit is contained in:
Echo 2026-01-15 14:53:42 +01:00 committed by GitHub
parent f2fb232e37
commit c09fbeb32f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 359 additions and 0 deletions

View File

@ -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<MiniCardProps> = ({
label,
value,
className,
hidden,
}) => {
if (!label) {
return null;
}
return (
<div
className={classNames(classes.card, className)}
inert={hidden ? '' : undefined}
>
<dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd>
</div>
);
};

View File

@ -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<MiniCardProps, 'label' | 'value'> & { key?: Key })[];
onOverflowClick?: MouseEventHandler;
}
export const MiniCardList: FC<MiniCardListProps> = ({
cards = [],
onOverflowClick,
}) => {
const {
wrapperRef,
listRef,
hiddenCount,
hasOverflow,
hiddenIndex,
maxWidth,
} = useOverflow();
return (
<div className={classes.wrapper} ref={wrapperRef}>
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
{cards.map((card, index) => (
<MiniCard
key={card.key ?? index}
label={card.label}
value={card.value}
hidden={index >= hiddenIndex}
/>
))}
</dl>
<button
type='button'
className={classNames(classes.more, !hasOverflow && classes.hidden)}
onClick={onOverflowClick}
>
<FormattedMessage
id='minicard.more_items'
defaultMessage='+ {count} more'
values={{ count: hiddenCount }}
/>
</button>
</div>
);
};
function useOverflow() {
const [hiddenIndex, setHiddenIndex] = useState(-1);
const [hiddenCount, setHiddenCount] = useState(0);
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
// This is the item container element.
const listRef = useRef<HTMLElement | null>(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<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(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<HTMLElement | null>(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,
};
}

View File

@ -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: <a href='https://example.com'>bowie-the-db.meow</a>,
},
{
label: 'Free playlists',
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
},
{ label: 'Location', value: 'Purris, France' },
],
onOverflowClick: action('Overflow clicked'),
},
render(args) {
return (
<div
style={{
resize: 'horizontal',
padding: '1rem',
border: '1px solid gray',
overflow: 'auto',
width: '400px',
minWidth: '100px',
}}
>
<MiniCardList {...args} />
</div>
);
},
} satisfies Meta<typeof MiniCardList>;
export default meta;
type Story = StoryObj<typeof meta>;
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.',
},
],
},
};

View File

@ -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;
}

View File

@ -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",