Skip to content
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

Merged
merged 31 commits into from
Aug 30, 2024
Merged

RAC Pending Button #6435

merged 31 commits into from
Aug 30, 2024

Conversation

snowystinger
Copy link
Member

@snowystinger snowystinger commented May 23, 2024

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:

  • 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:

@rspbot
Copy link

rspbot commented May 23, 2024

packages/react-aria-components/src/Button.tsx Outdated Show resolved Hide resolved
packages/react-aria-components/src/Button.tsx Outdated Show resolved Hide resolved
packages/react-aria-components/src/Button.tsx Outdated Show resolved Hide resolved
@rspbot
Copy link

rspbot commented Jun 11, 2024

@rspbot
Copy link

rspbot commented Jun 11, 2024

@rspbot
Copy link

rspbot commented Jun 11, 2024

# Conflicts:
#	packages/react-aria-components/package.json
@rspbot
Copy link

rspbot commented Jun 23, 2024

@rspbot
Copy link

rspbot commented Jun 23, 2024

@rspbot
Copy link

rspbot commented Jun 30, 2024

@rspbot
Copy link

rspbot commented Jul 25, 2024

@rspbot
Copy link

rspbot commented Aug 20, 2024

/**
* 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?
Copy link
Member Author

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

Copy link
Member

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

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();
Copy link
Member Author

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

@rspbot
Copy link

rspbot commented Aug 20, 2024

@rspbot
Copy link

rspbot commented Aug 20, 2024

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.

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?
Copy link
Member

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?

packages/react-aria-components/src/Button.tsx Outdated Show resolved Hide resolved
packages/react-aria-components/src/Button.tsx Outdated Show resolved Hide resolved
@rspbot
Copy link

rspbot commented Aug 29, 2024

@rspbot
Copy link

rspbot commented Aug 29, 2024

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.

LGTM, open to feedback on the new LiveAnnouncer option naming

@rspbot
Copy link

rspbot commented Aug 30, 2024

@rspbot
Copy link

rspbot commented Aug 30, 2024

## 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'
 }

@snowystinger snowystinger merged commit 3555336 into main Aug 30, 2024
29 checks passed
@snowystinger snowystinger deleted the rac-pending-button branch August 30, 2024 01:02

// 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();
Copy link
Member

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

Copy link
Member Author

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'
Copy link
Member

Choose a reason for hiding this comment

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

?

Copy link
Member Author

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);
};
}, []);
Copy link
Member

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}]
Copy link
Member

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?

Copy link
Member Author

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

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

Copy link
Member Author

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

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

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

Successfully merging this pull request may close these issues.

Allow disabled items to be focused
8 participants