One SearchInput to rule them all

This commit is contained in:
Josh Perez 2021-05-10 20:50:43 -04:00 committed by GitHub
parent c62b5a900e
commit 24b7790829
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 266 additions and 232 deletions

View File

@ -7329,39 +7329,8 @@ button.module-image__border-overlay:focus {
}
.module-left-pane__compose-search-form {
display: flex;
padding: 8px 16px;
margin-bottom: 8px;
&__input {
flex-grow: 1;
padding: 5px 12px;
border-radius: 17px;
border: none;
@include font-body-1;
@include light-theme {
background-color: $color-gray-05;
color: $color-gray-90;
border: solid 1px $color-gray-02;
}
@include dark-theme {
color: $color-gray-05;
background-color: $color-gray-95;
border: solid 1px $color-gray-80;
}
&:placeholder {
color: $color-gray-45;
}
&:focus {
border: solid 1px $ultramarine-ui-light;
outline: none;
}
}
}

View File

@ -29,36 +29,6 @@
@include modal-close-button;
}
&__search-input {
margin: 10px $padding;
padding: 5px 12px;
border-radius: 17px;
border: none;
@include font-body-2;
@include light-theme {
background-color: $color-gray-05;
color: $color-gray-90;
border: solid 1px $color-gray-02;
}
@include dark-theme {
color: $color-gray-05;
background-color: $color-gray-95;
border: solid 1px $color-gray-80;
}
&:placeholder {
color: $color-gray-45;
}
&:focus {
border: solid 1px $ultramarine-ui-light;
outline: none;
}
}
.module-ContactPills {
max-height: 50px;
}

View File

@ -57,7 +57,7 @@
}
}
&__scroller {
&__input__scroller {
max-height: 300px;
min-height: 300px;
padding: 16px;
@ -149,67 +149,6 @@
}
}
&__search {
border-radius: 8px;
border: none;
margin: 10px 16px;
padding: 5px 12px;
position: relative;
@include font-body-2;
@include light-theme {
background-color: $color-gray-02;
border: solid 1px $color-gray-02;
color: $color-gray-90;
}
@include dark-theme {
background: $color-gray-65;
border: solid 1px $color-gray-65;
color: $color-gray-05;
}
&--icon {
cursor: text;
height: 16px;
left: 8px;
position: absolute;
top: 6px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-45);
}
}
@include keyboard-mode {
&:focus-within {
border: solid 1px $ultramarine-ui-light;
outline: none;
}
}
&--input {
background: inherit;
border: none;
padding-left: 16px;
width: 100%;
&:placeholder {
color: $color-gray-45;
}
@include dark-theme {
color: $color-gray-05;
}
&:focus {
outline: none;
}
}
}
&__list-wrapper {
flex-grow: 1;
overflow: hidden;

View File

@ -0,0 +1,64 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-SearchInput {
&__container {
border-radius: 8px;
border: none;
margin: 10px 16px;
padding: 5px 12px;
position: relative;
@include font-body-2;
@include light-theme {
background-color: $color-gray-02;
border: solid 1px $color-gray-02;
color: $color-gray-90;
}
@include dark-theme {
background: $color-gray-90;
border: solid 1px $color-gray-90;
color: $color-gray-05;
}
&:focus-within {
border: solid 1px $ultramarine-ui-light;
outline: none;
}
}
&__icon {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-45);
cursor: text;
height: 16px;
left: 8px;
position: absolute;
top: 6px;
width: 16px;
}
&__input {
background: inherit;
border: none;
padding-left: 16px;
width: 100%;
&:placeholder {
color: $color-gray-45;
}
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
&:focus {
outline: none;
}
}
}

View File

@ -44,6 +44,7 @@
@import './components/Modal.scss';
@import './components/SafetyNumberChangeDialog.scss';
@import './components/SafetyNumberViewer.scss';
@import './components/SearchInput.scss';
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/TimelineWarning.scss';

View File

