-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Add AlphaPicker #11728
Changes from 13 commits
ded69ba
42cc3e5
9c950ff
c7157e5
921a86c
488916b
8a8dbe9
b1fd35d
e6efbc7
ce91990
f0e1636
9d79ef1
927c0a0
e34e33e
c0d1f53
d4f6b65
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@shopify/polaris': minor | ||
--- | ||
|
||
Added new AlphaPicker component |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,6 +44,7 @@ import { | |
TopBar, | ||
FooterHelp, | ||
Link, | ||
AlphaPicker, | ||
} from '../src'; | ||
import type {DropZoneProps, PageProps} from '../src'; | ||
|
||
|
@@ -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); | ||
|
@@ -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.'; | ||
const [previewValue, setPreviewValue] = useState(initialDescription); | ||
const [nameFieldValue, setNameFieldValue] = useState( | ||
defaultState.current.nameFieldValue, | ||
|
@@ -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); | ||
|
@@ -641,6 +656,26 @@ export function DetailsPage() { | |
onChange={setSelected} | ||
value={selected} | ||
/> | ||
<br /> | ||
<AlphaPicker | ||
onSelect={handleSelect} | ||
activator={{ | ||
label: 'Tags', | ||
placeholder: 'Search tags', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be for 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" /> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from './Listbox'; | ||
export * from './components'; |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Since There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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