177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
// 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;
|
|
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;
|
|
};
|
|
|
|
const getClassName = getClassNamesFor('ListTile');
|
|
|
|
/**
|
|
* 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,
|
|
onClick,
|
|
onContextMenu,
|
|
clickable,
|
|
subtitleMaxLines = 2,
|
|
disabled = false,
|
|
variant = 'item',
|
|
rootElement = 'div',
|
|
testId,
|
|
}: Props,
|
|
ref
|
|
) {
|
|
const isClickable = clickable ?? Boolean(onClick);
|
|
|
|
const rootProps = {
|
|
className: classNames(
|
|
getClassName(''),
|
|
isClickable && getClassName('--clickable'),
|
|
getClassName(`--variant-${variant}`)
|
|
),
|
|
onClick,
|
|
'aria-disabled': disabled ? true : undefined,
|
|
onContextMenu,
|
|
'data-testid': testId,
|
|
};
|
|
|
|
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>
|
|
);
|
|
};
|