Profile redesign: Fields iteration (#37682)

This commit is contained in:
Echo
2026-02-03 14:02:33 +01:00
committed by GitHub
parent 346ca87ee8
commit 0923e2cb26
13 changed files with 449 additions and 383 deletions

View File

@@ -1,33 +1,57 @@
import type { FC, ReactNode } from 'react';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import classNames from 'classnames';
import type { OmitUnion } from '@/mastodon/utils/types';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
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;
export type MiniCardProps = OmitUnion<
ComponentPropsWithoutRef<'div'>,
{
label: ReactNode;
value: ReactNode;
icon?: IconProp;
iconId?: string;
iconClassName?: string;
}
>;
return (
<div
className={classNames(classes.card, className)}
inert={hidden ? '' : undefined}
>
<dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd>
</div>
);
};
export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
(
{ label, value, className, hidden, icon, iconId, iconClassName, ...props },
ref,
) => {
if (!label) {
return null;
}
return (
<div
{...props}
className={classNames(
classes.card,
icon && classes.cardWithIcon,
className,
)}
ref={ref}
>
{icon && (
<Icon
id={iconId ?? 'minicard'}
icon={icon}
className={classNames(classes.icon, iconClassName)}
noFill
/>
)}
<dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd>
</div>
);
},
);
MiniCard.displayName = 'MiniCard';

View File

@@ -1,69 +1,37 @@
import type { FC, Key, MouseEventHandler } from 'react';
import { FormattedMessage } from 'react-intl';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, Key } from 'react';
import classNames from 'classnames';
import { useOverflow } from '@/mastodon/hooks/useOverflow';
import type { OmitUnion } from '@/mastodon/utils/types';
import { MiniCard } from '.';
import type { MiniCardProps } from '.';
import type { MiniCardProps as BaseCardProps } from '.';
import classes from './styles.module.css';
export type MiniCardProps = BaseCardProps & {
key?: Key;
};
interface MiniCardListProps {
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & {
key?: Key;
})[];
className?: string;
onOverflowClick?: MouseEventHandler;
cards?: MiniCardProps[];
}
export const MiniCardList: FC<MiniCardListProps> = ({
cards = [],
className,
onOverflowClick,
}) => {
const {
wrapperRef,
listRef,
hiddenCount,
hasOverflow,
hiddenIndex,
maxWidth,
} = useOverflow();
export const MiniCardList = forwardRef<
HTMLDListElement,
OmitUnion<ComponentPropsWithoutRef<'dl'>, MiniCardListProps>
>(({ cards = [], className, children, ...props }, ref) => {
if (!cards.length) {
return null;
}
return (
<div className={classNames(classes.wrapper, className)} 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={hasOverflow && index >= hiddenIndex}
className={card.className}
/>
))}
</dl>
{cards.length > 1 && (
<div>
<button
type='button'
className={classNames(classes.more, !hasOverflow && classes.hidden)}
onClick={onOverflowClick}
>
<FormattedMessage
id='minicard.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
</button>
</div>
)}
</div>
<dl {...props} className={classNames(classes.list, className)} ref={ref}>
{cards.map((card, index) => (
<MiniCard key={card.key ?? index} {...card} />
))}
{children}
</dl>
);
};
});
MiniCardList.displayName = 'MiniCardList';

View File

