Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AlphaPicker #11728

Merged
merged 16 commits into from
Mar 27, 2024
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();
sophschneider marked this conversation as resolved.
Show resolved Hide resolved
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an error in the console that only happens when I first open the Picker (and doesn't happen again after) but I'm having a hard time following the stack trace to pinpoint the exact error 🤔
error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've noticed this as well and in testing. It's from calling focus on a ref and I haven't figure out a way to get by it. Using useEffect causing scroll to top issues and same with autoFocus. Might need your help jamming on this one

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to debug this a bit locally. When I remove the textFieldFocus and setTextFieldFocus wherever they're defined/used in Picker, the error never shows and SearchField behavior still remained the same. The issue with that is we lose keyboard nav on the Listbox. But if I made the textFieldFocused context on Listbox tied to popoverActive, keyboard nav works as expected.

Since focused on SearchField is always set to true, I think this would work but also very happy to have a jam session to dive deeper and verify if there's any ramifications to this. I'm not 100% sure what the scroll to top issues you were having were but testing locally, I can still scroll on the overall DetailsPage regardless of whether Picker is open or not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should work! Thanks Lo!

{...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
Loading