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
37 changes: 36 additions & 1 deletion 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,10 @@ export function DetailsPage() {
emailFieldValue: '[email protected]',
nameFieldValue: 'Jaded Pixel',
});
const [query, setQuery] = useState('');
const [vendors, setVendors] = useState([
{value: 'Burberry', children: 'Burberry'},
]);
const skipToContentRef = useRef<HTMLAnchorElement>(null);
const [toastActive, setToastActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -65,7 +70,7 @@ export function DetailsPage() {
const [modalActive, setModalActive] = useState(false);
const [navItemActive, setNavItemActive] = useState('products');
const initialDescription =
'The M60-A represents the benchmark and equilibrium between function and design for us at Rama Works. The gently exaggerated design of the frame is not understated, but rather provocative. Inspiration and evolution from previous models are evident in the beautifully articulated design and the well defined aesthetic, the fingerprint of our ‘Industrial Modern’ designs.';
'The M60-A represents the benchmark and equilibrium between function and design for us at Rama Works. The gently exaggerated design of the frame is not understated, but rather provocative. Inspiration and evolution from previous models are evident in the beautifully articulated design and the well defined aesthetic, the fingerprint of our ‘Industrial Modern’ designs. The M60-A represents the benchmark and equilibrium between function and design for us at Rama Works. The gently exaggerated design of the frame is not understated, but rather provocative. Inspiration and evolution from previous models are evasdfasdfident in the beautifully articulated design and the well defined aesthetic, the fingerprint of our ‘Industrial Modern’ designs.';
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need a longer description or is this leftover from testing?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just testing, I'll revert

const [previewValue, setPreviewValue] = useState(initialDescription);
const [nameFieldValue, setNameFieldValue] = useState(
defaultState.current.nameFieldValue,
Expand All @@ -79,6 +84,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 @@ -641,6 +656,26 @@ export function DetailsPage() {
onChange={setSelected}
value={selected}
/>
<br />
<AlphaPicker
onSelect={handleSelect}
activator={{
label: 'Tags',
placeholder: 'Search tags',
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be for vendors?

                  label: 'Vendors',
                  placeholder: 'Search vendors',

}}
searchField={{
label: 'Search vendors',
placeholder: 'Search vendors',
autoComplete: 'off',
value: query,
onChange: (value) => setQuery(value),
}}
options={vendors}
addAction={{
value: query,
children: `Add ${query}`,
}}
/>
</LegacyCard.Section>
<LegacyCard.Section title="Collections" />
<LegacyCard.Section title="Tags" />
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';
263 changes: 263 additions & 0 deletions polaris-react/src/components/Picker/Picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import React, {useState, useCallback, useMemo, 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 */
kyledurand marked this conversation as resolved.
Show resolved Hide resolved
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 [query, setQuery] = useState<string>('');
const [filteredOptions, setFilteredOptions] = useState<OptionProps[] | null>(
options,
);
const [popoverActive, setPopoverActive] = useState(false);
const [activeOptionId, setActiveOptionId] = useState<string>();
const [activeItem, setActiveItem] = useState<string>();
const [textFieldLabelId, setTextFieldLabelId] = useState<string>();
const [listboxId, setListboxId] = useState<string>();
const [textFieldFocused, setTextFieldFocused] = useState<boolean>(false);
const shouldOpen = !popoverActive;

const handleClose = useCallback(() => {
setPopoverActive(false);
onClose?.();
}, [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,
setTextFieldFocused,
setTextFieldLabelId,
onTextFieldFocus: handleFocus,
onTextFieldChange: handleChange,
onTextFieldBlur: handleBlur,
}),
[
activeOptionId,
popoverActive,
listboxId,
setTextFieldFocused,
setTextFieldLabelId,
handleFocus,
handleChange,
handleBlur,
],
);

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

const listboxContextValue: ComboboxListboxType = useMemo(
() => ({
listboxId,
textFieldLabelId,
textFieldFocused,
willLoadMoreOptions,
setActiveOptionId,
setListboxId,
onKeyToBottom: onScrolledToBottom,
}),
[
listboxId,
textFieldLabelId,
textFieldFocused,
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 firstSelectedLabel = firstSelectedOption
? firstSelectedOption?.toString()
: activator.placeholder;

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

return (
<Popover
active={popoverActive}
activator={
<Activator
{...activator}
onClick={handleOpen}
placeholder={firstSelectedLabel}
/>
}
autofocusTarget="none"
preventFocusOnClose
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we focus the activator on close? right now focus is returning to the top of the page

Copy link
Member Author

Choose a reason for hiding this comment

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

Excellent catch. I fully expected this to just work but there was some conflicting focus management going on. Fixed now 👍

fullWidth
preferInputActivator={false}
preferredPosition="cover"
onClose={handleClose}
>
<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
/>
</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