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/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';
|
||||
|
|
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…
Reference in a new issue