diff --git a/.changeset/curly-chairs-speak.md b/.changeset/curly-chairs-speak.md new file mode 100644 index 00000000000..9d0858b1a78 --- /dev/null +++ b/.changeset/curly-chairs-speak.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Add a search field to filter ActionList that have more than 10 items diff --git a/polaris-react/locales/de.json b/polaris-react/locales/de.json index 97576fc65e1..3c851469765 100644 --- a/polaris-react/locales/de.json +++ b/polaris-react/locales/de.json @@ -391,6 +391,13 @@ "cancel": "Abbrechen" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Löschen", + "search": "Suchen", + "placeholder": "Aktionen durchsuchen" + } } } } diff --git a/polaris-react/locales/en.json b/polaris-react/locales/en.json index 75e21e7060b..1407ae0b7ed 100644 --- a/polaris-react/locales/en.json +++ b/polaris-react/locales/en.json @@ -8,6 +8,13 @@ "rollupButton": "View actions" } }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Clear", + "search": "Search", + "placeholder": "Search actions" + } + }, "Avatar": { "label": "Avatar", "labelWithInitials": "Avatar with initials {initials}" diff --git a/polaris-react/locales/es.json b/polaris-react/locales/es.json index e972c9a8567..f935bee6394 100644 --- a/polaris-react/locales/es.json +++ b/polaris-react/locales/es.json @@ -392,6 +392,13 @@ "cancel": "Cancelar" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Borrar", + "search": "Búsqueda", + "placeholder": "Buscar acciones" + } } } } diff --git a/polaris-react/locales/nb.json b/polaris-react/locales/nb.json index 5e34e39ab4e..2aeb7202b08 100644 --- a/polaris-react/locales/nb.json +++ b/polaris-react/locales/nb.json @@ -391,6 +391,13 @@ "cancel": "Avbryt" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Tøm", + "search": "Søk", + "placeholder": "Søk i handlinger" + } } } } diff --git a/polaris-react/locales/pl.json b/polaris-react/locales/pl.json index b62aad8ae65..606ad1b00a0 100644 --- a/polaris-react/locales/pl.json +++ b/polaris-react/locales/pl.json @@ -393,6 +393,13 @@ "cancel": "Anuluj" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Wyczyść", + "search": "Szukaj", + "placeholder": "Szukaj czynności" + } } } } diff --git a/polaris-react/locales/pt-BR.json b/polaris-react/locales/pt-BR.json index 0fa87854f96..663a1d02c5e 100644 --- a/polaris-react/locales/pt-BR.json +++ b/polaris-react/locales/pt-BR.json @@ -392,6 +392,13 @@ "cancel": "Cancelar" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Limpar", + "search": "Pesquisar", + "placeholder": "Pesquisar ações" + } } } } diff --git a/polaris-react/locales/pt-PT.json b/polaris-react/locales/pt-PT.json index a8f2caf1efb..0217e2b3843 100644 --- a/polaris-react/locales/pt-PT.json +++ b/polaris-react/locales/pt-PT.json @@ -392,6 +392,13 @@ "cancel": "Cancelar" } } + }, + "ActionList": { + "SearchField": { + "clearButtonLabel": "Limpar", + "search": "Pesquisar", + "placeholder": "Pesquisar ações" + } } } } diff --git a/polaris-react/playground/DetailsPage.tsx b/polaris-react/playground/DetailsPage.tsx index 46f54b024b4..a310fc2425b 100644 --- a/polaris-react/playground/DetailsPage.tsx +++ b/polaris-react/playground/DetailsPage.tsx @@ -580,7 +580,6 @@ export function DetailsPage() { actions: [ { content: 'Embed on a website', - onAction: () => console.log('embed'), }, { diff --git a/polaris-react/src/components/ActionList/ActionList.tsx b/polaris-react/src/components/ActionList/ActionList.tsx index 92ec54bdafc..07286654dd2 100644 --- a/polaris-react/src/components/ActionList/ActionList.tsx +++ b/polaris-react/src/components/ActionList/ActionList.tsx @@ -1,15 +1,16 @@ -import React, {useRef} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; +import type {ActionListItemDescriptor, ActionListSection} from '../../types'; +import {Key} from '../../types'; import { wrapFocusNextFocusableMenuItem, wrapFocusPreviousFocusableMenuItem, } from '../../utilities/focus'; -import {KeypressListener} from '../KeypressListener'; -import {Key} from '../../types'; -import type {ActionListItemDescriptor, ActionListSection} from '../../types'; import {Box} from '../Box'; +import {KeypressListener} from '../KeypressListener'; +import {useI18n} from '../../utilities/i18n'; -import {Section, Item} from './components'; +import {SearchField, Item, Section} from './components'; import type {ItemProps} from './components'; export interface ActionListProps { @@ -31,8 +32,11 @@ export function ActionList({ actionRole, onActionAnyItem, }: ActionListProps) { + const i18n = useI18n(); + let finalSections: readonly ActionListSection[] = []; const actionListRef = useRef(null); + const [searchText, setSeachText] = useState(''); if (items) { finalSections = [{items}, ...sections]; @@ -46,7 +50,14 @@ export function ActionList({ const elementTabIndex = hasMultipleSections && actionRole === 'menuitem' ? -1 : undefined; - const sectionMarkup = finalSections.map((section, index) => { + const filteredSections = finalSections?.map((section) => ({ + ...section, + items: section.items.filter((item) => + item.content?.toLowerCase().includes(searchText.toLowerCase()), + ), + })); + + const sectionMarkup = filteredSections.map((section, index) => { return section.items.length > 0 ? (
) : null; + const totalActions = + finalSections?.reduce( + (acc: number, section) => acc + section.items.length, + 0, + ) || 0; + + const totalFilteredActions = useMemo(() => { + const totalSectionItems = + filteredSections?.reduce( + (acc: number, section) => acc + section.items.length, + 0, + ) || 0; + + return totalSectionItems; + }, [filteredSections]); + + const showSearch = totalActions >= 8; + return ( - - {listeners} - {sectionMarkup} - + <> + {showSearch && ( + 0 ? '0' : '2'}> + setSeachText(value)} + /> + + )} + + {listeners} + {sectionMarkup} + + ); } diff --git a/polaris-react/src/components/ActionList/components/SearchField/SearchField.scss b/polaris-react/src/components/ActionList/components/SearchField/SearchField.scss new file mode 100644 index 00000000000..e5aa3d7158f --- /dev/null +++ b/polaris-react/src/components/ActionList/components/SearchField/SearchField.scss @@ -0,0 +1,236 @@ +@import '../../../../styles/common'; + +$icon-size: 20px; +$new-input-height: 36px; +$search-icon-width: calc(#{$icon-size} + var(--p-space-4)); + +$icon-size-se23: 18px; +$new-input-height-se23: 32px; +$search-icon-width-se23: calc(#{$icon-size-se23} + var(--p-space-3)); + +.SearchField { + // stylelint-disable -- Polaris component custom properties + --pc-search-field-backdrop: 1; + --pc-search-field-input: 2; + --pc-search-field-icon: 3; + --pc-search-field-action: 3; + // stylelint-enable + z-index: var(--p-z-index-11); + position: relative; + display: flex; + flex: 1 1 auto; + align-items: center; + border: var(--p-border-width-1) solid transparent; + width: 100%; +} +// We have both a focused class and a focus pseudo selector here +// because we allow "faked" focus for when the search is still +// active, but is not actually the focused element in the DOM +// (for example, while selecting a value from a filter in the +// search this input controls) +.focused .Input, +.Input:focus { + border: none; + color: var(--p-color-text); + + #{$se23} & { + color: var(--p-color-text-inverse); + } + + &::placeholder { + color: var(--p-color-text-subdued); + + #{$se23} & { + color: var(--p-color-text-inverse-subdued); + } + } +} + +.Input:focus-visible { + ~ .Backdrop { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + @include focus-ring($style: 'focused'); + } + + ~ .BackdropShowFocusBorder { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + border: var(--p-border-width-1) solid var(--pc-top-bar-border); + } + + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + ~ .Icon svg { + fill: var(--p-color-icon); + + #{$se23} & { + fill: var(--p-color-icon-subdued); + } + } +} + +.Input:focus-visible:not(:active) { + #{$se23} & { + // stylelint-disable-next-line -- se23 + ~ .Backdrop { + outline: var(--p-border-width-2) solid + var(--p-color-border-interactive-focus); + outline-offset: var(--p-space-05); + } + + // stylelint-disable-next-line -- se23 + ~ .Icon svg { + fill: var(--p-color-icon-subdued); + } + } +} + +.Input { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + @include text-style-input; + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + z-index: var(--pc-search-field-input); + height: $new-input-height; + width: 100%; + padding: 0 0 0 $search-icon-width; + border: none; + background-color: transparent; + outline: none; + color: var(--p-color-text); + will-change: fill, color; + transition: fill var(--p-motion-duration-200) var(--p-motion-ease), + color var(--p-motion-duration-200) var(--p-motion-ease); + + #{$se23} & { + padding: 0 $search-icon-width-se23 0 $search-icon-width-se23; + color: var(--p-color-text-inverse-subdued); + height: $new-input-height-se23; + border: var(--p-border-width-1-experimental) solid + var(--p-color-border-inverse); + border-radius: var(--p-border-radius-2); + + &:hover { + border-color: var(--p-color-border-hover); + } + + &:active, + &:focus { + box-shadow: inset 0 0 0 var(--p-border-width-1) var(--p-color-border); + } + } + + &::placeholder { + color: var(--p-color-text); + + #{$se23} & { + color: var(--p-color-text-inverse-subdued); + } + } + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button { + appearance: none; + } +} + +.Icon { + position: absolute; + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + z-index: var(--pc-search-field-icon); + top: 50%; + left: var(--p-space-2); + display: flex; + height: $icon-size; + pointer-events: none; + transform: translateY(-50%); + + #{$se23} & { + height: $icon-size-se23; + width: $icon-size-se23; + } + + svg { + fill: var(--p-color-icon); + + #{$se23} & { + fill: var(--p-color-icon-subdued); + } + } +} + +.Clear { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + @include focus-ring($size: 'wide'); + position: relative; + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + z-index: var(--pc-search-field-action); + border: none; + appearance: none; + background: transparent; + padding: var(--p-space-2); + + #{$se23} & { + position: absolute; + right: var(--p-space-1); + padding: var(--p-space-1); + } + + svg { + fill: var(--p-color-icon); + + #{$se23} & { + fill: var(--p-color-icon-subdued); + } + } + + &:focus, + &:hover { + outline: none; + } + + &:hover svg, + &:focus svg { + fill: var(--p-color-icon-hover); + + #{$se23} & { + fill: var(--p-color-icon-inverse); + } + } + + &:focus-visible { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + @include focus-ring($size: 'wide', $style: 'focused'); + } + + &:active { + svg { + fill: var(--p-color-icon-active); + } + + &::after { + border: none; + } + } +} + +.Backdrop { + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + @include focus-ring($border-width: 1px); + position: absolute; + // stylelint-disable-next-line -- generated by polaris-migrator DO NOT COPY + z-index: var(--pc-text-field-backdrop); + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: var(--p-color-bg); + border: var(--p-border-width-1) solid var(--p-color-border-input); + // stylelint-disable-next-line -- hard coded to address accessbility issue https://github.com/Shopify/polaris/issues/7838 + border-top-color: #898f94; + border-radius: var(--p-border-radius-1); + pointer-events: none; + + #{$se23} & { + border-radius: var(--p-border-radius-2); + border-width: var(--p-border-width-1-experimental); + background-color: var(--p-color-bg-input); + } +} diff --git a/polaris-react/src/components/ActionList/components/SearchField/SearchField.tsx b/polaris-react/src/components/ActionList/components/SearchField/SearchField.tsx new file mode 100644 index 00000000000..ae6b85e56cd --- /dev/null +++ b/polaris-react/src/components/ActionList/components/SearchField/SearchField.tsx @@ -0,0 +1,101 @@ +import {CircleCancelMinor, SearchMinor} from '@shopify/polaris-icons'; +import React, {useCallback, useId, useRef} from 'react'; + +import {useI18n} from '../../../../utilities/i18n'; +import {Icon} from '../../../Icon'; +import {Text} from '../../../Text'; + +import styles from './SearchField.scss'; + +export interface SearchFieldProps { + /** Initial value for the input */ + value: string; + /** Hint text to display */ + placeholder?: string; + /** Callback when value is changed */ + onChange(value: string): void; +} + +export function SearchField({value, placeholder, onChange}: SearchFieldProps) { + const i18n = useI18n(); + + const input = useRef(null); + const searchId = useId(); + + const handleChange = useCallback( + ({currentTarget}: React.ChangeEvent) => { + onChange(currentTarget.value); + }, + [onChange], + ); + + const handleClear = useCallback(() => { + if (!input.current) { + return; + } + + input.current.value = ''; + onChange(''); + input.current.focus(); + }, [onChange]); + + const clearMarkup = value !== '' && ( + + ); + + const handleRef = (ref: HTMLInputElement) => { + input.current = ref; + + // It won't focus if it's on the same tick as when it renders + setTimeout(() => { + ref?.focus(); + }); + }; + + return ( +
+ + + + + + + + + {clearMarkup} +
+
+ ); +} + +function preventDefault(event: React.KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault(); + } +} diff --git a/polaris-react/src/components/ActionList/components/SearchField/index.ts b/polaris-react/src/components/ActionList/components/SearchField/index.ts new file mode 100644 index 00000000000..9f5ef334723 --- /dev/null +++ b/polaris-react/src/components/ActionList/components/SearchField/index.ts @@ -0,0 +1 @@ +export * from './SearchField'; diff --git a/polaris-react/src/components/ActionList/components/SearchField/tests/SearchField.test.tsx b/polaris-react/src/components/ActionList/components/SearchField/tests/SearchField.test.tsx new file mode 100644 index 00000000000..a9142825ed2 --- /dev/null +++ b/polaris-react/src/components/ActionList/components/SearchField/tests/SearchField.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import {CircleCancelMinor} from '@shopify/polaris-icons'; +import {mountWithApp} from 'tests/utilities'; + +import {Icon} from '../../../../Icon'; +import {SearchField} from '../SearchField'; + +describe('', () => { + it('passes the placeholder prop to input', () => { + const textField = mountWithApp( + , + ); + + expect(textField).toContainReactComponent('input', { + placeholder: 'hello polaris', + }); + }); + + describe('clear content', () => { + it('will render a cancel icon when a value is provided', () => { + const textField = mountWithApp( + , + ); + + expect(textField).toContainReactComponent(Icon, { + source: CircleCancelMinor, + }); + }); + + it('will call the onChange with an empty string when the cancel button is pressed', () => { + const spy = jest.fn(); + const textField = mountWithApp( + , + ); + + textField.find('button')!.trigger('onClick'); + + expect(spy).toHaveBeenCalledWith(''); + }); + }); + + describe('onChange()', () => { + it('is called with the new value', () => { + const spy = jest.fn(); + const newValue = 'hello polaris'; + const textField = mountWithApp( + , + ); + + textField.find('input')!.trigger('onChange', { + currentTarget: { + value: newValue, + }, + }); + + expect(spy).toHaveBeenCalledWith(newValue); + }); + }); + + describe('onKeyDown', () => { + it("will prevent default on the 'enter' keydown", () => { + const spy = jest.fn(); + const textField = mountWithApp( + , + ); + + textField.find('input')!.trigger('onKeyDown', { + key: 'Enter', + preventDefault: spy, + }); + expect(spy).toHaveBeenCalled(); + }); + }); +}); + +function noop() {} diff --git a/polaris-react/src/components/ActionList/components/index.ts b/polaris-react/src/components/ActionList/components/index.ts index 1f6e50145f3..edefe0dc331 100644 --- a/polaris-react/src/components/ActionList/components/index.ts +++ b/polaris-react/src/components/ActionList/components/index.ts @@ -1,3 +1,5 @@ export * from './Item'; export * from './Section'; + +export * from './SearchField'; diff --git a/polaris-react/src/components/ActionList/tests/ActionList.test.tsx b/polaris-react/src/components/ActionList/tests/ActionList.test.tsx index 14ed6e9cab5..ed10230353e 100644 --- a/polaris-react/src/components/ActionList/tests/ActionList.test.tsx +++ b/polaris-react/src/components/ActionList/tests/ActionList.test.tsx @@ -4,7 +4,7 @@ import {mountWithApp} from 'tests/utilities'; import {ActionList} from '../ActionList'; import {Badge} from '../../Badge'; -import {Item, Section} from '../components'; +import {Item, SearchField, Section} from '../components'; import {Key} from '../../../types'; import {KeypressListener} from '../../KeypressListener'; @@ -236,4 +236,108 @@ describe('', () => { actionListButtons[actionListButtons.length - 1].domNode, ); }); + + it('does not render search with 7 or less items', () => { + const actionList = mountWithApp( + , + ); + + expect(actionList).not.toContainReactComponentTimes(SearchField, 1); + }); + + it('renders search with 8 or more items', () => { + const actionList = mountWithApp( + , + ); + + expect(actionList).toContainReactComponentTimes(SearchField, 1); + }); + + it('renders search with 10 or more items or section items', () => { + const actionList = mountWithApp( + , + ); + + expect(actionList).toContainReactComponentTimes(SearchField, 1); + }); + + it('filters items and section items with case-insensitive search', () => { + const actionList = mountWithApp( + , + ); + + const textField = actionList.find('input'); + textField!.trigger('onChange', { + currentTarget: { + value: 'item 1', + }, + }); + + expect(actionList).toContainReactComponentTimes(Item, 2); + // First Section will have no title since items without a section are grouped into a Section automatically + expect(actionList.findAll(Section)[1]).toContainReactText('Section 2'); + }); });