diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 0e818675496..6557ab7d789 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -492,7 +492,10 @@ class DragSession { // Announce first drop target after drag start announcement finishes. // Otherwise, it will never get announced because drag start announcement is assertive. if (!this.initialFocused) { - announce(item?.element.getAttribute('aria-label'), 'polite'); + let label = item?.element.getAttribute('aria-label'); + if (label) { + announce(label, 'polite'); + } this.initialFocused = true; } } diff --git a/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx b/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx index ae35d8098db..6e30d6a3811 100644 --- a/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx +++ b/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx @@ -17,19 +17,38 @@ const LIVEREGION_TIMEOUT_DELAY = 7000; let liveAnnouncer: LiveAnnouncer | null = null; +type Message = string | {'aria-labelledby': string}; + /** * Announces the message using screen reader technology. */ export function announce( - message: string, + message: Message, assertiveness: Assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY ) { if (!liveAnnouncer) { liveAnnouncer = new LiveAnnouncer(); + // wait for the live announcer regions to be added to the dom, then announce + // otherwise Safari won't announce the message if it's added too quickly + // found most times less than 100ms were not consistent when announcing with Safari + + // IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global. + // https://github.com/reactwg/react-18/discussions/102 + // if we're in a test environment, announce without waiting + // @ts-ignore + if (!(typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined')) { + setTimeout(() => { + if (liveAnnouncer?.isAttached()) { + liveAnnouncer?.announce(message, assertiveness, timeout); + } + }, 100); + } else { + liveAnnouncer.announce(message, assertiveness, timeout); + } + } else { + liveAnnouncer.announce(message, assertiveness, timeout); } - - liveAnnouncer.announce(message, assertiveness, timeout); } /** @@ -58,34 +77,40 @@ export function destroyAnnouncer() { // is simple enough to implement without React, so that's what we do here. // See this discussion for more details: https://github.com/reactwg/react-18/discussions/125#discussioncomment-2382638 class LiveAnnouncer { - node: HTMLElement | null; - assertiveLog: HTMLElement; - politeLog: HTMLElement; + node: HTMLElement | null = null; + assertiveLog: HTMLElement | null = null; + politeLog: HTMLElement | null = null; constructor() { - this.node = document.createElement('div'); - this.node.dataset.liveAnnouncer = 'true'; - // copied from VisuallyHidden - Object.assign(this.node.style, { - border: 0, - clip: 'rect(0 0 0 0)', - clipPath: 'inset(50%)', - height: '1px', - margin: '-1px', - overflow: 'hidden', - padding: 0, - position: 'absolute', - width: '1px', - whiteSpace: 'nowrap' - }); - - this.assertiveLog = this.createLog('assertive'); - this.node.appendChild(this.assertiveLog); - - this.politeLog = this.createLog('polite'); - this.node.appendChild(this.politeLog); - - document.body.prepend(this.node); + if (typeof document !== 'undefined') { + this.node = document.createElement('div'); + this.node.dataset.liveAnnouncer = 'true'; + // copied from VisuallyHidden + Object.assign(this.node.style, { + border: 0, + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + width: '1px', + whiteSpace: 'nowrap' + }); + + this.assertiveLog = this.createLog('assertive'); + this.node.appendChild(this.assertiveLog); + + this.politeLog = this.createLog('polite'); + this.node.appendChild(this.politeLog); + + document.body.prepend(this.node); + } + } + + isAttached() { + return this.node?.isConnected; } createLog(ariaLive: string) { @@ -105,18 +130,24 @@ class LiveAnnouncer { this.node = null; } - announce(message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) { + announce(message: Message, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) { if (!this.node) { return; } let node = document.createElement('div'); - node.textContent = message; + if (typeof message === 'object') { + // To read an aria-labelledby, the element must have an appropriate role, such as img. + node.setAttribute('role', 'img'); + node.setAttribute('aria-labelledby', message['aria-labelledby']); + } else { + node.textContent = message; + } if (assertiveness === 'assertive') { - this.assertiveLog.appendChild(node); + this.assertiveLog?.appendChild(node); } else { - this.politeLog.appendChild(node); + this.politeLog?.appendChild(node); } if (message !== '') { @@ -131,11 +162,11 @@ class LiveAnnouncer { return; } - if (!assertiveness || assertiveness === 'assertive') { + if ((!assertiveness || assertiveness === 'assertive') && this.assertiveLog) { this.assertiveLog.innerHTML = ''; } - if (!assertiveness || assertiveness === 'polite') { + if ((!assertiveness || assertiveness === 'polite') && this.politeLog) { this.politeLog.innerHTML = ''; } } diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index db922ee18e4..553c7c38f6c 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, screen, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import Filter from '@spectrum-icons/workflow/Filter'; @@ -3228,7 +3228,9 @@ describe('SearchAutocomplete', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); - expect(screen.getAllByRole('log')).toHaveLength(2); + expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenNthCalledWith(1, '3 options available.'); + expect(announce).toHaveBeenNthCalledWith(2, 'One'); platformMock.mockRestore(); }); diff --git a/packages/@react-spectrum/color/test/ColorPicker.test.js b/packages/@react-spectrum/color/test/ColorPicker.test.js index 81c1611ab42..f85a9091e77 100644 --- a/packages/@react-spectrum/color/test/ColorPicker.test.js +++ b/packages/@react-spectrum/color/test/ColorPicker.test.js @@ -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(); await user.click(button); @@ -67,7 +67,7 @@ describe('ColorPicker', function () { act(() => dialog.focus()); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); - expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'dark vibrant blue'); + expect(within(button).getByLabelText('dark vibrant blue')).toBeInTheDocument(); }); it('should have default value of black', async function () { @@ -81,7 +81,7 @@ describe('ColorPicker', function () { let button = getByRole('button'); expect(button).toHaveTextContent('Fill'); - expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'black'); + expect(within(button).getByLabelText('black')).toBeInTheDocument(); await user.click(button); @@ -132,6 +132,6 @@ describe('ColorPicker', function () { act(() => getByRole('dialog').focus()); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); - expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'vibrant orange'); + expect(within(button).getByLabelText('vibrant orange')).toBeInTheDocument(); }); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 6dd7e03003b..4322c8104f1 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, screen, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; @@ -5149,7 +5149,9 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); - expect(screen.getAllByRole('log')).toHaveLength(2); + expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenNthCalledWith(1, '3 options available.'); + expect(announce).toHaveBeenNthCalledWith(2, 'One'); platformMock.mockRestore(); }); diff --git a/packages/@react-spectrum/provider/test/Provider.test.tsx b/packages/@react-spectrum/provider/test/Provider.test.tsx index 85ccbaff1ed..5c7fbca3d87 100644 --- a/packages/@react-spectrum/provider/test/Provider.test.tsx +++ b/packages/@react-spectrum/provider/test/Provider.test.tsx @@ -10,13 +10,10 @@ * governing permissions and limitations under the License. */ -// needs to be imported first -// eslint-disable-next-line -import MatchMediaMock from 'jest-matchmedia-mock'; -// eslint-disable-next-line rsp-rules/sort-imports import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {ActionButton, Button} from '@react-spectrum/button'; import {Checkbox} from '@react-spectrum/checkbox'; +import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '../'; // eslint-disable-next-line rulesdir/useLayoutEffectRule import React, {useLayoutEffect, useRef} from 'react'; diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index 3cec174cd28..16238a69f8e 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -1,4 +1,5 @@ { + "button.pending": "قيد الانتظار", "contextualhelp.help": "مساعدة", "contextualhelp.info": "معلومات", "dialog.alert": "تنبيه", diff --git a/packages/@react-spectrum/s2/intl/bg-BG.json b/packages/@react-spectrum/s2/intl/bg-BG.json index 4f4a38e8b60..de5c2e5632b 100644 --- a/packages/@react-spectrum/s2/intl/bg-BG.json +++ b/packages/@react-spectrum/s2/intl/bg-BG.json @@ -1,4 +1,5 @@ { + "button.pending": "недовършено", "contextualhelp.help": "Помощ", "contextualhelp.info": "Информация", "dialog.alert": "Сигнал", diff --git a/packages/@react-spectrum/s2/intl/cs-CZ.json b/packages/@react-spectrum/s2/intl/cs-CZ.json index 4b27c040160..cc4c0d8b551 100644 --- a/packages/@react-spectrum/s2/intl/cs-CZ.json +++ b/packages/@react-spectrum/s2/intl/cs-CZ.json @@ -1,4 +1,5 @@ { + "button.pending": "čeká na vyřízení", "contextualhelp.help": "Nápověda", "contextualhelp.info": "Informace", "dialog.alert": "Výstraha", diff --git a/packages/@react-spectrum/s2/intl/da-DK.json b/packages/@react-spectrum/s2/intl/da-DK.json index 75b6c04a7f7..1db374712cd 100644 --- a/packages/@react-spectrum/s2/intl/da-DK.json +++ b/packages/@react-spectrum/s2/intl/da-DK.json @@ -1,4 +1,5 @@ { + "button.pending": "afventende", "contextualhelp.help": "Hjælp", "contextualhelp.info": "Oplysninger", "dialog.alert": "Advarsel", diff --git a/packages/@react-spectrum/s2/intl/de-DE.json b/packages/@react-spectrum/s2/intl/de-DE.json index 3341d224c19..47a4c66cf8f 100644 --- a/packages/@react-spectrum/s2/intl/de-DE.json +++ b/packages/@react-spectrum/s2/intl/de-DE.json @@ -1,4 +1,5 @@ { + "button.pending": "Ausstehend", "contextualhelp.help": "Hilfe", "contextualhelp.info": "Informationen", "dialog.alert": "Warnhinweis", diff --git a/packages/@react-spectrum/s2/intl/el-GR.json b/packages/@react-spectrum/s2/intl/el-GR.json index e2228e21689..667d76265f3 100644 --- a/packages/@react-spectrum/s2/intl/el-GR.json +++ b/packages/@react-spectrum/s2/intl/el-GR.json @@ -1,4 +1,5 @@ { + "button.pending": "σε εκκρεμότητα", "contextualhelp.help": "Βοήθεια", "contextualhelp.info": "Πληροφορίες", "dialog.alert": "Ειδοποίηση", diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index fcf594f23d1..efc4ad24bc0 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -1,4 +1,5 @@ { + "button.pending": "pending", "contextualhelp.info": "Information", "contextualhelp.help": "Help", "dialog.dismiss": "Dismiss", diff --git a/packages/@react-spectrum/s2/intl/es-ES.json b/packages/@react-spectrum/s2/intl/es-ES.json index a8f500adade..3170e2ffc75 100644 --- a/packages/@react-spectrum/s2/intl/es-ES.json +++ b/packages/@react-spectrum/s2/intl/es-ES.json @@ -1,4 +1,5 @@ { + "button.pending": "pendiente", "contextualhelp.help": "Ayuda", "contextualhelp.info": "Información", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/et-EE.json b/packages/@react-spectrum/s2/intl/et-EE.json index da18bfe4286..a0fa8843a85 100644 --- a/packages/@react-spectrum/s2/intl/et-EE.json +++ b/packages/@react-spectrum/s2/intl/et-EE.json @@ -1,4 +1,5 @@ { + "button.pending": "ootel", "contextualhelp.help": "Spikker", "contextualhelp.info": "Teave", "dialog.alert": "Teade", diff --git a/packages/@react-spectrum/s2/intl/fi-FI.json b/packages/@react-spectrum/s2/intl/fi-FI.json index 9de3af4b627..3c0daac680d 100644 --- a/packages/@react-spectrum/s2/intl/fi-FI.json +++ b/packages/@react-spectrum/s2/intl/fi-FI.json @@ -1,4 +1,5 @@ { + "button.pending": "odottaa", "contextualhelp.help": "Ohje", "contextualhelp.info": "Tiedot", "dialog.alert": "Hälytys", diff --git a/packages/@react-spectrum/s2/intl/fr-FR.json b/packages/@react-spectrum/s2/intl/fr-FR.json index 8f92a05a259..67cb90e9910 100644 --- a/packages/@react-spectrum/s2/intl/fr-FR.json +++ b/packages/@react-spectrum/s2/intl/fr-FR.json @@ -1,4 +1,5 @@ { + "button.pending": "En attente", "contextualhelp.help": "Aide", "contextualhelp.info": "Informations", "dialog.alert": "Alerte", diff --git a/packages/@react-spectrum/s2/intl/he-IL.json b/packages/@react-spectrum/s2/intl/he-IL.json index 18fad82e754..98a174053ba 100644 --- a/packages/@react-spectrum/s2/intl/he-IL.json +++ b/packages/@react-spectrum/s2/intl/he-IL.json @@ -1,4 +1,5 @@ { + "button.pending": "ממתין ל", "contextualhelp.help": "עזרה", "contextualhelp.info": "מידע", "dialog.alert": "התראה", diff --git a/packages/@react-spectrum/s2/intl/hr-HR.json b/packages/@react-spectrum/s2/intl/hr-HR.json index e7a52b2aecd..c62ebc64e75 100644 --- a/packages/@react-spectrum/s2/intl/hr-HR.json +++ b/packages/@react-spectrum/s2/intl/hr-HR.json @@ -1,4 +1,5 @@ { + "button.pending": "u tijeku", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", "dialog.alert": "Upozorenje", diff --git a/packages/@react-spectrum/s2/intl/hu-HU.json b/packages/@react-spectrum/s2/intl/hu-HU.json index a25fc093fd9..aba333c4292 100644 --- a/packages/@react-spectrum/s2/intl/hu-HU.json +++ b/packages/@react-spectrum/s2/intl/hu-HU.json @@ -1,4 +1,5 @@ { + "button.pending": "függőben levő", "contextualhelp.help": "Súgó", "contextualhelp.info": "Információ", "dialog.alert": "Figyelmeztetés", diff --git a/packages/@react-spectrum/s2/intl/it-IT.json b/packages/@react-spectrum/s2/intl/it-IT.json index 09f2bd4e477..fafd9ff8852 100644 --- a/packages/@react-spectrum/s2/intl/it-IT.json +++ b/packages/@react-spectrum/s2/intl/it-IT.json @@ -1,4 +1,5 @@ { + "button.pending": "in sospeso", "contextualhelp.help": "Aiuto", "contextualhelp.info": "Informazioni", "dialog.alert": "Avviso", diff --git a/packages/@react-spectrum/s2/intl/ja-JP.json b/packages/@react-spectrum/s2/intl/ja-JP.json index dd0c12e3507..d024a199b14 100644 --- a/packages/@react-spectrum/s2/intl/ja-JP.json +++ b/packages/@react-spectrum/s2/intl/ja-JP.json @@ -1,4 +1,5 @@ { + "button.pending": "保留", "contextualhelp.help": "ヘルプ", "contextualhelp.info": "情報", "dialog.alert": "アラート", diff --git a/packages/@react-spectrum/s2/intl/ko-KR.json b/packages/@react-spectrum/s2/intl/ko-KR.json index aa53b7ad523..96d28ef46cb 100644 --- a/packages/@react-spectrum/s2/intl/ko-KR.json +++ b/packages/@react-spectrum/s2/intl/ko-KR.json @@ -1,4 +1,5 @@ { + "button.pending": "보류 중", "contextualhelp.help": "도움말", "contextualhelp.info": "정보", "dialog.alert": "경고", diff --git a/packages/@react-spectrum/s2/intl/lt-LT.json b/packages/@react-spectrum/s2/intl/lt-LT.json index 161911a8dc5..0dc7880fa05 100644 --- a/packages/@react-spectrum/s2/intl/lt-LT.json +++ b/packages/@react-spectrum/s2/intl/lt-LT.json @@ -1,4 +1,5 @@ { + "button.pending": "laukiama", "contextualhelp.help": "Žinynas", "contextualhelp.info": "Informacija", "dialog.alert": "Įspėjimas", diff --git a/packages/@react-spectrum/s2/intl/lv-LV.json b/packages/@react-spectrum/s2/intl/lv-LV.json index cff522d2104..5ffba9f1fe2 100644 --- a/packages/@react-spectrum/s2/intl/lv-LV.json +++ b/packages/@react-spectrum/s2/intl/lv-LV.json @@ -1,4 +1,5 @@ { + "button.pending": "gaida", "contextualhelp.help": "Palīdzība", "contextualhelp.info": "Informācija", "dialog.alert": "Brīdinājums", diff --git a/packages/@react-spectrum/s2/intl/nb-NO.json b/packages/@react-spectrum/s2/intl/nb-NO.json index d119fae4b60..7d07f105644 100644 --- a/packages/@react-spectrum/s2/intl/nb-NO.json +++ b/packages/@react-spectrum/s2/intl/nb-NO.json @@ -1,4 +1,5 @@ { + "button.pending": "avventer", "contextualhelp.help": "Hjelp", "contextualhelp.info": "Informasjon", "dialog.alert": "Varsel", diff --git a/packages/@react-spectrum/s2/intl/nl-NL.json b/packages/@react-spectrum/s2/intl/nl-NL.json index a939246d939..92bb0ffb158 100644 --- a/packages/@react-spectrum/s2/intl/nl-NL.json +++ b/packages/@react-spectrum/s2/intl/nl-NL.json @@ -1,4 +1,5 @@ { + "button.pending": "in behandeling", "contextualhelp.help": "Help", "contextualhelp.info": "Informatie", "dialog.alert": "Melding", diff --git a/packages/@react-spectrum/s2/intl/pl-PL.json b/packages/@react-spectrum/s2/intl/pl-PL.json index d3f5a76619b..55967b46406 100644 --- a/packages/@react-spectrum/s2/intl/pl-PL.json +++ b/packages/@react-spectrum/s2/intl/pl-PL.json @@ -1,4 +1,5 @@ { + "button.pending": "oczekujące", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informacja", "dialog.alert": "Ostrzeżenie", diff --git a/packages/@react-spectrum/s2/intl/pt-BR.json b/packages/@react-spectrum/s2/intl/pt-BR.json index 5ce61a8d2f9..d3f4b5685c7 100644 --- a/packages/@react-spectrum/s2/intl/pt-BR.json +++ b/packages/@react-spectrum/s2/intl/pt-BR.json @@ -1,4 +1,5 @@ { + "button.pending": "pendente", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informações", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/pt-PT.json b/packages/@react-spectrum/s2/intl/pt-PT.json index 91a7bf758dd..20b09a23538 100644 --- a/packages/@react-spectrum/s2/intl/pt-PT.json +++ b/packages/@react-spectrum/s2/intl/pt-PT.json @@ -1,4 +1,5 @@ { + "button.pending": "pendente", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informação", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/ro-RO.json b/packages/@react-spectrum/s2/intl/ro-RO.json index c686f693d3a..1ff99c976a7 100644 --- a/packages/@react-spectrum/s2/intl/ro-RO.json +++ b/packages/@react-spectrum/s2/intl/ro-RO.json @@ -1,4 +1,5 @@ { + "button.pending": "în așteptare", "contextualhelp.help": "Ajutor", "contextualhelp.info": "Informaţii", "dialog.alert": "Alertă", diff --git a/packages/@react-spectrum/s2/intl/ru-RU.json b/packages/@react-spectrum/s2/intl/ru-RU.json index 2a4a73f9841..be0e4edca8d 100644 --- a/packages/@react-spectrum/s2/intl/ru-RU.json +++ b/packages/@react-spectrum/s2/intl/ru-RU.json @@ -1,4 +1,5 @@ { + "button.pending": "в ожидании", "contextualhelp.help": "Справка", "contextualhelp.info": "Информация", "dialog.alert": "Предупреждение", diff --git a/packages/@react-spectrum/s2/intl/sk-SK.json b/packages/@react-spectrum/s2/intl/sk-SK.json index ea3a205ca17..65e9aa4918c 100644 --- a/packages/@react-spectrum/s2/intl/sk-SK.json +++ b/packages/@react-spectrum/s2/intl/sk-SK.json @@ -1,4 +1,5 @@ { + "button.pending": "čakajúce", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informácie", "dialog.alert": "Upozornenie", diff --git a/packages/@react-spectrum/s2/intl/sl-SI.json b/packages/@react-spectrum/s2/intl/sl-SI.json index b0bf48b2710..99ca9b2da1b 100644 --- a/packages/@react-spectrum/s2/intl/sl-SI.json +++ b/packages/@react-spectrum/s2/intl/sl-SI.json @@ -1,4 +1,5 @@ { + "button.pending": "v teku", "contextualhelp.help": "Pomoč", "contextualhelp.info": "Informacije", "dialog.alert": "Opozorilo", diff --git a/packages/@react-spectrum/s2/intl/sr-SP.json b/packages/@react-spectrum/s2/intl/sr-SP.json index e8415d64da8..6ba5eba54c7 100644 --- a/packages/@react-spectrum/s2/intl/sr-SP.json +++ b/packages/@react-spectrum/s2/intl/sr-SP.json @@ -1,4 +1,5 @@ { + "button.pending": "nerešeno", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", "dialog.alert": "Upozorenje", diff --git a/packages/@react-spectrum/s2/intl/sv-SE.json b/packages/@react-spectrum/s2/intl/sv-SE.json index f519e51a167..c8263afc6fe 100644 --- a/packages/@react-spectrum/s2/intl/sv-SE.json +++ b/packages/@react-spectrum/s2/intl/sv-SE.json @@ -1,4 +1,5 @@ { + "button.pending": "väntande", "contextualhelp.help": "Hjälp", "contextualhelp.info": "Information", "dialog.alert": "Varning", diff --git a/packages/@react-spectrum/s2/intl/tr-TR.json b/packages/@react-spectrum/s2/intl/tr-TR.json index 44c58b7e27e..08129dd4161 100644 --- a/packages/@react-spectrum/s2/intl/tr-TR.json +++ b/packages/@react-spectrum/s2/intl/tr-TR.json @@ -1,4 +1,5 @@ { + "button.pending": "beklemede", "contextualhelp.help": "Yardım", "contextualhelp.info": "Bilgiler", "dialog.alert": "Uyarı", diff --git a/packages/@react-spectrum/s2/intl/uk-UA.json b/packages/@react-spectrum/s2/intl/uk-UA.json index 66164bfe48e..3295871900c 100644 --- a/packages/@react-spectrum/s2/intl/uk-UA.json +++ b/packages/@react-spectrum/s2/intl/uk-UA.json @@ -1,4 +1,5 @@ { + "button.pending": "в очікуванні", "contextualhelp.help": "Довідка", "contextualhelp.info": "Інформація", "dialog.alert": "Сигнал тривоги", diff --git a/packages/@react-spectrum/s2/intl/zh-CN.json b/packages/@react-spectrum/s2/intl/zh-CN.json index c48795c6d74..eeb3310c5fa 100644 --- a/packages/@react-spectrum/s2/intl/zh-CN.json +++ b/packages/@react-spectrum/s2/intl/zh-CN.json @@ -1,4 +1,5 @@ { + "button.pending": "待处理", "contextualhelp.help": "帮助", "contextualhelp.info": "信息", "dialog.alert": "警报", diff --git a/packages/@react-spectrum/s2/intl/zh-TW.json b/packages/@react-spectrum/s2/intl/zh-TW.json index f2494e26ba3..544c5f8e919 100644 --- a/packages/@react-spectrum/s2/intl/zh-TW.json +++ b/packages/@react-spectrum/s2/intl/zh-TW.json @@ -1,4 +1,5 @@ { + "button.pending": "待處理", "contextualhelp.help": "說明", "contextualhelp.info": "資訊", "dialog.alert": "警示", diff --git a/packages/@react-spectrum/s2/src/Button.tsx b/packages/@react-spectrum/s2/src/Button.tsx index 6c12a7e0d1e..3e1b99b45b4 100644 --- a/packages/@react-spectrum/s2/src/Button.tsx +++ b/packages/@react-spectrum/s2/src/Button.tsx @@ -14,14 +14,18 @@ import {baseColor, fontRelative, style} from '../style/spectrum-theme' with {typ import {ButtonRenderProps, ContextValue, Link, LinkProps, OverlayTriggerStateContext, Provider, Button as RACButton, ButtonProps as RACButtonProps} from 'react-aria-components'; import {centerBaseline} from './CenterBaseline'; import {centerPadding, focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useEffect, useState} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {IconContext} from './Icon'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {pressScale} from './pressScale'; +import {ProgressCircle} from './ProgressCircle'; import {SkeletonContext} from './Skeleton'; import {Text, TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; import {useFormProps} from './Form'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface ButtonStyleProps { @@ -62,10 +66,11 @@ export const LinkButtonContext = createContext({ ...focusRing(), + position: 'relative', display: 'flex', alignItems: { default: 'baseline', - ':has([slot=icon]:only-child)': 'center' + ':has([slot=icon]):not(:has([data-rsp-slot=text]))': 'center' }, justifyContent: 'center', textAlign: 'start', @@ -75,7 +80,7 @@ const button = style({ userSelect: 'none', minHeight: 'control', minWidth: { - ':has([slot=icon]:only-child)': 'control' + ':has([slot=icon]):not(:has([data-rsp-slot=text]))': 'control' }, borderRadius: 'pill', boxSizing: 'border-box', @@ -83,11 +88,11 @@ const button = style({ textDecoration: 'none', // for link buttons paddingX: { default: 'pill', - ':has([slot=icon]:only-child)': 0 + ':has([slot=icon]):not(:has([data-rsp-slot=text]))': 0 }, paddingY: 0, aspectRatio: { - ':has([slot=icon]:only-child)': 'square' + ':has([slot=icon]):not(:has([data-rsp-slot=text]))': 'square' }, transition: 'default', borderStyle: 'solid', @@ -105,7 +110,7 @@ const button = style({ type: 'marginTop', value: { default: fontRelative(-2), - ':has([slot=icon]:only-child)': 0 + ':has([slot=icon]):not(:has([data-rsp-slot=text]))': 0 } }, borderColor: { @@ -151,7 +156,8 @@ const button = style({ default: 'transparent', isHovered: 'gray-100', isPressed: 'gray-100', - isFocusVisible: 'gray-100' + isFocusVisible: 'gray-100', + isDisabled: 'transparent' } }, staticColor: { @@ -168,7 +174,8 @@ const button = style({ default: 'transparent', isHovered: 'transparent-white-100', isPressed: 'transparent-white-100', - isFocusVisible: 'transparent-white-100' + isFocusVisible: 'transparent-white-100', + isDisabled: 'transparent' } } }, @@ -185,7 +192,8 @@ const button = style({ default: 'transparent', isHovered: 'transparent-black-100', isPressed: 'transparent-black-100', - isFocusVisible: 'transparent-black-100' + isFocusVisible: 'transparent-black-100', + isDisabled: 'transparent' } } } @@ -275,9 +283,36 @@ const button = style({ function Button(props: ButtonProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, ButtonContext); props = useFormProps(props); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let { + isPending, + variant = 'primary', + fillStyle = 'fill', + size = 'M', + staticColor + } = props; let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); + let [isProgressVisible, setIsProgressVisible] = useState(false); + useEffect(() => { + let timeout: ReturnType; + + if (isPending) { + // Start timer when isPending is set to true. + timeout = setTimeout(() => { + setIsProgressVisible(true); + }, 1000); + } else { + // Exit loading state when isPending is set to false. */ + setIsProgressVisible(false); + } + return () => { + // Clean up on unmount or when user removes isPending prop before entering loading state. + clearTimeout(timeout); + }; + }, [isPending]); + return ( ) { ...renderProps, // Retain hover styles when an overlay is open. isHovered: renderProps.isHovered || overlayTriggerState?.isOpen || false, - variant: props.variant || 'primary', - fillStyle: props.fillStyle || 'fill', - size: props.size || 'M', - staticColor: props.staticColor + isDisabled: renderProps.isDisabled || isProgressVisible, + variant, + fillStyle, + size, + staticColor }, props.styles)}> {typeof props.children === 'string' ? {props.children} : props.children} + {isPending && +
+ {/* TODO: size based on t-shirt size once ProgressCircle supports custom sizes */} + +
+ }
); diff --git a/packages/@react-spectrum/s2/stories/Button.stories.tsx b/packages/@react-spectrum/s2/stories/Button.stories.tsx index 10a0b89c37e..329c7111653 100644 --- a/packages/@react-spectrum/s2/stories/Button.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Button.stories.tsx @@ -10,11 +10,13 @@ * governing permissions and limitations under the License. */ +import {action} from '@storybook/addon-actions'; import {Button, Text} from '../src'; import {categorizeArgTypes, StaticColorDecorator} from './utils'; import type {Meta, StoryObj} from '@storybook/react'; import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; +import {useEffect, useRef, useState} from 'react'; const meta: Meta = { component: Button, @@ -48,3 +50,57 @@ export const Example: Story = { ); } }; + + +export const PendingButton = { + render: (args) => { + return ( +
+ Press me + Press me +
external label
+ + Test + Test + Test + + + + Very long button with wrapping text to see what happens + +
+ ); + }, + parameters: { + docs: { + disable: true + } + } +}; + +function PendingButtonExample(props) { + let [isPending, setPending] = useState(false); + + let timeout = useRef | undefined>(undefined); + let handlePress = (e) => { + action('pressed')(e); + setPending(true); + timeout.current = setTimeout(() => { + setPending(false); + timeout.current = undefined; + }, 5000); + }; + + useEffect(() => { + return () => { + clearTimeout(timeout.current); + }; + }, []); + + return ( + + ); +} +``` + +```tsx snippet hidden + + + + + + + + + + +``` + +### Accessibility + +**Note:** +The `ProgressBar` must be in the accessibility tree as soon as the button becomes pending, even if it is not visible. +For example, if you'd like to delay showing a spinner until a minimum amount of time passes, you could use `opacity: 0` to hide it so it is still available to screen readers. +Do not use `visibility: hidden` or `display: none` since these remove the element from the accessibility tree. + +Additionally, you may choose to keep the button's contents in the DOM while the button is pending, e.g. to preserve the button's layout. +If you hide the contents with `visibility: hidden`, the accessibility label for the button will only include the ProgressBar, so it should have a descriptive `aria-label` (e.g. "Saving"). +You can also choose to keep the button's contents in the accessibility tree by using `opacity: 0`, in which case the `ProgressBar`'s label will be combined with the contents (e.g. "Save, pending"). + +Try the above example and the one below with a screen reader to see the difference in behavior. + +
+ Show example + +```tsx example +function PendingDelayed(props) { + let [isPending, setPending] = useState(false); + + let handlePress = (e) => { + setPending(true); + setTimeout(() => { + setPending(false); + }, 5000); + }; + + return ( + + ); +} +``` + +```css +@keyframes toggle { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: toggle 1s steps(1); + opacity: 1; +} + +.pending { + animation: toggle 1s reverse steps(1, jump-start); + opacity: 0; +} +``` + +
+ ## Link buttons The `Button` component always represents a button semantically. To create a link that visually looks like a button, use the [Link](Link.html) component instead. You can reuse the same styles you apply to the `Button` component on the `Link`. diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 46a2b6b604a..a3bd84757b8 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -44,6 +44,7 @@ "@react-aria/dnd": "^3.7.2", "@react-aria/focus": "^3.18.2", "@react-aria/interactions": "^3.22.2", + "@react-aria/live-announcer": "^3.3.4", "@react-aria/menu": "^3.15.3", "@react-aria/toolbar": "3.0.0-beta.8", "@react-aria/tree": "3.0.0-alpha.5", diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx index 1e057dc9b35..453d928f876 100644 --- a/packages/react-aria-components/src/Button.tsx +++ b/packages/react-aria-components/src/Button.tsx @@ -9,11 +9,28 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {AriaButtonProps, HoverEvents, mergeProps, useButton, useFocusRing, useHover} from 'react-aria'; -import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; + +import {announce} from '@react-aria/live-announcer'; +import { + AriaButtonProps, + HoverEvents, + mergeProps, + useButton, + useFocusRing, + useHover, + useId +} from 'react-aria'; +import { + ContextValue, + RenderProps, + SlotProps, + useContextProps, + useRenderProps +} from './utils'; import {createHideableComponent} from '@react-aria/collections'; import {filterDOMProps} from '@react-aria/utils'; -import React, {createContext, ForwardedRef} from 'react'; +import {ProgressBarContext} from './ProgressBar'; +import React, {createContext, ForwardedRef, useEffect, useRef} from 'react'; export interface ButtonRenderProps { /** @@ -40,7 +57,12 @@ export interface ButtonRenderProps { * Whether the button is disabled. * @selector [data-disabled] */ - isDisabled: boolean + isDisabled: boolean, + /** + * If the button is currently in the `isPending` state. + * @selector [data-pending] + */ + isPending?: boolean } export interface ButtonProps extends Omit, HoverEvents, SlotProps, RenderProps { @@ -65,7 +87,11 @@ export interface ButtonProps extends Omit) { [props, ref] = useContextProps(props, ref, ButtonContext); + props = disablePendingProps(props); let ctx = props as ButtonContextValue; + let {isPending} = ctx; let {buttonProps, isPressed} = useButton(props, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(props); - let {hoverProps, isHovered} = useHover(props); + let {hoverProps, isHovered} = useHover({ + ...props, + isDisabled: props.isDisabled || isPending + }); + let renderValues = { + isHovered, + isPressed: (ctx.isPressed || isPressed) && !isPending, + isFocused, + isFocusVisible, + isDisabled: props.isDisabled || false, + isPending + }; + let renderProps = useRenderProps({ ...props, - values: {isHovered, isPressed, isFocused, isFocusVisible, isDisabled: props.isDisabled || false}, + values: renderValues, defaultClassName: 'react-aria-Button' }); + let buttonId = useId(buttonProps.id); + let progressId = useId(); + + let ariaLabelledby = buttonProps['aria-labelledby']; + if (isPending) { + // aria-labelledby wins over aria-label + // https://www.w3.org/TR/accname-1.2/#computation-steps + if (ariaLabelledby) { + ariaLabelledby = `${ariaLabelledby} ${progressId}`; + } else if (buttonProps['aria-label']) { + ariaLabelledby = `${buttonId} ${progressId}`; + } + } + + let wasPending = useRef(isPending); + useEffect(() => { + let message = {'aria-labelledby': ariaLabelledby || buttonId}; + if (!wasPending.current && isFocused && isPending) { + announce(message, 'assertive'); + } else if (wasPending.current && isFocused && !isPending) { + announce(message, 'assertive'); + } + wasPending.current = isPending; + }, [isPending, isFocused, ariaLabelledby, buttonId]); + return ( ); } +function disablePendingProps(props) { + // Don't allow interaction while isPending is true + if (props.isPending) { + props.onPress = undefined; + props.onPressStart = undefined; + props.onPressEnd = undefined; + props.onPressChange = undefined; + props.onPressUp = undefined; + props.onKeyDown = undefined; + props.onKeyUp = undefined; + props.onClick = undefined; + props.href = undefined; + } + return props; +} + /** * A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ diff --git a/packages/react-aria-components/src/ToggleButton.tsx b/packages/react-aria-components/src/ToggleButton.tsx index 3cf1c82848d..6d822066d33 100644 --- a/packages/react-aria-components/src/ToggleButton.tsx +++ b/packages/react-aria-components/src/ToggleButton.tsx @@ -17,7 +17,7 @@ import {forwardRefType} from '@react-types/shared'; import React, {createContext, ForwardedRef, forwardRef} from 'react'; import {ToggleState, useToggleState} from 'react-stately'; -export interface ToggleButtonRenderProps extends ButtonRenderProps { +export interface ToggleButtonRenderProps extends Omit { /** * Whether the button is currently selected. * @selector [data-selected] diff --git a/packages/react-aria-components/stories/Button.stories.tsx b/packages/react-aria-components/stories/Button.stories.tsx index 605b568ab5b..62639c7f564 100644 --- a/packages/react-aria-components/stories/Button.stories.tsx +++ b/packages/react-aria-components/stories/Button.stories.tsx @@ -10,10 +10,12 @@ * governing permissions and limitations under the License. */ -import {Button} from 'react-aria-components'; +import {action} from '@storybook/addon-actions'; +import {Button, ProgressBar, Text} from 'react-aria-components'; import {mergeProps} from '@react-aria/utils'; import React, {useEffect, useRef, useState} from 'react'; import * as styles from './button-ripple.css'; +import * as styles2 from './button-pending.css'; export default { title: 'React Aria Components' @@ -25,6 +27,58 @@ export const ButtonExample = () => { ); }; +export const PendingButton = { + render: (args) => , + args: { + children: 'Press me' + } +}; + +function PendingButtonExample(props) { + let [isPending, setPending] = useState(false); + + let timeout = useRef | undefined>(undefined); + let handlePress = (e) => { + action('pressed')(e); + setPending(true); + timeout.current = setTimeout(() => { + setPending(false); + timeout.current = undefined; + }, 5000); + }; + + useEffect(() => { + return () => { + clearTimeout(timeout.current); + }; + }, []); + + return ( + + ); +} + export const RippleButtonExample = () => { return ( Press me diff --git a/packages/react-aria-components/stories/button-pending.css b/packages/react-aria-components/stories/button-pending.css new file mode 100644 index 00000000000..4c3689bb013 --- /dev/null +++ b/packages/react-aria-components/stories/button-pending.css @@ -0,0 +1,27 @@ +.button { + position: relative; + height: 30px; + display: flex; + align-items: center; + box-sizing: border-box; + outline: none; +} + +.spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + visibility: hidden; + display: none; +} +.spinner-pending { + opacity: 1; + visibility: visible; + display: block; +} + +.pending { + opacity: 0; +} diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js index d0d29121b55..1ba17971322 100644 --- a/packages/react-aria-components/test/Button.test.js +++ b/packages/react-aria-components/test/Button.test.js @@ -10,15 +10,16 @@ * governing permissions and limitations under the License. */ -import {Button, ButtonContext} from '../'; +import {Button, ButtonContext, ProgressBar, Text} from '../'; import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; -import React from 'react'; +import React, {useState} from 'react'; import userEvent from '@testing-library/user-event'; describe('Button', () => { let user; beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); }); it('should render a button with default class', () => { @@ -137,4 +138,63 @@ describe('Button', () => { fireEvent.mouseUp(button); expect(button).toHaveTextContent('Test'); }); + + // isPending state + it('displays a spinner when isPending prop is true', async function () { + let onPressSpy = jest.fn(); + function TestComponent() { + let [pending, setPending] = useState(false); + return ( + + ); + } + let {getByRole} = render(); + let button = getByRole('button'); + expect(button).not.toHaveAttribute('aria-disabled'); + await user.click(button); + // Button is disabled immediately, but spinner visibility is delayed + expect(button).toHaveAttribute('aria-disabled', 'true'); + // Multiple clicks shouldn't call onPressSpy + await user.click(button); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(onPressSpy).toHaveBeenCalledTimes(1); + }); + + // isPending anchor element + it('removes href attribute from anchor element when isPending is true', () => { + let {getByRole} = render( + + ); + let button = getByRole('button'); + expect(button).not.toHaveAttribute('href'); + }); }); diff --git a/scripts/migrateIntl.mjs b/scripts/migrateIntl.mjs index 931784c0f96..1a31cd30473 100644 --- a/scripts/migrateIntl.mjs +++ b/scripts/migrateIntl.mjs @@ -12,6 +12,7 @@ let mapToNewKeys = { let stringsToAllow = new Set([ 'breadcrumbs.more', + 'button.pending', 'menu.moreActions', 'dialog.alert', 'contextualhelp.info', diff --git a/yarn.lock b/yarn.lock index ede484a7d0e..6e92651960e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28720,6 +28720,7 @@ __metadata: "@react-aria/dnd": "npm:^3.7.2" "@react-aria/focus": "npm:^3.18.2" "@react-aria/interactions": "npm:^3.22.2" + "@react-aria/live-announcer": "npm:^3.3.4" "@react-aria/menu": "npm:^3.15.3" "@react-aria/toolbar": "npm:3.0.0-beta.8" "@react-aria/tree": "npm:3.0.0-alpha.5"