Skip to content

Commit

Permalink
Add AlphaPicker (Shopify#11728)
Browse files Browse the repository at this point in the history
### WHY are these changes introduced?

Closes: https://github.com/Shopify/polaris-internal/issues/1510

<!--
  Context about the problem that’s being addressed.
-->

### WHAT is this pull request doing?

Introduces a new picker, undocumented and prefixed with Alpha until it's
better tested in the admin.
Only single select / filtering needs to be working for the first release

<!--
  Summary of the changes committed.

Before / after screenshots are appreciated for UI changes. Make sure to
include alt text that describes the screenshot.

  Include a video if your changes include interactive content.

If you include an animated gif showing your change, wrapping it in a
details tag is recommended. Gifs usually autoplay, which can cause
accessibility issues for people reviewing your PR:

  <details>
    <summary>Summary of your gif(s)</summary>
    <img src="..." alt="Description of what the gif shows">
  </details>
-->

### How to 🎩

Change / add vendors
[here](https://admin.web.web-17ze.kyle-durand.us.spin.dev/store/shop1/products/5)

### 🎩 checklist

- [x] Tested a
[snapshot](https://github.com/Shopify/polaris/blob/main/documentation/Releasing.md#-snapshot-releases)
- [ ] Tested on
[mobile](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting.md#cross-browser-testing)
- [x] Tested on [multiple
browsers](https://help.shopify.com/en/manual/shopify-admin/supported-browsers)
- [x] Tested for
[accessibility](https://github.com/Shopify/polaris/blob/main/documentation/Accessibility%20testing.md)
- [ ] Updated the component's `README.md` with documentation changes
- [ ] [Tophatted
documentation](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting%20documentation.md)
changes in the style guide
  • Loading branch information
kyledurand authored Mar 27, 2024
1 parent a6f4b9d commit cf25021
Show file tree
Hide file tree
Showing 20 changed files with 695 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-wolves-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': minor
---

Added new AlphaPicker component
39 changes: 34 additions & 5 deletions polaris-react/playground/DetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
TopBar,
FooterHelp,
Link,
AlphaPicker,
} from '../src';
import type {DropZoneProps, PageProps} from '../src';

Expand All @@ -54,6 +55,11 @@ export function DetailsPage() {
emailFieldValue: '[email protected]',
nameFieldValue: 'Jaded Pixel',
});
const [query, setQuery] = useState('');
const [vendors, setVendors] = useState([
{value: 'The North Face', children: 'The North Face'},
{value: 'Patagonia', children: 'Patagonia'},
]);
const skipToContentRef = useRef<HTMLAnchorElement>(null);
const [toastActive, setToastActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -79,6 +85,16 @@ export function DetailsPage() {
const [supportSubject, setSupportSubject] = useState('');
const [supportMessage, setSupportMessage] = useState('');

const handleSelect = (selected: string) => {
setQuery('');
if (vendors.some((vendor) => vendor.children === selected)) return;

setVendors((vendors) => [
...vendors,
{value: selected, children: selected},
]);
};

const handleDiscard = useCallback(() => {
setEmailFieldValue(defaultState.current.emailFieldValue);
setNameFieldValue(defaultState.current.nameFieldValue);
Expand Down Expand Up @@ -635,11 +651,24 @@ export function DetailsPage() {
value={selected}
/>
<br />
<Select
label="Vendor"
options={options}
onChange={setSelected}
value={selected}
<AlphaPicker
onSelect={handleSelect}
activator={{
label: 'Vendor',
placeholder: 'None selected',
}}
searchField={{
label: 'Search vendors',
placeholder: 'Search or add new vendor',
autoComplete: 'off',
value: query,
onChange: (value) => setQuery(value),
}}
options={vendors}
addAction={{
value: query,
children: `Add ${query}`,
}}
/>
</LegacyCard.Section>
<LegacyCard.Section title="Collections" />
Expand Down
2 changes: 1 addition & 1 deletion polaris-react/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface ComboboxProps {
willLoadMoreOptions?: boolean;
/** Height to set on the Popover Pane. */
height?: string;
/** Callback fired when the bottom of the lisbox is reached. Use to lazy load when listbox option data is paginated. */
/** Callback fired when the bottom of the listbox is reached. Use to lazy load when listbox option data is paginated. */
onScrolledToBottom?(): void;
/** Callback fired when the popover closes */
onClose?(): void;
Expand Down
3 changes: 1 addition & 2 deletions polaris-react/src/components/Listbox/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export {Option} from './Option';
export type {OptionProps} from './Option';
export * from './Option';
export {TextOption} from './TextOption';
export {Loading} from './Loading';
export * from './Section';
Expand Down
1 change: 1 addition & 0 deletions polaris-react/src/components/Listbox/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './Listbox';
export * from './components';
258 changes: 258 additions & 0 deletions polaris-react/src/components/Picker/Picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import React, {useState, useMemo, useCallback, isValidElement} from 'react';
import {SearchIcon} from '@shopify/polaris-icons';

import {Popover} from '../Popover';
import {
ComboboxTextFieldContext,
ComboboxListboxContext,
ComboboxListboxOptionContext,
} from '../../utilities/combobox';
import type {
ComboboxTextFieldType,
ComboboxListboxType,
ComboboxListboxOptionType,
} from '../../utilities/combobox';
import {Box} from '../Box';
import type {TextFieldProps} from '../TextField';
import type {ListboxProps, OptionProps} from '../Listbox';
import {Listbox} from '../Listbox';
import type {IconProps} from '../Icon';
import {Icon} from '../Icon';

import {Activator, SearchField} from './components';
import type {ActivatorProps} from './components';

export interface PickerProps extends Omit<ListboxProps, 'children'> {
/** Configure the button that activates the Picker */
activator: ActivatorProps;
/** Allows more than one option to be selected */
allowMultiple?: boolean;
/** The options to be listed within the picker */
options?: OptionProps[];
/** Used to add a new picker option that isn't listed */
addAction?: OptionProps & {icon?: IconProps['source']};
/** TextField that allows filtering of options */
searchField?: TextFieldProps;
/** Whether or not more options are available to lazy load when the bottom of the listbox reached. Use the hasMoreResults boolean provided by the GraphQL API of the paginated data. */
willLoadMoreOptions?: boolean;
/** Height to set on the Popover Pane. */
height?: string;
/** Callback fired when the bottom of the listbox is reached. Use to lazy load when listbox option data is paginated. */
onScrolledToBottom?(): void;
/** Callback fired when the popover closes */
onClose?(): void;
}

const FILTER_REGEX = (value: string) => new RegExp(value, 'i');
const QUERY_REGEX = (value: string) => new RegExp(`^${value}$`, 'i');

export function Picker({
activator,
allowMultiple,
searchField,
options = [],
willLoadMoreOptions,
height,
addAction,
onScrolledToBottom,
onClose,
...listboxProps
}: PickerProps) {
const activatorRef = React.createRef<HTMLButtonElement>();
const [activeItem, setActiveItem] = useState<string>();
const [popoverActive, setPopoverActive] = useState(false);
const [activeOptionId, setActiveOptionId] = useState<string>();
const [textFieldLabelId, setTextFieldLabelId] = useState<string>();
const [listboxId, setListboxId] = useState<string>();
const [query, setQuery] = useState<string>('');
const [filteredOptions, setFilteredOptions] = useState<OptionProps[] | null>(
options,
);

const shouldOpen = !popoverActive;
const handleClose = useCallback(() => {
setPopoverActive(false);
onClose?.();
activatorRef.current?.focus();
}, [activatorRef, onClose]);

const handleOpen = useCallback(() => {
setPopoverActive(true);
}, []);

const handleFocus = useCallback(() => {
if (shouldOpen) handleOpen();
}, [shouldOpen, handleOpen]);

const handleChange = useCallback(() => {
if (shouldOpen) handleOpen();
}, [shouldOpen, handleOpen]);

const handleBlur = useCallback(() => {
if (popoverActive) {
handleClose();
setQuery('');
setFilteredOptions(options);
}
}, [popoverActive, handleClose, options]);

const textFieldContextValue: ComboboxTextFieldType = useMemo(
() => ({
activeOptionId,
expanded: popoverActive,
listboxId,
setTextFieldLabelId,
onTextFieldFocus: handleFocus,
onTextFieldChange: handleChange,
onTextFieldBlur: handleBlur,
}),
[
activeOptionId,
popoverActive,
listboxId,
setTextFieldLabelId,
handleFocus,
handleChange,
handleBlur,
],
);

const listboxOptionContextValue: ComboboxListboxOptionType = useMemo(
() => ({
allowMultiple,
}),
[allowMultiple],
);

const listboxContextValue: ComboboxListboxType = useMemo(
() => ({
listboxId,
textFieldLabelId,
textFieldFocused: popoverActive,
willLoadMoreOptions,
setActiveOptionId,
setListboxId,
onKeyToBottom: onScrolledToBottom,
}),
[
listboxId,
textFieldLabelId,
popoverActive,
willLoadMoreOptions,
setActiveOptionId,
setListboxId,
onScrolledToBottom,
],
);

const updateText = useCallback(
(value: string) => {
setQuery(value);

if (value === '') {
setFilteredOptions(options);
return;
}

const resultOptions = options?.filter((option) =>
FILTER_REGEX(value).exec(reactChildrenText(option.children)),
);
setFilteredOptions(resultOptions ?? []);
},
[options],
);

const firstSelectedOption = reactChildrenText(
options.find((option) => option.value === activeItem)?.children,
);

const queryMatchesExistingOption = options.some((option) =>
QUERY_REGEX(query).exec(reactChildrenText(option.children)),
);

return (
<Popover
activator={
<Activator
{...activator}
onClick={handleOpen}
selected={firstSelectedOption || ''}
placeholder={activator.placeholder}
ref={activatorRef}
/>
}
active={popoverActive}
autofocusTarget="none"
onClose={handleClose}
preferredPosition="cover"
preventFocusOnClose
>
<Popover.Pane onScrolledToBottom={onScrolledToBottom} height={height}>
{searchField ? (
<Box
paddingBlockStart="200"
paddingBlockEnd="100"
paddingInline="200"
borderBlockEndWidth="025"
borderColor="border"
>
<ComboboxTextFieldContext.Provider value={textFieldContextValue}>
<SearchField
{...searchField}
value={query}
onChange={(value) => {
updateText(value);
searchField.onChange?.(value, searchField.id ?? '');
}}
prefix={<Icon source={SearchIcon} />}
labelHidden
focused={popoverActive}
/>
</ComboboxTextFieldContext.Provider>
</Box>
) : null}

<ComboboxListboxContext.Provider value={listboxContextValue}>
<ComboboxListboxOptionContext.Provider
value={listboxOptionContextValue}
>
<Box paddingBlock="200">
<Listbox
{...listboxProps}
onSelect={(selected: string) => {
setQuery('');
updateText('');
setActiveItem(selected);
listboxProps.onSelect?.(selected);

if (!allowMultiple) {
handleClose();
}
}}
>
{filteredOptions?.map((option) => (
<Listbox.Option
key={option.value}
selected={option.value === activeItem}
{...option}
/>
))}
{addAction && query !== '' && !queryMatchesExistingOption ? (
<Listbox.Action {...addAction} value={query} />
) : null}
</Listbox>
</Box>
</ComboboxListboxOptionContext.Provider>
</ComboboxListboxContext.Provider>
</Popover.Pane>
</Popover>
);
}

const reactChildrenText = (children: React.ReactNode): string => {
if (typeof children === 'string') return children;

return isValidElement(children)
? reactChildrenText(children?.props?.children)
: '';
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.Activator {
background: none;
outline: none;
padding: var(--p-space-200) var(--p-space-300);
border-radius: var(--p-border-radius-200);
border: var(--p-border-width-025) solid var(--p-color-border);
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;

&:hover {
background-color: var(--p-color-bg-surface-hover);
}

&:active {
background-color: var(--p-color-bg-surface-active);
}

&:focus:not(:active) {
outline: var(--p-border-width-050) solid var(--p-color-border-focus);
outline-offset: var(--p-space-025);
}
}

.disabled {
pointer-events: none;
background-color: var(--p-color-bg-surface-disabled);
border-color: var(--p-color-border-disabled);
}
Loading

0 comments on commit cf25021

Please sign in to comment.