Skip to content

Conversation

@jluyau
Copy link
Member

@jluyau jluyau commented May 17, 2021

Closes #1921 1921

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

Adobe/Quarry

@jluyau jluyau changed the title initial TagGroup commit [WIP] TagGroup May 17, 2021
@jluyau
Copy link
Member Author

jluyau commented May 17, 2021

Looking for initial review on implementation.

Things I'm not quite about:

  • cycleMode name in GridKeyboardDelegate + functionality
  • Not sure if more implementation should exist in useTag and useTagGroup
  • should keep Removable class?

Need to:

  • update tests + docs
  • add slotting so that it can handle icons

@adobe-bot
Copy link

Build successful! 🎉

@jluyau
Copy link
Member Author

jluyau commented Aug 23, 2021

Latest TODO list:

  • [API] cycleMode prop (as mentioned in earlier comment)
  • [API] do we still need the Removable interface?
  • do more items need to move into useTagGroup and useTagList?
  • add slots for icon/avatar
  • check that if a tag is removed, focus is properly transferred to the previous/next tag
  • update tests
  • update docs

@tiffany-sy-lin
Copy link
Collaborator

[API] cycleMode prop (as mentioned in earlier comment)
[API] do we still need the Removable interface?

@snowystinger would you happen to know what these 2 points need to address? It's a bit on the vague side

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

LFDanLu
LFDanLu previously approved these changes Jan 27, 2022
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Some addition things I noted but I'm fine with handling them in a followup PR

Comment on lines +45 to +48
// Ignore disabled tags
if (this.disabledKeys.has(newKey)) {
return this.getKeyBelow(newKey);
}
Copy link
Member

Choose a reason for hiding this comment

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

Open question: should we be skipping the disabled tags?

Copy link
Member

Choose a reason for hiding this comment

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

ButtonGroup, ActionGroup, etc. do so I think so.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure, since tags are both data as well as interactive elements. Can any tag be disabled? or only the ones with some sort of interactive capability such as close?
Can probably sort out in a later PR, but I don't know if the answer is as clearcut as I would like

Copy link
Contributor

Choose a reason for hiding this comment

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

Originally I thought disabled tags should get skipped bc ActionGroup items do too, but if we should be able to delete a disabled tag then they need to be focusable. However it becomes an interactive element which then needs 4.5:1 color contrast which it won't get. Also agree it doesn't need to be a blocker for this PR

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR

Comment on lines +45 to +55
let {rowProps} = useGridRow({
node: item
}, state, tagRowRef);
// Don't want the row to be focusable or accessible via keyboard
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {tabIndex, ...otherRowProps} = rowProps;

let {gridCellProps} = useGridCell({
node: [...item.childNodes][0],
focusMode: 'cell'
}, state, tagRef);
Copy link
Member

Choose a reason for hiding this comment

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

Followup question for next PR: should we be focusing the row or the cell in the TagGroup? Will have to experiment because I had run into issues making focus stay on the row in CardView myself. v2 focused the rows rather that the cells

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR

Comment on lines +76 to +78
onFocus() {
state.selectionManager.setFocusedKey(item.childNodes[0].key);
},
Copy link
Member

Choose a reason for hiding this comment

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

curious, what prompted the addition of this? Was it to override the onFocus from useGridCell? If so I don't think you need to since the raf won't trigger here since we have focusMode: cell.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

{...styleProps}
{...mergeProps(tagProps, hoverProps)}
{...mergeProps(tagProps, hoverProps, focusProps)}
role="gridcell"
Copy link
Member

Choose a reason for hiding this comment

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

can get rid of this role since it comes from tagProps

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

Comment on lines +49 to +62
// add column of clear buttons if removable
if (isRemovable) {
childNodes.push({
key: `remove-${item.key}`,
type: 'cell',
index: 1,
value: null,
level: 0,
rendered: null,
textValue: item.textValue, // TODO localize?
hasChildNodes: false,
childNodes: []
});
}
Copy link
Member

