mirror of
https://github.com/mastodon/mastodon.git
synced 2026-01-16 23:26:40 +00:00
MiniCard and MiniCardList components (#37479)
This commit is contained in:
parent
f2fb232e37
commit
c09fbeb32f
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
208
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
208
app/javascript/mastodon/components/mini_card/list.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user