Adding ListTile and CircleCheckbox components

This commit is contained in:
Alvaro 2023-01-25 09:54:32 -07:00 committed by GitHub
parent da0a741a36
commit 2d9cbf4795
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 497 additions and 0 deletions

View file

@ -0,0 +1,92 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CircleCheckbox {
&__checkbox {
position: relative;
height: 20px;
width: 20px;
input[type='checkbox'] {
cursor: pointer;
height: 0;
position: absolute;
width: 0;
@include keyboard-mode {
&:focus {
&::before {
border-color: $color-ultramarine;
}
outline: none;
}
}
&::before {
@include rounded-corners;
background: inherit;
content: '';
display: block;
height: 20px;
position: absolute;
width: 20px;
@include light-theme {
border: 1.5px solid $color-gray-25;
}
@include dark-theme {
border: 1.5px solid $color-gray-65;
}
}
&:checked {
&::before {
background: $color-ultramarine;
border: 1.5px solid $color-ultramarine;
}
&::after {
border: solid $color-white;
border-width: 0 2px 2px 0;
content: '';
display: block;
height: 11px;
left: 7px;
position: absolute;
top: 3px;
transform: rotate(45deg);
width: 6px;
}
}
&:disabled {
cursor: inherit;
}
@include light-theme {
&:disabled {
&::before {
border-color: $color-gray-15;
}
}
&:disabled:checked {
&::before {
background: $color-gray-15;
}
}
}
@include dark-theme {
&:disabled {
&::before {
border-color: $color-gray-45;
}
}
&:disabled:checked {
&::before {
background: $color-gray-45;
}
}
}
}
}
}

View file

@ -0,0 +1,103 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
button.ListTile {
width: 100%;
}
.ListTile {
display: flex;
align-items: center;
padding: 6px 14px;
user-select: none;
// use a transparent border to inset the background
border: 2px solid transparent;
border-width: 2px 10px;
background-clip: padding-box;
border-radius: 20px / 12px;
// reset button styles
background-color: transparent;
color: inherit;
box-sizing: border-box;
text-align: inherit;
&--variant-panelrow {
padding: 8px 16px;
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
font-family: $inter;
.ListTile[aria-disabled='true'] & {
opacity: 0.5;
}
}
&__title {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
line-height: 20px;
}
&__subtitle {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: $color-gray-25;
line-height: 17px;
&--max-lines-1 {
-webkit-line-clamp: 1;
}
&--max-lines-2 {
-webkit-line-clamp: 2;
}
&--max-lines-3 {
-webkit-line-clamp: 3;
}
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
&[aria-disabled='true'] {
cursor: not-allowed;
}
&__leading {
margin-right: 12px;
}
&__trailing {
margin-left: 12px;
}
&--clickable {
cursor: pointer;
&:hover:not([aria-disabled='true']) {
@include light-theme {
background-color: $color-black-alpha-06;
}
@include dark-theme {
background-color: $color-white-alpha-06;
}
& .ConversationDetails-panel-row__actions {
opacity: 1;
}
}
}
}

View file

@ -49,6 +49,7 @@
@import './components/CallingToast.scss';
@import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss';
@import './components/CircleCheckbox.scss';
@import './components/CompositionArea.scss';
@import './components/CompositionTextArea.scss';
@import './components/ContactModal.scss';
@ -87,6 +88,7 @@
@import './components/LeftPaneDialog.scss';
@import './components/LeftPaneSearchInput.scss';
@import './components/Lightbox.scss';
@import './components/ListTile.scss';
@import './components/MediaEditor.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';

View file

@ -0,0 +1,30 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Props } from './CircleCheckbox';
import { CircleCheckbox } from './CircleCheckbox';
const createProps = (): Props => ({
checked: false,
name: 'check-me',
onChange: action('onChange'),
});
export default {
title: 'Components/CircleCheckbox',
};
export function Normal(): JSX.Element {
return <CircleCheckbox {...createProps()} />;
}
export function Checked(): JSX.Element {
return <CircleCheckbox {...createProps()} checked />;
}
export function Disabled(): JSX.Element {
return <CircleCheckbox {...createProps()} disabled />;
}

View file