@@ -1,30 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import LinkIcon from '@/material-icons/400-24px/link_2.svg?react';
import { MiniCardList } from './list';
const meta = {
title: 'Components/MiniCard',
component: MiniCardList,
args: {
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;
@@ -38,10 +20,12 @@ export const Default: Story = {
{
label: 'Website',
value: <a href='https://example.com'>bowie-the-db.meow</a>,
icon: LinkIcon,
},
{
label: 'Free playlists',
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
icon: LinkIcon,
},
{ label: 'Location', value: 'Purris, France' },
],
@@ -54,11 +38,13 @@ export const LongValue: Story = {
{
label: 'Username',
value: 'bowie-the-dj',
style: { maxWidth: '250px' },
},
{
label: 'Bio',
value:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
style: { maxWidth: '250px' },
},
],
},

View File

@@ -1,52 +1,49 @@
.wrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 4px;
}
.list {
display: flex;
gap: 4px;
overflow: hidden;
position: relative;
}
.card,
.more {
.card {
border: 1px solid var(--color-border-primary);
padding: 8px;
border-radius: 8px;
flex-shrink: 0;
}
.more {
display: grid;
grid-template-rows: repeat(2, 1fr);
column-gap: 8px;
font-size: 13px;
line-height: 1rem;
color: var(--color-text-secondary);
font-weight: 600;
appearance: none;
background: none;
aspect-ratio: 1;
height: 100%;
transition: all 300ms linear;
}
.more:hover {
background-color: var(--color-bg-brand-softer);
color: var(--color-text-primary);
.cardWithIcon {
grid-template-columns: 16px 1fr;
}
.hidden {
display: none;
}
.label {
color: var(--color-text-secondary);
margin-bottom: 2px;
.icon {
grid-row: span 2;
grid-column: 1;
width: 100%;
height: auto;
align-self: center;
fill: currentColor;
}
.value {
color: var(--color-text-primary);
font-weight: 600;
font-weight: 500;
a {
color: var(--color-text-brand);
text-decoration: none;
transition: color 0.2s ease-in-out;
outline: none;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
}
.label,
@@ -54,4 +51,8 @@
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.cardWithIcon & {
grid-column: 2;
}
}

View File

@@ -1,21 +1,23 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { AccountFields } from '@/mastodon/components/account_fields';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { Icon } from '@/mastodon/components/icon';
import { MiniCardList } from '@/mastodon/components/mini_card/list';
import { IconButton } from '@/mastodon/components/icon_button';
import { MiniCard } from '@/mastodon/components/mini_card';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useOverflowScroll } from '@/mastodon/hooks/useOverflow';
import type { Account } from '@/mastodon/models/account';
import { useAppDispatch } from '@/mastodon/store';
import { isValidUrl } from '@/mastodon/utils/checks';
import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react';
import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react';
import IconLink from '@/material-icons/400-24px/link_2.svg?react';
import { isRedesignEnabled } from '../common';
@@ -57,61 +59,94 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink();
const cards = useMemo(
() =>
account.fields
.toArray()
.map(({ value_emojified, name_emojified, verified_at }) => ({
label: (
<>
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
{...htmlHandlers}
/>
{!!verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</>
),
value: (
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
),
className: classNames(
classes.fieldCard,
!!verified_at && classes.fieldCardVerified,
),
})),
[account.emojis, account.fields, htmlHandlers],
);
const intl = useIntl();
const dispatch = useAppDispatch();
const handleOverflowClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_FIELDS',
modalProps: { accountId: account.id },
}),
);
}, [account.id, dispatch]);
const {
bodyRef,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
handleScroll,
} = useOverflowScroll();
return (
<MiniCardList
cards={cards}
className={classes.fieldList}
onOverflowClick={handleOverflowClick}
/>
<div
className={classNames(
classes.fieldWrapper,
canScrollLeft && classes.fieldWrapperLeft,
canScrollRight && classes.fieldWrapperRight,
)}
>
{canScrollLeft && (
<IconButton
icon='more'
iconComponent={IconLeftArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_prev',
defaultMessage: 'Show previous',
})}
className={classes.fieldArrowButton}
onClick={handleLeftNav}
/>
)}
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<MiniCard
key={key}
label={
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
title={name}
{...htmlHandlers}
/>
}
value={
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
title={value_plain ?? undefined}
{...htmlHandlers}
/>
}
icon={fieldIcon(verified_at, value_plain)}
className={classNames(
classes.fieldCard,
verified_at && classes.fieldCardVerified,
)}
/>
),
)}
</dl>
{canScrollRight && (
<IconButton
icon='more'
iconComponent={IconRightArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_next',
defaultMessage: 'Show next',
})}
className={classes.fieldArrowButton}
onClick={handleRightNav}
/>
)}
</div>
);
};
function fieldIcon(verified_at: string | null, value_plain: string | null) {
if (verified_at) {
return IconVerified;
} else if (value_plain && isValidUrl(value_plain)) {
return IconLink;
}
return undefined;
}

View File

@@ -1,95 +0,0 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import classes from './redesign.module.scss';
export const AccountFieldsModal: FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const account = useAccount(accountId);
const htmlHandlers = useElementHandledLink();
if (!account) {
return (
<div className='modal-root__modal dialog-modal'>
<LoadingIndicator />
</div>
);
}
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
icon='close'
className={classes.modalCloseButton}
onClick={onClose}
iconComponent={CloseIcon}
title={intl.formatMessage({
id: 'account_fields_modal.close',
defaultMessage: 'Close',
})}
/>
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
<FormattedMessage
id='account_fields_modal.title'
defaultMessage="{name}'s info"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</span>
</div>
<div className='dialog-modal__content'>
<AnimateEmojiProvider>
<dl className={classes.modalFieldsList}>
{account.fields.map((field, index) => (
<div
key={index}
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
>
<EmojiHTML
as='dt'
htmlString={field.name_emojified}
extraEmojis={account.emojis}
className='translate'
{...htmlHandlers}
/>
<dd>
<EmojiHTML
as='span'
htmlString={field.value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
{!!field.verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</dd>
</div>
))}
</dl>
</AnimateEmojiProvider>
</div>
</div>
);
};

