diff --git a/.circleci/config.yml b/.circleci/config.yml index 656d727fd72..ade9fac8733 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -482,12 +482,17 @@ jobs: steps: - attach_workspace: at: /tmp/dist - + - run: + name: Install AzCopy + command: | + wget -O azcopy.tar.gz https://aka.ms/downloadazcopy-v10-linux + tar -xf azcopy.tar.gz + mv ./azcopy_linux_amd64_*/azcopy /usr/local/bin/ - run: name: deploy command: | if [ $AZURE_STORAGE_SAS_TOKEN ]; then - az storage blob upload-batch -d reactspectrum -s /tmp/dist --account-name reactspectrum + azcopy copy "/tmp/dist/*" "https://reactspectrum.blob.core.windows.net/reactspectrum${AZURE_STORAGE_SAS_TOKEN}" --recursive fi # Separate deploy workflow for the test docs built w/ verdaccio packages so it doesn't hold up the other deploy workflows @@ -497,11 +502,19 @@ jobs: steps: - attach_workspace: at: /tmp/verdaccio_dist + - run: + name: Install AzCopy + command: | + wget -O azcopy.tar.gz https://aka.ms/downloadazcopy-v10-linux + tar -xf azcopy.tar.gz + mv ./azcopy_linux_amd64_*/azcopy /usr/local/bin/ - run: name: deploy command: | if [ $AZURE_STORAGE_SAS_TOKEN ]; then - az storage blob upload-batch -d reactspectrum/$CIRCLE_SHA1/verdaccio -s /tmp/verdaccio_dist/*/verdaccio --account-name reactspectrum + for dir in /tmp/verdaccio_dist/*/verdaccio; do + azcopy copy "$dir/*" "https://reactspectrum.blob.core.windows.net/reactspectrum/$CIRCLE_SHA1/verdaccio${AZURE_STORAGE_SAS_TOKEN}" --recursive + done fi deploy-production: @@ -510,9 +523,15 @@ jobs: steps: - attach_workspace: at: /tmp/dist + - run: + name: Install AzCopy + command: | + wget -O azcopy.tar.gz https://aka.ms/downloadazcopy-v10-linux + tar -xf azcopy.tar.gz + mv ./azcopy_linux_amd64_*/azcopy /usr/local/bin/ - run: name: deploy - command: az storage blob upload-batch -d "\$web" -s /tmp/dist/production/docs --account-name reactspectrum --overwrite true + command: azcopy copy "/tmp/dist/production/docs/*" "https://reactspectrum.blob.core.windows.net/\$web${AZURE_STORAGE_SAS_TOKEN}" --recursive comment: executor: rsp diff --git a/.eslintrc.js b/.eslintrc.js index d6fce72a85b..d40afd976c7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -120,7 +120,8 @@ module.exports = { 'AsyncIterable': 'readonly', 'FileSystemFileEntry': 'readonly', 'FileSystemDirectoryEntry': 'readonly', - 'FileSystemEntry': 'readonly' + 'FileSystemEntry': 'readonly', + 'IS_REACT_ACT_ENVIRONMENT': 'readonly' }, settings: { jsdoc: { diff --git a/package.json b/package.json index 1f310b6f26c..05f649831f1 100644 --- a/package.json +++ b/package.json @@ -251,6 +251,7 @@ "@parcel/transformer-css": { "cssModules": { "global": true, + "pattern": "[content-hash]_[local]", "exclude": [ "**/*.global.css", "packages/@react-aria/example-theme/**", diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts index 2172fd2def3..1a50aab51e1 100644 --- a/packages/@internationalized/date/src/queries.ts +++ b/packages/@internationalized/date/src/queries.ts @@ -194,6 +194,7 @@ export function startOfWeek(date: DateValue, locale: string): DateValue { export function endOfWeek(date: ZonedDateTime, locale: string): ZonedDateTime; export function endOfWeek(date: CalendarDateTime, locale: string): CalendarDateTime; export function endOfWeek(date: CalendarDate, locale: string): CalendarDate; +export function endOfWeek(date: DateValue, locale: string): DateValue; export function endOfWeek(date: DateValue, locale: string): DateValue { return startOfWeek(date, locale).add({days: 6}); } diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 7293237b279..f318b3f3e95 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -275,7 +275,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: // Otherwise, when tapping on a segment in Android Chrome and then entering text, // composition events will be fired that break the DOM structure and crash the page. let selection = window.getSelection(); - if (ref.current.contains(selection.anchorNode)) { + if (ref.current && ref.current.contains(selection.anchorNode)) { selection.collapse(ref.current); } }); 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/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 1bb1e36c224..eddd8602bc9 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -410,7 +410,7 @@ export function usePress(props: PressHookProps): PressResult { // Due to browser inconsistencies, especially on mobile browsers, we prevent // default on pointer down and handle focusing the pressable element ourselves. - if (shouldPreventDefault(e.currentTarget as Element)) { + if (shouldPreventDefaultDown(e.currentTarget as Element)) { e.preventDefault(); } @@ -452,7 +452,7 @@ export function usePress(props: PressHookProps): PressResult { // Chrome and Firefox on touch Windows devices require mouse down events // to be canceled in addition to pointer events, or an extra asynchronous // focus event will be fired. - if (shouldPreventDefault(e.currentTarget as Element)) { + if (shouldPreventDefaultDown(e.currentTarget as Element)) { e.preventDefault(); } @@ -510,6 +510,25 @@ export function usePress(props: PressHookProps): PressResult { if (!allowTextSelectionOnPress) { restoreTextSelection(state.target); } + + // Prevent subsequent touchend event from triggering onClick on unrelated elements on Android. See below. + // Both 'touch' and 'pen' pointerTypes trigger onTouchEnd, but 'mouse' does not. + if ('ontouchend' in state.target && e.pointerType !== 'mouse') { + addGlobalListener(state.target, 'touchend', onTouchEnd, {once: true}); + } + } + }; + + // This is a workaround for an Android Chrome/Firefox issue where click events are fired on an incorrect element + // if the original target is removed during onPointerUp (before onClick). + // https://github.com/adobe/react-spectrum/issues/1513 + // https://issues.chromium.org/issues/40732224 + // Note: this event must be registered directly on the element, not via React props in order to work. + // https://github.com/facebook/react/issues/9809 + let onTouchEnd = (e: TouchEvent) => { + // Don't preventDefault if we actually want the default (e.g. submit/link click). + if (shouldPreventDefaultUp(e.target as Element)) { + e.preventDefault(); } }; @@ -534,7 +553,7 @@ export function usePress(props: PressHookProps): PressResult { // Due to browser inconsistencies, especially on mobile browsers, we prevent // default on mouse down and handle focusing the pressable element ourselves. - if (shouldPreventDefault(e.currentTarget)) { + if (shouldPreventDefaultDown(e.currentTarget)) { e.preventDefault(); } @@ -914,16 +933,16 @@ function isOverTarget(point: EventPoint, target: Element) { return areRectanglesOverlapping(rect, pointRect); } -function shouldPreventDefault(target: Element) { +function shouldPreventDefaultDown(target: Element) { // We cannot prevent default if the target is a draggable element. return !(target instanceof HTMLElement) || !target.hasAttribute('draggable'); } -function shouldPreventDefaultKeyboard(target: Element, key: string) { +function shouldPreventDefaultUp(target: Element) { if (target instanceof HTMLInputElement) { - return !isValidInputKey(target, key); + return false; } - + if (target instanceof HTMLButtonElement) { return target.type !== 'submit' && target.type !== 'reset'; } @@ -935,6 +954,14 @@ function shouldPreventDefaultKeyboard(target: Element, key: string) { return true; } +function shouldPreventDefaultKeyboard(target: Element, key: string) { + if (target instanceof HTMLInputElement) { + return !isValidInputKey(target, key); + } + + return shouldPreventDefaultUp(target); +} + const nonTextInputTypes = new Set([ 'checkbox', 'radio', diff --git a/packages/@react-aria/interactions/stories/usePress-stories.css b/packages/@react-aria/interactions/stories/usePress-stories.css new file mode 100644 index 00000000000..67b21f28be9 --- /dev/null +++ b/packages/@react-aria/interactions/stories/usePress-stories.css @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +.outer-div { + background-color: #333; + width: 320px; + height: 320px; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + margin-bottom: 1000px; +} + +.open-btn { + background-color: blue; + font-size: 32px; + padding: 10px; +} + +.visit-link { + color: #9999ff; + font-size: 16px; +} + +.fake-modal { + background-color: rgba(224, 64, 0, 0.5); + position: absolute; + width: 320px; + height: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.side-by-side { + color: white; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.my-btn { + background-color: rgb(0, 128, 0); + cursor: pointer; + padding: 5px; +} + +.fake-modal h1 { + color: white; + font-size: 60px; + margin: 0; + padding: 0; +} + +.fake-modal .close-btn { + background-color: red; + font-size: 16px; + padding: 10px; +} + +.OnPress { + cursor: pointer; + color: #ffffff; +} diff --git a/packages/@react-aria/interactions/stories/usePress.stories.tsx b/packages/@react-aria/interactions/stories/usePress.stories.tsx new file mode 100644 index 00000000000..0faee2343ee --- /dev/null +++ b/packages/@react-aria/interactions/stories/usePress.stories.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import styles from './usePress-stories.css'; +import {usePress} from '@react-aria/interactions'; + +export default { + title: 'usePress' +}; + +export function TouchIssue() { + const [opened, setOpened] = React.useState(false); + const handleOpen = React.useCallback(() => { + console.log('opening'); + setOpened(true); + }, []); + const handleClose = React.useCallback(() => { + console.log('closing'); + setOpened(false); + }, []); + const handleOnClick = React.useCallback(() => { + alert('clicked it'); + }, []); + + return ( +
+ + Open + +
+
Some text
+ + Another Link + + +
+ + {opened && ( +
+

Header

+
+ + Close 1 + + + Close 2 + + + Close 3 + +
+
+ )} +
+ ); +} + +function OnPress(props) { + const {className, onPress, children} = props; + + const {pressProps} = usePress({ + onPress + }); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index bd6e83134b8..23273ff726f 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -822,6 +822,61 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse'})); expect(el).not.toHaveStyle('user-select: none'); }); + + it('should preventDefault on touchend to prevent click events on the wrong element', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(false); + }); + + it('should not preventDefault on touchend when element is a submit button', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is an ', function () { + let res = render(); + + let el = res.getByRole('button'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is an ', function () { + let res = render(); + + let el = res.getByRole('checkbox'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is a link', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); }); describe('mouse events', function () { 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-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 8bda10460f0..7a8a5f6ed34 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -74,32 +74,27 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); } - getNextKey(key: Key) { - key = this.collection.getKeyAfter(key); + private findNextNonDisabled(key: Key, getNext: (key: Key) => Key | null): Key | null { while (key != null) { let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { + if (item?.type === 'item' && !this.isDisabled(item)) { return key; } - key = this.collection.getKeyAfter(key); + key = getNext(key); } return null; } + getNextKey(key: Key) { + key = this.collection.getKeyAfter(key); + return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key)); + } + getPreviousKey(key: Key) { key = this.collection.getKeyBefore(key); - while (key != null) { - let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyBefore(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } private findKey( @@ -151,6 +146,14 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } getKeyRightOf(key: Key) { + // This is a temporary solution for CardView until we refactor useSelectableCollection. + // https://github.com/orgs/adobe/projects/19/views/32?pane=issue&itemId=77825042 + let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyRightOf' : 'getKeyLeftOf'; + if (this.layoutDelegate[layoutDelegateMethod]) { + key = this.layoutDelegate[layoutDelegateMethod](key); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + } + if (this.layout === 'grid') { if (this.orientation === 'vertical') { return this.getNextColumn(key, this.direction === 'rtl'); @@ -165,6 +168,12 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } getKeyLeftOf(key: Key) { + let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyLeftOf' : 'getKeyRightOf'; + if (this.layoutDelegate[layoutDelegateMethod]) { + key = this.layoutDelegate[layoutDelegateMethod](key); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + } + if (this.layout === 'grid') { if (this.orientation === 'vertical') { return this.getNextColumn(key, this.direction === 'ltr'); @@ -180,30 +189,12 @@ export class ListKeyboardDelegate implements KeyboardDelegate { getFirstKey() { let key = this.collection.getFirstKey(); - while (key != null) { - let item = this.collection.getItem(key); - if (item?.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyAfter(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key)); } getLastKey() { let key = this.collection.getLastKey(); - while (key != null) { - let item = this.collection.getItem(key); - if (item.type === 'item' && !this.isDisabled(item)) { - return key; - } - - key = this.collection.getKeyBefore(key); - } - - return null; + return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } getKeyPageAbove(key: Key) { @@ -280,7 +271,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return key; } - key = this.getKeyBelow(key); + key = this.getNextKey(key); } return null; diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index 9cbd5ec3717..ede87adc37f 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -17,12 +17,11 @@ export interface ComboBoxOptions extends UserOpts, BaseTesterOpts { user: any, trigger?: HTMLElement } -// TODO: Probably should set up some base classes to reduce duplication of this setup (user/interactiontype) -// advanceTimer isn't used in all places + export class ComboBoxTester { private user; private _interactionType: UserOpts['interactionType']; - private _combobox: HTMLElement | undefined; + private _combobox: HTMLElement; private _trigger: HTMLElement | undefined; constructor(opts: ComboBoxOptions) { @@ -38,13 +37,17 @@ export class ComboBoxTester { this._combobox = combobox; } - // TODO: This is for if user need to directly set the trigger button element (aka the element provided in setElement was the combobox input or the trigger is somewhere unexpected) + // This is for if user need to directly set the trigger button element (aka the element provided in setElement was the combobox input or the trigger is somewhere unexpected) if (trigger) { this._trigger = trigger; } else { let trigger = within(root).queryByRole('button', {hidden: true}); if (trigger) { this._trigger = trigger; + } else { + // For cases like https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ where the combobox + // is also the trigger button + this._trigger = this._combobox; } } } @@ -57,7 +60,7 @@ export class ComboBoxTester { let {triggerBehavior = 'manual', interactionType = this._interactionType} = opts; let trigger = this.trigger; let combobox = this.combobox; - let isDisabled = trigger.hasAttribute('disabled'); + let isDisabled = trigger!.hasAttribute('disabled'); if (interactionType === 'mouse') { if (triggerBehavior === 'focus') { @@ -146,18 +149,10 @@ export class ComboBoxTester { }; get combobox() { - if (!this._combobox) { - throw new Error('Combobox input element hasn\'t been set yet. Did you call `setElement()` yet?'); - } - return this._combobox; } get trigger() { - if (!this._trigger) { - throw new Error('Combobox trigger element hasn\'t been set yet. Did you call `setElement()` or `setTrigger()` yet?'); - } - return this._trigger; } @@ -186,7 +181,7 @@ export class ComboBoxTester { } } - get focusedOptions() { + get focusedOption() { let focusedOptionId = this.combobox.getAttribute('aria-activedescendant'); return focusedOptionId ? document.getElementById(focusedOptionId) : undefined; } diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index 64cbc5e3d60..7043c97d32b 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -20,7 +20,7 @@ export interface GridListOptions extends UserOpts, BaseTesterOpts { export class GridListTester { private user; private _interactionType: UserOpts['interactionType']; - private _gridlist: HTMLElement | undefined; + private _gridlist: HTMLElement; constructor(opts: GridListOptions) { @@ -64,7 +64,7 @@ export class GridListTester { if (index != null) { row = this.rows[index]; } else if (text != null) { - row = within(this.gridlist).getByText(text); + row = within(this?.gridlist).getByText(text); while (row && row.getAttribute('role') !== 'row') { row = row.parentElement; } @@ -98,15 +98,11 @@ export class GridListTester { // TODO: do we really need this getter? Theoretically the user already has the reference to the gridlist get gridlist() { - if (!this._gridlist) { - throw new Error('Gridlist element hasn\'t been set yet. Did you call `setElement()` yet?'); - } - return this._gridlist; } get rows() { - return within(this.gridlist).queryAllByRole('row'); + return within(this?.gridlist).queryAllByRole('row'); } get selectedRows() { diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 9404aaa0d56..02d9b925c08 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -23,7 +23,7 @@ export class MenuTester { private user; private _interactionType: UserOpts['interactionType']; private _advanceTimer: UserOpts['advanceTimer']; - private _trigger: HTMLElement | undefined; + private _trigger: HTMLElement; private _isSubmenu: boolean = false; constructor(opts: MenuOptions) { @@ -40,13 +40,11 @@ export class MenuTester { let trigger = within(root).queryByRole('button'); if (trigger) { this._trigger = trigger; + } else { + this._trigger = root; } } - if (this._trigger == null) { - this._trigger = root; - } - this._isSubmenu = isSubmenu || false; } @@ -131,8 +129,6 @@ export class MenuTester { } = opts; let trigger = this.trigger; if (!trigger.getAttribute('aria-controls')) { - // TODO: technically this would need the user to pass in if their menu needs long press if we want calling selectOption to - // work without needing to call open first. Bit annoying though, maybe I add opts and have one of them be needsLongPress? await this.open({needsLongPress}); } @@ -147,7 +143,7 @@ export class MenuTester { act(() => menu.focus()); } - await this.keyboardNavigateToOption({option}); + await this.keyboardNavigateToOption({option: option!}); await this.user.keyboard(`[${keyboardActivation}]`); } else { if (interactionType === 'mouse') { @@ -262,10 +258,6 @@ export class MenuTester { }; get trigger() { - if (!this._trigger) { - throw new Error('Menu trigger element hasn\'t been set yet. Did you call `setElement()` yet?'); - } - return this._trigger; } diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index fd1d317340d..0773d55ccb3 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -20,7 +20,7 @@ export interface SelectOptions extends UserOpts, BaseTesterOpts { export class SelectTester { private user; private _interactionType: UserOpts['interactionType']; - private _trigger: HTMLElement | undefined; + private _trigger: HTMLElement; constructor(opts: SelectOptions) { let {root, user, interactionType} = opts; @@ -138,10 +138,6 @@ export class SelectTester { }; get trigger() { - if (!this._trigger) { - throw new Error('Select trigger hasn\'t been set yet. Did you call `setElement()` yet?'); - } - return this._trigger; } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 68e7ae42c14..1f019706ae3 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -25,7 +25,7 @@ export class TableTester { private user; private _interactionType: UserOpts['interactionType']; private _advanceTimer: UserOpts['advanceTimer']; - private _table: HTMLElement | undefined; + private _table: HTMLElement; constructor(opts: TableOptions) { let {root, user, interactionType, advanceTimer} = opts; @@ -248,10 +248,6 @@ export class TableTester { }; get table() { - if (!this._table) { - throw new Error('Table element hasn\'t been set yet. Did you call `setTable()` yet?'); - } - return this._table; } diff --git a/packages/@react-aria/test-utils/src/user.ts b/packages/@react-aria/test-utils/src/user.ts index eaf16581969..888f60ad44c 100644 --- a/packages/@react-aria/test-utils/src/user.ts +++ b/packages/@react-aria/test-utils/src/user.ts @@ -33,24 +33,24 @@ export interface BaseTesterOpts { root: HTMLElement } -let keyToUtil = {'SelectTester': SelectTester, 'TableTester': TableTester, 'MenuTester': MenuTester, 'ComboBoxTester': ComboBoxTester, 'GridListTester': GridListTester} as const; +let keyToUtil = {'Select': SelectTester, 'Table': TableTester, 'Menu': MenuTester, 'ComboBox': ComboBoxTester, 'GridList': GridListTester} as const; export type PatternNames = keyof typeof keyToUtil; // Conditional type: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html type ObjectType = - T extends 'SelectTester' ? SelectTester : - T extends 'TableTester' ? TableTester : - T extends 'MenuTester' ? MenuTester : - T extends 'ComboBoxTester' ? ComboBoxTester : - T extends 'GridListTester' ? GridListTester : + T extends 'Select' ? SelectTester : + T extends 'Table' ? TableTester : + T extends 'Menu' ? MenuTester : + T extends 'ComboBox' ? ComboBoxTester : + T extends 'GridList' ? GridListTester : never; type ObjectOptionsTypes = - T extends 'SelectTester' ? SelectOptions : - T extends 'TableTester' ? TableOptions : - T extends 'MenuTester' ? MenuOptions : - T extends 'ComboBoxTester' ? ComboBoxOptions : - T extends 'GridListTester' ? GridListOptions : + T extends 'Select' ? SelectOptions : + T extends 'Table' ? TableOptions : + T extends 'Menu' ? MenuOptions : + T extends 'ComboBox' ? ComboBoxOptions : + T extends 'GridList' ? GridListOptions : never; let defaultAdvanceTimer = async (waitTime: number | undefined) => await new Promise((resolve) => setTimeout(resolve, waitTime)); diff --git a/packages/@react-aria/utils/src/openLink.tsx b/packages/@react-aria/utils/src/openLink.tsx index 489e4f6275b..e0b099508cc 100644 --- a/packages/@react-aria/utils/src/openLink.tsx +++ b/packages/@react-aria/utils/src/openLink.tsx @@ -148,8 +148,9 @@ function openSyntheticLink(target: Element, modifiers: Modifiers) { export function useSyntheticLinkProps(props: LinkDOMProps) { let router = useRouter(); + const href = router.useHref(props.href ?? ''); return { - 'data-href': props.href ? router.useHref(props.href) : undefined, + 'data-href': props.href ? href : undefined, 'data-target': props.target, 'data-rel': props.rel, 'data-download': props.download, @@ -172,8 +173,9 @@ export function getSyntheticLinkProps(props: LinkDOMProps) { export function useLinkProps(props: LinkDOMProps) { let router = useRouter(); + const href = router.useHref(props?.href ?? ''); return { - href: props?.href ? router.useHref(props?.href) : undefined, + href: props?.href ? href : undefined, target: props?.target, rel: props?.rel, download: props?.download, diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index e00d04a041f..2dd7135e586 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -29,7 +29,7 @@ export interface LoadMoreProps { */ scrollOffset?: number, /** The data currently loaded. */ - items?: any[] + items?: any } export function useLoadMore(props: LoadMoreProps, ref: RefObject) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 363adaa0058..67e17ece306 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -143,12 +143,17 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { let dom = ref.current; - if (!dom) { + if (!dom && !isUpdatingSize.current) { return; } + // Prevent reentrancy when resize observer fires, triggers re-layout that results in + // content size update, causing below layout effect to fire. This avoids infinite loops. + isUpdatingSize.current = true; + let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientWidth'); let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientHeight'); @@ -177,27 +182,31 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); useLayoutEffect(() => { - // React doesn't allow flushSync inside effects, so queue a microtask. - // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). - queueMicrotask(() => { - if (!didUpdateSize.current) { - didUpdateSize.current = true; - updateSize(flushSync); + if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { + // React doesn't allow flushSync inside effects, so queue a microtask. + // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). + // If we are in an `act` environment, update immediately without a microtask so you don't need + // to mock timers in tests. In this case, the update is synchronous already. + // IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global. + // https://github.com/reactwg/react-18/discussions/102 + // @ts-ignore + if (typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined') { + updateSize(fn => fn()); + } else { + queueMicrotask(() => updateSize(flushSync)); } - }); - }, [updateSize]); - useEffect(() => { - if (!didUpdateSize.current) { - // If useEffect ran before the above microtask, we are in a synchronous render (e.g. act). - // Update the size here so that you don't need to mock timers in tests. - didUpdateSize.current = true; - updateSize(fn => fn()); } - }, [updateSize]); + + lastContentSize.current = contentSize; + }); + let onResize = useCallback(() => { updateSize(flushSync); }, [updateSize]); 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 e107383ba15..efee099d566 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'; @@ -308,7 +308,7 @@ describe('ComboBox', function () { it('can be disabled', async function () { let tree = renderComboBox({isDisabled: true}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); act(() => { comboboxTester.combobox.focus(); @@ -334,7 +334,7 @@ describe('ComboBox', function () { it('can be readonly', async function () { let tree = renderComboBox({isReadOnly: true, defaultInputValue: 'Blargh'}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); act(() => { comboboxTester.combobox.focus(); @@ -362,7 +362,7 @@ describe('ComboBox', function () { it('features default behavior of completionMode suggest and menuTrigger input', async function () { let tree = renderComboBox(); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); let combobox = comboboxTester.combobox; expect(combobox).not.toHaveAttribute('aria-controls'); @@ -390,7 +390,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(combobox).toHaveAttribute('aria-activedescendant', comboboxTester.focusedOptions.id); + expect(combobox).toHaveAttribute('aria-activedescendant', comboboxTester.focusedOption.id); }); describe('refs', function () { @@ -420,7 +420,7 @@ describe('ComboBox', function () { describe('menuTrigger = focus', function () { it('opens menu when combobox is focused', async function () { let tree = renderComboBox({menuTrigger: 'focus'}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); let button = comboboxTester.trigger; let combobox = comboboxTester.combobox; @@ -434,7 +434,7 @@ describe('ComboBox', function () { it('opens menu when combobox is focused by clicking button', async function () { let tree = renderComboBox({menuTrigger: 'focus'}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); let button = comboboxTester.trigger; let combobox = comboboxTester.combobox; @@ -452,7 +452,7 @@ describe('ComboBox', function () { let {getByRole} = renderComboBox(); let button = getByRole('button'); let combobox = getByRole('combobox'); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: combobox, trigger: button}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: combobox, trigger: button}); expect(comboboxTester.listbox).toBeFalsy(); await comboboxTester.open(); @@ -486,7 +486,7 @@ describe('ComboBox', function () { it('opens for touch', async () => { let tree = renderComboBox({}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); let combobox = comboboxTester.combobox; expect(document.activeElement).not.toBe(combobox); @@ -507,7 +507,7 @@ describe('ComboBox', function () { it('resets the focused item when re-opening the menu', async function () { let tree = renderComboBox({}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); await comboboxTester.open(); expect(comboboxTester.combobox).not.toHaveAttribute('aria-activedescendant'); @@ -857,7 +857,7 @@ describe('ComboBox', function () { it('resets input text if reselecting a selected option with click', async function () { let tree = renderComboBox({defaultSelectedKey: '2'}); - let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container}); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); let combobox = comboboxTester.combobox; expect(combobox.value).toBe('Two'); @@ -5090,7 +5090,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/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index d669596df4d..57fb545e7d3 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -160,7 +160,7 @@ describe('ListView', function () { ); let grid = getByRole('grid'); - let gridListTester = testUtilUser.createTester('GridListTester', {root: grid}); + let gridListTester = testUtilUser.createTester('GridList', {root: grid}); expect(grid).toBeVisible(); expect(grid).toHaveAttribute('aria-label', 'List'); @@ -840,7 +840,7 @@ describe('ListView', function () { it('should support select all and clear all via keyboard', async function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); let grid = tree.getByRole('grid'); - let gridListTester = testUtilUser.createTester('GridListTester', {root: grid}); + let gridListTester = testUtilUser.createTester('GridList', {root: grid}); let rows = gridListTester.rows; await gridListTester.toggleRowSelection({index: 0}); @@ -872,7 +872,7 @@ describe('ListView', function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction}); - let gridListTester = testUtilUser.createTester('GridListTester', {root: tree.getByRole('grid')}); + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); await gridListTester.triggerRowAction({index: 1}); expect(onSelectionChange).not.toHaveBeenCalled(); @@ -893,7 +893,7 @@ describe('ListView', function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction}); - let gridListTester = testUtilUser.createTester('GridListTester', {root: tree.getByRole('grid')}); + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); await gridListTester.triggerRowAction({index: 1}); expect(onSelectionChange).not.toHaveBeenCalled(); @@ -954,7 +954,7 @@ describe('ListView', function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction}); - let gridListTester = testUtilUser.createTester('GridListTester', {root: tree.getByRole('grid')}); + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); gridListTester.setInteractionType('keyboard'); await gridListTester.triggerRowAction({index: 1}); diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index b82cb5664be..93bc5694d91 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -109,7 +109,7 @@ describe('MenuTrigger', function () { async function verifyMenuToggle(Component, triggerProps = {}, menuProps = {}, triggerEvent) { let tree = renderComponent(Component, triggerProps, menuProps); let triggerButton = tree.getByRole('button'); - let menuTester = testUtilUser.createTester('MenuTester', {root: triggerButton}); + let menuTester = testUtilUser.createTester('Menu', {root: triggerButton}); expect(onOpenChange).toBeCalledTimes(0); @@ -200,7 +200,7 @@ describe('MenuTrigger', function () { }); async function openAndTriggerMenuItem(tree, role, selectionMode, triggerEvent) { - let menuTester = testUtilUser.createTester('MenuTester', {root: tree.container}); + let menuTester = testUtilUser.createTester('Menu', {root: tree.container}); await menuTester.open(); let menuItems = menuTester.options; let itemToAction = menuItems[1]; @@ -215,7 +215,7 @@ describe('MenuTrigger', function () { ${'MenuTrigger'} | ${MenuTrigger} | ${{onOpenChange, closeOnSelect: false}} `('$Name doesn\'t close on menu item selection if closeOnSelect=false', async function ({Component, props}) { tree = renderComponent(Component, props, {selectionMode: 'single', onSelectionChange}); - let menuTester = testUtilUser.createTester('MenuTester', {root: tree.container}); + let menuTester = testUtilUser.createTester('Menu', {root: tree.container}); expect(onOpenChange).toBeCalledTimes(0); await menuTester.open(); @@ -253,7 +253,7 @@ describe('MenuTrigger', function () { ${'MenuTrigger'} | ${MenuTrigger} | ${{onOpenChange, closeOnSelect: false}} `('$Name doesn\'t closes menu on item selection via ENTER press if closeOnSelect=false', async function ({Component, props}) { tree = renderComponent(Component, props, {selectionMode: 'single', onSelectionChange}); - let menuTester = testUtilUser.createTester('MenuTester', {root: tree.container}); + let menuTester = testUtilUser.createTester('Menu', {root: tree.container}); expect(onOpenChange).toBeCalledTimes(0); await menuTester.open(); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 8721a877c51..9b04a3b5d63 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -90,7 +90,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); expect(queryByRole('listbox')).toBeNull(); let picker = selectTester.trigger; @@ -201,7 +201,7 @@ describe('Picker', function () { ); expect(queryByRole('listbox')).toBeNull(); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); selectTester.setInteractionType('keyboard'); let picker = selectTester.trigger; @@ -236,7 +236,7 @@ describe('Picker', function () { ); expect(queryByRole('listbox')).toBeNull(); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; // TODO: for these keyboard event, IMO we don't have to include in the test utils since the user can pretty // easily define what specific keyboard interactions they want to do. We can handle firing the various intermediate interactions @@ -525,7 +525,7 @@ describe('Picker', function () { ); expect(queryByRole('listbox')).toBeNull(); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; await selectTester.open(); @@ -976,7 +976,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; expect(picker).toHaveTextContent('Select…'); await selectTester.open(); @@ -1008,7 +1008,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; expect(picker).toHaveTextContent('Select…'); await selectTester.open(); @@ -1098,7 +1098,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); selectTester.setInteractionType('keyboard'); let picker = selectTester.trigger; @@ -1175,7 +1175,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; expect(picker).toHaveTextContent('Select…'); expect(onOpenChangeSpy).toHaveBeenCalledTimes(0); @@ -1379,7 +1379,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; expect(picker).toHaveTextContent('Select…'); await selectTester.open(); @@ -1541,7 +1541,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; expect(picker).toHaveTextContent('Two'); await selectTester.open(); @@ -2166,7 +2166,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; let input = document.querySelector('[name=picker]'); expect(input).toHaveAttribute('required'); @@ -2195,7 +2195,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByRole('button')}); + let selectTester = testUtilUser.createTester('Select', {root: getByRole('button')}); let picker = selectTester.trigger; let input = document.querySelector('[name=picker]'); expect(picker).not.toHaveAttribute('aria-describedby'); @@ -2236,7 +2236,7 @@ describe('Picker', function () { } let {getByTestId} = render(); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('picker')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('picker')}); let picker = selectTester.trigger; let input = document.querySelector('[name=picker]'); expect(picker).not.toHaveAttribute('aria-describedby'); @@ -2286,7 +2286,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('picker')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('picker')}); let picker = selectTester.trigger; let input = document.querySelector('[name=picker]'); expect(input).toHaveAttribute('required'); @@ -2319,7 +2319,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('picker')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('picker')}); let picker = selectTester.trigger; let input = document.querySelector('[name=picker]'); expect(picker).toHaveAttribute('aria-describedby'); @@ -2342,7 +2342,7 @@ describe('Picker', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('picker')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('picker')}); let picker = selectTester.trigger; expect(picker).toHaveAttribute('aria-describedby'); expect(document.getElementById(picker.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); diff --git a/packages/@react-spectrum/picker/test/TempUtilTest.test.js b/packages/@react-spectrum/picker/test/TempUtilTest.test.js index 71f8b38c5c6..dbbd1475274 100644 --- a/packages/@react-spectrum/picker/test/TempUtilTest.test.js +++ b/packages/@react-spectrum/picker/test/TempUtilTest.test.js @@ -102,7 +102,7 @@ describe('Picker/Select ', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: screen.getByTestId('test')}); + let selectTester = testUtilUser.createTester('Select', {root: screen.getByTestId('test')}); await selectTester.selectOption({optionText: 'Three'}); expect(selectTester.trigger).toHaveTextContent('Three'); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -128,7 +128,7 @@ describe('Picker/Select ', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: screen.getByTestId('test')}); + let selectTester = testUtilUser.createTester('Select', {root: screen.getByTestId('test')}); await selectTester.selectOption({optionText: 'Cat'}); expect(selectTester.trigger).toHaveTextContent('Cat'); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -210,7 +210,7 @@ describe('Picker/Select ', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: screen.getByTestId('test')}); + let selectTester = testUtilUser.createTester('Select', {root: screen.getByTestId('test')}); await selectTester.selectOption({optionText: 'Three'}); expect(selectTester.trigger).toHaveTextContent('Three'); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -236,7 +236,7 @@ describe('Picker/Select ', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: screen.getAllByTestId('test')[0]}); + let selectTester = testUtilUser.createTester('Select', {root: screen.getAllByTestId('test')[0]}); await selectTester.selectOption({optionText: 'Cat'}); expect(selectTester.trigger).toHaveTextContent('Cat'); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -262,7 +262,7 @@ describe('Picker/Select ', function () { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: screen.getByTestId('test')}); + let selectTester = testUtilUser.createTester('Select', {root: screen.getByTestId('test')}); await selectTester.open(); expect(await screen.findByTestId('tray')).toContainElement(selectTester.listbox); }); 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/package.json b/packages/@react-spectrum/s2/package.json index 31e669e72c8..58807f93157 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -130,6 +130,7 @@ "@react-aria/interactions": "^3.22.2", "@react-aria/utils": "^3.25.2", "@react-spectrum/utils": "^3.11.10", + "@react-stately/virtualizer": "^4.0.1", "@react-types/color": "3.0.0-rc.1", "@react-types/dialog": "^3.5.8", "@react-types/provider": "^3.7.2", diff --git a/packages/@react-spectrum/s2/src/ActionButton.tsx b/packages/@react-spectrum/s2/src/ActionButton.tsx index 31c74c58ac3..e02fb5b3b91 100644 --- a/packages/@react-spectrum/s2/src/ActionButton.tsx +++ b/packages/@react-spectrum/s2/src/ActionButton.tsx @@ -11,15 +11,17 @@ */ import {baseColor, fontRelative, style} from '../style/spectrum-theme' with { type: 'macro' }; -import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton} from 'react-aria-components'; +import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton, Text} from 'react-aria-components'; import {centerBaseline} from './CenterBaseline'; import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with { type: 'macro' }; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; -import {Text, TextContext} from './Content'; +import {SkeletonContext} from './Skeleton'; +import {TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; +import {useFormProps} from './Form'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface ActionButtonStyleProps { @@ -175,6 +177,7 @@ export const ActionButtonContext = createContext) { [props, ref] = useSpectrumContextProps(props, ref, ActionButtonContext); + props = useFormProps(props as any); let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); @@ -193,6 +196,7 @@ function ActionButton(props: ActionButtonProps, ref: FocusableRef extends Pick, - Pick, 'children' | 'items' | 'disabledKeys' | 'onAction' | 'size'>, - Pick, + Pick, 'children' | 'items' | 'disabledKeys' | 'onAction'>, + Pick, StyleProps, DOMProps, AriaLabelingProps { - } + menuSize?: 'S' | 'M' | 'L' | 'XL' +} export const ActionMenuContext = createContext, FocusableRefValue>>(null); @@ -41,7 +42,6 @@ function ActionMenu(props: ActionMenuProps, ref: FocusableR buttonProps['aria-label'] = stringFormatter.format('menu.moreActions'); } - // size independently controlled? return ( (props: ActionMenuProps, ref: FocusableR shouldFlip={props.shouldFlip}> @@ -64,7 +64,7 @@ function ActionMenu(props: ActionMenuProps, ref: FocusableR items={props.items} disabledKeys={props.disabledKeys} onAction={props.onAction} - size={props.size}> + size={props.menuSize}> {/* @ts-ignore TODO: fix type, right now this component is the same as Menu */} {props.children} diff --git a/packages/@react-spectrum/s2/src/Avatar.tsx b/packages/@react-spectrum/s2/src/Avatar.tsx index da7ba47049b..7b8790ef928 100644 --- a/packages/@react-spectrum/s2/src/Avatar.tsx +++ b/packages/@react-spectrum/s2/src/Avatar.tsx @@ -15,6 +15,7 @@ import {createContext, forwardRef} from 'react'; import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; import {filterDOMProps} from '@react-aria/utils'; import {getAllowedOverrides, StylesPropWithoutWidth, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {Image} from './Image'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -71,16 +72,17 @@ function Avatar(props: AvatarProps, ref: DOMRef) { let remSize = size / 16 + 'rem'; let isLarge = size >= 64; return ( - {alt} ); } diff --git a/packages/@react-spectrum/s2/src/Badge.tsx b/packages/@react-spectrum/s2/src/Badge.tsx index 44c79b0d2cf..6ba6216dcf0 100644 --- a/packages/@react-spectrum/s2/src/Badge.tsx +++ b/packages/@react-spectrum/s2/src/Badge.tsx @@ -18,6 +18,7 @@ import {filterDOMProps} from '@react-aria/utils'; import {fontRelative, style} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; import React, {createContext, forwardRef, ReactNode} from 'react'; +import {SkeletonWrapper} from './Skeleton'; import {Text, TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -25,13 +26,13 @@ import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface BadgeStyleProps { /** * The size of the badge. - * + * * @default 'S' */ size?: 'S' | 'M' | 'L' | 'XL', /** * The variant changes the background color of the badge. When badge has a semantic meaning, they should use the variant for semantic colors. - * + * * @default 'neutral' */ variant?: 'accent' | 'informative' | 'neutral' | 'positive' | 'notice' | 'negative' | 'gray' | 'red' | 'orange' | 'yellow' | 'charteuse' | 'celery' | 'green' | 'seafoam' | 'cyan' | 'blue' | 'indigo' | 'purple' | 'fuchsia' | 'magenta' | 'pink' | 'turquoise' | 'brown' | 'cinnamon' | 'silver', @@ -201,17 +202,20 @@ function Badge(props: BadgeProps, ref: DOMRef) { styles: style({size: fontRelative(20), marginStart: '--iconMargin', flexShrink: 0}) }] ]}> - - { - typeof children === 'string' || isTextOnly - ? {children} - : children - } - + + + { + typeof children === 'string' || isTextOnly + ? {children} + : children + } + + ); } diff --git a/packages/@react-spectrum/s2/src/Button.tsx b/packages/@react-spectrum/s2/src/Button.tsx index fa29d9e14d8..3e1b99b45b4 100644 --- a/packages/@react-spectrum/s2/src/Button.tsx +++ b/packages/@react-spectrum/s2/src/Button.tsx @@ -14,12 +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 { @@ -60,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', @@ -73,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', @@ -81,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', @@ -103,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: { @@ -149,7 +156,8 @@ const button = style({ default: 'transparent', isHovered: 'gray-100', isPressed: 'gray-100', - isFocusVisible: 'gray-100' + isFocusVisible: 'gray-100', + isDisabled: 'transparent' } }, staticColor: { @@ -166,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' } } }, @@ -183,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' } } } @@ -272,9 +282,37 @@ 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 */} + +
+ }
); @@ -313,6 +385,7 @@ export {_Button as Button}; function LinkButton(props: LinkButtonProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, LinkButtonContext); + props = useFormProps(props); let domRef = useFocusableRef(ref); let overlayTriggerState = useContext(OverlayTriggerStateContext); @@ -332,6 +405,7 @@ function LinkButton(props: LinkButtonProps, ref: FocusableRef }, props.styles)}> , StyleProps { + /** The children of the Card. */ + children: ReactNode | ((renderProps: CardRenderProps) => ReactNode), + /** + * The size of the Card. + * @default 'M' + */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + /** + * The amount of internal padding within the Card. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** + * The visual style of the Card. + * @default 'primary' + */ + variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet' +} + +const borderRadius = { + default: 'lg', + size: { + XS: 'default', + S: 'default' + } +} as const; + +let card = style({ + display: 'flex', + flexDirection: 'column', + position: 'relative', + borderRadius, + '--s2-container-bg': { + type: 'backgroundColor', + value: { + variant: { + primary: 'elevated', + secondary: 'layer-1' + }, + forcedColors: 'ButtonFace' + } + }, + backgroundColor: { + default: '--s2-container-bg', + variant: { + tertiary: 'transparent', + quiet: 'transparent' + } + }, + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated', + forcedColors: '[0 0 0 1px ButtonBorder]', + variant: { + tertiary: { + // Render border with box-shadow to avoid affecting layout. + default: `[0 0 0 1px ${colorToken('gray-100')}]`, + isHovered: `[0 0 0 1px ${colorToken('gray-200')}]`, + isFocusVisible: `[0 0 0 1px ${colorToken('gray-200')}]`, + isSelected: 'none', + forcedColors: '[0 0 0 1px ButtonBorder]' + }, + quiet: 'none' + } + }, + forcedColorAdjust: 'none', + transition: 'default', + fontFamily: 'sans', + overflow: { + default: 'clip', + variant: { + quiet: 'visible' + } + }, + contain: 'layout', + disableTapHighlight: true, + userSelect: { + isCardView: 'none' + }, + cursor: { + isLink: 'pointer' + }, + width: { + size: { + XS: 112, + S: 192, + M: 240, + L: 320, + XL: size(400) + }, + isCardView: 'full' + }, + height: 'full', + '--card-spacing': { + type: 'paddingTop', + value: { + density: { + compact: { + size: { + XS: size(6), + S: 8, + M: 12, + L: 16, + XL: 20 + } + }, + regular: { + size: { + XS: 8, + S: 12, + M: 16, + L: 20, + XL: 24 + } + }, + spacious: { + size: { + XS: 12, + S: 16, + M: 20, + L: 24, + XL: 28 + } + } + } + } + }, + '--card-padding-y': { + type: 'paddingTop', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + '--card-padding-x': { + type: 'paddingStart', + value: { + default: '--card-spacing', + variant: { + quiet: 0 + } + } + }, + paddingY: '--card-padding-y', + paddingX: '--card-padding-x', + boxSizing: 'border-box', + ...focusRing(), + outlineStyle: { + default: 'none', + isFocusVisible: 'solid', + // Focus ring moves to preview when quiet. + variant: { + quiet: 'none' + } + } +}, getAllowedOverrides()); + +let selectionIndicator = style({ + position: 'absolute', + inset: 0, + zIndex: 2, + borderRadius, + pointerEvents: 'none', + borderWidth: 2, + borderStyle: 'solid', + borderColor: 'gray-1000', + transition: 'default', + opacity: { + default: 0, + isSelected: 1 + }, + // Quiet cards with no checkbox have an extra inner stroke + // to distinguish the selection indicator from the preview. + outlineColor: lightDark('transparent-white-600', 'transparent-black-600'), + outlineOffset: -4, + outlineStyle: { + default: 'none', + isStrokeInner: 'solid' + }, + outlineWidth: 2 +}); + +let preview = style({ + position: 'relative', + transition: 'default', + overflow: 'clip', + marginX: '[calc(var(--card-padding-x) * -1)]', + marginTop: '[calc(var(--card-padding-y) * -1)]', + marginBottom: { + ':last-child': '[calc(var(--card-padding-y) * -1)]' + }, + borderRadius: { + isQuiet: borderRadius + }, + boxShadow: { + isQuiet: { + isHovered: 'elevated', + isFocusVisible: 'elevated', + isSelected: 'elevated' + } + }, + ...focusRing(), + outlineStyle: { + default: 'none', + isQuiet: { + isFocusVisible: 'solid' + } + } +}); + +const image = style({ + width: 'full', + aspectRatio: '[3/2]', + objectFit: 'cover', + userSelect: 'none', + pointerEvents: 'none' +}); + +let title = style({ + font: 'title', + fontSize: { + size: { + XS: 'title-xs', + S: 'title-xs', + M: 'title-sm', + L: 'title', + XL: 'title-lg' + } + }, + lineClamp: 3, + gridArea: 'title' +}); + +let description = style({ + font: 'body', + fontSize: { + size: { + XS: 'body-2xs', + S: 'body-2xs', + M: 'body-xs', + L: 'body-sm', + XL: 'body' + } + }, + lineClamp: 3, + gridArea: 'description' +}); + +let content = style({ + display: 'grid', + // By default, all elements are displayed in a stack. + // If an action menu is present, place it next to the title. + gridTemplateColumns: { + default: ['1fr'], + ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] + }, + gridTemplateAreas: { + default: [ + 'title', + 'description' + ], + ':has([data-slot=menu])': [ + 'title menu', + 'description description' + ] + }, + columnGap: 4, + flexGrow: 1, + alignItems: 'baseline', + alignContent: 'space-between', + rowGap: { + size: { + XS: 4, + S: 4, + M: size(6), + L: size(6), + XL: 8 + } + }, + paddingTop: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingBottom: { + default: '[calc(var(--card-spacing) * 1.5 / 2)]', + ':last-child': 0 + } +}); + +let actionMenu = style({ + gridArea: 'menu', + // Don't cause the row to expand, preserve gap between title and description text. + // Would use -100% here but it doesn't work in Firefox. + marginY: '[calc(-1 * self(height))]' +}); + +let footer = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'end', + justifyContent: 'space-between', + gap: 8, + paddingTop: '[calc(var(--card-spacing) * 1.5 / 2)]' +}); + +export const CardViewContext = createContext<'div' | typeof GridListItem>('div'); +export const CardContext = createContext, DOMRefValue>>(null); + +interface InternalCardContextValue { + isQuiet: boolean, + size: 'XS' | 'S' | 'M' | 'L' | 'XL', + isSelected: boolean, + isHovered: boolean, + isFocusVisible: boolean, + isPressed: boolean, + isCheckboxSelection: boolean +} + +const InternalCardContext = createContext({ + isQuiet: false, + size: 'M', + isSelected: false, + isHovered: false, + isFocusVisible: false, + isPressed: false, + isCheckboxSelection: true +}); + +const actionButtonSize = { + XS: 'XS', + S: 'XS', + M: 'S', + L: 'M', + XL: 'L' +} as const; + +export const Card = forwardRef(function Card(props: CardProps, ref: DOMRef) { + [props] = useSpectrumContextProps(props, ref, CardContext); + let domRef = useDOMRef(ref); + let {density = 'regular', size = 'M', variant = 'primary', UNSAFE_className = '', UNSAFE_style, styles, id, ...otherProps} = props; + let isQuiet = variant === 'quiet'; + let isSkeleton = useIsSkeleton(); + let children = ( + + + {typeof props.children === 'function' ? props.children({size}) : props.children} + + + ); + + let ElementType = useContext(CardViewContext); + if (ElementType === 'div' || isSkeleton) { + return ( +
+ + {children} + +
+ ); + } + + let press = pressScale(domRef, UNSAFE_style); + return ( + UNSAFE_className + card({...renderProps, isCardView: true, isLink: !!props.href, size, density, variant}, styles)} + style={renderProps => + // Only the preview in quiet cards scales down on press + variant === 'quiet' ? UNSAFE_style : press(renderProps) + }> + {({selectionMode, selectionBehavior, isHovered, isFocusVisible, isSelected, isPressed}) => ( + + {/* Selection indicator and checkbox move inside the preview for quiet cards */} + {!isQuiet && } + {!isQuiet && selectionMode !== 'none' && selectionBehavior === 'toggle' && + + } + {/* this makes the :first-child selector work even with the checkbox */} +
+ {children} +
+
+ )} +
+ ); +}); + +function SelectionIndicator() { + let {size, isSelected, isQuiet, isCheckboxSelection} = useContext(InternalCardContext); + return ( +
+ ); +} + +function CardCheckbox() { + let {size} = useContext(InternalCardContext); + return ( +
+ +
+ ); +} + +export interface CardPreviewProps extends UnsafeStyles, DOMProps { + children: ReactNode +} + +export const CardPreview = forwardRef(function CardPreview(props: CardPreviewProps, ref: DOMRef) { + let {size, isQuiet, isHovered, isFocusVisible, isSelected, isPressed, isCheckboxSelection} = useContext(InternalCardContext); + let {UNSAFE_className, UNSAFE_style} = props; + let domRef = useDOMRef(ref); + return ( +
+ {isQuiet && } + {isQuiet && isCheckboxSelection && } +
+ {props.children} +
+
+ ); +}); + +const collection = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: { + default: 4, + size: { + XS: 2, + S: 2 + } + } +}); + +const collectionImage = style({ + width: 'full', + aspectRatio: { + default: 'square', + ':nth-last-child(4):first-child': '[3/2]' + }, + gridColumnEnd: { + ':nth-last-child(4):first-child': 'span 3' + }, + objectFit: 'cover', + pointerEvents: 'none', + userSelect: 'none' +}); + +export const CollectionCardPreview = forwardRef(function CollectionCardPreview(props: CardPreviewProps, ref: DOMRef) { + let {size} = useContext(InternalCardContext)!; + return ( + +
+ + {props.children} + +
+
+ ); +}); + +export interface AssetCardProps extends Omit {} + +export const AssetCard = forwardRef(function AssetCard(props: AssetCardProps, ref: DOMRef) { + return ( + + {composeRenderProps(props.children, children => ( + +
+ {icon} +
+ + ); + }, + styles: style({ + height: 'auto', + maxSize: 160, + // TODO: this is made up. + width: '[50%]' + }) + }] + ]}> + {children} +
+ ))} +
+ ); +}); + +const avatarSize = { + XS: 24, + S: 48, + M: 64, + L: 64, + XL: 80 +} as const; + +export interface UserCardProps extends Omit { + // Quiet is not supported due to lack of indent between preview and avatar. + variant?: 'primary' | 'secondary' | 'tertiary' +} + +export const UserCard = forwardRef(function UserCard(props: CardProps, ref: DOMRef) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); + +const buttonSize = { + XS: 'S', + S: 'S', + M: 'M', + L: 'L', + XL: 'XL' +} as const; + +export interface ProductCardProps extends Omit { + // Quiet is not supported due to lack of indent between preview and thumbnail. + variant?: 'primary' | 'secondary' | 'tertiary' +} + +export const ProductCard = forwardRef(function ProductCard(props: ProductCardProps, ref: DOMRef) { + let {size = 'M'} = props; + return ( + + {composeRenderProps(props.children, children => ( + + {children} + + ))} + + ); +}); diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx new file mode 100644 index 00000000000..41f35ad237e --- /dev/null +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -0,0 +1,597 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + GridList as AriaGridList, + GridLayoutOptions, + GridListItem, + GridListProps, + UNSTABLE_Virtualizer +} from 'react-aria-components'; +import {CardContext, CardViewContext} from './Card'; +import {DOMRef, forwardRefType, Key, LayoutDelegate, LoadingState, Node} from '@react-types/shared'; +import {focusRing, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {forwardRef, useMemo, useState} from 'react'; +import {ImageCoordinator} from './ImageCoordinator'; +import {InvalidationContext, Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {useDOMRef} from '@react-spectrum/utils'; +import {useEffectEvent, useLayoutEffect, useLoadMore, useResizeObserver} from '@react-aria/utils'; + +export interface CardViewProps extends Omit, 'layout' | 'keyboardNavigationBehavior' | 'selectionBehavior' | 'className' | 'style'>, UnsafeStyles { + /** + * The layout of the cards. + * @default 'grid' + */ + layout?: 'grid' | 'waterfall', + /** + * The size of the cards. + * @default 'M' + */ + size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + /** + * The amount of space between the cards. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** + * The visual style of the cards. + * @default 'primary' + */ + variant?: 'primary' | 'secondary' | 'tertiary' | 'quiet', + /** + * How selection should be displayed. + * @default 'checkbox' + */ + selectionStyle?: 'checkbox' | 'highlight', + /** The loading state of the CardView. */ + loadingState?: LoadingState, + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + onLoadMore?: () => void, + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight +} + +class FlexibleGridLayout extends Layout, GridLayoutOptions> { + protected contentSize: Size = new Size(); + protected layoutInfos: Map = new Map(); + + update(invalidationContext: InvalidationContext): void { + let { + minItemSize = new Size(200, 200), + maxItemSize = new Size(Infinity, Infinity), + minSpace = new Size(18, 18), + maxColumns = Infinity + } = invalidationContext.layoutOptions || {}; + let visibleWidth = this.virtualizer.visibleRect.width; + + // The max item width is always the entire viewport. + // If the max item height is infinity, scale in proportion to the max width. + let maxItemWidth = Math.min(maxItemSize.width, visibleWidth); + let maxItemHeight = Number.isFinite(maxItemSize.height) + ? maxItemSize.height + : Math.floor((minItemSize.height / minItemSize.width) * maxItemWidth); + + // Compute the number of rows and columns needed to display the content + let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width)); + let numColumns = Math.max(1, Math.min(maxColumns, columns)); + + // Compute the available width (minus the space between items) + let width = visibleWidth - (minSpace.width * Math.max(0, numColumns)); + + // Compute the item width based on the space available + let itemWidth = Math.floor(width / numColumns); + itemWidth = Math.max(minItemSize.width, Math.min(maxItemWidth, itemWidth)); + + // Compute the item height, which is proportional to the item width + let t = ((itemWidth - minItemSize.width) / Math.max(1, maxItemWidth - minItemSize.width)); + let itemHeight = minItemSize.height + Math.floor((maxItemHeight - minItemSize.height) * t); + itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight)); + + // Compute the horizontal spacing and content height + let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); + + let rows = Math.ceil(this.virtualizer.collection.size / numColumns); + let iterator = this.virtualizer.collection[Symbol.iterator](); + let y = rows > 0 ? minSpace.height : 0; + let newLayoutInfos = new Map(); + let skeleton: Node | null = null; + let skeletonCount = 0; + for (let row = 0; row < rows; row++) { + let maxHeight = 0; + let rowLayoutInfos: LayoutInfo[] = []; + for (let col = 0; col < numColumns; col++) { + // Repeat skeleton until the end of the current row. + let node = skeleton || iterator.next().value; + if (!node) { + break; + } + + if (node.type === 'skeleton') { + skeleton = node; + } + + let key = skeleton ? `${skeleton.key}-${skeletonCount++}` : node.key; + let content = skeleton ? {...skeleton} : node; + let x = horizontalSpacing + col * (itemWidth + horizontalSpacing); + let oldLayoutInfo = this.layoutInfos.get(key); + let height = itemHeight; + let estimatedSize = true; + if (oldLayoutInfo) { + height = oldLayoutInfo.rect.height; + estimatedSize = invalidationContext.sizeChanged || oldLayoutInfo.estimatedSize || (oldLayoutInfo.content !== content); + } + + let rect = new Rect(x, y, itemWidth, height); + let layoutInfo = new LayoutInfo(node.type, key, rect); + layoutInfo.estimatedSize = estimatedSize; + layoutInfo.allowOverflow = true; + layoutInfo.content = content; + newLayoutInfos.set(key, layoutInfo); + rowLayoutInfos.push(layoutInfo); + + maxHeight = Math.max(maxHeight, rect.height); + } + + for (let layoutInfo of rowLayoutInfos) { + layoutInfo.rect.height = maxHeight; + } + + y += maxHeight + minSpace.height; + + // Keep adding skeleton rows until we fill the viewport + if (skeleton && row === rows - 1 && y < this.virtualizer.visibleRect.height) { + rows++; + } + } + + this.layoutInfos = newLayoutInfos; + this.contentSize = new Size(this.virtualizer.visibleRect.width, y); + } + + getLayoutInfo(key: Key): LayoutInfo { + return this.layoutInfos.get(key)!; + } + + getContentSize(): Size { + return this.contentSize; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let layoutInfos: LayoutInfo[] = []; + for (let layoutInfo of this.layoutInfos.values()) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer.isPersistedKey(layoutInfo.key)) { + layoutInfos.push(layoutInfo); + } + } + return layoutInfos; + } + + updateItemSize(key: Key, size: Size) { + let layoutInfo = this.layoutInfos.get(key); + if (!size || !layoutInfo) { + return false; + } + + if (size.height !== layoutInfo.rect.height) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect.height = size.height; + newLayoutInfo.estimatedSize = false; + this.layoutInfos.set(key, newLayoutInfo); + return true; + } + + return false; + } +} + +class WaterfallLayoutInfo extends LayoutInfo { + column = 0; + + copy(): WaterfallLayoutInfo { + let res = super.copy() as WaterfallLayoutInfo; + res.column = this.column; + return res; + } +} + +class WaterfallLayout extends Layout, GridLayoutOptions> implements LayoutDelegate { + protected contentSize: Size = new Size(); + protected layoutInfos: Map = new Map(); + protected numColumns = 0; + + update(invalidationContext: InvalidationContext): void { + let { + minItemSize = new Size(200, 200), + maxItemSize = new Size(Infinity, Infinity), + minSpace = new Size(18, 18), + maxColumns = Infinity + } = invalidationContext.layoutOptions || {}; + let visibleWidth = this.virtualizer.visibleRect.width; + + // The max item width is always the entire viewport. + // If the max item height is infinity, scale in proportion to the max width. + let maxItemWidth = Math.min(maxItemSize.width, visibleWidth); + let maxItemHeight = Number.isFinite(maxItemSize.height) + ? maxItemSize.height + : Math.floor((minItemSize.height / minItemSize.width) * maxItemWidth); + + // Compute the number of rows and columns needed to display the content + let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width)); + let numColumns = Math.max(1, Math.min(maxColumns, columns)); + + // Compute the available width (minus the space between items) + let width = visibleWidth - (minSpace.width * Math.max(0, numColumns)); + + // Compute the item width based on the space available + let itemWidth = Math.floor(width / numColumns); + itemWidth = Math.max(minItemSize.width, Math.min(maxItemWidth, itemWidth)); + + // Compute the item height, which is proportional to the item width + let t = ((itemWidth - minItemSize.width) / Math.max(1, maxItemWidth - minItemSize.width)); + let itemHeight = minItemSize.height + Math.floor((maxItemHeight - minItemSize.height) * t); + itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight)); + + // Compute the horizontal spacing and content height + let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); + + // Setup an array of column heights + let columnHeights = Array(numColumns).fill(minSpace.height); + let newLayoutInfos = new Map(); + let addNode = (key: Key, node: Node) => { + let oldLayoutInfo = this.layoutInfos.get(key); + let height = itemHeight; + let estimatedSize = true; + if (oldLayoutInfo) { + height = oldLayoutInfo.rect.height; + estimatedSize = invalidationContext.sizeChanged || oldLayoutInfo.estimatedSize || oldLayoutInfo.content !== node; + } + + // Figure out which column to place the item in, and compute its position. + // Preserve the previous column index so items don't jump around during resizing unless the number of columns changed. + let prevColumn = numColumns === this.numColumns ? oldLayoutInfo?.column : undefined; + let column = prevColumn ?? columnHeights.reduce((minIndex, h, i) => h < columnHeights[minIndex] ? i : minIndex, 0); + let x = horizontalSpacing + column * (itemWidth + horizontalSpacing); + let y = columnHeights[column]; + + let rect = new Rect(x, y, itemWidth, height); + let layoutInfo = new WaterfallLayoutInfo(node.type, key, rect); + layoutInfo.estimatedSize = estimatedSize; + layoutInfo.allowOverflow = true; + layoutInfo.content = node; + layoutInfo.column = column; + newLayoutInfos.set(key, layoutInfo); + + columnHeights[column] += layoutInfo.rect.height + minSpace.height; + }; + + let skeletonCount = 0; + for (let node of this.virtualizer.collection) { + if (node.type === 'skeleton') { + // Add skeleton cards until every column has at least one, and we fill the viewport. + let startingHeights = [...columnHeights]; + while ( + !columnHeights.every((h, i) => h !== startingHeights[i]) || + Math.min(...columnHeights) < this.virtualizer.visibleRect.height + ) { + let key = `${node.key}-${skeletonCount++}`; + let content = this.layoutInfos.get(key)?.content || {...node}; + addNode(key, content); + } + break; + } else { + addNode(node.key, node); + } + } + + // Reset all columns to the maximum for the next section + let maxHeight = Math.max(...columnHeights); + this.contentSize = new Size(this.virtualizer.visibleRect.width, maxHeight); + this.layoutInfos = newLayoutInfos; + this.numColumns = numColumns; + } + + getLayoutInfo(key: Key): LayoutInfo { + return this.layoutInfos.get(key)!; + } + + getContentSize(): Size { + return this.contentSize; + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let layoutInfos: LayoutInfo[] = []; + for (let layoutInfo of this.layoutInfos.values()) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer.isPersistedKey(layoutInfo.key)) { + layoutInfos.push(layoutInfo); + } + } + return layoutInfos; + } + + updateItemSize(key: Key, size: Size) { + let layoutInfo = this.layoutInfos.get(key); + if (!size || !layoutInfo) { + return false; + } + + if (size.height !== layoutInfo.rect.height) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect.height = size.height; + newLayoutInfo.estimatedSize = false; + this.layoutInfos.set(key, newLayoutInfo); + return true; + } + + return false; + } + + // Override keyboard navigation to work spacially. + getKeyRightOf(key: Key): Key | null { + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = new Rect(layoutInfo.rect.maxX, layoutInfo.rect.y, this.virtualizer.visibleRect.maxX - layoutInfo.rect.maxX, layoutInfo.rect.height); + let layoutInfos = this.getVisibleLayoutInfos(rect); + let bestKey: Key | null = null; + let bestDistance = Infinity; + for (let candidate of layoutInfos) { + if (candidate.key === key) { + continue; + } + + // Find the closest item in the x direction with the most overlap in the y direction. + let deltaX = candidate.rect.x - rect.x; + let overlapY = Math.min(candidate.rect.maxY, rect.maxY) - Math.max(candidate.rect.y, rect.y); + let distance = deltaX - overlapY; + if (distance < bestDistance) { + bestDistance = distance; + bestKey = candidate.key; + } + } + + return bestKey; + } + + getKeyLeftOf(key: Key): Key | null { + let layoutInfo = this.getLayoutInfo(key); + if (!layoutInfo) { + return null; + } + + let rect = new Rect(0, layoutInfo.rect.y, layoutInfo.rect.x, layoutInfo.rect.height); + let layoutInfos = this.getVisibleLayoutInfos(rect); + let bestKey: Key | null = null; + let bestDistance = Infinity; + for (let candidate of layoutInfos) { + if (candidate.key === key) { + continue; + } + + // Find the closest item in the x direction with the most overlap in the y direction. + let deltaX = rect.maxX - candidate.rect.maxX; + let overlapY = Math.min(candidate.rect.maxY, rect.maxY) - Math.max(candidate.rect.y, rect.y); + let distance = deltaX - overlapY; + if (distance < bestDistance) { + bestDistance = distance; + bestKey = candidate.key; + } + } + + return bestKey; + } + + // This overrides the default behavior of shift selection to work spacially + // rather than following the order of the items in the collection (which may appear unpredictable). + getKeyRange(from: Key, to: Key): Key[] { + let fromLayoutInfo = this.getLayoutInfo(from); + let toLayoutInfo = this.getLayoutInfo(to); + if (!fromLayoutInfo || !toLayoutInfo) { + return []; + } + + // Find items where half of the area intersects the rectangle + // formed from the first item to the last item in the range. + let rect = fromLayoutInfo.rect.union(toLayoutInfo.rect); + let keys: Key[] = []; + for (let layoutInfo of this.layoutInfos.values()) { + if (rect.intersection(layoutInfo.rect).area > layoutInfo.rect.area / 2) { + keys.push(layoutInfo.key); + } + } + return keys; + } +} + +const layoutOptions = { + XS: { + compact: { + minSpace: new Size(6, 6), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + }, + regular: { + minSpace: new Size(8, 8), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + }, + spacious: { + minSpace: new Size(12, 12), + minItemSize: new Size(100, 100), + maxItemSize: new Size(140, 140) + } + }, + S: { + compact: { + minSpace: new Size(8, 8), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + }, + regular: { + minSpace: new Size(12, 12), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + }, + spacious: { + minSpace: new Size(16, 16), + minItemSize: new Size(150, 150), + maxItemSize: new Size(210, 210) + } + }, + M: { + compact: { + minSpace: new Size(12, 12), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + }, + regular: { + minSpace: new Size(16, 16), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + }, + spacious: { + minSpace: new Size(20, 20), + minItemSize: new Size(200, 200), + maxItemSize: new Size(280, 280) + } + }, + L: { + compact: { + minSpace: new Size(16, 16), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + }, + regular: { + minSpace: new Size(20, 20), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + }, + spacious: { + minSpace: new Size(24, 24), + minItemSize: new Size(270, 270), + maxItemSize: new Size(370, 370) + } + }, + XL: { + compact: { + minSpace: new Size(20, 20), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + }, + regular: { + minSpace: new Size(24, 24), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + }, + spacious: { + minSpace: new Size(28, 28), + minItemSize: new Size(340, 340), + maxItemSize: new Size(460, 460) + } + } +}; + +const SIZES = ['XS', 'S', 'M', 'L', 'XL'] as const; + +const cardViewStyles = style({ + overflowY: { + default: 'auto', + isLoading: 'hidden' + }, + display: { + isEmpty: 'flex' + }, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + ...focusRing(), + outlineStyle: { + default: 'none', + isEmpty: { + isFocusVisible: 'solid' + } + }, + outlineOffset: -2 +}, getAllowedOverrides({height: true})); + +function CardView(props: CardViewProps, ref: DOMRef) { + let {children, layout: layoutName = 'grid', size: sizeProp = 'M', density = 'regular', variant = 'primary', selectionStyle = 'checkbox', UNSAFE_className = '', UNSAFE_style, styles, ...otherProps} = props; + let domRef = useDOMRef(ref); + let layout = useMemo(() => { + return layoutName === 'waterfall' ? new WaterfallLayout() : new FlexibleGridLayout(); + }, [layoutName]); + + // This calculates the maximum t-shirt size where at least two columns fit in the available width. + let [maxSizeIndex, setMaxSizeIndex] = useState(SIZES.length - 1); + let updateSize = useEffectEvent(() => { + let w = domRef.current?.clientWidth ?? 0; + let i = SIZES.length - 1; + while (i > 0) { + let opts = layoutOptions[SIZES[i]][density]; + if (w >= opts.minItemSize.width * 2 + opts.minSpace.width * 3) { + break; + } + i--; + } + setMaxSizeIndex(i); + }); + + useResizeObserver({ + ref: domRef, + box: 'border-box', + onResize: updateSize + }); + + useLayoutEffect(() => { + updateSize(); + }, [updateSize]); + + // The actual rendered t-shirt size is the minimum between the size prop and the maximum possible size. + let size = SIZES[Math.min(maxSizeIndex, SIZES.indexOf(sizeProp))]; + let options = layoutOptions[size][density]; + + useLoadMore({ + isLoading: props.loadingState !== 'idle' && props.loadingState !== 'error', + items: props.items, // TODO: ideally this would be the collection. items won't exist for static collections, or those using + onLoadMore: props.onLoadMore + }, domRef); + + let ctx = useMemo(() => ({size, variant}), [size, variant]); + + return ( + + + + + UNSAFE_className + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, styles)}> + {children} + + + + + + ); +} + +const _CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(CardView); +export {_CardView as CardView}; diff --git a/packages/@react-spectrum/s2/src/Checkbox.tsx b/packages/@react-spectrum/s2/src/Checkbox.tsx index 041317d10f2..393e063c7ea 100644 --- a/packages/@react-spectrum/s2/src/Checkbox.tsx +++ b/packages/@react-spectrum/s2/src/Checkbox.tsx @@ -141,29 +141,41 @@ function Checkbox({children, ...props}: CheckboxProps, ref: FocusableRef (props.UNSAFE_className || '') + wrapper({...renderProps, isInForm, size: props.size || 'M'}, props.styles)}> - {renderProps => ( - <> - -
- {renderProps.isIndeterminate && - - } - {renderProps.isSelected && !renderProps.isIndeterminate && - - } -
-
- {children} - - )} + {renderProps => { + let checkbox = ( +
+ {renderProps.isIndeterminate && + + } + {renderProps.isSelected && !renderProps.isIndeterminate && + + } +
+ ); + + // Only render checkbox without center baseline if no label. + // This avoids expanding the checkbox height to the font's line height. + if (!children) { + return checkbox; + } + + return ( + <> + + {checkbox} + + {children} + + ); + }} ); } diff --git a/packages/@react-spectrum/s2/src/Content.tsx b/packages/@react-spectrum/s2/src/Content.tsx index 168e7fdb3db..13b27bdc563 100644 --- a/packages/@react-spectrum/s2/src/Content.tsx +++ b/packages/@react-spectrum/s2/src/Content.tsx @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {ContextValue, Keyboard as KeyboardAria, Header as RACHeader, Heading as RACHeading, SlotProps, Text as TextAria, useContextProps} from 'react-aria-components'; -import {createContext, forwardRef, ImgHTMLAttributes, ReactNode} from 'react'; +import {ContextValue, Keyboard as KeyboardAria, Header as RACHeader, Heading as RACHeading, TextContext as RACTextContext, SlotProps, Text as TextAria} from 'react-aria-components'; +import {createContext, forwardRef, ImgHTMLAttributes, ReactNode, useContext} from 'react'; import {DOMRef, DOMRefValue} from '@react-types/shared'; import {StyleString} from '../style/types'; import {UnsafeStyles} from './style-utils'; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton, useSkeletonText} from './Skeleton'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface ContentProps extends UnsafeStyles, SlotProps { @@ -102,17 +103,26 @@ export const TextContext = createContext function Text(props: ContentProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TextContext); let domRef = useDOMRef(ref); - let {UNSAFE_className = '', UNSAFE_style, styles, isHidden, slot, ...otherProps} = props; + let {UNSAFE_className = '', UNSAFE_style, styles, isHidden, slot, children, ...otherProps} = props; + let racContext = useContext(RACTextContext); + let isSkeleton = useIsSkeleton(); + [children, UNSAFE_style] = useSkeletonText(children, UNSAFE_style); if (isHidden) { return null; } + + slot = slot && racContext && 'slots' in racContext && !racContext.slots?.[slot] ? undefined : slot; return ( + slot={slot || undefined}> + {children} + ); } @@ -164,24 +174,3 @@ const _Footer = forwardRef(Footer); export {_Footer as Footer}; export const ImageContext = createContext, HTMLImageElement>>({}); - -function Image(props: ImgHTMLAttributes, ref: DOMRef) { - let domRef = useDOMRef(ref); - [props, domRef] = useContextProps(props, domRef, ImageContext); - if (props.hidden) { - return null; - } - - if (props.alt == null) { - console.warn( - 'The `alt` prop was not provided to an image. ' + - 'Add `alt` text for screen readers, or set `alt=""` prop to indicate that the image ' + - 'is decorative or redundant with displayed text and should not be announced by screen readers.' - ); - } - - return {props.alt}; -} - -const _Image = forwardRef(Image); -export {_Image as Image}; diff --git a/packages/@react-spectrum/s2/src/Form.tsx b/packages/@react-spectrum/s2/src/Form.tsx index 4bd30020f23..1732520f5c1 100644 --- a/packages/@react-spectrum/s2/src/Form.tsx +++ b/packages/@react-spectrum/s2/src/Form.tsx @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {createContext, forwardRef, ReactNode, useContext} from 'react'; +import {createContext, forwardRef, ReactNode, useContext, useMemo} from 'react'; import {DOMRef, SpectrumLabelableProps} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {Form as RACForm, FormProps as RACFormProps} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton} from './Skeleton'; interface FormStyleProps extends Omit { /** @@ -36,11 +37,29 @@ export interface FormProps extends FormStyleProps, Omit(null); export function useFormProps(props: T): T { let ctx = useContext(FormContext); - if (ctx) { - return {...ctx, ...props}; - } + let isSkeleton = useIsSkeleton(); + return useMemo(() => { + let result: T = props; + if (ctx || isSkeleton) { + result = {...props}; + } - return props; + if (ctx) { + // This is a subset of mergeProps. We just need to merge non-undefined values. + for (let key in ctx) { + if (result[key] === undefined) { + result[key] = ctx[key]; + } + } + } + + // Skeleton always wins over local props. + if (isSkeleton) { + result.isDisabled = true; + } + + return result; + }, [ctx, props, isSkeleton]); } function Form(props: FormProps, ref: DOMRef) { diff --git a/packages/@react-spectrum/s2/src/GridList.tsx b/packages/@react-spectrum/s2/src/GridList.tsx deleted file mode 100644 index 9231720bbcf..00000000000 --- a/packages/@react-spectrum/s2/src/GridList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2024 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { - GridList as AriaGridList, - GridListItem as AriaGridListItem, - Button, - GridListItemProps, - GridListProps -} from 'react-aria-components'; - -import {Checkbox} from './Checkbox'; - - -export function GridList( - {children, ...props}: GridListProps -) { - return ( - - {children} - - ); -} - -export function GridListItem({children, ...props}: GridListItemProps) { - let textValue = typeof children === 'string' ? children : undefined; - return ( - - {({selectionMode, selectionBehavior, allowsDragging}) => ( - <> - {/* Add elements for drag and drop and selection. */} - {allowsDragging && } - {selectionMode === 'multiple' && selectionBehavior === 'toggle' && ( - - )} - {children} - - )} - - ); -} diff --git a/packages/@react-spectrum/s2/src/Image.tsx b/packages/@react-spectrum/s2/src/Image.tsx new file mode 100644 index 00000000000..339fb4c6066 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Image.tsx @@ -0,0 +1,240 @@ +import {ContextValue, SlotProps} from 'react-aria-components'; +import {createContext, ForwardedRef, forwardRef, HTMLAttributeReferrerPolicy, ReactNode, useCallback, useContext, useMemo, useReducer, useRef} from 'react'; +import {DefaultImageGroup, ImageGroup} from './ImageCoordinator'; +import {loadingStyle, useIsSkeleton, useLoadingAnimation} from './Skeleton'; +import {mergeStyles} from '../style/runtime'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {StyleString} from '../style/types'; +import {UnsafeStyles} from './style-utils'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface ImageProps extends UnsafeStyles, SlotProps { + /** The URL of the image. */ + src?: string, + // TODO + // srcSet?: string, + // sizes?: string, + /** Accessible alt text for the image. */ + alt?: string, + /** + * Indicates if the fetching of the image must be done using a CORS request. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin). + */ + crossOrigin?: 'anonymous' | 'use-credentials', + /** + * Whether the browser should decode images synchronously or asynchronously. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding). + */ + decoding?: 'async' | 'auto' | 'sync', + // Only supported in React 19... + // fetchPriority?: 'high' | 'low' | 'auto', + /** + * Whether the image should be loaded immediately or lazily when scrolled into view. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#loading). + */ + loading?: 'eager' | 'lazy', + /** + * A string indicating which referrer to use when fetching the resource. + * [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#referrerpolicy). + */ + referrerPolicy?: HTMLAttributeReferrerPolicy, + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StyleString, + /** A function that is called to render a fallback when the image fails to load. */ + renderError?: () => ReactNode, + /** + * A group of images to coordinate between, matching the group passed to the `` component. + * If not provided, the default image group is used. + */ + group?: ImageGroup +} + +interface ImageContextValue extends ImageProps { + hidden?: boolean +} + +export const ImageContext = createContext>(null); + +type ImageState = 'loading' | 'loaded' | 'revealed' | 'error'; +interface State { + state: ImageState, + src: string, + startTime: number, + loadTime: number +} + +type Action = + | {type: 'update', src: string} + | {type: 'loaded'} + | {type: 'revealed'} + | {type: 'error'}; + +function createState(src: string): State { + return { + state: 'loading', + src, + startTime: Date.now(), + loadTime: 0 + }; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'update': { + return { + state: 'loading', + src: action.src, + startTime: Date.now(), + loadTime: 0 + }; + } + case 'loaded': + case 'error': { + return { + ...state, + state: action.type + }; + } + case 'revealed': { + return { + ...state, + state: 'revealed', + loadTime: Date.now() - state.startTime + }; + } + default: + return state; + } +} + +const wrapperStyles = style({ + backgroundColor: 'gray-100', + overflow: 'hidden' +}); + +const imgStyles = style({ + display: 'block', + width: 'full', + height: 'full', + objectFit: '[inherit]', + objectPosition: '[inherit]', + opacity: { + default: 0, + isRevealed: 1 + }, + transition: { + default: 'none', + isTransitioning: 'opacity' + }, + transitionDuration: 500 +}); + +function Image(props: ImageProps, domRef: ForwardedRef) { + [props, domRef] = useSpectrumContextProps(props, domRef, ImageContext); + + let { + src = '', + styles, + UNSAFE_className = '', + UNSAFE_style, + renderError, + group = DefaultImageGroup, + // TODO + // srcSet, + // sizes, + alt, + crossOrigin, + decoding, + loading, + referrerPolicy + } = props; + let hidden = (props as ImageContextValue).hidden; + + let {revealAll, register, unregister, load} = useContext(group); + let [{state, src: lastSrc, loadTime}, dispatch] = useReducer(reducer, src, createState); + + if (src !== lastSrc && !hidden) { + dispatch({type: 'update', src}); + } + + if (state === 'loaded' && revealAll && !hidden) { + dispatch({type: 'revealed'}); + } + + let imgRef = useRef(null); + useLayoutEffect(() => { + if (hidden) { + return; + } + + register(src); + return () => { + unregister(src); + }; + }, [hidden, register, unregister, src]); + + let onLoad = useCallback(() => { + load(src); + dispatch({type: 'loaded'}); + }, [load, src]); + + let onError = useCallback(() => { + dispatch({type: 'error'}); + unregister(src); + }, [unregister, src]); + + let isSkeleton = useIsSkeleton(); + let isAnimating = isSkeleton || state === 'loading' || state === 'loaded'; + let animation = useLoadingAnimation(isAnimating); + useLayoutEffect(() => { + if (hidden) { + return; + } + + // If the image is already loaded, update state immediately instead of waiting for onLoad. + if (state === 'loading' && imgRef.current?.complete) { + // Queue a microtask so we don't hit React's update limit. + // TODO: is this necessary? + queueMicrotask(onLoad); + } + + animation(domRef.current); + }); + + if (props.alt == null) { + console.warn( + 'The `alt` prop was not provided to an image. ' + + 'Add `alt` text for screen readers, or set `alt=""` prop to indicate that the image ' + + 'is decorative or redundant with displayed text and should not be announced by screen readers.' + ); + } + + let errorState = !isSkeleton && state === 'error' && renderError?.(); + let isRevealed = state === 'revealed' && !isSkeleton; + let isTransitioning = isRevealed && loadTime > 200; + return useMemo(() => hidden ? null : ( +
+ {errorState} + {!errorState && ( + {alt} + )} +
+ ), [hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, src, alt, crossOrigin, decoding, loading, referrerPolicy, onLoad, onError, isRevealed, isTransitioning]); +} + +const _Image = forwardRef(Image); +export {_Image as Image}; diff --git a/packages/@react-spectrum/s2/src/ImageCoordinator.tsx b/packages/@react-spectrum/s2/src/ImageCoordinator.tsx new file mode 100644 index 00000000000..b0497bc28d4 --- /dev/null +++ b/packages/@react-spectrum/s2/src/ImageCoordinator.tsx @@ -0,0 +1,163 @@ +import {Context, createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useReducer} from 'react'; + +export interface ImageCoordinatorProps { + /** Children within the ImageCoordinator. */ + children: ReactNode, + /** + * Time in milliseconds after which images are always displayed, even if all images are not yet loaded. + * @default 5000 + */ + timeout?: number, + /** + * A group of images to coordinate between, matching the group passed to the `` component. + * If not provided, the default image group is used. + */ + group?: ImageGroup +} + +export type ImageGroup = Context; + +interface ImageGroupValue { + revealAll: boolean, + register(url: string): void, + unregister(url: string): void, + load(url: string): void +} + +const defaultContext: ImageGroupValue = { + revealAll: true, + register() {}, + unregister() {}, + load() {} +}; + +export const DefaultImageGroup = createImageGroup(); + +export function createImageGroup(): ImageGroup { + return createContext(defaultContext); +} + +interface State { + loadedAll: boolean, + timedOut: boolean, + loadStartTime: number, + loaded: Map +} + +type Action = + | {type: 'register', url: string} + | {type: 'unregister', url: string} + | {type: 'load', url: string} + | {type: 'timeout'}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'register': { + if (state.loaded.get(action.url) !== false) { + let loaded = new Map(state.loaded); + loaded.set(action.url, false); + return { + loadedAll: false, + // If we had previously loaded all items, then reset the timed out state + // since this is the first item of a new batch. + timedOut: state.loadedAll ? false : state.timedOut, + loadStartTime: state.loadedAll ? Date.now() : state.loadStartTime, + loaded + }; + } + return state; + } + case 'unregister': { + if (state.loaded.has(action.url)) { + let loaded = new Map(state.loaded); + loaded.delete(action.url); + return { + loadedAll: isAllLoaded(loaded), + timedOut: state.timedOut, + loadStartTime: state.loadStartTime, + loaded + }; + } + return state; + } + case 'load': { + if (state.loaded.get(action.url) === false) { + let loaded = new Map(state.loaded); + loaded.set(action.url, true); + return { + loadedAll: isAllLoaded(loaded), + timedOut: state.timedOut, + loadStartTime: state.loadStartTime, + loaded + }; + } + return state; + } + case 'timeout': { + if (!state.loadedAll && !state.timedOut) { + return { + ...state, + timedOut: true + }; + } + return state; + } + default: + return state; + } +} + +function isAllLoaded(loaded: Map) { + for (let isLoaded of loaded.values()) { + if (!isLoaded) { + return false; + } + } + return true; +} + +/** + * An ImageCoordinator coordinates loading behavior for a group of images. + * Images within an ImageCoordinator are revealed together once all of them have loaded. + */ +export function ImageCoordinator(props: ImageCoordinatorProps) { + // If we are already inside another ImageCoordinator, just pass + // through children and coordinate loading at the root. + let ctx = useContext(props.group || DefaultImageGroup); + if (ctx !== defaultContext) { + return props.children; + } + + return ; +} + +function ImageCoordinatorRoot(props: ImageCoordinatorProps) { + let {children, timeout = 5000, group = DefaultImageGroup} = props; + let [{loadedAll, timedOut, loadStartTime}, dispatch] = useReducer(reducer, { + loadedAll: true, + timedOut: false, + loadStartTime: 0, + loaded: new Map() + }); + + let register = useCallback((url: string) => dispatch({type: 'register', url}), []); + let unregister = useCallback((url: string) => dispatch({type: 'unregister', url}), []); + let load = useCallback((url: string) => dispatch({type: 'load', url}), []); + + useEffect(() => { + if (!loadedAll) { + let timeoutId = setTimeout(() => { + dispatch({type: 'timeout'}); + }, loadStartTime + timeout - Date.now()); + + return () => clearTimeout(timeoutId); + } + }, [loadStartTime, loadedAll, timeout]); + + let revealAll = loadedAll || timedOut; + return useMemo(() => ( + + {children} + + ), [group, children, revealAll, register, unregister, load]); +} diff --git a/packages/@react-spectrum/s2/src/Link.tsx b/packages/@react-spectrum/s2/src/Link.tsx index 63b5d578046..88855cab268 100644 --- a/packages/@react-spectrum/s2/src/Link.tsx +++ b/packages/@react-spectrum/s2/src/Link.tsx @@ -11,11 +11,13 @@ */ import {ContextValue, LinkRenderProps, Link as RACLink, LinkProps as RACLinkProps} from 'react-aria-components'; -import {createContext, forwardRef, ReactNode} from 'react'; +import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {SkeletonContext, useSkeletonText} from './Skeleton'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useFocusableRef} from '@react-spectrum/utils'; +import {useLayoutEffect} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface LinkStyleProps { @@ -38,7 +40,7 @@ export interface LinkProps extends Omit>>(null); -const link = style({ +const link = style({ ...focusRing(), borderRadius: 'sm', font: { @@ -83,15 +85,27 @@ const link = style({ function Link(props: LinkProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, LinkContext); - let {variant = 'primary', staticColor, isQuiet, isStandalone, UNSAFE_style, UNSAFE_className = '', styles} = props; + let {variant = 'primary', staticColor, isQuiet, isStandalone, UNSAFE_style, UNSAFE_className = '', styles, children} = props; let domRef = useFocusableRef(ref); + let isSkeleton = useContext(SkeletonContext) || false; + [children, UNSAFE_style] = useSkeletonText(children, UNSAFE_style); + + useLayoutEffect(() => { + if (domRef.current) { + // TODO: should RAC Link pass through inert? + domRef.current.inert = isSkeleton; + } + }, [domRef, isSkeleton]); + return ( UNSAFE_className + link({...renderProps, variant, staticColor, isQuiet, isStandalone}, styles)} /> + className={renderProps => UNSAFE_className + link({...renderProps, variant, staticColor, isQuiet, isStandalone, isSkeleton}, styles)}> + {children} + ); } diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 63864e4e1fa..bafa0263622 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -36,8 +36,9 @@ import {createContext, forwardRef, JSX, ReactNode, useContext, useRef} from 'rea import {divider} from './Divider'; import {DOMRef, DOMRefValue} from '@react-types/shared'; import {forwardRefType} from './types'; -import {HeaderContext, HeadingContext, ImageContext, KeyboardContext, Text, TextContext} from './Content'; +import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content'; import {IconContext} from './Icon'; // chevron right removed?? +import {ImageContext} from './Image'; import LinkOutIcon from '../ui-icons/LinkOut'; import {mergeStyles} from '../style/runtime'; import {Placement, useLocale} from 'react-aria'; @@ -228,6 +229,7 @@ let image = style({ marginEnd: 'text-to-visual', marginTop: fontRelative(6), // made up, need feedback alignSelf: 'center', + borderRadius: 'sm', size: { default: 40, size: { @@ -470,7 +472,7 @@ export function MenuItem(props: MenuItemProps) { } }], [KeyboardContext, {styles: keyboard({size, isDisabled: renderProps.isDisabled})}], - [ImageContext, {className: image({size})}] + [ImageContext, {styles: image({size})}] ]}> {renderProps.selectionMode === 'single' && !isLink && !renderProps.hasSubmenu && } {renderProps.selectionMode === 'multiple' && !isLink && !renderProps.hasSubmenu && ( diff --git a/packages/@react-spectrum/s2/src/Meter.tsx b/packages/@react-spectrum/s2/src/Meter.tsx index 0c599491951..9bf6ef24dd6 100644 --- a/packages/@react-spectrum/s2/src/Meter.tsx +++ b/packages/@react-spectrum/s2/src/Meter.tsx @@ -21,6 +21,8 @@ import {DOMRef, DOMRefValue} from '@react-types/shared'; import {FieldLabel} from './Field'; import {fieldLabel, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {size, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {SkeletonWrapper} from './Skeleton'; +import {Text} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -121,10 +123,12 @@ function Meter(props: MeterProps, ref: DOMRef) { {({percentage, valueText}) => ( <> {label && {label}} - {label && {valueText}} -
-
-
+ {label && {valueText}} + +
+
+
+ )} diff --git a/packages/@react-spectrum/s2/src/ProgressCircle.tsx b/packages/@react-spectrum/s2/src/ProgressCircle.tsx index 45ae92cddcc..67fa069fc4d 100644 --- a/packages/@react-spectrum/s2/src/ProgressCircle.tsx +++ b/packages/@react-spectrum/s2/src/ProgressCircle.tsx @@ -10,13 +10,12 @@ * governing permissions and limitations under the License. */ -import {baseColor, style} from '../style/spectrum-theme' with {type: 'macro'}; -import {clamp} from '@react-aria/utils'; import {ContextValue, ProgressBar as RACProgressBar, ProgressBarProps as RACProgressBarProps} from 'react-aria-components'; -import {createContext, CSSProperties, forwardRef} from 'react'; +import {createContext, forwardRef} from 'react'; import {DOMRef, DOMRefValue} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {keyframes} from '../style/style-macro' with {type: 'macro'}; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -37,455 +36,85 @@ export interface ProgressCircleStyleProps { export const ProgressCircleContext = createContext>>(null); -const fillMask1Frames = keyframes(` -0% { - transform: rotate(90deg); -} - -1.69% { - transform: rotate(72.3deg); -} - -3.39% { - transform: rotate(55.5deg); -} - -5.08% { - transform: rotate(40.3deg); -} - -6.78% { - transform: rotate(25deg); -} - -8.47% { - transform: rotate(10.6deg); -} - -10.17%, 40.68% { - transform: rotate(0deg); -} - -42.37% { - transform: rotate(5.3deg); -} - -44.07% { - transform: rotate(13.4deg); -} - -45.76% { - transform: rotate(20.6deg); -} - -47.46% { - transform: rotate(29deg); -} - -49.15% { - transform: rotate(36.5deg); -} - -50.85% { - transform: rotate(42.6deg); -} - -52.54% { - transform: rotate(48.8deg); -} - -54.24% { - transform: rotate(54.2deg); -} - -55.93% { - transform: rotate(59.4deg); -} - -57.63% { - transform: rotate(63.2deg); -} - -59.32% { - transform: rotate(67.2deg); -} - -61.02% { - transform: rotate(70.8deg); -} - -62.71% { - transform: rotate(73.8deg); -} - -64.41% { - transform: rotate(76.2deg); -} - -66.1% { - transform: rotate(78.7deg); -} - -67.8% { - transform: rotate(80.6deg); -} - -69.49% { - transform: rotate(82.6deg); -} - -71.19% { - transform: rotate(83.7deg); -} - -72.88% { - transform: rotate(85deg); -} - -74.58% { - transform: rotate(86.3deg); -} - -76.27% { - transform: rotate(87deg); -} - -77.97% { - transform: rotate(87.7deg); -} - -79.66% { - transform: rotate(88.3deg); -} - -81.36% { - transform: rotate(88.6deg); -} - -83.05% { - transform: rotate(89.2deg); -} - -84.75% { - transform: rotate(89.2deg); -} - -86.44% { - transform: rotate(89.5deg); -} - -88.14% { - transform: rotate(89.9deg); -} - -89.83% { - transform: rotate(89.7deg); -} - -91.53% { - transform: rotate(90.1deg); -} - -93.22% { - transform: rotate(90.2deg); -} - -94.92% { - transform: rotate(90.1deg); -} - -96.61% { - transform: rotate(90deg); -} - -98.31% { - transform: rotate(89.8deg); -} - -100% { - transform: rotate(90deg); -} -`); - -const fillMask2Frames = keyframes(` -0%, 8.47% { - transform: rotate(180deg); -} - -10.17% { - transform: rotate(179.2deg); -} - -11.86% { - transform: rotate(164deg); -} - -13.56% { - transform: rotate(151.8deg); -} - -15.25% { - transform: rotate(140.8deg); -} - -16.95% { - transform: rotate(130.3deg); -} - -18.64% { - transform: rotate(120.4deg); -} - -20.34% { - transform: rotate(110.8deg); -} - -22.03% { - transform: rotate(101.6deg); -} - -23.73% { - transform: rotate(93.5deg); -} - -25.42% { - transform: rotate(85.4deg); -} - -27.12% { - transform: rotate(78.1deg); -} - -28.81% { - transform: rotate(71.2deg); -} - -30.51% { - transform: rotate(89.1deg); -} - -32.2% { - transform: rotate(105.5deg); -} - -33.9% { - transform: rotate(121.3deg); -} - -35.59% { - transform: rotate(135.5deg); -} - -37.29% { - transform: rotate(148.4deg); -} - -38.98% { - transform: rotate(161deg); -} - -40.68% { - transform: rotate(173.5deg); -} - -42.37%, 100% { - transform: rotate(180deg); -} -`); - -const fillsRotate = keyframes(` -0% {transform: rotate(-90deg)} -100% {transform: rotate(270deg)} -`); - -const circleDims = { - height: { - default: 32, - size: { - S: 16, - L: 64 - } - }, - width: { +// Double check the types passed to each style, may not need all for each +const wrapper = style({ + size: { default: 32, size: { S: 16, L: 64 } }, - aspectRatio: 'square' -} as const; - -// Double check the types passed to each style, may not need all for each -const wrapper = style({ - ...circleDims, - display: 'inline-block', - position: 'relative' -}, getAllowedOverrides()); - -const trackStyles = { - ...circleDims, - boxSizing: 'border-box', - borderStyle: 'solid', - borderWidth: { - default: '[3px]', - size: { - S: 2, - L: 4 - } - }, - borderRadius: 'full' -} as const; + aspectRatio: 'square', + display: 'inline-block' +}, getAllowedOverrides({height: true})); const track = style({ - ...trackStyles, - borderColor: { - default: baseColor('gray-300'), + stroke: { + default: 'gray-300', staticColor: { - white: { - default: baseColor('transparent-white-300'), - forcedColors: 'Background' - }, - // TODO: no designs for this one - black: { - default: baseColor('transparent-black-300'), - forcedColors: 'Background' - } + white: 'transparent-white-300', + black: 'transparent-black-300' }, forcedColors: 'Background' } }); const fill = style({ - ...trackStyles, - borderColor: { - default: baseColor('blue-900'), + stroke: { + default: 'blue-900', staticColor: { - white: { - default: baseColor('transparent-white-900'), - forcedColors: 'Highlight' - }, - // TODO: no designs for this one - black: { - default: baseColor('transparent-black-900'), - forcedColors: 'Highlight' - } + white: 'transparent-white-900', + black: 'transparent-black-900' }, forcedColors: 'Highlight' - } -}); - -const fillsWrapperStyles = { - position: 'absolute', - top: 0, - left: 0, - size: 'full' -} as const; - -const fillsWrapper = style({ - ...fillsWrapperStyles -}); - -const fillsWrapperIndeterminate = style({ - ...fillsWrapperStyles, - willChange: 'transform', - transform: 'translateZ(0)', - animation: fillsRotate, - animationDuration: 1000, - animationTimingFunction: '[cubic-bezier(.25,.78,.48,.89)]', - animationIterationCount: 'infinite', + }, + rotate: -90, transformOrigin: 'center' }); -const commonFillMask = { - position: 'absolute', - width: '[50%]', - height: 'full', - transformOrigin: '[100% center]', - overflow: 'hidden' -} as const; - -const fillMask1 = style({ - ...commonFillMask, - transform: 'rotate(180deg)' -}); - -const fillMask2 = style({ - ...commonFillMask, - transform: 'rotate(0deg)' -}); - -const commonFillSubMask = { - width: 'full', - height: 'full', - transformOrigin: '[100% center]', - transform: 'rotate(-180deg)', - overflow: 'hidden' -} as const; - -const commonFillSubMaskIndeterminate = { - transform: 'translateZ(0)', - willChange: 'transform', - animationDuration: 1000, - animationTimingFunction: 'linear', - animationIterationCount: 'infinite' -} as const; +export interface ProgressCircleProps extends Omit, ProgressCircleStyleProps, StyleProps {} -const fillSubMask = style({ - ...commonFillSubMask -}); +const rotationAnimation = keyframes(` + 0% { + transform: rotate(0deg); + } -const fillSubMask1Indeterminate = style({ - ...commonFillSubMask, - animation: fillMask1Frames, - ...commonFillSubMaskIndeterminate -}); + 100% { + transform: rotate(360deg); + } +`); -const fillSubMask2Indeterminate = style({ - ...commonFillSubMask, - animation: fillMask2Frames, - ...commonFillSubMaskIndeterminate -}); +// stroke-dashoffset represents `100 - percentage`. See below for how this works. +const dashoffsetAnimation = keyframes(` + 0%, 100% { + stroke-dashoffset: 75; + } -export interface ProgressCircleProps extends Omit, ProgressCircleStyleProps, StyleProps {} + 30% { + stroke-dashoffset: 20; + } +`); function ProgressCircle(props: ProgressCircleProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, ProgressCircleContext); let { - value = 0, - minValue = 0, - maxValue = 100, size = 'M', staticColor, - isIndeterminate = false, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, UNSAFE_style, UNSAFE_className = '' } = props; let domRef = useDOMRef(ref); - value = clamp(value, minValue, maxValue); - - let subMask1Style: CSSProperties = {}; - let subMask2Style: CSSProperties = {}; - if (!isIndeterminate) { - let percentage = (value - minValue) / (maxValue - minValue) * 100; - let angle; - if (percentage > 0 && percentage <= 50) { - angle = -180 + (percentage / 50 * 180); - subMask1Style.transform = `rotate(${angle}deg)`; - subMask2Style.transform = 'rotate(-180deg)'; - } else if (percentage > 50) { - angle = -180 + (percentage - 50) / 50 * 180; - subMask1Style.transform = 'rotate(0deg)'; - subMask2Style.transform = `rotate(${angle}deg)`; - } + let strokeWidth = 3; + if (size === 'S') { + strokeWidth = 2; + } else if (size === 'L') { + strokeWidth = 4; } - if (!ariaLabel && !ariaLabelledby) { - console.warn('ProgressCircle requires an aria-label or aria-labelledby attribute for accessibility'); - } + // SVG strokes are centered, so subtract half the stroke width from the radius to create an inner stroke. + let radius = `calc(50% - ${strokeWidth / 2}px)`; return ( ) ...renderProps, size }, props.styles)}> -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {({percentage, isIndeterminate}) => ( + + + + + )} ); } diff --git a/packages/@react-spectrum/s2/src/SegmentedControl.tsx b/packages/@react-spectrum/s2/src/SegmentedControl.tsx new file mode 100644 index 00000000000..3b3f2d8cc1d --- /dev/null +++ b/packages/@react-spectrum/s2/src/SegmentedControl.tsx @@ -0,0 +1,260 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {centerBaseline} from './CenterBaseline'; +import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, Radio, RadioGroup, RadioGroupProps, RadioGroupStateContext, RadioProps} from 'react-aria-components'; +import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react'; +import {DOMRef, DOMRefValue, FocusableRef} from '@react-types/shared'; +import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {IconContext} from './Icon'; +import {pressScale} from './pressScale'; +import {size, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {Text, TextContext} from './Content'; +import {useDOMRef, useFocusableRef} from '@react-spectrum/utils'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface SegmentedControlProps extends Omit, StyleProps{ + /** + * The content to display in the segmented control. + */ + children: ReactNode, + /** + * Whether the segmented control is disabled. + */ + isDisabled?: boolean, + /** + * Defines a string value that labels the current element. + */ + 'aria-label': string +} +export interface SegmentedControlItemProps extends Omit, StyleProps { + /** + * The content to display in the control item. + */ + children: ReactNode +} + +export const SegmentedControlContext = createContext>>(null); + +const segmentedControl = style<{size: string}>({ + font: 'control', + display: 'flex', + backgroundColor: 'gray-100', + borderRadius: 'lg', + width: 'full' +}, getAllowedOverrides()); + +const controlItem = style({ + position: 'relative', + display: 'flex', + forcedColorAdjust: 'none', + color: { + default: 'gray-700', + isHovered: 'neutral-subdued', + isSelected: 'neutral', + isDisabled: 'disabled', + forcedColors: { + default: 'ButtonText', + isDisabled: 'GrayText', + isSelected: 'HighlightText' + } + }, + // TODO: update this padding for icon-only items when we introduce the non-track style back + paddingX: { + default: 'edge-to-text', + ':has([slot=icon]:only-child)': size(6) + }, + height: 32, + alignItems: 'center', + flexBasis: 0, + flexGrow: 1, + flexShrink: 0, + justifyContent: 'center', + whiteSpace: 'nowrap', + disableTapHighlight: true, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}, getAllowedOverrides()); + +const slider = style({ + ...focusRing(), + backgroundColor: 'gray-25', + left: 0, + width: 'full', + height: 'full', + position: 'absolute', + boxSizing: 'border-box', + borderStyle: 'solid', + borderWidth: 2, + borderColor: { + default: 'gray-900', + isDisabled: 'disabled' + }, + borderRadius: 'lg' +}); + +interface InternalSegmentedControlContextProps { + register?: (value: string, isDisabled?: boolean) => void, + prevRef?: RefObject, + currentSelectedRef?: RefObject +} + +interface DefaultSelectionTrackProps { + defaultValue?: string | null, + value?: string | null, + children?: ReactNode, + prevRef: RefObject, + currentSelectedRef: RefObject +} + +const InternalSegmentedControlContext = createContext({}); + +function SegmentedControl(props: SegmentedControlProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, SegmentedControlContext); + let { + defaultValue, + value + } = props; + let domRef = useDOMRef(ref); + + let prevRef = useRef(null); + let currentSelectedRef = useRef(null); + + let onChange = (value: string) => { + if (currentSelectedRef.current) { + prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); + } + + if (props.onChange) { + props.onChange(value); + } + }; + + return ( + + + {props.children} + + + ); +} + +function DefaultSelectionTracker(props: DefaultSelectionTrackProps) { + let state = useContext(RadioGroupStateContext); + let isRegistered = useRef(!(props.defaultValue == null && props.value == null)); + + // default select the first available item + let register = useCallback((value: string) => { + if (state && !isRegistered.current) { + isRegistered.current = true; + state.setSelectedValue(value); + } + }, []); + + return ( + + {props.children} + + ); +} + +function SegmentedControlItem(props: SegmentedControlItemProps, ref: FocusableRef) { + let inputRef = useRef(null); + let domRef = useFocusableRef(ref, inputRef); + let divRef = useRef(null); + let {register, prevRef, currentSelectedRef} = useContext(InternalSegmentedControlContext); + let state = useContext(RadioGroupStateContext); + let isSelected = props.value === state?.selectedValue; + // do not apply animation if a user has the prefers-reduced-motion setting + let isReduced = false; + if (window?.matchMedia) { + isReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + useLayoutEffect(() => { + register?.(props.value); + }, []); + + useLayoutEffect(() => { + if (isSelected && prevRef?.current && currentSelectedRef?.current && !isReduced) { + let currentItem = currentSelectedRef?.current.getBoundingClientRect(); + + let deltaX = prevRef?.current.left - currentItem?.left; + + currentSelectedRef.current.animate( + [ + {transform: `translateX(${deltaX}px)`, width: `${prevRef?.current.width}px`}, + {transform: 'translateX(0px)', width: `${currentItem.width}px`} + ], + { + duration: 200, + easing: 'ease-out' + } + ); + + prevRef.current = null; + } + }, [isSelected]); + + return ( + (props.UNSAFE_className || '') + controlItem({...renderProps}, props.styles)} > + {({isSelected, isFocusVisible, isPressed, isDisabled}) => ( + <> + {isSelected &&
} + +
+ {typeof props.children === 'string' ? {props.children} : props.children} +
+
+ + ) + } + + ); +} + +/** + * A control items represents an individual control within a segmented control. + */ +const _SegmentedControlItem = /*#__PURE__*/ forwardRef(SegmentedControlItem); +export {_SegmentedControlItem as SegmentedControlItem}; + +/** + * A segmented control is a mutually exclusive group of buttons, with or without a track. + */ +const _SegmentedControl = /*#__PURE__*/ forwardRef(SegmentedControl); +export {_SegmentedControl as SegmentedControl}; diff --git a/packages/@react-spectrum/s2/src/Skeleton.tsx b/packages/@react-spectrum/s2/src/Skeleton.tsx new file mode 100644 index 00000000000..ff218feefd3 --- /dev/null +++ b/packages/@react-spectrum/s2/src/Skeleton.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {cloneElement, createContext, CSSProperties, ReactElement, ReactNode, Ref, useCallback, useContext, useRef} from 'react'; +import {colorToken} from '../style/tokens' with {type: 'macro'}; +import {mergeRefs} from '@react-aria/utils'; +import {mergeStyles} from '../style/runtime'; +import {raw} from '../style/style-macro' with {type: 'macro'}; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {StyleString} from '../style/types'; + +let reduceMotion = typeof window?.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; + +export function useLoadingAnimation(isAnimating: boolean) { + let animationRef = useRef(null); + return useCallback((element: HTMLElement | null) => { + if (isAnimating && !animationRef.current && element && !reduceMotion) { + // Use web animation API instead of CSS animations so that we can + // synchronize it between all loading elements on the page (via startTime). + animationRef.current = element.animate( + [ + {backgroundPosition: '100%'}, + {backgroundPosition: '0%'} + ], + { + duration: 2000, + iterations: Infinity, + easing: 'ease-in-out' + } + ); + animationRef.current.startTime = 0; + } else if (!isAnimating && animationRef.current) { + animationRef.current.cancel(); + animationRef.current = null; + } + }, [isAnimating]); +} + +export type SkeletonElement = ReactElement<{ + children?: ReactNode, + className?: string, + ref?: Ref, + inert?: boolean | 'true' +}>; + +export const SkeletonContext = createContext(null); +export function useIsSkeleton(): boolean { + return useContext(SkeletonContext) || false; +} + +export interface SkeletonProps { + children: ReactNode, + isLoading: boolean +} + +export function Skeleton({children, isLoading}: SkeletonProps) { + // Disable all form components inside a skeleton. + return ( + + {children} + + ); +} + +export const loadingStyle = raw(` + background-image: linear-gradient(to right, ${colorToken('gray-100')} 33%, light-dark(${colorToken('gray-25')}, ${colorToken('gray-300')}), ${colorToken('gray-100')} 66%); + background-size: 300%; + * { + visibility: hidden; + } +`, 'UNSAFE_overrides'); + +export function useSkeletonText(children: ReactNode, style: CSSProperties | undefined): [ReactNode, CSSProperties | undefined] { + let isSkeleton = useContext(SkeletonContext); + if (isSkeleton) { + children = {children}; + style = { + ...style, + // This ensures the ellipsis on truncated text is also hidden. + // -webkit-text-fill-color overrides any `color` property that is also set. + WebkitTextFillColor: 'transparent' + }; + } + return [children, style]; +} + +// Rendered inside to create skeleton line boxes via box-decoration-break. +export function SkeletonText({children}) { + return ( + + {children} + + ); +} + +// Clones the child element and displays it with skeleton styling. +export function SkeletonWrapper({children}: {children: SkeletonElement}) { + let isLoading = useContext(SkeletonContext); + let animation = useLoadingAnimation(isLoading || false); + if (isLoading == null) { + return children; + } + + let childRef = 'ref' in children ? children.ref as any : children.props.ref; + return ( + + {isLoading ? cloneElement(children, { + ref: mergeRefs(childRef, animation), + className: (children.props.className || '') + ' ' + loadingStyle, + inert: 'true' + }) : children} + + ); +} + +// Adds default border radius around icons when displayed in a skeleton. +export function useSkeletonIcon(styles: StyleString): StyleString { + let isSkeleton = useContext(SkeletonContext); + if (isSkeleton) { + return mergeStyles(style({borderRadius: 'sm'}), styles); + } + return styles || '' as StyleString; +} diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx new file mode 100644 index 00000000000..fc6c5ea7be9 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {createLeafComponent} from '@react-aria/collections'; +import {ReactNode} from 'react'; +import {Skeleton} from './Skeleton'; + +export interface SkeletonCollectionProps { + children: () => ReactNode +} + +let cache = new WeakMap(); +export const SkeletonCollection = createLeafComponent('skeleton', (props: SkeletonCollectionProps, ref, node) => { + // Cache rendering based on node object identity. This allows the children function to randomize + // its content (e.g. heights) and preserve on re-renders. + // TODO: do we need a `dependencies` prop here? + let cached = cache.get(node); + if (!cached) { + cached = ( + + {props.children()} + + ); + cache.set(node, cached); + } + return cached; +}); diff --git a/packages/@react-spectrum/s2/src/StatusLight.tsx b/packages/@react-spectrum/s2/src/StatusLight.tsx index 5e5c5b60c04..662c23fdf39 100644 --- a/packages/@react-spectrum/s2/src/StatusLight.tsx +++ b/packages/@react-spectrum/s2/src/StatusLight.tsx @@ -17,7 +17,9 @@ import {createContext, forwardRef, ReactNode} from 'react'; import {filterDOMProps} from '@react-aria/utils'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {size, style} from '../style/spectrum-theme' with {type: 'macro'}; +import {Text} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; +import {useIsSkeleton} from './Skeleton'; import {useSpectrumContextProps} from './useSpectrumContextProps'; interface StatusLightStyleProps { @@ -64,7 +66,7 @@ const wrapper = style({ disableTapHighlight: true }, getAllowedOverrides()); -const light = style({ +const light = style({ size: { size: { S: 8, @@ -94,7 +96,8 @@ const light = style({ cinnamon: 'cinnamon', brown: 'brown', silver: 'silver' - } + }, + isSkeleton: 'gray-200' } }); @@ -102,6 +105,7 @@ function StatusLight(props: StatusLightProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, StatusLightContext); let {children, size = 'M', variant, role, UNSAFE_className = '', UNSAFE_style, styles} = props; let domRef = useDOMRef(ref); + let isSkeleton = useIsSkeleton(); if (!children && !props['aria-label']) { console.warn('If no children are provided, an aria-label must be specified'); @@ -119,11 +123,11 @@ function StatusLight(props: StatusLightProps, ref: DOMRef) { style={UNSAFE_style} className={UNSAFE_className + wrapper({size, variant}, styles)}> - - {children} + {children}
); } diff --git a/packages/@react-spectrum/s2/src/ToggleButton.tsx b/packages/@react-spectrum/s2/src/ToggleButton.tsx index 85599b7ce99..e5bc90b4ab7 100644 --- a/packages/@react-spectrum/s2/src/ToggleButton.tsx +++ b/packages/@react-spectrum/s2/src/ToggleButton.tsx @@ -18,9 +18,11 @@ import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {fontRelative, style} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; import {pressScale} from './pressScale'; +import {SkeletonContext} from './Skeleton'; import {StyleProps} from './style-utils'; import {Text, TextContext} from './Content'; import {useFocusableRef} from '@react-spectrum/utils'; +import {useFormProps} from './Form'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface ToggleButtonProps extends Omit, StyleProps, ActionButtonStyleProps { @@ -34,6 +36,7 @@ export const ToggleButtonContext = createContext) { [props, ref] = useSpectrumContextProps(props, ref, ToggleButtonContext); + props = useFormProps(props as any); let domRef = useFocusableRef(ref); return ( ; -export type StylesPropWithHeight = StyleString<(typeof allowedOverrides)[number] | (typeof heightProperties)[number]>; +export type StylesPropWithHeight = StyleString<(typeof allowedOverrides)[number] | (typeof widthProperties)[number] | (typeof heightProperties)[number]>; export type StylesPropWithoutWidth = StyleString<(typeof allowedOverrides)[number]>; export interface UnsafeStyles { /** Sets the CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. Only use as a **last resort**. Use the `style` macro via the `styles` prop instead. */ 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 ( + + + + + + + Card title + Card description. Give a concise overview of the context or functionality that's mentioned in the card title. + +
+ +
+
+
+); + +Product.argTypes = { + ...specificArgTypes, + variant: { + control: 'radio', + options: ['primary', 'secondary', 'tertiary'] + } +}; + +export const Collection = (args: any) => ( +
+ + + + + + + + + Travel +
+ + 20 photos +
+
+
+ + + + + + + + Architecture +
+ + 15 photos +
+
+
+
+); + +export const PreviewOverlay = (args: any) => ( + + + + + Free + + + + +); + +export const Custom = (args: any) => ( +
+ + + + + +
+
+ ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('select')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); let trigger = selectTester.trigger; expect(trigger).toHaveTextContent('Cat'); }); @@ -150,14 +150,14 @@ describe('Select', () => { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('select')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); let trigger = selectTester.trigger; expect(trigger).toHaveTextContent('1 - Cat'); }); it('supports placeholder', () => { let {getByTestId} = render(); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('select')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); let trigger = selectTester.trigger; expect(trigger).toHaveTextContent('Select an animal'); }); @@ -184,7 +184,7 @@ describe('Select', () => { ); - let selectTester = testUtilUser.createTester('SelectTester', {root: getByTestId('select')}); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); let trigger = selectTester.trigger; expect(trigger).toHaveTextContent('open'); @@ -229,7 +229,7 @@ describe('Select', () => { ); let wrapper = getByTestId('test-select'); - let selectTester = testUtilUser.createTester('SelectTester', {root: wrapper}); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); let trigger = selectTester.trigger; let select = wrapper; let input = document.querySelector('[name=select]'); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 29057eeeb5f..8acab554cb8 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -193,7 +193,7 @@ describe('Table', () => { it('should render with default classes', () => { let {getByRole} = renderTable(); - let tableTester = testUtilUser.createTester('TableTester', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); let table = tableTester.table; expect(table).toHaveAttribute('class', 'react-aria-Table'); @@ -617,7 +617,7 @@ describe('Table', () => { ); - let tableTester = testUtilUser.createTester('TableTester', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); await tableTester.triggerRowAction({index: 0}); expect(onAction).toHaveBeenCalled(); }); @@ -629,7 +629,7 @@ describe('Table', () => { columnProps: {allowsSorting: true} }); - let tableTester = testUtilUser.createTester('TableTester', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); let columns = tableTester.columns; expect(columns[0]).toHaveAttribute('aria-sort', 'ascending'); @@ -666,7 +666,7 @@ describe('Table', () => { it('supports removing rows', async () => { let {rerender, getByRole} = render(); - let tableTester = testUtilUser.createTester('TableTester', {root: getByRole('grid')}); + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); await user.tab(); fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); diff --git a/packages/@react-spectrum/s2/stories/GridList.stories.tsx b/scripts/copyIconDts.js similarity index 51% rename from packages/@react-spectrum/s2/stories/GridList.stories.tsx rename to scripts/copyIconDts.js index 76bad583fb6..c1f6ad6e9e8 100644 --- a/packages/@react-spectrum/s2/stories/GridList.stories.tsx +++ b/scripts/copyIconDts.js @@ -10,29 +10,10 @@ * governing permissions and limitations under the License. */ -import {GridList, GridListItem} from '../src/GridList'; +const glob = require('glob').sync; +const fs = require('fs'); -import type {Meta} from '@storybook/react'; - -const meta: Meta = { - component: GridList, - parameters: { - layout: 'centered' - } -}; - -export default meta; - -export const Example = (args: any) => ( - - Chocolate - Mint - Strawberry - Vanilla - -); - -Example.args = { - onAction: null, - selectionMode: 'multiple' -}; +// We have to copy icon.d.ts for each icon/illustration so TypeScript's import autocomplete works. +for (let file of glob('packages/@react-spectrum/s2/{icons,illustrations/**}/*.mjs')) { + fs.copyFileSync('packages/@react-spectrum/s2/icon.d.ts', file.replace('.mjs', '.d.ts')); +} 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 e83e2dc5985..5a6e56426b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7429,6 +7429,7 @@ __metadata: dependencies: "@adobe/spectrum-tokens": "npm:^13.0.0-beta.46" "@parcel/plugin": "npm:2.0.0-dev.1601" + "@svgr/core": "npm:^6.2.0" languageName: unknown linkType: soft @@ -7549,6 +7550,7 @@ __metadata: "@react-aria/test-utils": "npm:1.0.0-alpha.1" "@react-aria/utils": "npm:^3.25.2" "@react-spectrum/utils": "npm:^3.11.10" + "@react-stately/virtualizer": "npm:^4.0.1" "@react-types/color": "npm:3.0.0-rc.1" "@react-types/dialog": "npm:^3.5.8" "@react-types/provider": "npm:^3.7.2" @@ -23110,91 +23112,91 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-arm64@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-darwin-arm64@npm:1.26.0" +"lightningcss-darwin-arm64@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-darwin-arm64@npm:1.27.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"lightningcss-darwin-x64@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-darwin-x64@npm:1.26.0" +"lightningcss-darwin-x64@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-darwin-x64@npm:1.27.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"lightningcss-freebsd-x64@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-freebsd-x64@npm:1.26.0" +"lightningcss-freebsd-x64@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-freebsd-x64@npm:1.27.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"lightningcss-linux-arm-gnueabihf@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.26.0" +"lightningcss-linux-arm-gnueabihf@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.27.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"lightningcss-linux-arm64-gnu@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-linux-arm64-gnu@npm:1.26.0" +"lightningcss-linux-arm64-gnu@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.27.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"lightningcss-linux-arm64-musl@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-linux-arm64-musl@npm:1.26.0" +"lightningcss-linux-arm64-musl@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.27.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"lightningcss-linux-x64-gnu@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-linux-x64-gnu@npm:1.26.0" +"lightningcss-linux-x64-gnu@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.27.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"lightningcss-linux-x64-musl@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-linux-x64-musl@npm:1.26.0" +"lightningcss-linux-x64-musl@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-linux-x64-musl@npm:1.27.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"lightningcss-win32-arm64-msvc@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-win32-arm64-msvc@npm:1.26.0" +"lightningcss-win32-arm64-msvc@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.27.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"lightningcss-win32-x64-msvc@npm:1.26.0": - version: 1.26.0 - resolution: "lightningcss-win32-x64-msvc@npm:1.26.0" +"lightningcss-win32-x64-msvc@npm:1.27.0": + version: 1.27.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.27.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "lightningcss@npm:^1.22.1": - version: 1.26.0 - resolution: "lightningcss@npm:1.26.0" + version: 1.27.0 + resolution: "lightningcss@npm:1.27.0" dependencies: detect-libc: "npm:^1.0.3" - lightningcss-darwin-arm64: "npm:1.26.0" - lightningcss-darwin-x64: "npm:1.26.0" - lightningcss-freebsd-x64: "npm:1.26.0" - lightningcss-linux-arm-gnueabihf: "npm:1.26.0" - lightningcss-linux-arm64-gnu: "npm:1.26.0" - lightningcss-linux-arm64-musl: "npm:1.26.0" - lightningcss-linux-x64-gnu: "npm:1.26.0" - lightningcss-linux-x64-musl: "npm:1.26.0" - lightningcss-win32-arm64-msvc: "npm:1.26.0" - lightningcss-win32-x64-msvc: "npm:1.26.0" + lightningcss-darwin-arm64: "npm:1.27.0" + lightningcss-darwin-x64: "npm:1.27.0" + lightningcss-freebsd-x64: "npm:1.27.0" + lightningcss-linux-arm-gnueabihf: "npm:1.27.0" + lightningcss-linux-arm64-gnu: "npm:1.27.0" + lightningcss-linux-arm64-musl: "npm:1.27.0" + lightningcss-linux-x64-gnu: "npm:1.27.0" + lightningcss-linux-x64-musl: "npm:1.27.0" + lightningcss-win32-arm64-msvc: "npm:1.27.0" + lightningcss-win32-x64-msvc: "npm:1.27.0" dependenciesMeta: lightningcss-darwin-arm64: optional: true @@ -23216,7 +23218,7 @@ __metadata: optional: true lightningcss-win32-x64-msvc: optional: true - checksum: 10c0/66ddf97c249ac71fee1a3fa2a9e7039359a8dc5b8a660037310cada69b85a87303c6ea5c37df18405e4a21eb567bcd00b299dbf9b1bc87e87ba1c7a37ab08f0c + checksum: 10c0/5292b277ebbefdd952cb7b9ccd20dd2c185a7eae9b4393960386b7b8c4d644492a413a91d05ca9dcb72c775bbb8d79b235a3415d66410c47464039394d022109 languageName: node linkType: hard @@ -28722,6 +28724,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"