-
Notifications
You must be signed in to change notification settings - Fork 582
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
Fixes for ActionList
semantics
#4272
Changes from 45 commits
7f21a16
fa708b1
e087f87
76e2606
eab6fd9
8b4f311
614749a
bfb8fe2
4b18eed
d13668d
10d9866
0939a90
cc6a63c
1cd9fb1
3d1daf3
5999a77
9f77b09
087ab1f
f4a5021
ba31e40
8e2921d
afa8d9f
4b1f2b4
10cd551
65c23c8
70659ed
1a9b3e0
1f783fa
6774f93
5ed547f
cf5ecf9
2f13c76
f32f416
84f041e
55632b1
8bc5c15
ce70779
3e8af88
4c8a4de
0d076dc
d2faa9a
54eab93
d1c1d60
9bbc8a2
1ceaa0c
d0a6c54
77027ad
0298349
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 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
(Behind feature flag) ActionList: Utilizes `<button>` inside of `<li>` for interactive items. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ import theme from '../theme' | |
import {ActionList} from '.' | ||
import {behavesAsComponent, checkExports} from '../utils/testing' | ||
import {BaseStyles, ThemeProvider, SSRProvider, ActionMenu} from '..' | ||
import {FeatureFlags} from '../FeatureFlags' | ||
broccolinisoup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
function SimpleActionList(): JSX.Element { | ||
return ( | ||
|
@@ -378,4 +379,70 @@ describe('ActionList', () => { | |
const heading = getByText('Group Heading') | ||
expect(list).toHaveAttribute('aria-label', heading.textContent) | ||
}) | ||
|
||
it('should render ActionList.Item as button when feature flag is enabled', async () => { | ||
const featureFlag = { | ||
primer_react_action_list_item_as_button: true, | ||
} | ||
|
||
const {container} = HTMLRender( | ||
<FeatureFlags flags={featureFlag}> | ||
<ActionList> | ||
<ActionList.Item disabled={true}>Item 1</ActionList.Item> | ||
<ActionList.Item>Item 2</ActionList.Item> | ||
</ActionList> | ||
</FeatureFlags>, | ||
) | ||
|
||
const button = container.querySelector('button') | ||
expect(button).toHaveTextContent('Item 1') | ||
|
||
// Ensure passed prop "disabled" is applied to the button | ||
expect(button).toHaveAttribute('aria-disabled', 'true') | ||
|
||
const listItems = container.querySelectorAll('li') | ||
expect(listItems.length).toBe(2) | ||
}) | ||
|
||
it('should render ActionList.Item as li when feature flag is disabled', async () => { | ||
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. Would we want to test some of the listSemantics conditions as well or is it not necessary? For example;
Just a suggestion! 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. That is a great suggestion! Just added a new test! |
||
const {container} = HTMLRender( | ||
<FeatureFlags flags={{primer_react_action_list_item_as_button: false}}> | ||
<ActionList> | ||
<ActionList.Item>Item 1</ActionList.Item> | ||
<ActionList.Item>Item 2</ActionList.Item> | ||
</ActionList> | ||
</FeatureFlags>, | ||
) | ||
|
||
const listitem = container.querySelector('li') | ||
const button = container.querySelector('button') | ||
|
||
expect(listitem).toHaveTextContent('Item 1') | ||
expect(listitem).toHaveAttribute('tabindex', '0') | ||
expect(button).toBeNull() | ||
|
||
const listItems = container.querySelectorAll('li') | ||
expect(listItems.length).toBe(2) | ||
}) | ||
|
||
it('should render ActionList.Item as li when feature flag is enabled and has proper aria role', async () => { | ||
const {container} = HTMLRender( | ||
<FeatureFlags flags={{primer_react_action_list_item_as_button: false}}> | ||
<ActionList role="listbox"> | ||
<ActionList.Item role="option">Item 1</ActionList.Item> | ||
<ActionList.Item role="option">Item 2</ActionList.Item> | ||
</ActionList> | ||
</FeatureFlags>, | ||
) | ||
|
||
const listitem = container.querySelector('li') | ||
const button = container.querySelector('button') | ||
|
||
expect(listitem).toHaveTextContent('Item 1') | ||
expect(listitem).toHaveAttribute('tabindex', '0') | ||
expect(button).toBeNull() | ||
|
||
const listItems = container.querySelectorAll('li') | ||
expect(listItems.length).toBe(2) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -21,6 +21,7 @@ import {getVariantStyles, ItemContext, TEXT_ROW_HEIGHT, ListContext} from './sha | |||
import type {VisualProps} from './Visuals' | ||||
import {LeadingVisual, TrailingVisual} from './Visuals' | ||||
import {ConditionalWrapper} from '../internal/components/ConditionalWrapper' | ||||
import {useFeatureFlag} from '../FeatureFlags' | ||||
|
||||
const LiBox = styled.li<SxProp>(sx) | ||||
|
||||
|
@@ -77,6 +78,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
const {container, afterSelect, selectionAttribute, defaultTrailingVisual} = | ||||
React.useContext(ActionListContainerContext) | ||||
|
||||
const buttonSemantics = useFeatureFlag('primer_react_action_list_item_as_button') | ||||
|
||||
// Be sure to avoid rendering the container unless there is a default | ||||
const wrappedDefaultTrailingVisual = defaultTrailingVisual ? ( | ||||
<TrailingVisual>{defaultTrailingVisual}</TrailingVisual> | ||||
|
@@ -95,7 +98,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
|
||||
const onSelect = React.useCallback( | ||||
( | ||||
event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>, | ||||
event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, | ||||
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 wonder if loosening the type here (which I think is a good way to eliminate the integration issue with dotcom) might have any "tangible" disadvantages? 🤔 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. It's definitely not as strict, but it does ease the integration in dotcom. I think with the feature flag it makes it a bit trickier as the type is dependent on the FF being enabled. The issue I had when I was dealing with TS early on was that there are two potential things it could be; a list item or a button. In addition to that, the I did try adding both types 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. Yeah I remember that! Let's see how this type works then and since it is under ff, we can always work on it! 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. Hello from the future! This came up during release conductor stuff this week in dotcom, it seems like this change will cause typed handlers to fail since they are using the more specific type. Since we've switched to a more generic type, these more specific ones will fail 😭 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. Hey Josh! This was a tricky situation since the current type I made the types more generic in dotcom in my integration test PR that should clean up the failures you're seeing. It does involve making the type a bit more generic, but we don't seem to lose out on too much. Let me know what you think though! |
||||
// eslint-disable-next-line @typescript-eslint/ban-types | ||||
afterSelect?: Function, | ||||
) => { | ||||
|
@@ -146,6 +149,12 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
}, | ||||
} | ||||
|
||||
const listItemStyles = { | ||||
display: 'flex', | ||||
// show between 2 items | ||||
':not(:first-of-type)': {'--divider-color': theme?.colors.actionListItem.inlineDivider}, | ||||
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. just checking, Is this and the following line conflicting? react/packages/react/src/ActionList/Item.tsx Line 216 in 087ab1f
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 believe this was added to fix some styles when 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 don't remember sorry 😢 It has been a while since I pushed that draft PR. We can comment it out and see how the stlyes are different to figure out if it still works fine in this PR. 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 went ahead and removed it, and it seems like it was needed for cases where a divider is used and the "button" semantics are applied. I think it's worth keeping for this 🤔 |
||||
} | ||||
|
||||
const styles = { | ||||
position: 'relative', | ||||
display: 'flex', | ||||
|
@@ -231,15 +240,15 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
} | ||||
|
||||
const clickHandler = React.useCallback( | ||||
(event: React.MouseEvent<HTMLLIElement>) => { | ||||
(event: React.MouseEvent<HTMLElement>) => { | ||||
if (disabled || inactive) return | ||||
onSelect(event, afterSelect) | ||||
}, | ||||
[onSelect, disabled, inactive, afterSelect], | ||||
) | ||||
|
||||
const keyPressHandler = React.useCallback( | ||||
(event: React.KeyboardEvent<HTMLLIElement>) => { | ||||
(event: React.KeyboardEvent<HTMLElement>) => { | ||||
if (disabled || inactive) return | ||||
if ([' ', 'Enter'].includes(event.key)) { | ||||
if (event.key === ' ') { | ||||
|
@@ -259,8 +268,28 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
const inlineDescriptionId = `${itemId}--inline-description` | ||||
const blockDescriptionId = `${itemId}--block-description` | ||||
const inactiveWarningId = inactive && !showInactiveIndicator ? `${itemId}--warning-message` : undefined | ||||
// Ensures ActionList.Item retains list item semantics if a valid ARIA role is applied, or if item is inactive | ||||
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. ❤️ |
||||
const listSemantics = listRole === 'listbox' || listRole === 'menu' || inactive || container === 'NavList' | ||||
|
||||
const ButtonItemWrapper = React.forwardRef(({as: Component = 'button', children, ...props}, forwardedRef) => { | ||||
return ( | ||||
<Box | ||||
as={Component as React.ElementType} | ||||
sx={merge<BetterSystemStyleObject>(styles, sxProp)} | ||||
ref={forwardedRef} | ||||
{...props} | ||||
> | ||||
{children} | ||||
</Box> | ||||
) | ||||
}) as PolymorphicForwardRefComponent<React.ElementType, ActionListItemProps> | ||||
|
||||
let DefaultItemWrapper = React.Fragment | ||||
if (buttonSemantics) { | ||||
DefaultItemWrapper = listSemantics ? React.Fragment : ButtonItemWrapper | ||||
} | ||||
|
||||
const ItemWrapper = _PrivateItemWrapper || React.Fragment | ||||
const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper | ||||
|
||||
// only apply aria-selected and aria-checked to selectable items | ||||
const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option'] | ||||
|
@@ -281,20 +310,44 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>( | |||
id: itemId, | ||||
} | ||||
|
||||
const containerProps = _PrivateItemWrapper ? {role: itemRole ? 'none' : undefined} : menuItemProps | ||||
let containerProps | ||||
let wrapperProps | ||||
|
||||
const wrapperProps = _PrivateItemWrapper ? menuItemProps : {} | ||||
if (buttonSemantics) { | ||||
containerProps = _PrivateItemWrapper | ||||
? {role: itemRole ? 'none' : undefined, ...props} | ||||
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||||
(listSemantics && {...menuItemProps, ...props, ref: forwardedRef}) || {} | ||||
|
||||
wrapperProps = _PrivateItemWrapper | ||||
? menuItemProps | ||||
: !listSemantics && { | ||||
...menuItemProps, | ||||
...props, | ||||
styles: merge<BetterSystemStyleObject>(styles, sxProp), | ||||
ref: forwardedRef, | ||||
} | ||||
} else { | ||||
containerProps = _PrivateItemWrapper ? {role: itemRole ? 'none' : undefined} : {...menuItemProps, ...props} | ||||
wrapperProps = _PrivateItemWrapper ? menuItemProps : {} | ||||
} | ||||
|
||||
return ( | ||||
<ItemContext.Provider | ||||
value={{variant, disabled, inactive: Boolean(inactiveText), inlineDescriptionId, blockDescriptionId}} | ||||
> | ||||
<LiBox | ||||
ref={forwardedRef} | ||||
sx={merge<BetterSystemStyleObject>(styles, sxProp)} | ||||
ref={buttonSemantics || listSemantics ? forwardedRef : null} | ||||
sx={ | ||||
buttonSemantics | ||||
? merge<BetterSystemStyleObject>( | ||||
listSemantics || _PrivateItemWrapper ? styles : listItemStyles, | ||||
listSemantics || _PrivateItemWrapper ? sxProp : {}, | ||||
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 am not sure if I understand this line, sorry. Does that mean we don't allow custom overrides for 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. No worries! This is basically saying that, if the When it's not Let me know if this still makes sense, or if you have suggestions on how we can make this more readable 🤔 |
||||
) | ||||
: merge<BetterSystemStyleObject>(styles, sxProp) | ||||
} | ||||
data-variant={variant === 'danger' ? variant : undefined} | ||||
{...containerProps} | ||||
{...props} | ||||
> | ||||
<ItemWrapper {...wrapperProps}> | ||||
<Selection selected={selected} /> | ||||
|
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 wonder if we can enable the flag in all our stories and see how the changes are working?
I haven't tried it but maybe wrapping the story here with the feature flag component? 🤔
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.
We definitely could! But would we want to keep this on even after we ship this PR, or would it just during this PR?
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.
Sorry for the late response! I was thinking enabling the ff in this PR to make sure the changes are working as expected before releasing it to dotcom. It is like testing it in the small surface area (primer/react) and once we confirm that the changes are good, we can release.
This is only a suggestion though. If you think we can directly release it that is okay 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.
Added! Should now be enabled for all stories!