Skip to content

Conversation

jcfilben
Copy link
Collaborator

@jcfilben jcfilben commented Aug 12, 2022

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.
  • The correct query is used. (Refer to this list of queries)
  • userEvent is used in place of fireEvent.
  • asFragment() is used for snapshot testing.

Any background context you want to provide?

Applicable WCAG Rules for this component:

  • 1.3.5 Identify Input Purpose (AA)
  • 1.3.6 Identify Purpose (AAA)
  • 1.4.1 Use of Color (A)
  • 1.4.4 Resize Text (AA)
  • 1.4.3 Contrast (Minimum) (AA)
  • 1.4.6 Contrast (Enhanced) (AAA)
  • 1.4.10 Reflow (AA)
  • 1.4.11 Non-text Contrast (AA)
  • 1.4.12 Text Spacing (AA)
  • 2.1.1 Keyboard (A)
  • 2.1.2 No Keyboard Trap (A)
  • 2.1.3 Keyboard (No Exception) (AAA)
  • 2.4.7 Focus Visible (AA)
  • 3.2.1 On Focus (A)
  • 4.1.1 Parsing (A)
  • 4.1.2 Name, Role, Value (A)
  • 4.1.3 Status Message (AA)

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

@jcfilben jcfilben marked this pull request as draft August 12, 2022 19:41
Copy link
Contributor

@ericsoderberghp ericsoderberghp left a 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)) {
Copy link
Contributor

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.

Copy link
Collaborator Author

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(
Copy link
Contributor

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?

Copy link
Collaborator Author

@jcfilben jcfilben Aug 18, 2022

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?

Copy link
Contributor

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) {
Copy link
Contributor

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.

Copy link
Collaborator Author

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
Copy link
Contributor

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.

Copy link
Collaborator Author

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

Copy link
Contributor

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 = (
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 to render this always? It seems like we don't always use it.

Copy link
Collaborator Author

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

@jcfilben jcfilben marked this pull request as ready for review August 18, 2022 14:30
@jcfilben jcfilben requested review from britt6612 and taysea August 18, 2022 18:13
@ericsoderberghp
Copy link
Contributor

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?

@jcfilben
Copy link
Collaborator Author

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

@jcfilben jcfilben marked this pull request as draft August 19, 2022 17:47
Copy link
Collaborator

@taysea taysea left a 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.

Copy link
Collaborator

@MikeKingdom MikeKingdom left a 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>
Copy link
Collaborator

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.

Copy link
Collaborator Author

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
Copy link
Collaborator

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?

Copy link
Collaborator Author

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
Copy link
Collaborator

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

Copy link
Collaborator Author

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
Copy link
Collaborator

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

Copy link
Collaborator Author

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}
Copy link
Contributor

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?

Copy link
Collaborator Author

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}
Copy link
Contributor

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?

Copy link
Collaborator Author

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}
Copy link
Contributor

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?

Copy link
Collaborator Author

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}
Copy link
Contributor

Choose a reason for hiding this comment

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

these too

Copy link
Collaborator Author

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;
Copy link
Collaborator

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?

Copy link
Collaborator Author

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;
Copy link
Collaborator

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

Copy link
Collaborator Author

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

Copy link
Contributor

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.');
Copy link
Collaborator

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

Copy link
Collaborator

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."

Copy link
Collaborator Author

@jcfilben jcfilben Sep 1, 2022

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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
}, 2000);
}, 2000); // value chosen based on length of a11yLimit message

Copy link
Collaborator Author

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
Copy link
Collaborator

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.

Copy link
Collaborator

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}
         ....

Copy link
Collaborator Author

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(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const iLabel = getOptionLabel(
const optionLabel = getOptionLabel(

feel like this is a little more aligned w grommet conventions

Copy link
Collaborator Author

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 =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const iValue =
const optionValue =

feel like this is a little more aligned w grommet conventions

Copy link
Collaborator Author

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);
Copy link
Collaborator

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?

Copy link
Collaborator Author

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);
Copy link
Collaborator

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?

Copy link
Collaborator Author

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 ? (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
{children && child ? (
{child ? (

Are children required in order for child to receive a value?

Copy link
Collaborator Author

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 &&
Copy link
Contributor

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}

Copy link
Collaborator Author

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};
Copy link
Contributor

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.

Copy link
Collaborator Author

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))
Copy link
Contributor

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(...)?

Copy link
Collaborator Author

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(...))

Copy link
Contributor

Choose a reason for hiding this comment

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

What about normalizedValue?.some?.(...)

Copy link
Collaborator Author

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 =
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: "iValue" -> "optionValue"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

return optionLabelKey;
return undefined;
}, [labelKey, allOptions, optionIndexesInValue, selectValue]);
const displayLabelKey = useMemo(
Copy link
Contributor

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.

Copy link
Collaborator Author

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`
Copy link
Contributor

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"?

Copy link
Collaborator Author

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(
Copy link
Contributor

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"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

export const getOptionValue = (index, options, valueKey) =>
applyKey(options[index], valueKey);

export const checkDisabled = (
Copy link
Contributor

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]);

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 just a lot of lines of code for dependencies in the other useCallbacks() in SelectContainer.js

Copy link
Collaborator Author

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);
Copy link
Contributor

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?

Copy link
Collaborator Author

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 '' &&
Copy link
Contributor

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'?

Copy link
Collaborator Author

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

@@ -1395,6 +1395,9 @@ export interface ThemeType {
searchInput?: ReactComponentElement<any>;
step?: number;
};
selectMultiple?: {
visibleInline?: number;
Copy link
Contributor

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".

Copy link
Collaborator Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

MultiSelect Component
5 participants