Choose a reason for hiding this comment

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

Think we can remove this since we aren't supporting keyboard focusing the clear button separately? We can keep if there is future plans to reimplement that behavior. Same with the columnCount above

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR


// Don't want the grid to be focusable or accessible via keyboard
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {tabIndex, ...otherGridProps} = gridProps;
Copy link
Member

Choose a reason for hiding this comment

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

Do we need this? Feel like we should keep the tabIndex on the TagGroup and just add css to prevent the outline flicker from happening

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was running into focus issues with keyboard nav when the group had a tabIndex. for the most part i was trying emulate ActionGroup's tabIndex-ing

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will look into it more in followup PR

*/

/// <reference types="css-module-types" />

Copy link
Member

Choose a reason for hiding this comment

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

Should probably export Item as well like how ActionGroup and other components that use Item do

export {Item} from '@react-stately/collections';

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

Comment on lines +36 to +38
export interface SpectrumTagProps<T> extends TagProps<T> {
state: GridState<any, any>
}
Copy link
Member

Choose a reason for hiding this comment

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

Don't think we expose Tag right? Can probably move this to TagProps or even send it through a context provider instead of through props

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

const formatMessage = useMessageFormatter(intlMessages);
const removeString = formatMessage('remove');
const tagId = useId();
const labelId = useId();
Copy link
Collaborator

Choose a reason for hiding this comment

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

labelId is only returned in the aria-labelledby for clearButtonProps and is not assigned as an id for the Tag's label.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

clearButtonProps: mergeProps(pressProps, {
'aria-label': removeString,
'aria-labelledby': `${buttonId} ${tagId}`,
'aria-labelledby': `${buttonId} ${labelId}`,
Copy link
Collaborator

Choose a reason for hiding this comment

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

labelId is an invalid id reference because it is not applied as an id for the Tab's label element.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

@adobe-bot
Copy link

Build successful! 🎉

icon: {UNSAFE_className: classNames(styles, 'spectrum-Tag-icon'), size: 'XS'},
text: {UNSAFE_className: classNames(styles, 'spectrum-Tag-content', {'tags-removable': isRemovable})}
}}>
{typeof children === 'string' ? <div ref={labelRef} {...labelProps}><Text>{children}</Text></div> : children}
Copy link
Member

@snowystinger snowystinger Feb 3, 2022

Choose a reason for hiding this comment

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

isn't this fundamentally different from what a user would pass in? I think it should be

Suggested change
{typeof children === 'string' ? <div ref={labelRef} {...labelProps}><Text>{children}</Text></div> : children}
{typeof children === 'string' ? <Text>{children}</Text> : children}

where labelRef and labelProps are passed on the text slot? though what is the labelRef actually used for? i don't see it passed anywhere, looks like it's created and assigned, nothing else. So we might be able to just delete that.

Copy link
Collaborator

@tiffany-sy-lin tiffany-sy-lin Feb 3, 2022

Choose a reason for hiding this comment

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

The labelProps has the labelId which in this case is used for voiceover in the remove scenario. I don't mind removing labelRef and any other unused refs.

Copy link
Member

Choose a reason for hiding this comment

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

I think we need to remove that extra div as well though, it's not something that the user would provide, which means that providing a string vs providing React element children would be different right?

<Tag>apples</Tag>

would be labelled differently than

<Tag><Text>apples</Text></Tag>

Or am I reading into this the wrong way?

Copy link
Collaborator

Choose a reason for hiding this comment

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

you're right. i didn't consider that when adding labelProps. i might have to restyle the children scenario

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

Copy link
Member

@ktabors ktabors left a comment

Choose a reason for hiding this comment

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

Generally looks fine. I think a number fo Daniel's concerns need to be addressed.

