-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
RAC Pending Button #6435
RAC Pending Button #6435
Conversation
# Conflicts: # packages/react-aria-components/stories/Button.stories.tsx
# Conflicts: # packages/react-aria-components/package.json
8cb1a5d
to
3013156
Compare
/** | ||
* Announces the message using screen reader technology. | ||
*/ | ||
export function announce( | ||
message: string, | ||
assertiveness: Assertiveness = 'assertive', | ||
timeout = LIVEREGION_TIMEOUT_DELAY | ||
timeout = LIVEREGION_TIMEOUT_DELAY, | ||
isIds = false // better name? better description? |
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 so i could pass ids to live announcer instead of a string message. this allows me to announce anything the same way without needed to extract the textContent or anything else
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.
announceNode/Element or something like that? Maybe this could be a mode
arg or something that takes 'string | id' and defaults to string?
@@ -3228,7 +3228,9 @@ describe('SearchAutocomplete', function () { | |||
|
|||
let listbox = getByRole('listbox'); | |||
expect(listbox).toBeVisible(); | |||
expect(screen.getAllByRole('log')).toHaveLength(2); | |||
expect(announce).toHaveBeenCalledTimes(2); |
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 tests were honestly checking something silly, so I've updated them to check useful information
@@ -40,7 +40,7 @@ describe('ColorPicker', function () { | |||
|
|||
let button = getByRole('button'); | |||
expect(button).toHaveTextContent('Fill'); | |||
expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'vibrant red'); | |||
expect(within(button).getByLabelText('vibrant red')).toBeInTheDocument(); |
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.
was a brittle test, these will last better
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just some small comments, but otherwise verified the behavior on Chrome and Safari
/** | ||
* Announces the message using screen reader technology. | ||
*/ | ||
export function announce( | ||
message: string, | ||
assertiveness: Assertiveness = 'assertive', | ||
timeout = LIVEREGION_TIMEOUT_DELAY | ||
timeout = LIVEREGION_TIMEOUT_DELAY, | ||
isIds = false // better name? better description? |
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.
announceNode/Element or something like that? Maybe this could be a mode
arg or something that takes 'string | id' and defaults to string?
# Conflicts: # packages/react-aria-components/package.json # yarn.lock
…into rac-pending-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.
LGTM, open to feedback on the new LiveAnnouncer option naming
## API Changes
react-aria-components/react-aria-components:Button Button {
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode | ((ButtonRenderProps & {
defaultChildren: ReactNode | undefined
})) => ReactNode
className?: string | ((ButtonRenderProps & {
defaultClassName: string | undefined
})) => string
excludeFromTabOrder?: boolean
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onFocusChange?: (boolean) => void
onHoverEnd?: (HoverEvent) => void
onHoverStart?: (HoverEvent) => void
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
slot?: string | null
style?: CSSProperties | ((ButtonRenderProps & {
defaultStyle: CSSProperties
})) => CSSProperties
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
} /react-aria-components:ButtonProps ButtonProps {
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode | ((ButtonRenderProps & {
defaultChildren: ReactNode | undefined
})) => ReactNode
className?: string | ((ButtonRenderProps & {
defaultClassName: string | undefined
})) => string
excludeFromTabOrder?: boolean
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onFocusChange?: (boolean) => void
onHoverEnd?: (HoverEvent) => void
onHoverStart?: (HoverEvent) => void
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
slot?: string | null
style?: CSSProperties | ((ButtonRenderProps & {
defaultStyle: CSSProperties
})) => CSSProperties
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
} /react-aria-components:ButtonRenderProps ButtonRenderProps {
isDisabled: boolean
isFocusVisible: boolean
isFocused: boolean
isHovered: boolean
+ isPending?: boolean
isPressed: boolean
} @react-aria/live-announcer/@react-aria/live-announcer:announce announce {
message: string
assertiveness: Assertiveness
timeout: any
+ mode: 'message' | 'ids'
returnVal: undefined
} @react-spectrum/s2/@react-spectrum/s2:ActionButton ActionButton {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode
excludeFromTabOrder?: boolean
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
isQuiet?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
slot?: string | null
staticColor?: 'black' | 'white'
styles?: StylesProp
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
} /@react-spectrum/s2:Button Button {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode
excludeFromTabOrder?: boolean
fillStyle?: 'fill' | 'outline' = 'fill'
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onFocusChange?: (boolean) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
size?: 'S' | 'M' | 'L' | 'XL' = 'M'
slot?: string | null
staticColor?: 'white' | 'black'
styles?: StylesProp
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
variant?: 'primary' | 'secondary' | 'accent' | 'negative' = 'primary'
} /@react-spectrum/s2:ActionButtonProps ActionButtonProps {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode
excludeFromTabOrder?: boolean
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
isQuiet?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
slot?: string | null
staticColor?: 'black' | 'white'
styles?: StylesProp
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
} /@react-spectrum/s2:ButtonProps ButtonProps {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
aria-controls?: string
aria-describedby?: string
aria-details?: string
aria-expanded?: boolean | 'true' | 'false'
aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
aria-label?: string
aria-labelledby?: string
aria-pressed?: boolean | 'true' | 'false' | 'mixed'
autoFocus?: boolean
children?: ReactNode
excludeFromTabOrder?: boolean
fillStyle?: 'fill' | 'outline' = 'fill'
form?: string
formAction?: string
formEncType?: string
formMethod?: string
formNoValidate?: boolean
formTarget?: string
id?: string
isDisabled?: boolean
+ isPending?: boolean
name?: string
onBlur?: (FocusEvent<Target>) => void
onFocus?: (FocusEvent<Target>) => void
onFocusChange?: (boolean) => void
onKeyUp?: (KeyboardEvent) => void
onPress?: (PressEvent) => void
onPressChange?: (boolean) => void
onPressEnd?: (PressEvent) => void
onPressStart?: (PressEvent) => void
onPressUp?: (PressEvent) => void
preventFocusOnPress?: boolean
size?: 'S' | 'M' | 'L' | 'XL' = 'M'
slot?: string | null
staticColor?: 'white' | 'black'
styles?: StylesProp
type?: 'button' | 'submit' | 'reset' = 'button'
value?: string
variant?: 'primary' | 'secondary' | 'accent' | 'negative' = 'primary'
} |
|
||
// singleton, setup immediately so that the DOM is primed for the first announcement as soon as possible | ||
// Safari has a race condition where the first announcement is not read if we wait until the first announce call | ||
let liveAnnouncer: LiveAnnouncer | null = new LiveAnnouncer(); |
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.
That means it can never be tree shaken anymore
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.
ah bugger, ok, will look into that as well
/** | ||
* Announces the message using screen reader technology. | ||
*/ | ||
export function announce( | ||
message: string, | ||
assertiveness: Assertiveness = 'assertive', | ||
timeout = LIVEREGION_TIMEOUT_DELAY | ||
timeout = LIVEREGION_TIMEOUT_DELAY, | ||
mode: 'message' | 'ids' = '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.
?
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.
be able to send in a set of id's for use in aria-labelledby
which is then live announced
vs sending the message in directly. this allows us to send messages which would otherwise require textContent or some other not friendly way of converting HTML to a string
return () => { | ||
clearTimeout(timeout.current); | ||
}; | ||
}, []); |
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 it's just a demo we can probably assume that it's not gonna unmount before the timeout. Nice to keep the example simple.
<Provider | ||
values={[ | ||
[TextContext, {id: contentId, ref: textCallbackRef}], | ||
[ProgressBarContext, {id: progressId, style: {display: isPending ? undefined : 'none'}, isIndeterminate: true, ref: progressCallbackRef}] |
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 if the developer unmounts the progress bar rather than using display none? Isn't it a bit of an assumption that they'd do it this way?
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.
that's what the useEffect thrown errors are for, to tell people it won't work that way
I agree though, this is less than ideal
]}> | ||
{renderProps.children} | ||
</Provider> | ||
<VisuallyHidden> |
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.
Would be nice to avoid extra elements in all buttons when they aren't using pending state
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, now that i'm using the LiveAnnouncer, I'm trying to see if I can just not render this unless we're actually pending
|
||
const isPendingAriaLiveLabel = `${hasAriaLabel ? buttonProps['aria-label'] : ''}`.trim(); | ||
let isPendingAriaLiveLabelledby = hasAriaLabel ? (buttonProps['aria-labelledby']?.replace(buttonId, safariDupeLabellingId) ?? safariDupeLabellingId) : `${contentId} ${safariDupeLabellingId}`.trim(); | ||
isPendingAriaLiveLabelledby = isPendingAriaLiveLabelledby + ' ' + progressId; |
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.
Would be nice to document what all this stuff does
Closes #3662
I've opted to re-implement it in RAC as opposed to pushing it down into the hooks because the hacks we use to work around AT require a bunch of dom nodes, which either leads to a lot of oddly named collections of element props being returned by the useButton hook, or a new hook which handles it. Either way results in API additions which would be not great to maintain.
✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project: