diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e7f94c3ad7..b12ada477f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -16,6 +16,7 @@ @import "./components/views/beacon/_RoomLiveShareWarning.pcss"; @import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; +@import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss"; @import "./components/views/location/_LocationShareMenu.pcss"; diff --git a/res/css/components/views/elements/_FilterDropdown.pcss b/res/css/components/views/elements/_FilterDropdown.pcss new file mode 100644 index 0000000000..98808a8b1a --- /dev/null +++ b/res/css/components/views/elements/_FilterDropdown.pcss @@ -0,0 +1,77 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FilterDropdown { + .mx_Dropdown_menu { + margin-top: $spacing-4; + left: unset; + right: -$spacing-12; + width: 232px; + + border: 1px solid $quinary-content; + border-radius: 8px; + box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05); + + .mx_Dropdown_option_highlight { + background-color: $system; + } + } + + .mx_Dropdown_input { + height: 24px; + background-color: $quinary-content; + border-color: $quinary-content; + color: $secondary-content; + border-radius: 4px; + + &:focus { + border-color: $quinary-content; + } + } + + .mx_Dropdown_arrow { + background: $secondary-content; + } +} + +.mx_FilterDropdown_option { + position: relative; + width: 100%; + box-sizing: border-box; + padding: $spacing-8 0 $spacing-8 $spacing-20; + + font-size: $font-12px; + line-height: $font-15px; + color: $primary-content; +} + +.mx_FilterDropdown_optionSelectedIcon { + height: 14px; + width: 14px; + position: absolute; + top: $spacing-8; + left: 0; +} + +.mx_FilterDropdown_optionLabel { + font-weight: $font-semi-bold; + display: block; +} + +.mx_FilterDropdown_optionDescription { + color: $secondary-content; + margin-top: $spacing-4; +} diff --git a/res/img/element-icons/roomlist/checkmark.svg b/res/img/element-icons/roomlist/checkmark.svg index 3be39fc9b2..ab6ced94a3 100644 --- a/res/img/element-icons/roomlist/checkmark.svg +++ b/res/img/element-icons/roomlist/checkmark.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index b9d8122ec5..d662a5acff 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -68,7 +68,7 @@ class MenuOption extends React.Component { } } -interface IProps { +export interface DropdownProps { id: string; // ARIA label label: string; @@ -108,13 +108,13 @@ interface IState { * but somewhat simpler as react-select is 79KB of minified * javascript. */ -export default class Dropdown extends React.Component { +export default class Dropdown extends React.Component { private readonly buttonRef = createRef(); private dropdownRootElement: HTMLDivElement = null; private ignoreEvent: MouseEvent = null; private childrenByKey: Record = {}; - constructor(props: IProps) { + constructor(props: DropdownProps) { super(props); this.reindexChildren(this.props.children); diff --git a/src/components/views/elements/FilterDropdown.tsx b/src/components/views/elements/FilterDropdown.tsx new file mode 100644 index 0000000000..2b5bd0d3e9 --- /dev/null +++ b/src/components/views/elements/FilterDropdown.tsx @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import { Icon as CheckmarkIcon } from '../../../../res/img/element-icons/roomlist/checkmark.svg'; +import Dropdown, { DropdownProps } from './Dropdown'; + +export type FilterDropdownOption = { + id: FilterKeysType; + label: string; + description?: string; +}; +type FilterDropdownProps = Omit & { + value: FilterKeysType; + options: FilterDropdownOption[]; + // A label displayed before the selected value + // in the dropdown input + selectedLabel?: string; +}; + +const getSelectedFilterOptionComponent = ( + options: FilterDropdownOption[], selectedLabel?: string, +) => (filterKey: FilterKeysType) => { + const option = options.find(({ id }) => id === filterKey); + if (!option) { + return null; + } + if (selectedLabel) { + return `${selectedLabel}: ${option.label}`; + } + return option.label; + }; + +/** + * Dropdown styled for list filtering + */ +export const FilterDropdown = ( + { + value, + options, + selectedLabel, + className, + ...restProps + }: FilterDropdownProps, +): React.ReactElement> => { + return (options, selectedLabel)} + > + { options.map(({ id, label, description }) => +
+ { id === value && } + + { label } + + { + !!description + && { description } + } +
, + ) } +
; +}; diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 5af3d30a36..5687cdf0af 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { _t } from '../../../../languageHandler'; import AccessibleButton from '../../elements/AccessibleButton'; -import Dropdown from '../../elements/Dropdown'; +import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropdown'; import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceSecurityCard from './DeviceSecurityCard'; @@ -45,13 +45,14 @@ interface Props { const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => (right.last_seen_ts || 0) - (left.last_seen_ts || 0); -const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) => +const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) => filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []) .sort(sortDevicesByLatestActivity); const ALL_FILTER_ID = 'ALL'; +type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID; -const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => { +const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => { switch (filter) { case DeviceSecurityVariation.Verified: return
@@ -95,7 +96,7 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string } } }; -const getNoResultsMessage = (filter: DeviceSecurityVariation): string => { +const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => { switch (filter) { case DeviceSecurityVariation.Verified: return _t('No verified sessions found.'); @@ -107,7 +108,7 @@ const getNoResultsMessage = (filter: DeviceSecurityVariation): string => { return _t('No sessions found.'); } }; -interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void} +interface NoResultsProps { filter?: DeviceSecurityVariation, clearFilter: () => void} const NoResults: React.FC = ({ filter, clearFilter }) =>
{ getNoResultsMessage(filter) } @@ -158,7 +159,7 @@ const FilteredDeviceList: React.FC = ({ }) => { const sortedDevices = getFilteredSortedDevices(devices, filter); - const options = [ + const options: FilterDropdownOption[] = [ { id: ALL_FILTER_ID, label: _t('All') }, { id: DeviceSecurityVariation.Verified, @@ -180,7 +181,7 @@ const FilteredDeviceList: React.FC = ({ }, ]; - const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => { + const onFilterOptionChange = (filterId: DeviceFilterKey) => { onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); }; @@ -189,16 +190,14 @@ const FilteredDeviceList: React.FC = ({ { _t('Sessions') } - id='device-list-filter' label={_t('Filter devices')} value={filter || ALL_FILTER_ID} onOptionChange={onFilterOptionChange} - > - { options.map(({ id, label }) => -
{ label }
, - ) } -
+ options={options} + selectedLabel={_t('Show')} + />
{ !!sortedDevices.length ? diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ae602ac216..1e0450ae60 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1734,6 +1734,7 @@ "Inactive": "Inactive", "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", "Filter devices": "Filter devices", + "Show": "Show", "Security recommendations": "Security recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "View all": "View all", diff --git a/test/components/views/elements/FilterDropdown-test.tsx b/test/components/views/elements/FilterDropdown-test.tsx new file mode 100644 index 0000000000..3c30e70ac1 --- /dev/null +++ b/test/components/views/elements/FilterDropdown-test.tsx @@ -0,0 +1,68 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +import { FilterDropdown } from '../../../../src/components/views/elements/FilterDropdown'; +import { flushPromises, mockPlatformPeg } from '../../../test-utils'; + +mockPlatformPeg(); + +describe('', () => { + const options = [ + { id: 'one', label: 'Option one' }, + { id: 'two', label: 'Option two', description: 'with description' }, + ]; + const defaultProps = { + className: 'test', + value: 'one', + options, + id: 'test', + label: 'test label', + onOptionChange: jest.fn(), + }; + const getComponent = (props = {}): JSX.Element => + (); + + const openDropdown = async (container: HTMLElement): Promise => await act(async () => { + const button = container.querySelector('[role="button"]'); + expect(button).toBeTruthy(); + fireEvent.click(button as Element); + await flushPromises(); + }); + + it('renders selected option', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('renders when selected option is not in options', () => { + const { container } = render(getComponent({ value: 'oops' })); + expect(container).toMatchSnapshot(); + }); + + it('renders selected option with selectedLabel', () => { + const { container } = render(getComponent({ selectedLabel: 'Show' })); + expect(container).toMatchSnapshot(); + }); + + it('renders dropdown options in menu', async () => { + const { container } = render(getComponent()); + await openDropdown(container); + expect(container.querySelector('.mx_Dropdown_menu')).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap b/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap new file mode 100644 index 0000000000..627c92f3eb --- /dev/null +++ b/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders dropdown options in menu 1`] = ` +
+
+
+
+ + Option one + +
+
+
+
+ + Option two + + + with description + +
+
+
+`; + +exports[` renders selected option 1`] = ` +
+
+ +
+
+`; + +exports[` renders selected option with selectedLabel 1`] = ` +
+
+ +
+
+`; + +exports[` renders when selected option is not in options 1`] = ` +
+
+ +
+`; diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx index ecfbc0489d..68ef60e76b 100644 --- a/test/components/views/settings/devices/FilteredDeviceList-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -94,11 +94,11 @@ describe('', () => { ) => await act(async () => { const dropdown = container.querySelector('[aria-label="Filter devices"]'); - fireEvent.click(dropdown); + fireEvent.click(dropdown as Element); // tick to let dropdown render await flushPromises(); - fireEvent.click(container.querySelector(`#device-list-filter__${option}`)); + fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element); }); it('does not display filter description when filter is falsy', () => { @@ -198,7 +198,7 @@ describe('', () => { act(() => { const tile = getByTestId(`device-tile-${hundredDaysOld.device_id}`); const toggle = tile.querySelector('[aria-label="Toggle device details"]'); - fireEvent.click(toggle); + fireEvent.click(toggle as Element); }); expect(onDeviceExpandToggle).toHaveBeenCalledWith(hundredDaysOld.device_id);