@ -34,6 +34,7 @@ import {
} from '../quill/util';
import { SignalClipboard } from '../quill/signal-clipboard';
import { DirectionalBlot } from '../quill/block/blot';
import { getClassNamesFor } from '../util/getClassNamesFor';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
@ -80,17 +81,7 @@ export type Props = {
};
const MAX_LENGTH = 64 * 1024;
function getClassName(
moduleClassName?: string,
modifier?: string | null
): string | undefined {
if (!moduleClassName || !modifier) {
return undefined;
}
return `${moduleClassName}${modifier}`;
}
const BASE_CLASS_NAME = 'module-composition-input';
export const CompositionInput: React.ComponentType<Props> = props => {
const {
@ -525,7 +516,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return (
<ReactQuill
className="module-composition-input__quill"
className={`${BASE_CLASS_NAME}__quill`}
onFocus={() => callbacksRef.current.onFocus()}
onBlur={() => callbacksRef.current.onBlur()}
onChange={() => callbacksRef.current.onChange()}
@ -642,29 +633,19 @@ export const CompositionInput: React.ComponentType<Props> = props => {
// eslint-disable-next-line max-len
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
return (
<Manager>
<Reference>
{({ ref }) => (
<div
className={classNames(
'module-composition-input__input',
getClassName(moduleClassName, '__input')
)}
ref={ref}
>
<div className={getClassName('__input')} ref={ref}>
<div
ref={scrollerRef}
onClick={focus}
className={classNames(
'module-composition-input__input__scroller',
large
? 'module-composition-input__input__scroller--large'
: null,
getClassName(moduleClassName, '__scroller'),
large
? getClassName(moduleClassName, '__scroller--large')
: null
getClassName('__input__scroller'),
large ? getClassName('__input__scroller--large') : null
)}
>
{reactQuill}

View File

@ -25,6 +25,7 @@ import { EmojiPickDataType } from './emoji/EmojiPicker';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { BodyRangeType, LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { assert } from '../util/assert';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
@ -325,20 +326,15 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<div className="module-ForwardMessageModal__search">
<i className="module-ForwardMessageModal__search--icon" />
<input
type="text"
className="module-ForwardMessageModal__search--input"
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
</div>
<SearchInput
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {

View File

@ -8,6 +8,7 @@ import { noop } from 'lodash';
import { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
import { Theme } from '../util/theme';
import { getClassNamesFor } from '../util/getClassNamesFor';
type PropsType = {
children: ReactNode;
@ -19,6 +20,8 @@ type PropsType = {
theme?: Theme;
};
const BASE_CLASS_NAME = 'module-Modal';
export function Modal({
children,
hasXButton,
@ -31,23 +34,23 @@ export function Modal({
const [scrolled, setScrolled] = useState(false);
const hasHeader = Boolean(hasXButton || title);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
return (
<ModalHost onClose={onClose} theme={theme}>
<div
className={classNames(
'module-Modal',
hasHeader ? 'module-Modal--has-header' : 'module-Modal--no-header',
moduleClassName
getClassName(''),
getClassName(hasHeader ? '--has-header' : '--no-header')
)}
>
{hasHeader && (
<div className="module-Modal__header">
<div className={getClassName('__header')}>
{hasXButton && (
<button
aria-label={i18n('close')}
type="button"
className="module-Modal__close-button"
className={getClassName('__close-button')}
tabIndex={0}
onClick={() => {
onClose();
@ -57,8 +60,8 @@ export function Modal({
{title && (
<h1
className={classNames(
'module-Modal__title',
hasXButton ? 'module-Modal__title--with-x-button' : null
getClassName('__title'),
hasXButton ? getClassName('__title--with-x-button') : null
)}
>
{title}
@ -67,9 +70,10 @@ export function Modal({
</div>
)}
<div
className={classNames('module-Modal__body', {
'module-Modal__body--scrolled': scrolled,
})}
className={classNames(
getClassName('__body'),
scrolled ? getClassName('__body--scrolled') : null
)}
onScroll={event => {
setScrolled((event.target as HTMLDivElement).scrollTop > 2);
}}
@ -83,6 +87,14 @@ export function Modal({
Modal.Footer = ({
children,
}: Readonly<{ children: ReactNode }>): ReactElement => (
<div className="module-Modal__footer">{children}</div>
moduleClassName,
}: Readonly<{
children: ReactNode;
moduleClassName?: string;
}>): ReactElement => (
<div
className={getClassNamesFor(BASE_CLASS_NAME, moduleClassName)('__footer')}
>
{children}
</div>
);

View File

@ -0,0 +1,48 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ChangeEvent, KeyboardEvent, forwardRef } from 'react';
import { getClassNamesFor } from '../util/getClassNamesFor';
export type PropTypes = {
readonly disabled?: boolean;
readonly moduleClassName?: string;
readonly onChange: (ev: ChangeEvent<HTMLInputElement>) => unknown;
readonly onKeyDown?: (ev: KeyboardEvent<HTMLInputElement>) => unknown;
readonly placeholder: string;
readonly value: string;
};
const BASE_CLASS_NAME = 'module-SearchInput';
export const SearchInput = forwardRef<HTMLInputElement, PropTypes>(
(
{
disabled = false,
moduleClassName,
onChange,
onKeyDown,
placeholder,
value,
},
ref
) => {
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
return (
<div className={getClassName('__container')}>
<i className={getClassName('__icon')} />
<input
className={getClassName('__input')}
dir="auto"
disabled={disabled}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
ref={ref}
type="text"
value={value}
/>
</div>
);
}
);

View File

@ -4,6 +4,8 @@
import React from 'react';
import classNames from 'classnames';
import { getClassNamesFor } from '../util/getClassNamesFor';
export const SpinnerSvgSizes = ['small', 'normal'] as const;
export type SpinnerSvgSize = typeof SpinnerSvgSizes[number];
@ -29,37 +31,38 @@ export const Spinner = ({
size,
svgSize,
direction,
}: Props): JSX.Element => (
<div
className={classNames(
'module-spinner__container',
`module-spinner__container--${svgSize}`,
direction ? `module-spinner__container--${direction}` : null,
direction ? `module-spinner__container--${svgSize}-${direction}` : null,
moduleClassName ? `${moduleClassName}__container` : null
)}
style={{
height: size,
width: size,
}}
>
}: Props): JSX.Element => {
const getClassName = getClassNamesFor('module-spinner', moduleClassName);
return (
<div
className={classNames(
'module-spinner__circle',
`module-spinner__circle--${svgSize}`,
direction ? `module-spinner__circle--${direction}` : null,
direction ? `module-spinner__circle--${svgSize}-${direction}` : null,
moduleClassName ? `${moduleClassName}__circle` : null
getClassName('__container'),
getClassName(`__container--${svgSize}`),
getClassName(direction && `__container--${direction}`),
getClassName(direction && `__container--${svgSize}-${direction}`)
)}
/>
<div
className={classNames(
'module-spinner__arc',
`module-spinner__arc--${svgSize}`,
direction ? `module-spinner__arc--${direction}` : null,
direction ? `module-spinner__arc--${svgSize}-${direction}` : null,
moduleClassName ? `${moduleClassName}__arc` : null
)}
/>
</div>
);
style={{
height: size,
width: size,
}}
>
<div
className={classNames(
getClassName('__circle'),
getClassName(`__circle--${svgSize}`),
getClassName(direction && `__circle--${direction}`),
getClassName(direction && `__circle--${svgSize}-${direction}`)
)}
/>
<div
className={classNames(
getClassName('__arc'),
getClassName(`__arc--${svgSize}`),
getClassName(direction && `__arc--${direction}`),
getClassName(direction && `__arc--${svgSize}-${direction}`)
)}
/>
</div>
);
};

View File

@ -22,6 +22,7 @@ import { ContactPill } from '../../../ContactPill';
import { ConversationList, Row, RowType } from '../../../ConversationList';
import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox';
import { Button, ButtonVariant } from '../../../Button';
import { SearchInput } from '../../../SearchInput';
type PropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
@ -130,9 +131,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
<h1 className="module-AddGroupMembersModal__header">
{i18n('AddGroupMembersModal--title')}
</h1>
<input
type="text"
className="module-AddGroupMembersModal__search-input"
<SearchInput
disabled={candidateContacts.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {

View File

@ -9,6 +9,7 @@ import { ConversationType } from '../../state/ducks/conversations';
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
import { ContactPills } from '../ContactPills';
import { ContactPill } from '../ContactPill';
import { SearchInput } from '../SearchInput';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
@ -152,17 +153,13 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
return (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('contactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
<SearchInput
moduleClassName="module-left-pane__compose-search-form"
onChange={onChangeComposeSearchTerm}
placeholder={i18n('contactSearchPlaceholder')}
ref={focusRef}
value={this.searchTerm}
/>
{Boolean(this.selectedContacts.length) && (
<ContactPills>

View File

@ -8,6 +8,7 @@ import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { SearchInput } from '../SearchInput';
import { LocalizerType } from '../../types/Util';
import {
instance as phoneNumberInstance,
@ -93,17 +94,13 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
}>): ReactChild {
return (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('contactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
<SearchInput
moduleClassName="module-left-pane__compose-search-form"
onChange={onChangeComposeSearchTerm}
placeholder={i18n('contactSearchPlaceholder')}
ref={focusRef}
value={this.searchTerm}
/>
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">

View File

@ -0,0 +1,41 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getClassNamesFor } from '../../util/getClassNamesFor';
describe('getClassNamesFor', () => {
it('returns a function', () => {
const f = getClassNamesFor('hello-world');
assert.isFunction(f);
});
it('returns a function that adds a modifier', () => {
const f = getClassNamesFor('module');
assert.equal(f('__modifier'), 'module__modifier');
});
it('does not add anything if there is no modifier', () => {
const f = getClassNamesFor('module');
assert.equal(f(), '');
assert.equal(f(undefined && '__modifier'), '');
});
it('but does return the top level module if the modifier is empty string', () => {
const f = getClassNamesFor('module1', 'module2');
assert.equal(f(''), 'module1 module2');
});
it('adds multiple class names', () => {
const f = getClassNamesFor('module1', 'module2', 'module3');
assert.equal(
f('__modifier'),
'module1__modifier module2__modifier module3__modifier'
);
});
it('skips parent modules that are undefined', () => {
const f = getClassNamesFor('module1', undefined, 'module3');
assert.equal(f('__modifier'), 'module1__modifier module3__modifier');
});
});

View File

@ -0,0 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
export function getClassNamesFor(
...modules: Array<string | undefined>
): (modifier?: string) => string {
return modifier => {
const cx = modules.map(parentModule =>
parentModule && modifier !== undefined
? `${parentModule}${modifier}`
: undefined
);
return classNames(cx);
};
}