signal-desktop/ts/components/ListTile.tsx

183 lines
5.3 KiB
TypeScript
Raw Normal View History

// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { useMemo } from 'react';
import uuid from 'uuid';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { CircleCheckbox } from './CircleCheckbox';
export type Props = {
title: string | JSX.Element;
subtitle?: string | JSX.Element;
leading?: string | JSX.Element;
trailing?: string | JSX.Element;
2023-04-05 20:48:00 +00:00
moduleClassName?: string;
onClick?: () => void;
onContextMenu?: (ev: React.MouseEvent<Element, MouseEvent>) => void;
// show hover highlight,
// defaults to true if onClick is defined
clickable?: boolean;
// defaults to 2
subtitleMaxLines?: 1 | 2 | 3;
// defaults to false
disabled?: boolean;
// defaults to item
variant?: 'item' | 'panelrow';
// defaults to div
rootElement?: 'div' | 'button';
testId?: string;
'aria-selected'?: boolean;
};
/**
* A single row that typically contains some text and leading/trailing icons/widgets
*
* Mostly intended for items on a list: conversations, contacts, groups, options, etc
* where all items have the same height.
*
* If wrapping with <label> and using a checkbox, don't use 'button' as the rootElement
* as it conflicts with click-label-to-check behavior.
*
* Anatomy:
* - leading (optional): widget on the left, typically an avatar
* - title: single-line of main text
* - subtitle (optional): 1-3 lines of subtitle text
* - trailing (optional): widget on the right, typically a icon-button, checkbox, etc
*
* Behavior:
* - highlights on hover if clickable
* - clamps title to 1 line
* - clamps subtitle to 1-3 lines
* - no margins
*
* Variants:
* - item: default, intended for selection lists (especially in modals)
* - panelrow: more horizontal padding, intended for information rows (usually not in
* modals) that tend to occupy more horizontal space
*/
export function ListTile(
params: Props & React.RefAttributes<HTMLButtonElement>
): JSX.Element {
// forwardRef makes it impossible to add extra static fields to the function type so
// we have to create this inner implementation that can be wrapped with a non-arrow
// function. A bit weird, but looks fine at call-site.
return <ListTileImpl {...params} />;
}
const ListTileImpl = React.forwardRef<HTMLButtonElement, Props>(
function ListTileImpl(
{
title,
subtitle,
leading,
trailing,
2023-04-05 20:48:00 +00:00
moduleClassName,
onClick,
onContextMenu,
clickable,
subtitleMaxLines = 2,
disabled = false,
variant = 'item',
rootElement = 'div',
testId,
...ariaProps
}: Props,
ref
) {
const isClickable = clickable ?? Boolean(onClick);
2023-04-05 20:48:00 +00:00
const getClassName = getClassNamesFor('ListTile', moduleClassName);
const rootProps = {
className: classNames(
getClassName(''),
isClickable && getClassName('--clickable'),
getClassName(`--variant-${variant}`)
),
onClick,
'aria-disabled': disabled ? true : undefined,
onContextMenu,
'data-testid': testId,
...ariaProps,
};
const contents = (
<>
{leading && <div className="ListTile__leading">{leading}</div>}
<div className="ListTile__content">
<div className="ListTile__title">{title}</div>
{subtitle && (
<div
className={classNames(
'ListTile__subtitle',
`ListTile__subtitle--max-lines-${subtitleMaxLines}`
)}
>
{subtitle}
</div>
)}
</div>
{trailing && <div className="ListTile__trailing">{trailing}</div>}
</>
);
return rootElement === 'button' ? (
<button type="button" {...rootProps} ref={ref}>
{contents}
</button>
) : (
<div {...rootProps}>{contents}</div>
);
}
);
// although these heights are not required for ListTile (which sizes itself based on
// content), they are useful as constants for ListView.calculateRowHeight
/** Effective ListTile height for an avatar (leading) size 36 */
ListTile.heightFull = 64;
/** Effective ListTile height for an avatar (leading) size 48 */
ListTile.heightCompact = 52;
/**
* ListTile with a trailing checkbox.
*
* It also wraps the ListTile with a <label> to get typical click-label-to-check behavior
*
* Same API except for:
* - no "trailing" param since it is populated by the checkbox
* - isChecked
*/
ListTile.checkbox = (
props: Omit<Props, 'trailing'> & { isChecked: boolean }
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const htmlId: string = useMemo(() => uuid(), []);
const { onClick, disabled, isChecked, ...otherProps } = props;
return (
<label
htmlFor={htmlId}
// `onClick` is will double-fire if we're enabled. We want it to fire when we're
// disabled so we can show any "can't add contact" modals, etc. This won't
// work for keyboard users, though, because labels are not tabbable.
{...(disabled ? { onClick } : {})}
>
<ListTile
{...otherProps}
disabled={disabled}
trailing={
<CircleCheckbox
id={htmlId}
checked={isChecked}
onChange={onClick}
disabled={disabled}
/>
}
/>
</label>
);
};