@ -0,0 +1,50 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { getClassNamesFor } from '../util/getClassNamesFor';
export type Props = {
id?: string;
checked?: boolean;
disabled?: boolean;
isRadio?: boolean;
name?: string;
onChange?: (value: boolean) => unknown;
onClick?: () => unknown;
};
/**
* A fancy checkbox
*
* It's only the checkbox, it does NOT produce a label.
* It is a controlled component, you must provide a value and
* update it yourself onClick/onChange.
*/
export function CircleCheckbox({
id,
checked,
disabled,
isRadio,
name,
onChange,
onClick,
}: Props): JSX.Element {
const getClassName = getClassNamesFor('CircleCheckbox');
return (
<div className={getClassName('__checkbox')}>
<input
checked={Boolean(checked)}
disabled={disabled}
aria-disabled={disabled}
id={id}
name={name}
onChange={onChange && (ev => onChange(ev.target.checked))}
onClick={onClick}
type={isRadio ? 'radio' : 'checkbox'}
/>
</div>
);
}

View file

@ -0,0 +1,106 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Story } from '@storybook/react';
import React from 'react';
import { ListTile } from './ListTile';
import type { Props } from './ListTile';
import { Emojify } from './conversation/Emojify';
import { CircleCheckbox } from './CircleCheckbox';
export default {
title: 'Components/ListTile',
component: ListTile,
};
const lorem =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam feugiat quam vitae semper facilisis. Praesent eu efficitur dui. Donec semper mattis nisl non hendrerit.';
function TemplateList(width: number): Story<Props> {
// eslint-disable-next-line react/display-name
return args => {
return (
<div
style={{
width,
height: 400,
overflow: 'auto',
outline: '1px solid gray',
}}
>
<ListTile
{...args}
subtitle="Checkbox"
trailing={<CircleCheckbox />}
clickable
/>
<ListTile
{...args}
subtitle="Checkbox"
trailing={<CircleCheckbox checked />}
clickable
/>
<ListTile {...args} trailing={undefined} />
<ListTile {...args} title={`Long title - ${lorem}`} />
<ListTile {...args} subtitle="Disabled" disabled />
<ListTile
{...args}
title={<Emojify text="Emoji in title 📞" />}
subtitle="Clickable"
clickable
/>
<ListTile
{...args}
title={<Emojify text="With a LOT of emoji 🚗" />}
subtitle={
<Emojify text="😂, 😃, 🧘🏻‍♂️, 🌍, 🌦️, 🍞, 🚗, 📞, 🎉, ❤️, 🍆, 🍑 and 🏁" />
}
/>
<ListTile
{...args}
subtitle={`One line max - ${lorem}`}
subtitleMaxLines={1}
/>
<ListTile
{...args}
subtitle={`Two lines max - ${lorem}`}
subtitleMaxLines={2}
/>
<ListTile
{...args}
subtitle={`Three lines max - ${lorem}`}
subtitleMaxLines={3}
/>
<ListTile
{...args}
subtitle="Button root element"
rootElement="button"
/>
</div>
);
};
}
const circleAvatar = (
<div
style={{ borderRadius: '100%', background: 'gray', width: 36, height: 36 }}
/>
);
export const Item = TemplateList(400).bind({});
Item.args = {
leading: circleAvatar,
title: <Emojify text="Some user" />,
subtitle: 'Hello my friend',
clickable: true,
};
export const PanelRow = TemplateList(800).bind({});
PanelRow.args = {
leading: circleAvatar,
title: 'Some user',
subtitle: 'Hello my friend',
trailing: <div className="ConversationDetails-panel-row__right">Admin</div>,
clickable: false,
variant: 'panelrow',
};

114
ts/components/ListTile.tsx Normal file
View file

@ -0,0 +1,114 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React from 'react';
import { getClassNamesFor } from '../util/getClassNamesFor';
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';
};
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 const ListTile = React.forwardRef<HTMLButtonElement, Props>(
function ListTile(
{
title,
subtitle,
leading,
trailing,
onClick,
onContextMenu,
clickable,
subtitleMaxLines = 2,
disabled = false,
variant = 'item',
rootElement = 'div',
}: 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,
};
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>
);
}
);