-
Notifications
You must be signed in to change notification settings - Fork 1k
SelectMultiple Component #6275
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
SelectMultiple Component #6275
Conversation
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.
A few things to start with.
I'm hoping we can try to improve on the complexity currently in Select. For example, requiring value
to always be an Array. There's also a few of (...) : (...)
when rendering where each part is rather large. I'm wondering if there's a way to simplify by splitting things up a bit. Just thinking out loud at the moment.
const optionIndexesInValue = useMemo(() => { | ||
const result = []; | ||
allOptions.forEach((option, index) => { | ||
if (Array.isArray(valuedValue)) { |
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.
Can we assume for SelectMultiple that the value is always an Array? I'm wondering if that can help simplify the code.
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.
Yes, done in latest commit
|
||
const SelectIcon = getSelectIcon(icon, theme, open); | ||
|
||
const optionLabel = useCallback( |
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.
Since we're going to call this function to render every option anyway, I'm wondering if we can skip the useCallback
and just call getOptionLabel()
directly as needed?
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.
Done in latest commit. Should I do this for getOptionLabel
as well?
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.
yep, there were a few in the same pattern
valueKey && valueKey.reduce ? applyKey(i, valueKey) : i; | ||
const indexOptions = allOptions.indexOf(i); | ||
const iLabel = optionLabel(indexOptions); | ||
if (value.indexOf(iValue) < 5) { |
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.
This is nested pretty deep in here. I'm wondering if there's a way to restructure it to break it up a bit.
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.
I moved some of the code here out into a separate function. (See visibleValue()
)
} | ||
|
||
return ( | ||
<Button |
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.
I'm wondering if we can make a more common Button for use in both Select and SelectMultiple? Not the children, but the props.
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.
I changed this button to be SelectOption
. There are a few places we use SelectOption so I am currently looking to see if there is enough similarities in them make some props common for SelectOption
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.
Excellent! That's the kind of thing I am hoping we can do more of.
</Box> | ||
); | ||
|
||
const defaultSelectTextInput = ( |
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 to render this always? It seems like we don't always use it.
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.
I changed this in that latest commit
What about splitting it up into even more discrete components? For example, SelectContainer, SelectValue, SelectClear, SelectOptions, SelectOption. Where each has its own file and properties related solely to its function? |
Sounds good I will try something like this out. Marking this PR as a draft in the meantime while I work on this |
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.
Nice work on this big component! I noticed there's a lot of considerations for accessibility, thank you!
I've dropped some initial comments on naming/some functionality things I noted. I'll probably have more questions as I can dig deeper into the code but these were just some first things.
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.
What does separateSelected
do? It isn't obvious to me what effect it has.
background="brand" | ||
round="large" | ||
> | ||
<Text size="small">{season}</Text> |
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.
The font color and icon color in HPE theme is not very readable.
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.
Fixed, changed the background color to 'light-3' instead of 'brand'
pad={{ vertical: 'xsmall', horizontal: 'small' }} | ||
margin="xsmall" | ||
> | ||
Select Season |
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.
This should probably be text-weak or whatever the placeholder color is?
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.
Good idea, I changed the text to 'text-weak' (same as placeholder)
</Box> | ||
{value && value.length > theme.selectMultiple.visibleInline && ( | ||
<Box alignSelf="start"> | ||
<Button |
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.
This button looks a bit odd in grommet or base theme since it has no padding between its border and the select's borders
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.
I added some padding around the button
// Uncomment <Grommet> lines when using outside of storybook | ||
// <Grommet theme={...}> | ||
<Box fill align="center" pad="small"> | ||
<SelectMultiple |
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.
This example looks odd with the 0 of 10 selected
text when nothing is selected and wasn't clear, especially with the placeholder being the default text color
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.
I changed this so that when nothing is selected the 0 out of x
doesn't show and instead we just see the placeholder or valueLabel
ref, | ||
) => ( | ||
<SelectTextInput | ||
a11yTitle={a11yTitle} |
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.
Can this just be part of ...rest
?
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.
Yes, fixed
defaultCursor={disabled === true || undefined} | ||
focusIndicator={false} | ||
id={id ? `${id}__input` : undefined} | ||
name={name} |
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.
Can this just be part of ...rest
?
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.
Fixed
{...rest} | ||
tabIndex="-1" | ||
type="text" | ||
placeholder={placeholder} |
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.
Can this just be part of ...rest
?
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.
Fixed
placeholder={placeholder} | ||
plain | ||
readOnly | ||
value={value} |
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.
these too
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.
Fixed
|
||
export interface SelectMultipleProps extends BasicSelectProps { | ||
defaultValue?: (string | number | object)[]; | ||
help?: React.ReactNode; |
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.
Can this just be a string of text?
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.
Yes, added string
to the type definition
limit?: number; | ||
value?: (string | number | object)[]; | ||
showSelectedInline?: boolean; | ||
separateSelected?: boolean; |
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.
separateSelected
doesn't immediately make sense to me. Thinking out loud with some ideas:
- selectionsTop
- sortSelectedOnClose
- liftSelected
- groupSelected
Also, should the value default to true
? or will that cause issues
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.
I changed the prop name to sortSelectedOnClose
and have it default to true
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.
My one concern with using "sort" is that if the caller has ordered the options to be: [A, C, B, E, D] and the user selects [C, B], the name "sortSelectedOnClose" implies to me that the component will covert the selected to [B, C].
} | ||
} | ||
if (usingKeyboard) | ||
setShowA11yLimit('Selected. Maximum selection limit reached.'); |
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.
This string message seems to read weirdly
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.
Should it just be "Maximum selection limit reached."
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.
I know the wording seems a little strange, just some context: this message gets read when an option is selected and the number of selected options is now equal to the limit. I wanted to give some confirmation that the option was actually selected. I was thinking if the message just read 'Maximum selection limit reached' the user might be confused as to whether their option was selected or not which is why I have it read 'Selected' first.
if (showA11yLimit !== undefined) { | ||
setTimeout(() => { | ||
setShowA11yLimit(undefined); | ||
}, 2000); |
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.
}, 2000); | |
}, 2000); // value chosen based on length of a11yLimit message |
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.
Done
flex={false} | ||
pad={{ horizontal: 'small', top: 'xsmall' }} | ||
> | ||
<Box |
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.
This whole return block feels a bit repetitive -- should this Box be built into SelectionSummary? Then depending on if showSelectedInline
or not, the Box props could be altered. Seems like since SelectionSummary should present in a row, that the box defining the row should be part of it.
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.
let inlineContent = (
<SelectionSummary
allOptions={allOptions}
clearRef={clearRef}
disabled={disabled}
disabledKey={disabledKey}
isSelected={isSelected}
labelKey={labelKey}
limit={limit}
onChange={onChange}
options={options}
search={search}
setActiveIndex={setActiveIndex}
value={value}
valueKey={valueKey}
boxProps={showSelectedInline
? { pad: { vertical: 'xsmall' },
direction: "row",
justify: "between",
gap: "small",
fill: "horizontal" }
: { .... }
}
/>
);
if (showSelectedInline) inlineContent = (
<Box
direction="row"
justify="between"
flex={false}
pad={{ horizontal: 'small', top: 'xsmall' }}
>
{inlineContent}
<Button
icon={<FormUp />}
onClick={onClose}
a11yTitle="Close Select"
plain
/>
</Box>
);
...
return ( ....
<StyledContainer>
{inlineContent}
....
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.
Good idea, I broke this out as suggested
); | ||
const optionSelected = value.includes(iValue); | ||
const optionActive = activeIndex === index; | ||
const iLabel = getOptionLabel( |
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.
const iLabel = getOptionLabel( | |
const optionLabel = getOptionLabel( |
feel like this is a little more aligned w grommet conventions
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.
Yes, fixed in latest commit
show={activeIndex !== -1 ? activeIndex : undefined} | ||
> | ||
{(option, index, optionRef) => { | ||
const iValue = |
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.
const iValue = | |
const optionValue = |
feel like this is a little more aligned w grommet conventions
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.
Yes, fixed in latest commit
iLabel.toLowerCase().indexOf(search) >= 0 | ||
) { | ||
// code to bold search term in matching options | ||
const boldIndex = iLabel.toLowerCase().indexOf(search); |
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.
How do I test this functionality?
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.
When you type in the search you should see substrings that match your search term bolded. I realized that this wasn't working on the grommet theme, but fixed this in the latest commit.
labelKey || valueKey, | ||
)}`, | ||
); | ||
}, 200); |
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.
add comment for how this value was chosen?
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.
Done
key={iLabel} | ||
id={`selected-${iValue}`} | ||
> | ||
{children && child ? ( |
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.
{children && child ? ( | |
{child ? ( |
Are children required in order for child
to receive a value?
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.
Yes, I simplified this in the latest commit
const StyledSelectBox = styled(Box)` | ||
${(props) => !props.callerPlain && controlBorderStyle}; | ||
${(props) => | ||
props.theme.select && |
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.
With the way ?.
works, do we need the intervening checks still? Can this just be ${(props) => props.theme.select?.control?.extend}
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.
Changed to ${(props) => props.theme.select?.control?.extend}
in latest commit
import { SelectMultiplePropTypes } from './propTypes'; | ||
|
||
const StyledSelectBox = styled(Box)` | ||
${(props) => !props.callerPlain && controlBorderStyle}; |
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.
I realize this is inherited from prior Select, but I'm wondering if "plainSelect" might be a bit more descriptive rather than "callerPlain" as a property name.
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.
Changed to plainSelect
in latest commit
if ( | ||
normalizedValue && | ||
normalizedValue.length > 0 && | ||
normalizedValue.some((v) => v === applyKey(option, valueKey)) |
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.
Can these all be simplified to: normalizedValue?.some(...)
?
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.
If I just have normalizedValue?.some(...)
I get the error: normalizedValue.some is not a function
. I modified this to if (normalizedValue.length && normalizedValue?.some(...))
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.
What about normalizedValue?.some?.(...)
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.
Yeah that works! I'll push up that change
useEffect(() => { | ||
if (separateSelected && ((open && search) || !open)) { | ||
const selectedOptions = optionsProp.filter((option) => { | ||
const iValue = |
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.
suggestion: "iValue" -> "optionValue"
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.
Done
…ault and incorporated other feedback from review
return optionLabelKey; | ||
return undefined; | ||
}, [labelKey, allOptions, optionIndexesInValue, selectValue]); | ||
const displayLabelKey = useMemo( |
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.
As an example of custom hooks, this could be useDisplayLabelKey(labelKey, allOptions, optionIndexesInValue, selectValue)
. Not required, just illustrating.
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.
Good to know, thanks for the example
props.theme.select.container && props.theme.select.container.extend}; | ||
`; | ||
|
||
// position relative is so scroll can be managed correctly | ||
export const OptionsBox = styled.div` |
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.
Since this isn't actually a "Box", can we call it something like "OptionsContainer"?
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.
Yes, done
@@ -294,7 +244,13 @@ const SelectContainer = forwardRef( | |||
let nextActiveIndex = activeIndex + 1; | |||
while ( | |||
nextActiveIndex < options.length && | |||
isDisabled(nextActiveIndex) | |||
checkDisabled( |
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.
Suggest "checkDisabled" -> "isDisabled", to align more with "isSelected"
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.
Done
src/js/components/Select/utils.js
Outdated
export const getOptionValue = (index, options, valueKey) => | ||
applyKey(options[index], valueKey); | ||
|
||
export const checkDisabled = ( |
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.
If you made this a custom hook, it might make its usage cleaner:
const isDisabled = useDisabled(disabled, disabledKey, options, valueKey);
...
if (isDisabled(nextActiveIndex) ...
where useDisabled
would then simply be:
const useDisabled(disabled, disabledKey, options, valueKey) = useCallback((index) => {...}, [disabled, disabledKey, options, valueKey]);
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.
There's just a lot of lines of code for dependencies in the other useCallbacks() in SelectContainer.js
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.
Changed to use useDisabled
hook
} else if (optionsRef.current) { | ||
setActiveIndex(0); | ||
} | ||
}, 100); |
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.
Can you add a comment indicating why "100" was chosen?
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.
Yes, done
|
||
if (!children && search) { | ||
if ( | ||
typeof optionLabel === typeof '' && |
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.
Why not typeof optionLabel === 'string'
?
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.
Changed to typeof optionLabel === 'string'
in latest commit
src/js/themes/base.d.ts
Outdated
@@ -1395,6 +1395,9 @@ export interface ThemeType { | |||
searchInput?: ReactComponentElement<any>; | |||
step?: number; | |||
}; | |||
selectMultiple?: { | |||
visibleInline?: number; |
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.
Suggest "visibleInline" -> "maxInline" to be a little clearer that this is a maximum. I think "inline" implies "visible".
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.
Good idea, I changed this to maxInline
in the latest commit
What does this PR do?
Adds the SelectMultiple component.
Where should the reviewer start?
What testing has been done on this PR?
Added storybook stories
How should this be manually tested?
Do Jest tests follow these best practices?
Note: still working on adding unit tests
screen
is used for querying.userEvent
is used in place offireEvent
.asFragment()
is used for snapshot testing.Any background context you want to provide?
Applicable WCAG Rules for this component:
What are the relevant issues?
Closes #6260
Screenshots (if appropriate)
Do the grommet docs need to be updated?
Yes
Should this PR be mentioned in the release notes?
Yes
Is this change backwards compatible or is it a breaking change?
Backwards compatible