Adding ListTile and CircleCheckbox components
This commit is contained in:
parent
da0a741a36
commit
2d9cbf4795
7 changed files with 497 additions and 0 deletions
92
stylesheets/components/CircleCheckbox.scss
Normal file
92
stylesheets/components/CircleCheckbox.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
103
stylesheets/components/ListTile.scss
Normal file
103
stylesheets/components/ListTile.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,6 +49,7 @@
|
||||||
@import './components/CallingToast.scss';
|
@import './components/CallingToast.scss';
|
||||||
@import './components/ChatColorPicker.scss';
|
@import './components/ChatColorPicker.scss';
|
||||||
@import './components/Checkbox.scss';
|
@import './components/Checkbox.scss';
|
||||||
|
@import './components/CircleCheckbox.scss';
|
||||||
@import './components/CompositionArea.scss';
|
@import './components/CompositionArea.scss';
|
||||||
@import './components/CompositionTextArea.scss';
|
@import './components/CompositionTextArea.scss';
|
||||||
@import './components/ContactModal.scss';
|
@import './components/ContactModal.scss';
|
||||||
|
@ -87,6 +88,7 @@
|
||||||
@import './components/LeftPaneDialog.scss';
|
@import './components/LeftPaneDialog.scss';
|
||||||
@import './components/LeftPaneSearchInput.scss';
|
@import './components/LeftPaneSearchInput.scss';
|
||||||
@import './components/Lightbox.scss';
|
@import './components/Lightbox.scss';
|
||||||
|
@import './components/ListTile.scss';
|
||||||
@import './components/MediaEditor.scss';
|
@import './components/MediaEditor.scss';
|
||||||
@import './components/MediaQualitySelector.scss';
|
@import './components/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
|
|
30
ts/components/CircleCheckbox.stories.tsx
Normal file
30
ts/components/CircleCheckbox.stories.tsx
Normal 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 />;
|
||||||
|
}
|
50
ts/components/CircleCheckbox.tsx
Normal file
50
ts/components/CircleCheckbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
106
ts/components/ListTile.stories.tsx
Normal file
106
ts/components/ListTile.stories.tsx
Normal 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
114
ts/components/ListTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
Loading…
Add table
Add a link
Reference in a new issue