View File

@@ -125,36 +125,112 @@ svg.badgeIcon {
}
}
.fieldList {
.fieldWrapper {
margin-top: 16px;
width: 100%;
position: relative;
}
.fieldWrapper::before,
.fieldWrapper::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 1;
}
.fieldWrapper::before {
left: 0;
background: linear-gradient(
to left,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapper::after {
right: 0;
background: linear-gradient(
to right,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapperLeft::before {
opacity: 1;
}
.fieldWrapperRight::after {
opacity: 1;
}
.fieldList {
display: flex;
flex-wrap: nowrap;
gap: 4px;
scroll-snap-type: x mandatory;
scroll-padding-left: 40px;
scroll-padding-right: 40px;
scroll-behavior: smooth;
overflow-x: scroll;
scrollbar-width: none;
overflow-y: visible;
}
.fieldCard {
position: relative;
scroll-snap-align: start;
a {
color: var(--color-text-brand);
text-decoration: none;
&:focus-visible,
&:focus-within {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
:is(dt, dd) {
max-width: 200px;
}
}
.fieldCardVerified {
background-color: var(--color-bg-brand-softer);
dt {
padding-right: 1rem;
}
.fieldIconVerified {
position: absolute;
top: 4px;
right: 4px;
}
}
.fieldIconVerified {
width: 1rem;
height: 1rem;
.fieldArrowButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--color-bg-elevated);
box-shadow: 0 1px 4px 0 var(--color-shadow-primary);
border-radius: 9999px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
outline-offset: 2px;
z-index: 2;
&:first-child {
left: 4px;
}
&:last-child {
right: 4px;
}
&:hover,
&:focus,
&:focus-visible {
background-color: color-mix(
in oklab,
var(--color-bg-brand-base) var(--overlay-strength-brand),
var(--color-bg-elevated)
);
}
}
.fieldNumbersWrapper {

View File

@@ -9,7 +9,7 @@ import { useParams } from 'react-router';
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
import { useAppHistory } from '@/mastodon/components/router';
import { Tag } from '@/mastodon/components/tags/tag';
import { useOverflow } from '@/mastodon/hooks/useOverflow';
import { useOverflowButton } from '@/mastodon/hooks/useOverflow';
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -30,7 +30,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
// Get list of tags with overflow handling.
const [showOverflow, setShowOverflow] = useState(false);
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
useOverflow();
useOverflowButton();
// Handle whether to show all tags.
const handleOverflowClick: MouseEventHandler = useCallback(() => {

View File

@@ -1,9 +1,10 @@
import { useEffect, useCallback, useRef, useState, useId } from 'react';
import { useEffect, useCallback, useId } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import { useOverflowScroll } from '@/mastodon/hooks/useOverflow';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
@@ -178,74 +179,24 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
);
const bodyRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
void dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
const handleLeftNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft -= 200;
}, []);
const handleRightNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft += 200;
}, []);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
const {
bodyRef,
handleScroll,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
} = useOverflowScroll({ absoluteDistance: true });
if (dismissed || (!isLoading && suggestions.length === 0)) {
return null;
}

View File

@@ -88,7 +88,6 @@ export const MODAL_COMPONENTS = {
'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_FIELDS': () => import('mastodon/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })),
};
export default class ModalRoot extends PureComponent {

View File

@@ -1,14 +1,15 @@
import type { MutableRefObject, RefCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Calculate and manage overflow of child elements within a container.
* Hook to manage overflow of items in a container with a "more" button.
*
* To use, wire up the `wrapperRef` to the container element, and the `listRef` to the
* child element that contains the items to be measured. If autoResize is true,
* the list element will have its max-width set to prevent wrapping. The listRef element
* requires both position:relative and overflow:hidden styles to work correctly.
*/
export function useOverflow({
export function useOverflowButton({
autoResize,
padding = 4,
}: { autoResize?: boolean; padding?: number } = {}) {
@@ -76,6 +77,111 @@ export function useOverflow({
}
}, [autoResize, maxWidth]);
const { listRefCallback, wrapperRefCallback } = useOverflowObservers({
onRecalculate: handleRecalculate,
onListRef: listRef,
});
return {
hiddenCount,
hasOverflow: hiddenCount > 0,
wrapperRef: wrapperRefCallback,
hiddenIndex,
maxWidth,
listRef: listRefCallback,
recalculate: handleRecalculate,
};
}
export function useOverflowScroll({
widthOffset = 200,
absoluteDistance = false,
} = {}) {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const bodyRef = useRef<HTMLElement | null>(null);
// Recalculate scrollable state
const handleRecalculate = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, []);
const { wrapperRefCallback } = useOverflowObservers({
onRecalculate: handleRecalculate,
onWrapperRef: bodyRef,
});
useEffect(() => {
handleRecalculate();
}, [handleRecalculate]);
// Handle scroll event using requestAnimationFrame to avoid excessive recalculations.
const handleScroll = useCallback(() => {
requestAnimationFrame(handleRecalculate);
}, [handleRecalculate]);
// Jump a full screen minus the width offset so that we don't skip a lot.
const handleLeftNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft -= absoluteDistance
? widthOffset
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
}, [absoluteDistance, widthOffset]);
const handleRightNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft += absoluteDistance
? widthOffset
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
}, [absoluteDistance, widthOffset]);
return {
bodyRef: wrapperRefCallback,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
handleScroll,
};
}
export function useOverflowObservers({
onRecalculate,
onListRef,
onWrapperRef,
}: {
onRecalculate: () => void;
onListRef?: RefCallback<HTMLElement> | MutableRefObject<HTMLElement | null>;
onWrapperRef?:
| RefCallback<HTMLElement>
| MutableRefObject<HTMLElement | null>;
}) {
// This is the item container element.
const listRef = useRef<HTMLElement | null>(null);
// Set up observers to watch for size and content changes.
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
@@ -83,10 +189,10 @@ export function useOverflow({
// Helper to get or create the resize observer.
const resizeObserver = useCallback(() => {
const observer = (resizeObserverRef.current ??= new ResizeObserver(
handleRecalculate,
onRecalculate,
));
return observer;
}, [handleRecalculate]);
}, [onRecalculate]);
// Iterate through children and observe them for size changes.
const handleChildrenChange = useCallback(() => {
@@ -100,8 +206,8 @@ export function useOverflow({
}
}
}
handleRecalculate();
}, [handleRecalculate, resizeObserver]);
onRecalculate();
}, [onRecalculate, resizeObserver]);
// Helper to get or create the mutation observer.
const mutationObserver = useCallback(() => {
@@ -129,9 +235,14 @@ export function useOverflow({
if (node) {
wrapperRef.current = node;
handleObserve();
if (typeof onWrapperRef === 'function') {
onWrapperRef(node);
} else if (onWrapperRef && 'current' in onWrapperRef) {
onWrapperRef.current = node;
}
}
},
[handleObserve],
[handleObserve, onWrapperRef],
);
// If there are changes to the children, recalculate which are visible.
@@ -140,9 +251,14 @@ export function useOverflow({
if (node) {
listRef.current = node;
handleObserve();
if (typeof onListRef === 'function') {
onListRef(node);
} else if (onListRef && 'current' in onListRef) {
onListRef.current = node;
}
}
},
[handleObserve],
[handleObserve, onListRef],
);
useEffect(() => {
@@ -161,12 +277,7 @@ export function useOverflow({
}, [handleObserve]);
return {
hiddenCount,
hasOverflow: hiddenCount > 0,
wrapperRef: wrapperRefCallback,
hiddenIndex,
maxWidth,
listRef: listRefCallback,
recalculate: handleRecalculate,
wrapperRefCallback,
listRefCallback,
};
}

View File

@@ -46,6 +46,8 @@
"account.featured.hashtags": "Hashtags",
"account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts",
"account.fields.scroll_next": "Show next",
"account.fields.scroll_prev": "Show previous",
"account.filters.all": "All activity",
"account.filters.boosts_toggle": "Show boosts",
"account.filters.posts_boosts": "Posts and boosts",
@@ -129,8 +131,6 @@
"account.unmute": "Unmute @{name}",
"account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute",
"account_fields_modal.close": "Close",
"account_fields_modal.title": "{name}'s info",
"account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
@@ -653,7 +653,6 @@
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"loading_indicator.label": "Loading…",
"media_gallery.hide": "Hide",
"minicard.more_items": "+{count}",
"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",

View File

@@ -0,0 +1,11 @@
export function isValidUrl(
url: string,
allowedProtocols = ['https:'],
): boolean {
try {
const parsedUrl = new URL(url);
return allowedProtocols.includes(parsedUrl.protocol);
} catch {
return false;
}
}