@@ -0,0 +1,91 @@
/*
* Copyright 2020 Adobe. All rights reserved.
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't hold this up, but the copyright should be 2022.

id: buttonId,
title: removeString,
isDisabled,
role
Copy link
Member

Choose a reason for hiding this comment

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

Is this why when I turn on OSX voiceover and ctl-opt-arrow to the ClearButton it only says "dimmed" and not "dimmed and disabled button"?

@@ -1,50 +0,0 @@
/*
Copy link
Member

Choose a reason for hiding this comment

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

I see @snowystinger's suggestion to remove this, but his comment made it seem like a useTagGroup.test.js would be useful. This could be followup.

Copy link
Member

Choose a reason for hiding this comment

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

integration meaning only test at the component level, I don't think useTagGroup would be a super useful test either. Testing hooks can be a bit contrived, better to use a component to put them through real world examples I think

collection: gridCollection,
focusMode: 'cell'
});
let keyboardDelegate = new TagKeyboardDelegate({
Copy link
Member

Choose a reason for hiding this comment

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

When testing I noticed that home, end, page up, and page down didn't work. Should they?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR

Name | Component | TagComponent | props
${'TagGroup'} | ${TagGroup} | ${Tag} | ${{isReadOnly: true, isRemovable: true, onRemove: onRemoveSpy}}
`('$Name can be read only', ({Component, TagComponent, props}) => {
${'TagGroup'} | ${TagGroup} | ${Item} | ${{isDisabled: true, isRemovable: true, onRemove: onRemoveSpy}}
Copy link
Member

Choose a reason for hiding this comment

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

Why is this an it.each story if there is only one condition? The same for the rest of them.

A lot of these have the same component definition, should these exist in a beforeEach()?

Copy link
Member

Choose a reason for hiding this comment

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

we have a lot of tests like this, it's probably just copied, not worried about it

Copy link
Collaborator

Choose a reason for hiding this comment

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

i copied the structure from other tests (because they looked cool haha). i can change them to single tests

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR

expect(tagDisabled).toHaveAttribute('tabIndex', '-1');
let tagRowNotDisabled = tagGroup.children[0];
let tagNotDisabled = tagRowNotDisabled.children[0];
act(() => {tagNotDisabled.focus();});
Copy link
Member

Choose a reason for hiding this comment

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

Should we confirm it is focused by checking the document.activeElement?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it better practice to use document.activeElement? i came across .focus() and thought it seemed fine

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR

${'(left/right arrows, ltr + horizontal) TagGroup'} | ${{locale: 'de-DE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowRight, index: 1}, {action: pressArrowLeft, index: 0}, {action: pressArrowLeft, index: 0}]}
${'(left/right arrows, rtl + horizontal) TagGroup'} | ${{locale: 'ar-AE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowRight, index: 0}, {action: pressArrowLeft, index: 1}, {action: pressArrowLeft, index: 2}]}
${'(up/down arrows, ltr + horizontal) TagGroup'} | ${{locale: 'de-DE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowDown, index: 1}, {action: pressArrowUp, index: 0}, {action: pressArrowUp, index: 0}]}
${'(up/down arrows, rtl + horizontal) TagGroup'} | ${{locale: 'ar-AE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowDown, index: 1}, {action: pressArrowUp, index: 0}, {action: pressArrowUp, index: 0}]}
Copy link
Member

Choose a reason for hiding this comment

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

This last test does two steps at position 0, maybe start at position 2 and move to position 0? This would make it more similar to the second test.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

expect(tagGroup).toHaveAttribute('aria-labelledby', 'tag group');
});

it('TagGroup allows aria-label on Item', function () {
Copy link
Member

Choose a reason for hiding this comment

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

API question, does this mean we support a complex Item that is just an Icon and an aria-label? I confirmed that I was able to do this in storybook.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I tried to emulate actiongroup (since they have similar structures). one of the action stories has aria-label on the Item so i added it as a test for taggroup. although i don't have any stories with aria-label in Item...so i am happy to remove this if it's not suppose to be supported

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address/continue conversation in followup PR


let buttonBefore = getByLabelText('ButtonBefore');
let buttonAfter = getByLabelText('ButtonAfter');
let inputs = getAllByRole('gridcell');
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this variable be called tags? I'm looking at the last test where it is and the textfields are called inputs. This was confusing because the meaning of inputs changed between tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

good point. will change

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

Copy link
Collaborator

@majornista majornista left a comment

Choose a reason for hiding this comment

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

  1. Is there a plan to manage focus when a Tag is removed from the TagGroup?
  2. Do we need stories where onRemove does more than just trigger an event?
  3. We need to account for aria-disabled state on the row or gridcell for disabled keys.
  4. We need to account for an empty TagGroup, where the TagGroup element should either not render, or its should have role="group" instead of role="grid".

}
const pressProps = {
onPress: e => onRemove(children, e)
onPress: e => onRemove?.(children, e)
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we use item.key, I think we should then be able to use the keyboard delegate to shift focus the next or previous Tag on remove when one exists.

Comment on lines +81 to +88
tagProps: mergeProps(domProps, gridCellProps, {
'aria-errormessage': props['aria-errormessage'],
'aria-label': props['aria-label'],
onKeyDown: !isDisabled && isRemovable ? onKeyDown : null,
tabIndex: (isFocused || state.selectionManager.focusedKey == null) && !isDisabled ? 0 : -1,
onFocus() {
state.selectionManager.setFocusedKey(item.childNodes[0].key);
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

isDisabled does not add aria-disabled="true" to the Tag element with role="gridcell", which causes aXe accessibility automation tool to throw the following error regarding the color contrast for the Tag text content in Storybook example: "#react-aria1908471221-9 > .tags_spectrum-Tag-content_f7ae1 Element has insufficient color contrast of 2.08 (foreground color: #5c5c5c, background color: #2c2c2c, font size: 9.0pt (12px), font weight: normal). Expected contrast ratio of 4.5:1." To reproduce, open https://reactspectrum.blob.core.windows.net/reactspectrum/b7ddd9f7dec2dbf18633a71801a4ca50506e4762/storybook-16/index.html?path=/story/taggroup--disabledkeys, and in the Storybook Accessibility addOn, press the Tests Completed button to rerun the tests.

Note that this error does not seem to happen when the entire TagGroup is disabled, because aria-disabled="true" gets added to the TagGroup element with role="grid".

Copy link
Collaborator

Choose a reason for hiding this comment

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

will add aria-disabled

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR


// Don't want the grid to be focusable or accessible via keyboard
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {tabIndex, ...otherGridProps} = gridProps;
Copy link
Collaborator

Choose a reason for hiding this comment

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

When there are no Tags within the TagGroup, the container with role="grid" will render with no descendants. This will cause aXe automated accessibility testing tool to throw an error, because elements with role="grid" are expected to have rows, or a rowgroup containing rows, as children.

In the case of an empty TagGroup, the TagGroup could either not render at all or use role="group", which is allowed to be empty, instead of role="grid".

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will address in followup PR

@dannify dannify changed the title [WIP] TagGroup TagGroup Feb 4, 2022
snowystinger
snowystinger previously approved these changes Feb 4, 2022
@adobe-bot
Copy link

Build successful! 🎉

@adobe-bot
Copy link

Build successful! 🎉

@tiffany-sy-lin tiffany-sy-lin mentioned this pull request Feb 10, 2022
17 tasks
@tiffany-sy-lin
Copy link
Collaborator

Followup ticket with list of items to address #2837

Copy link
Member

@dannify dannify left a comment

Choose a reason for hiding this comment

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

I think this is good for an alpha. I agree that the role of grid might be overkill for a TagGroup. We don't have selection in this component so we might be able to get away with a role of "list" instead since the tags are not interactive and there is nothing very special about it?

@adobe-bot
Copy link

Build successful! 🎉

@dannify dannify dismissed majornista’s stale review February 11, 2022 20:24

Will be handled in a followup

@dannify dannify merged commit cc5deac into main Feb 11, 2022
@dannify dannify deleted the taggroup branch February 11, 2022 20:25
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.

Implement TagGroup component