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
+
+
+
+ {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 (
-
);
}
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 ;
-}
-
-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 && (
+
+ )}
+
+ ), [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 (
+
+ );
+}
diff --git a/packages/@react-spectrum/s2/stories/Card.stories.tsx b/packages/@react-spectrum/s2/stories/Card.stories.tsx
new file mode 100644
index 00000000000..9f781e86d8f
--- /dev/null
+++ b/packages/@react-spectrum/s2/stories/Card.stories.tsx
@@ -0,0 +1,309 @@
+/*
+ * 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 {ActionMenu, AssetCard, Avatar, Badge, Button, Card, CardPreview, CardProps, CollectionCardPreview, Content, Divider, Footer, Image, MenuItem, Meter, ProductCard, Skeleton, StatusLight, Text, UserCard} from '../src';
+import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg';
+import FolderGradient from 'illustration:../spectrum-illustrations/gradient/S2_fill_folderClose_generic2_160.svg';
+import type {Meta} from '@storybook/react';
+import Project from '../s2wf-icons/S2_Icon_Project_20_N.svg';
+import Select from '../s2wf-icons/S2_Icon_Select_20_N.svg';
+import {style} from '../style/spectrum-theme' with {type: 'macro'};
+
+const meta: Meta = {
+ component: Card,
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['autodocs'],
+ args: {
+ isLoading: false
+ },
+ argTypes: {
+ href: {table: {disable: true}},
+ download: {table: {disable: true}},
+ hrefLang: {table: {disable: true}},
+ referrerPolicy: {table: {disable: true}},
+ rel: {table: {disable: true}},
+ routerOptions: {table: {disable: true}},
+ ping: {table: {disable: true}},
+ target: {table: {disable: true}},
+ value: {table: {disable: true}},
+ textValue: {table: {disable: true}},
+ onAction: {table: {disable: true}},
+ isDisabled: {table: {disable: true}}
+ },
+ decorators: (children, {args}) => (
+
+ {children(args)}
+
+ )
+};
+
+export default meta;
+
+export const Example = (args: any) => (
+
+
+
+
+
+
+ Card title
+
+ Test
+
+ Card description. Give a concise overview of the context or functionality that's mentioned in the card title.
+
+ {args.size !== 'XS' && <>
+
+
+ >}
+
+
+
+ Card title
+ Card description. Give a concise overview of the context or functionality that's mentioned in the card title.
+
+ {args.size !== 'XS' && <>
+
+
+ >}
+
+
+);
+
+const specificArgTypes = {
+ density: {
+ table: {
+ disable: true
+ }
+ }
+};
+
+export const Asset = (args: any) => (
+
+
+
+
+
+
+ Desert Sunset
+ PNG • 2/3/2024
+
+
+
+
+
+
+
+ Projects
+ 10 items • 6/14/2024
+
+
+
+);
+
+Asset.argTypes = specificArgTypes;
+
+export const User = (args: any) => (
+
+
+
+
+
+
+
+ Card title
+ Card description. Give a concise overview of the context or functionality that's mentioned in the card title.
+
+
+
+
+
+
+ Card title
+ Card description. Give a concise overview of the context or functionality that's mentioned in the card title.
+
+
+
+
+);
+
+User.argTypes = {
+ ...specificArgTypes,
+ variant: {
+ control: 'radio',
+ options: ['primary', 'secondary', 'tertiary']
+ }
+};
+
+export const Product = (args: any) => (
+
+
+
+
+
+
+
+ Card title
+ Card description. Give a concise overview of the context or functionality that's mentioned in the card title.
+
+
+
+
+
+
+ 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) => (
+
+
+
+
+
+
+
+
+
+ Click through rate
+
+
+ 1.012%
+ 21% ↑ average
+
+
+
+
+
+
+
+
+
+ Yummburger
+
+
+
35k experiences use this
+
+
+
+ 25% click through rate
+
+ 56k clicks
+ 3.46% last month
+
+
+
+);
+
+Custom.argTypes = {
+ size: {
+ table: {
+ disable: true
+ }
+ }
+};
diff --git a/packages/@react-spectrum/s2/stories/CardView.stories.tsx b/packages/@react-spectrum/s2/stories/CardView.stories.tsx
new file mode 100644
index 00000000000..59247de4492
--- /dev/null
+++ b/packages/@react-spectrum/s2/stories/CardView.stories.tsx
@@ -0,0 +1,257 @@
+/*
+ * 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 {ActionMenu, Avatar, Card, CardPreview, CardView, CardViewProps, Collection, CollectionCardPreview, Content, Heading, IllustratedMessage, Image, MenuItem, SkeletonCollection, Text} from '../src';
+import EmptyIcon from 'illustration:../spectrum-illustrations/gradient/S2_fill_image_generic1_160.svg';
+import ErrorIcon from '../spectrum-illustrations/linear/AlertNotice';
+import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg';
+import type {Meta} from '@storybook/react';
+import {style} from '../style/spectrum-theme' with {type: 'macro'};
+import {useAsyncList} from 'react-stately';
+
+const meta: Meta = {
+ component: CardView,
+ parameters: {
+ layout: 'fullscreen'
+ },
+ tags: ['autodocs']
+};
+
+export default meta;
+
+const cardViewStyles = style({
+ width: {
+ default: 'screen',
+ viewMode: {
+ docs: 'full'
+ }
+ },
+ height: {
+ default: 'screen',
+ viewMode: {
+ docs: '[600px]'
+ }
+ }
+});
+
+type Item = {
+ id: number,
+ user: {
+ name: string,
+ profile_image: { small: string }
+ },
+ urls: { regular: string },
+ description: string,
+ alt_description: string,
+ width: number,
+ height: number
+};
+
+const avatarSize = {
+ XS: 16,
+ S: 20,
+ M: 24,
+ L: 28,
+ XL: 32
+} as const;
+
+function PhotoCard({item, layout}: {item: Item, layout: string}) {
+ return (
+
+ {({size}) => (<>
+
+ (
+
+
+
+ )} />
+
+
+ {item.description || item.alt_description}
+ {size !== 'XS' &&
+ Test
+ }
+
+
+ >)}
+
+ );
+}
+
+export const Example = (args: CardViewProps, {viewMode}) => {
+ let list = useAsyncList- ({
+ async load({signal, cursor, items}) {
+ let page = cursor || 1;
+ let res = await fetch(
+ `https://api.unsplash.com/topics/nature/photos?page=${page}&per_page=30&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
+ {signal}
+ );
+ let nextItems = await res.json();
+ // Filter duplicates which might be returned by the API.
+ let existingKeys = new Set(items.map(i => i.id));
+ nextItems = nextItems.filter(i => !existingKeys.has(i.id) && (i.description || i.alt_description));
+ return {items: nextItems, cursor: nextItems.length ? page + 1 : null};
+ }
+ });
+
+ let loadingState = args.loadingState === 'idle' ? list.loadingState : args.loadingState;
+ let items = loadingState === 'loading' ? [] : list.items;
+
+ return (
+
+
+ {item => }
+
+ {(loadingState === 'loading' || loadingState === 'loadingMore') && (
+
+ {() => (
+
+ )}
+
+ )}
+
+ );
+};
+
+Example.args = {
+ loadingState: 'idle',
+ onAction: null,
+ selectionMode: 'multiple'
+};
+
+export const Empty = (args: CardViewProps, {viewMode}) => {
+ return (
+ (
+
+
+ Create your first asset.
+ Get started by uploading or importing some assets.
+
+ )}>
+ {[]}
+
+ );
+};
+
+interface Topic {
+ id: string,
+ title: string,
+ total_photos: number,
+ links: {html: string},
+ preview_photos: {id: string, urls: {small: string}}[]
+}
+
+function TopicCard({topic}: {topic: Topic}) {
+ return (
+
+
+ {topic.preview_photos.slice(0, 4).map(photo => (
+
+ ))}
+
+
+ {topic.title}
+
+
+ {topic.total_photos.toLocaleString()} photos
+
+
+
+ );
+}
+
+export const CollectionCards = (args: CardViewProps, {viewMode}) => {
+ let list = useAsyncList({
+ async load({signal, cursor}) {
+ let page = cursor || 1;
+ let res = await fetch(
+ `https://api.unsplash.com/topics?page=${page}&per_page=30&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
+ {signal}
+ );
+ let items = await res.json();
+ return {items, cursor: items.length ? page + 1 : null};
+ }
+ });
+
+ let loadingState = args.loadingState === 'idle' ? list.loadingState : args.loadingState;
+ let items = loadingState === 'loading' ? [] : list.items;
+
+ return (
+
+
+ {topic => }
+
+ {(loadingState === 'loading' || loadingState === 'loadingMore') && (
+
+ {() => (
+
+ )}
+
+ )}
+
+ );
+};
+
+CollectionCards.args = {
+ loadingState: 'idle',
+ onAction: null
+};
diff --git a/packages/@react-spectrum/s2/stories/ProgressCircle.stories.tsx b/packages/@react-spectrum/s2/stories/ProgressCircle.stories.tsx
index 329fa435c90..45d81a9143a 100644
--- a/packages/@react-spectrum/s2/stories/ProgressCircle.stories.tsx
+++ b/packages/@react-spectrum/s2/stories/ProgressCircle.stories.tsx
@@ -13,6 +13,7 @@
import type {Meta, StoryObj} from '@storybook/react';
import {ProgressCircle} from '../src';
import {StaticColorDecorator} from './utils';
+import {style} from '../style/spectrum-theme' with {type: 'macro'};
const meta: Meta = {
component: ProgressCircle,
@@ -46,3 +47,10 @@ export const Example: Story = {
}
}
};
+
+export const CustomSize = (args) => ;
+CustomSize.parameters = {
+ docs: {
+ disable: true
+ }
+};
diff --git a/packages/@react-spectrum/s2/stories/SegmentedControl.stories.tsx b/packages/@react-spectrum/s2/stories/SegmentedControl.stories.tsx
new file mode 100644
index 00000000000..73c72a5c135
--- /dev/null
+++ b/packages/@react-spectrum/s2/stories/SegmentedControl.stories.tsx
@@ -0,0 +1,82 @@
+/*
+ * 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 AlignBottom from '../s2wf-icons/S2_Icon_AlignBottom_20_N.svg';
+import AlignCenter from '../s2wf-icons/S2_Icon_AlignCenter_20_N.svg';
+import AlignLeft from '../s2wf-icons/S2_Icon_AlignLeft_20_N.svg';
+import ListBulleted from '../s2wf-icons/S2_Icon_ListBulleted_20_N.svg';
+import ListMultiSelect from '../s2wf-icons/S2_Icon_ListMultiSelect_20_N.svg';
+import ListNumbered from '../s2wf-icons/S2_Icon_ListNumbered_20_N.svg';
+import type {Meta} from '@storybook/react';
+import {SegmentedControl, SegmentedControlItem, Text} from '../src';
+import {style} from '../style/spectrum-theme' with {type: 'macro'};
+
+
+const meta: Meta = {
+ component: SegmentedControl,
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['autodocs']
+};
+
+export default meta;
+
+export const Example = (args: any) => (
+
+ Day
+ Week
+ Month
+ Year
+
+);
+
+Example.args = {
+ 'aria-label': 'Time granularity'
+};
+
+export const WithIcons = (args: any) => (
+
+ Unordered
+ Ordered
+ Task List
+
+);
+
+WithIcons.args = {
+ 'aria-label': 'List organization'
+};
+
+export const OnlyIcons = (args: any) => (
+
+
+
+
+
+);
+
+OnlyIcons.args = {
+ 'aria-label': 'Text alignment'
+};
+
+export const CustomWidth = (args: any) => (
+
+ Overview
+ Specs
+ Guidelines
+ Accessibility
+
+);
+
+CustomWidth.args = {
+ 'aria-label': 'Getting started'
+};
diff --git a/packages/@react-spectrum/s2/stories/assets/placeholder.png b/packages/@react-spectrum/s2/stories/assets/placeholder.png
new file mode 100644
index 00000000000..601b8f91b14
Binary files /dev/null and b/packages/@react-spectrum/s2/stories/assets/placeholder.png differ
diff --git a/packages/@react-spectrum/s2/stories/assets/preview.png b/packages/@react-spectrum/s2/stories/assets/preview.png
new file mode 100644
index 00000000000..064d5877ccf
Binary files /dev/null and b/packages/@react-spectrum/s2/stories/assets/preview.png differ
diff --git a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js
index 31edc0eaf2a..e3541d10c02 100644
--- a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js
+++ b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js
@@ -42,7 +42,7 @@ describe('style-macro', () => {
@layer _.a, _.b, _.c, UNSAFE_overrides;
@layer _.b {
- .A-13alit4b {
+ .A-13alit4c {
&:first-child {
margin-top: 0.25rem;
}
@@ -51,7 +51,7 @@ describe('style-macro', () => {
@layer _.c.e {
@media (min-width: 1024px) {
- .A-13alit4ec {
+ .A-13alit4ed {
&:first-child {
margin-top: 0.5rem;
}
@@ -61,7 +61,7 @@ describe('style-macro', () => {
"
`);
- expect(js).toMatchInlineSnapshot('" . A-13alit4b A-13alit4ec"');
+ expect(js).toMatchInlineSnapshot('" . A-13alit4c A-13alit4ed"');
});
it('should support self references', () => {
@@ -102,7 +102,7 @@ describe('style-macro', () => {
}
- .DH {
+ .DI {
padding-inline-end: calc(var(--k, var(--o)) * 3 / 8);
}
@@ -117,7 +117,7 @@ describe('style-macro', () => {
}
- .-_375tnm_C-H {
+ .-_375tnm_C-I {
--C: calc(var(--k, var(--o)) * 3 / 8);
}
}
diff --git a/packages/@react-spectrum/s2/style/runtime.ts b/packages/@react-spectrum/s2/style/runtime.ts
index 3780d515e34..5b567e2ab56 100644
--- a/packages/@react-spectrum/s2/style/runtime.ts
+++ b/packages/@react-spectrum/s2/style/runtime.ts
@@ -36,7 +36,7 @@ import {StyleString} from './types';
// };
// }
-export function mergeStyles(...styles: (StyleString | null | undefined)[]): string {
+export function mergeStyles(...styles: (StyleString | null | undefined)[]): StyleString {
let definedStyles = styles.filter(Boolean) as StyleString[];
if (definedStyles.length === 1) {
return definedStyles[0];
@@ -53,7 +53,7 @@ export function mergeStyles(...styles: (StyleString | null | undefined)[]): stri
for (let value of map.values()) {
res += value;
}
- return res;
+ return res as StyleString;
}
function parse(s: string) {
diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts
index 6afdda71e92..8765e1519ca 100644
--- a/packages/@react-spectrum/s2/style/spectrum-theme.ts
+++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts
@@ -101,7 +101,7 @@ function generateSpacing(px: K): {[P in K[number]]: string}
const baseSpacing = generateSpacing([
0,
- // 2, // spacing-50 !! TODO: should we support this?
+ 2, // spacing-50
4, // spacing-75
8, // spacing-100
12, // spacing-200
@@ -215,7 +215,6 @@ const sizing = {
...scaledSpacing,
auto: 'auto',
full: '100%',
- screen: '100vh',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
@@ -241,6 +240,16 @@ const sizing = {
}
};
+const height = {
+ ...sizing,
+ screen: '100vh'
+};
+
+const width = {
+ ...sizing,
+ screen: '100vw'
+};
+
const margin = {
...spacing,
...negativeSpacing,
@@ -506,7 +515,8 @@ export const style = createTheme({
base: colorToken('background-base-color'),
'layer-1': colorToken('background-layer-1-color'),
'layer-2': weirdColorToken('background-layer-2-color'),
- pasteboard: weirdColorToken('background-pasteboard-color')
+ pasteboard: weirdColorToken('background-pasteboard-color'),
+ elevated: weirdColorToken('background-elevated-color')
}),
borderColor: createColorProperty({
...color,
@@ -575,18 +585,18 @@ export const style = createTheme({
},
rowGap: spacing,
columnGap: spacing,
- height: sizing,
- width: sizing,
- containIntrinsicWidth: sizing,
- containIntrinsicHeight: sizing,
- minHeight: sizing,
+ height,
+ width,
+ containIntrinsicWidth: width,
+ containIntrinsicHeight: height,
+ minHeight: height,
maxHeight: {
- ...sizing,
+ ...height,
none: 'none'
},
- minWidth: sizing,
+ minWidth: width,
maxWidth: {
- ...sizing,
+ ...width,
none: 'none'
},
borderStartWidth: createRenamedProperty('borderInlineStartWidth', borderWidth),
diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts
index 988c50c881d..1246487e916 100644
--- a/packages/@react-spectrum/s2/style/style-macro.ts
+++ b/packages/@react-spectrum/s2/style/style-macro.ts
@@ -607,9 +607,9 @@ function getStaticClassName(rules: Rule[]): string {
return rules.map(rule => (rule.prelude.startsWith('.') ? ' ' + rule.prelude.slice(1) : '') + (Array.isArray(rule.body) ? getStaticClassName(rule.body) : '')).join('');
}
-export function raw(this: MacroContext | void, css: string) {
+export function raw(this: MacroContext | void, css: string, layer = '_.a') {
let className = generateArbitraryValueSelector(css, true);
- css = `@layer _.a {
+ css = `@layer ${layer} {
.${className} {
${css}
}
diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js
index 6843e8da36c..4955f80dcdb 100644
--- a/packages/@react-spectrum/table/test/Table.test.js
+++ b/packages/@react-spectrum/table/test/Table.test.js
@@ -2395,7 +2395,7 @@ export let tableTests = () => {
it('should support selecting all via the checkbox', async function () {
let onSelectionChange = jest.fn();
let tree = renderTable({onSelectionChange});
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('keyboard');
checkSelectAll(tree, 'unchecked');
@@ -2845,7 +2845,7 @@ export let tableTests = () => {
let onSelectionChange = jest.fn();
let onAction = jest.fn();
let tree = renderTable({onSelectionChange, onAction, selectionStyle: 'highlight'});
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('touch');
act(() => jest.runAllTimers());
@@ -2983,7 +2983,7 @@ export let tableTests = () => {
it('should toggle selection with touch', async function () {
let onSelectionChange = jest.fn();
let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'});
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('touch');
expect(tree.queryByLabelText('Select All')).toBeNull();
@@ -3015,7 +3015,7 @@ export let tableTests = () => {
let onSelectionChange = jest.fn();
let onAction = jest.fn();
let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction});
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
await tableTester.toggleRowSelection({text: 'Foo 5'});
expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.');
@@ -3136,7 +3136,7 @@ export let tableTests = () => {
jest.runAllTimers();
});
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('touch');
await user.click(document.body);
@@ -4368,7 +4368,7 @@ export let tableTests = () => {
it('should add sort direction info to the column header\'s aria-describedby for Android', async function () {
let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android');
let tree = render( );
- let tableTester = testUtilUser.createTester('TableTester', {root: tree.getByRole('grid')});
+ let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('keyboard');
let columnheaders = tableTester.columns;
expect(columnheaders).toHaveLength(3);
diff --git a/packages/@react-spectrum/table/test/TestTableUtils.test.js b/packages/@react-spectrum/table/test/TestTableUtils.test.js
index c10cdb3e793..422889e426e 100644
--- a/packages/@react-spectrum/table/test/TestTableUtils.test.js
+++ b/packages/@react-spectrum/table/test/TestTableUtils.test.js
@@ -74,7 +74,7 @@ describe('Table ', function () {
it('basic flow with TableTester', async function () {
render( );
- let tableTester = testUtilRealTimer.createTester('TableTester', {root: screen.getByTestId('test')});
+ let tableTester = testUtilRealTimer.createTester('Table', {root: screen.getByTestId('test')});
tableTester.setInteractionType(interactionType);
await tableTester.toggleRowSelection({index: 2});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
@@ -104,7 +104,7 @@ describe('Table ', function () {
it('basic flow with TableTester (testing menu sort change and highlight selection)', async function () {
render( );
- let tableTester = testUtilRealTimer.createTester('TableTester', {root: screen.getByTestId('test')});
+ let tableTester = testUtilRealTimer.createTester('Table', {root: screen.getByTestId('test')});
tableTester.setInteractionType(interactionType);
await tableTester.toggleRowSelection({index: 2});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
@@ -146,7 +146,7 @@ describe('Table ', function () {
it('basic flow with TableTester', async function () {
render( );
- let tableTester = testUtilFakeTimer.createTester('TableTester', {root: screen.getByTestId('test')});
+ let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')});
tableTester.setInteractionType(interactionType);
await tableTester.toggleRowSelection({index: 2});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
@@ -176,7 +176,7 @@ describe('Table ', function () {
it('basic flow with TableTester (testing menu sort change and highlight selection)', async function () {
render( );
- let tableTester = testUtilFakeTimer.createTester('TableTester', {root: screen.getByTestId('test')});
+ let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')});
tableTester.setInteractionType(interactionType);
await tableTester.toggleRowSelection({index: 2, focusToSelect: true});
diff --git a/packages/@react-spectrum/tree/chromatic/TreeView.chromatic-fc.tsx b/packages/@react-spectrum/tree/chromatic-fc/TreeView.stories.tsx
similarity index 100%
rename from packages/@react-spectrum/tree/chromatic/TreeView.chromatic-fc.tsx
rename to packages/@react-spectrum/tree/chromatic-fc/TreeView.stories.tsx
diff --git a/packages/@react-spectrum/tree/chromatic/TreeView.chromatic.tsx b/packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx
similarity index 100%
rename from packages/@react-spectrum/tree/chromatic/TreeView.chromatic.tsx
rename to packages/@react-spectrum/tree/chromatic/TreeView.stories.tsx
diff --git a/packages/@react-spectrum/tree/src/TreeView.tsx b/packages/@react-spectrum/tree/src/TreeView.tsx
index 19450fa09f9..1bdcad50e08 100644
--- a/packages/@react-spectrum/tree/src/TreeView.tsx
+++ b/packages/@react-spectrum/tree/src/TreeView.tsx
@@ -140,7 +140,7 @@ const treeCellGrid = style({
display: 'grid',
width: 'full',
alignItems: 'center',
- gridTemplateColumns: ['minmax(0, auto)', 'minmax(0, auto)', 'minmax(0, auto)', 'minmax(0, auto)', 'minmax(0, auto)', '1fr', 'minmax(0, auto)', 'auto'],
+ gridTemplateColumns: ['minmax(0, auto)', 'minmax(0, auto)', 'minmax(0, auto)', 10, 'minmax(0, auto)', '1fr', 'minmax(0, auto)', 'auto'],
gridTemplateRows: '1fr',
gridTemplateAreas: [
'drag-handle checkbox level-padding expand-button icon content actions actionmenu'
diff --git a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx
index d9180ec4087..9f1b3b10473 100644
--- a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx
+++ b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx
@@ -144,22 +144,16 @@ TreeExampleStatic.story = {
},
argTypes: {
selectionMode: {
- control: {
- type: 'radio',
- options: ['none', 'single', 'multiple']
- }
+ control: 'radio',
+ options: ['none', 'single', 'multiple']
},
selectionStyle: {
- control: {
- type: 'radio',
- options: ['checkbox', 'highlight']
- }
+ control: 'radio',
+ options: ['checkbox', 'highlight']
},
disabledBehavior: {
- control: {
- type: 'radio',
- options: ['selection', 'all']
- }
+ control: 'radio',
+ options: ['selection', 'all']
},
disallowEmptySelection: {
control: {
diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts
index c6018bb0a45..7135ddd517b 100644
--- a/packages/@react-stately/list/src/useListState.ts
+++ b/packages/@react-stately/list/src/useListState.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {Collection, CollectionStateBase, Key, Node} from '@react-types/shared';
+import {Collection, CollectionStateBase, Key, LayoutDelegate, Node} from '@react-types/shared';
import {ListCollection} from './ListCollection';
import {MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection';
import {useCallback, useEffect, useMemo, useRef} from 'react';
@@ -20,7 +20,12 @@ export interface ListProps extends CollectionStateBase, MultipleSelectionS
/** Filter function to generate a filtered list of nodes. */
filter?: (nodes: Iterable>) => Iterable>,
/** @private */
- suppressTextValueWarning?: boolean
+ suppressTextValueWarning?: boolean,
+ /**
+ * A delegate object that provides layout information for items in the collection.
+ * This can be used to override the behavior of shift selection.
+ */
+ layoutDelegate?: LayoutDelegate
}
export interface ListState {
@@ -39,7 +44,7 @@ export interface ListState {
* of items from props, and manages multiple selection state.
*/
export function useListState(props: ListProps): ListState {
- let {filter} = props;
+ let {filter, layoutDelegate} = props;
let selectionState = useMultipleSelectionState(props);
let disabledKeys = useMemo(() =>
@@ -52,8 +57,8 @@ export function useListState(props: ListProps): ListState
- new SelectionManager(collection, selectionState)
- , [collection, selectionState]
+ new SelectionManager(collection, selectionState, {layoutDelegate})
+ , [collection, selectionState, layoutDelegate]
);
// Reset focused key if that item is deleted from the collection.
diff --git a/packages/@react-stately/selection/src/SelectionManager.ts b/packages/@react-stately/selection/src/SelectionManager.ts
index a754222beaa..8ec96a01bde 100644
--- a/packages/@react-stately/selection/src/SelectionManager.ts
+++ b/packages/@react-stately/selection/src/SelectionManager.ts
@@ -15,6 +15,7 @@ import {
FocusStrategy,
Selection as ISelection,
Key,
+ LayoutDelegate,
LongPressEvent,
Node,
PressEvent,
@@ -26,7 +27,8 @@ import {MultipleSelectionManager, MultipleSelectionState} from './types';
import {Selection} from './Selection';
interface SelectionManagerOptions {
- allowsCellSelection?: boolean
+ allowsCellSelection?: boolean,
+ layoutDelegate?: LayoutDelegate
}
/**
@@ -37,12 +39,14 @@ export class SelectionManager implements MultipleSelectionManager {
private state: MultipleSelectionState;
private allowsCellSelection: boolean;
private _isSelectAll: boolean;
+ private layoutDelegate: LayoutDelegate | null;
constructor(collection: Collection>, state: MultipleSelectionState, options?: SelectionManagerOptions) {
this.collection = collection;
this.state = state;
this.allowsCellSelection = options?.allowsCellSelection ?? false;
this._isSelectAll = null;
+ this.layoutDelegate = options?.layoutDelegate || null;
}
/**
@@ -253,6 +257,10 @@ export class SelectionManager implements MultipleSelectionManager {
}
private getKeyRangeInternal(from: Key, to: Key) {
+ if (this.layoutDelegate?.getKeyRange) {
+ return this.layoutDelegate.getKeyRange(from, to);
+ }
+
let keys: Key[] = [];
let key = from;
while (key) {
diff --git a/packages/@react-stately/virtualizer/src/LayoutInfo.ts b/packages/@react-stately/virtualizer/src/LayoutInfo.ts
index 0f6ffb39792..014f76960df 100644
--- a/packages/@react-stately/virtualizer/src/LayoutInfo.ts
+++ b/packages/@react-stately/virtualizer/src/LayoutInfo.ts
@@ -36,6 +36,11 @@ export class LayoutInfo {
*/
parentKey: Key | null;
+ /**
+ * Content for this view if it was generated by the layout rather than coming from the Collection.
+ */
+ content: any | null;
+
/**
* The rectangle describing the size and position of this view.
*/
@@ -82,6 +87,7 @@ export class LayoutInfo {
this.type = type;
this.key = key;
this.parentKey = null;
+ this.content = null;
this.rect = rect;
this.estimatedSize = false;
this.isSticky = false;
@@ -100,6 +106,7 @@ export class LayoutInfo {
res.opacity = this.opacity;
res.transform = this.transform;
res.parentKey = this.parentKey;
+ res.content = this.content;
res.isSticky = this.isSticky;
res.zIndex = this.zIndex;
res.allowOverflow = this.allowOverflow;
diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts
index ffb650e1084..9279ffa8bda 100644
--- a/packages/@react-stately/virtualizer/src/Virtualizer.ts
+++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts
@@ -114,8 +114,8 @@ export class Virtualizer {
}
private _renderView(reusableView: ReusableView) {
- let {type, key} = reusableView.layoutInfo;
- reusableView.content = this.collection.getItem(key);
+ let {type, key, content} = reusableView.layoutInfo;
+ reusableView.content = content || this.collection.getItem(key);
reusableView.rendered = this._renderContent(type, reusableView.content);
}
diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts
index c3982621d63..15271fa4da5 100644
--- a/packages/@react-types/shared/src/collections.d.ts
+++ b/packages/@react-types/shared/src/collections.d.ts
@@ -142,7 +142,9 @@ export interface LayoutDelegate {
/** Returns the visible rectangle of the collection. */
getVisibleRect(): Rect,
/** Returns the size of the scrollable content in the collection. */
- getContentSize(): Size
+ getContentSize(): Size,
+ /** Returns a list of keys between `from` and `to`. */
+ getKeyRange?(from: Key, to: Key): Key[]
}
/**
diff --git a/packages/dev/parcel-transformer-react-docgen-typescript/ReactDocgenTSTransformer.ts b/packages/dev/parcel-transformer-react-docgen-typescript/ReactDocgenTSTransformer.ts
index 5abdd540500..9304f606a77 100644
--- a/packages/dev/parcel-transformer-react-docgen-typescript/ReactDocgenTSTransformer.ts
+++ b/packages/dev/parcel-transformer-react-docgen-typescript/ReactDocgenTSTransformer.ts
@@ -48,7 +48,8 @@ let excludedProps = new Set([
'onHoverChange',
'onFocus',
'onBlur',
- 'onFocusChange'
+ 'onFocusChange',
+ 'onScroll'
]);
let docGenParser = withCompilerOptions(compilerOptions, {
shouldExtractLiteralValuesFromEnum: true,
diff --git a/packages/dev/parcel-transformer-s2-icon/IconTransformer.js b/packages/dev/parcel-transformer-s2-icon/IconTransformer.js
index 985a07bb202..36dbe66d0c3 100644
--- a/packages/dev/parcel-transformer-s2-icon/IconTransformer.js
+++ b/packages/dev/parcel-transformer-s2-icon/IconTransformer.js
@@ -57,10 +57,10 @@ module.exports = new Transformer({
'var(--spectrum-global-color-gray-800, #292929)': `var(--iconPrimary, var(--lightningcss-light, ${tokens['gray-800'].sets.light.value}) var(--lightningcss-dark, ${tokens['gray-800'].sets.dark.value}))`
},
typescript: true,
+ ref: true,
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx']
})
- ).replace('export default SvgComponent;', '');
- // will need to use svgr's templating to add ref support if we want that https://github.com/facebook/create-react-app/pull/5457
+ ).replace('export default ForwardRef;', '');
let newFile = template(asset, iconName, optimized);
return [{
type: 'tsx',
@@ -87,6 +87,7 @@ function template(asset, iconName, svg) {
import {IconProps, ${context}, IconContextValue} from '${normalizedPath.includes('@react-spectrum/s2') ? '~/src/Icon' : '@react-spectrum/s2'}';
import {SVGProps, useRef} from 'react';
import {useContextProps} from 'react-aria-components';
+import {SkeletonWrapper, useSkeletonIcon} from '~/src/Skeleton';
${svg.replace('import { SVGProps } from "react";', '')}
@@ -110,15 +111,17 @@ export default function ${iconRename}(props: IconProps) {
}
let svg = (
-
+
+
+
);
if (render) {
diff --git a/packages/dev/parcel-transformer-s2-icon/package.json b/packages/dev/parcel-transformer-s2-icon/package.json
index 8aa6e6ad3c3..1eb84968a46 100644
--- a/packages/dev/parcel-transformer-s2-icon/package.json
+++ b/packages/dev/parcel-transformer-s2-icon/package.json
@@ -7,7 +7,8 @@
},
"dependencies": {
"@adobe/spectrum-tokens": "^13.0.0-beta.46",
- "@parcel/plugin": "2.0.0-dev.1601"
+ "@parcel/plugin": "2.0.0-dev.1601",
+ "@svgr/core": "^6.2.0"
},
"rsp": {
"type": "cli"
diff --git a/packages/react-aria-components/docs/Button.mdx b/packages/react-aria-components/docs/Button.mdx
index 6c71a53319f..e28c676475b 100644
--- a/packages/react-aria-components/docs/Button.mdx
+++ b/packages/react-aria-components/docs/Button.mdx
@@ -159,6 +159,142 @@ A `Button` can be disabled using the `isDisabled` prop.
+## Pending
+
+A `Button` can be put into a pending state using the `isPending` prop.
+This is useful when an action takes a long time to complete, and you want to provide feedback to the user that the action is in progress.
+The pending state announces the state change to assistive technologies and disables interactions with the exception of focus.
+
+A [ProgressBar](ProgressBar.html) component is required to show the pending state correctly.
+Make sure to internationalize the `aria-label` you pass to the [ProgressBar](ProgressBar.html) component.
+
+```tsx example
+import {useState} from 'react';
+import {ProgressBar, Text} from 'react-aria-components';
+
+function PendingButton(props) {
+ let [isPending, setPending] = useState(false);
+
+ let handlePress = (e) => {
+ setPending(true);
+ setTimeout(() => {
+ setPending(false);
+ }, 5000);
+ };
+
+ return (
+
+ {({isPending}) => (
+ <>
+ {!isPending && Save }
+ {isPending && (
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+```
+
+```tsx snippet hidden
+
+
+
+
+
+
+
+
+
+
+```
+
+### Accessibility
+
+**Note:**
+The `ProgressBar` must be in the accessibility tree as soon as the button becomes pending, even if it is not visible.
+For example, if you'd like to delay showing a spinner until a minimum amount of time passes, you could use `opacity: 0` to hide it so it is still available to screen readers.
+Do not use `visibility: hidden` or `display: none` since these remove the element from the accessibility tree.
+
+Additionally, you may choose to keep the button's contents in the DOM while the button is pending, e.g. to preserve the button's layout.
+If you hide the contents with `visibility: hidden`, the accessibility label for the button will only include the ProgressBar, so it should have a descriptive `aria-label` (e.g. "Saving").
+You can also choose to keep the button's contents in the accessibility tree by using `opacity: 0`, in which case the `ProgressBar`'s label will be combined with the contents (e.g. "Save, pending").
+
+Try the above example and the one below with a screen reader to see the difference in behavior.
+
+
+ Show example
+
+```tsx example
+function PendingDelayed(props) {
+ let [isPending, setPending] = useState(false);
+
+ let handlePress = (e) => {
+ setPending(true);
+ setTimeout(() => {
+ setPending(false);
+ }, 5000);
+ };
+
+ return (
+
+ {({isPending}) => (
+ <>
+ Save
+ {isPending && (
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+```
+
+```css
+@keyframes toggle {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ animation: toggle 1s steps(1);
+ opacity: 1;
+}
+
+.pending {
+ animation: toggle 1s reverse steps(1, jump-start);
+ opacity: 0;
+}
+```
+
+
+
## Link buttons
The `Button` component always represents a button semantically. To create a link that visually looks like a button, use the [Link](Link.html) component instead. You can reuse the same styles you apply to the `Button` component on the `Link`.
diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json
index 46a2b6b604a..a3bd84757b8 100644
--- a/packages/react-aria-components/package.json
+++ b/packages/react-aria-components/package.json
@@ -44,6 +44,7 @@
"@react-aria/dnd": "^3.7.2",
"@react-aria/focus": "^3.18.2",
"@react-aria/interactions": "^3.22.2",
+ "@react-aria/live-announcer": "^3.3.4",
"@react-aria/menu": "^3.15.3",
"@react-aria/toolbar": "3.0.0-beta.8",
"@react-aria/tree": "3.0.0-alpha.5",
diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx
index 1e057dc9b35..453d928f876 100644
--- a/packages/react-aria-components/src/Button.tsx
+++ b/packages/react-aria-components/src/Button.tsx
@@ -9,11 +9,28 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
-import {AriaButtonProps, HoverEvents, mergeProps, useButton, useFocusRing, useHover} from 'react-aria';
-import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils';
+
+import {announce} from '@react-aria/live-announcer';
+import {
+ AriaButtonProps,
+ HoverEvents,
+ mergeProps,
+ useButton,
+ useFocusRing,
+ useHover,
+ useId
+} from 'react-aria';
+import {
+ ContextValue,
+ RenderProps,
+ SlotProps,
+ useContextProps,
+ useRenderProps
+} from './utils';
import {createHideableComponent} from '@react-aria/collections';
import {filterDOMProps} from '@react-aria/utils';
-import React, {createContext, ForwardedRef} from 'react';
+import {ProgressBarContext} from './ProgressBar';
+import React, {createContext, ForwardedRef, useEffect, useRef} from 'react';
export interface ButtonRenderProps {
/**
@@ -40,7 +57,12 @@ export interface ButtonRenderProps {
* Whether the button is disabled.
* @selector [data-disabled]
*/
- isDisabled: boolean
+ isDisabled: boolean,
+ /**
+ * If the button is currently in the `isPending` state.
+ * @selector [data-pending]
+ */
+ isPending?: boolean
}
export interface ButtonProps extends Omit, HoverEvents, SlotProps, RenderProps {
@@ -65,7 +87,11 @@ export interface ButtonProps extends Omit) {
[props, ref] = useContextProps(props, ref, ButtonContext);
+ props = disablePendingProps(props);
let ctx = props as ButtonContextValue;
+ let {isPending} = ctx;
let {buttonProps, isPressed} = useButton(props, ref);
let {focusProps, isFocused, isFocusVisible} = useFocusRing(props);
- let {hoverProps, isHovered} = useHover(props);
+ let {hoverProps, isHovered} = useHover({
+ ...props,
+ isDisabled: props.isDisabled || isPending
+ });
+ let renderValues = {
+ isHovered,
+ isPressed: (ctx.isPressed || isPressed) && !isPending,
+ isFocused,
+ isFocusVisible,
+ isDisabled: props.isDisabled || false,
+ isPending
+ };
+
let renderProps = useRenderProps({
...props,
- values: {isHovered, isPressed, isFocused, isFocusVisible, isDisabled: props.isDisabled || false},
+ values: renderValues,
defaultClassName: 'react-aria-Button'
});
+ let buttonId = useId(buttonProps.id);
+ let progressId = useId();
+
+ let ariaLabelledby = buttonProps['aria-labelledby'];
+ if (isPending) {
+ // aria-labelledby wins over aria-label
+ // https://www.w3.org/TR/accname-1.2/#computation-steps
+ if (ariaLabelledby) {
+ ariaLabelledby = `${ariaLabelledby} ${progressId}`;
+ } else if (buttonProps['aria-label']) {
+ ariaLabelledby = `${buttonId} ${progressId}`;
+ }
+ }
+
+ let wasPending = useRef(isPending);
+ useEffect(() => {
+ let message = {'aria-labelledby': ariaLabelledby || buttonId};
+ if (!wasPending.current && isFocused && isPending) {
+ announce(message, 'assertive');
+ } else if (wasPending.current && isFocused && !isPending) {
+ announce(message, 'assertive');
+ }
+ wasPending.current = isPending;
+ }, [isPending, isFocused, ariaLabelledby, buttonId]);
+
return (
+ data-pending={isPending || undefined}
+ data-focus-visible={isFocusVisible || undefined}>
+
+ {renderProps.children}
+
+
);
}
+function disablePendingProps(props) {
+ // Don't allow interaction while isPending is true
+ if (props.isPending) {
+ props.onPress = undefined;
+ props.onPressStart = undefined;
+ props.onPressEnd = undefined;
+ props.onPressChange = undefined;
+ props.onPressUp = undefined;
+ props.onKeyDown = undefined;
+ props.onKeyUp = undefined;
+ props.onClick = undefined;
+ props.href = undefined;
+ }
+ return props;
+}
+
/**
* A button allows a user to perform an action, with mouse, touch, and keyboard interactions.
*/
diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx
index 6aba32cf838..ed3294c9ff5 100644
--- a/packages/react-aria-components/src/GridList.tsx
+++ b/packages/react-aria-components/src/GridList.tsx
@@ -96,7 +96,8 @@ function GridListInner({props, collection, gridListRef: ref}:
let state = useListState({
...props,
collection,
- children: undefined
+ children: undefined,
+ layoutDelegate
});
let collator = useCollator({usage: 'search', sensitivity: 'base'});
@@ -391,6 +392,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G
}],
[TextContext, {
slots: {
+ [DEFAULT_SLOT]: {},
description: descriptionProps
}
}],
diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx
index c9a40dfb85c..bbe9f539932 100644
--- a/packages/react-aria-components/src/ListBox.tsx
+++ b/packages/react-aria-components/src/ListBox.tsx
@@ -102,7 +102,8 @@ function ListBox(props: ListBoxProps, ref: ForwardedRef ;
}
diff --git a/packages/react-aria-components/src/ToggleButton.tsx b/packages/react-aria-components/src/ToggleButton.tsx
index 3cf1c82848d..6d822066d33 100644
--- a/packages/react-aria-components/src/ToggleButton.tsx
+++ b/packages/react-aria-components/src/ToggleButton.tsx
@@ -17,7 +17,7 @@ import {forwardRefType} from '@react-types/shared';
import React, {createContext, ForwardedRef, forwardRef} from 'react';
import {ToggleState, useToggleState} from 'react-stately';
-export interface ToggleButtonRenderProps extends ButtonRenderProps {
+export interface ToggleButtonRenderProps extends Omit {
/**
* Whether the button is currently selected.
* @selector [data-selected]
diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx
index 37f8a281752..a885ad7d444 100644
--- a/packages/react-aria-components/src/Virtualizer.tsx
+++ b/packages/react-aria-components/src/Virtualizer.tsx
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {CollectionRenderer, CollectionRendererContext} from './Collection';
+import {CollectionBranchProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps} from './Collection';
import {DropPosition, DropTarget, DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared';
import {Layout, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer';
import React, {createContext, ReactElement, ReactNode, useContext, useMemo} from 'react';
@@ -24,73 +24,88 @@ export interface LayoutOptionsDelegate {
interface ILayout extends Layout, O>, Partial, LayoutOptionsDelegate {}
-export interface VirtualizerProps {
+export interface VirtualizerProps {
/** The child collection to virtualize (e.g. ListBox, GridList, or Table). */
children: ReactNode,
/** The layout object that determines the position and size of the visible elements. */
- layout: ILayout
+ layout: ILayout,
+ /** Options for the layout. */
+ layoutOptions?: O
}
const VirtualizerContext = createContext | null>(null);
+const LayoutContext = createContext, 'layout' | 'layoutOptions'> | null>(null);
-export function Virtualizer(props: VirtualizerProps) {
- let {children, layout} = props;
+export function Virtualizer(props: VirtualizerProps) {
+ let {children, layout, layoutOptions} = props;
let renderer: CollectionRenderer = useMemo(() => ({
isVirtualized: true,
layoutDelegate: layout,
dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined,
- CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}) {
- let layoutOptions = layout.useLayoutOptions?.();
- let state = useVirtualizerState({
- layout,
- collection,
- renderView: (type, item) => {
- return item?.render?.(item);
- },
- onVisibleRectChange(rect) {
- let element = scrollRef?.current;
- if (element) {
- element.scrollLeft = rect.x;
- element.scrollTop = rect.y;
- }
- },
- persistedKeys,
- layoutOptions
- });
-
- let {contentProps} = useScrollView({
- onVisibleRectChange: state.setVisibleRect,
- contentSize: state.contentSize,
- onScrollStart: state.startScrolling,
- onScrollEnd: state.endScrolling
- }, scrollRef!);
-
- if (state.contentSize.area === 0) {
- return null;
- }
-
- return (
-
-
- {renderChildren(null, state.visibleViews, renderDropIndicator)}
-
-
- );
- },
- CollectionBranch({parent, renderDropIndicator}) {
- let virtualizer = useContext(VirtualizerContext);
- let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!;
- return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator);
- }
+ CollectionRoot,
+ CollectionBranch
}), [layout]);
return (
- {children}
+
+ {children}
+
);
}
+function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}: CollectionRootProps) {
+ let {layout, layoutOptions} = useContext(LayoutContext)!;
+ let layoutOptions2 = layout.useLayoutOptions?.();
+ let state = useVirtualizerState({
+ layout,
+ collection,
+ renderView: (type, item) => {
+ return item?.render?.(item);
+ },
+ onVisibleRectChange(rect) {
+ let element = scrollRef?.current;
+ if (element) {
+ element.scrollLeft = rect.x;
+ element.scrollTop = rect.y;
+ }
+ },
+ persistedKeys,
+ layoutOptions: useMemo(() => {
+ if (layoutOptions && layoutOptions2) {
+ return {...layoutOptions, ...layoutOptions2};
+ }
+ return layoutOptions || layoutOptions2;
+ }, [layoutOptions, layoutOptions2])
+ });
+
+ let {contentProps} = useScrollView({
+ onVisibleRectChange: state.setVisibleRect,
+ contentSize: state.contentSize,
+ onScrollStart: state.startScrolling,
+ onScrollEnd: state.endScrolling
+ }, scrollRef!);
+
+ if (state.contentSize.area === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {renderChildren(null, state.visibleViews, renderDropIndicator)}
+
+
+ );
+}
+
+function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) {
+ let virtualizer = useContext(VirtualizerContext);
+ let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!;
+ return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator);
+}
+
function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) {
return children.map(view => renderWrapper(parent, view, renderDropIndicator));
}
diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx
index 731afea4226..97c686fb243 100644
--- a/packages/react-aria-components/src/utils.tsx
+++ b/packages/react-aria-components/src/utils.tsx
@@ -73,7 +73,7 @@ export interface StyleRenderProps {
/** The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. */
className?: string | ((values: T & {defaultClassName: string | undefined}) => string),
/** The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element. A function may be provided to compute the style based on component state. */
- style?: CSSProperties | ((values: T & {defaultStyle: CSSProperties}) => CSSProperties)
+ style?: CSSProperties | ((values: T & {defaultStyle: CSSProperties}) => CSSProperties | undefined)
}
export interface RenderProps extends StyleRenderProps {
diff --git a/packages/react-aria-components/stories/Button.stories.tsx b/packages/react-aria-components/stories/Button.stories.tsx
index 605b568ab5b..62639c7f564 100644
--- a/packages/react-aria-components/stories/Button.stories.tsx
+++ b/packages/react-aria-components/stories/Button.stories.tsx
@@ -10,10 +10,12 @@
* governing permissions and limitations under the License.
*/
-import {Button} from 'react-aria-components';
+import {action} from '@storybook/addon-actions';
+import {Button, ProgressBar, Text} from 'react-aria-components';
import {mergeProps} from '@react-aria/utils';
import React, {useEffect, useRef, useState} from 'react';
import * as styles from './button-ripple.css';
+import * as styles2 from './button-pending.css';
export default {
title: 'React Aria Components'
@@ -25,6 +27,58 @@ export const ButtonExample = () => {
);
};
+export const PendingButton = {
+ render: (args) => ,
+ args: {
+ children: 'Press me'
+ }
+};
+
+function PendingButtonExample(props) {
+ let [isPending, setPending] = useState(false);
+
+ let timeout = useRef | undefined>(undefined);
+ let handlePress = (e) => {
+ action('pressed')(e);
+ setPending(true);
+ timeout.current = setTimeout(() => {
+ setPending(false);
+ timeout.current = undefined;
+ }, 5000);
+ };
+
+ useEffect(() => {
+ return () => {
+ clearTimeout(timeout.current);
+ };
+ }, []);
+
+ return (
+
+ {({isPending}) => (
+ <>
+ {props.children}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
export const RippleButtonExample = () => {
return (
Press me
diff --git a/packages/react-aria-components/stories/button-pending.css b/packages/react-aria-components/stories/button-pending.css
new file mode 100644
index 00000000000..4c3689bb013
--- /dev/null
+++ b/packages/react-aria-components/stories/button-pending.css
@@ -0,0 +1,27 @@
+.button {
+ position: relative;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ outline: none;
+}
+
+.spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ opacity: 0;
+ visibility: hidden;
+ display: none;
+}
+.spinner-pending {
+ opacity: 1;
+ visibility: visible;
+ display: block;
+}
+
+.pending {
+ opacity: 0;
+}
diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx
index 74c075a33f4..4f1eb12f3e7 100644
--- a/packages/react-aria-components/test/AriaMenu.test-util.tsx
+++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx
@@ -105,7 +105,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('has default behavior (button renders, menu is closed)', function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
expect(triggerButton).toBeTruthy();
@@ -122,7 +122,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('toggles the menu display on button click', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
await menuTester.open();
@@ -144,7 +144,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('will not close the menu when mousing over the trigger again without lifting press', function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
fireEvent.mouseEnter(triggerButton);
@@ -158,7 +158,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('closes the menu on click outside', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
await menuTester.open();
@@ -185,7 +185,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('can open the menu display via Enter key', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
let triggerButton = menuTester.trigger;
@@ -206,7 +206,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('can open the menu display via ArrowDown key', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
let triggerButton = menuTester.trigger;
@@ -227,7 +227,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('can open the menu display via ArrowUp key', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
let triggerButton = menuTester.trigger;
@@ -248,7 +248,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('moves focus up and down with the arrow keys', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
let triggerButton = menuTester.trigger;
@@ -274,7 +274,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('closes regardless of Space or Enter to activate an option', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await menuTester.open();
@@ -297,7 +297,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('closes if menu is tabbed away from', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await menuTester.open();
@@ -314,7 +314,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('has hidden dismiss buttons for screen readers', async function () {
let tree = renderers.standard();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await menuTester.open();
@@ -336,7 +336,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
describe('single selection', function () {
it('selects an option via mouse', async function () {
let tree = (renderers.singleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
await menuTester.open();
@@ -364,7 +364,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('selects an option via keyboard and autoFocuses it next time the menu is opened via keyboard, does not clear if menu is closed with Esc', async function () {
let tree = (renderers.singleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container, interactionType: 'keyboard'});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container, interactionType: 'keyboard'});
let triggerButton = menuTester.trigger;
await menuTester.open();
@@ -405,7 +405,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('selects an option via keyboard and does not close if it was selected with Space', async function () {
let tree = (renderers.singleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
let triggerButton = menuTester.trigger;
@@ -431,7 +431,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('ignores keyboard repeat events', async function () {
let tree = (renderers.singleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await user.tab();
await user.keyboard('{Enter>}');
@@ -450,7 +450,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
describe('multiple selection', function () {
it('selects options via mouse, autofocuses the last selected option when menu is reopened', async function () {
let tree = (renderers.multipleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
await menuTester.open();
act(() => {jest.runAllTimers();});
@@ -483,7 +483,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('selects options via keyboard and autoFocuses next time the menu is opened via keyboard, does not clear if menu is closed with Esc', async function () {
let tree = (renderers.multipleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await menuTester.open();
@@ -520,7 +520,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('selects options via keyboard and immediately closes on selection if Enter was used', async function () {
let tree = (renderers.multipleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await menuTester.open();
@@ -549,7 +549,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('ignores keyboard repeat events', async function () {
let tree = (renderers.multipleSelection!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
menuTester.setInteractionType('keyboard');
await user.tab();
await user.keyboard('{Enter>}');
@@ -568,7 +568,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
describe('disabled trigger', function () {
it('does not trigger', async function () {
let tree = (renderers.disabledTrigger!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
let triggerButton = menuTester.trigger;
await menuTester.open();
@@ -583,7 +583,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
describe('sibling focusable element', function () {
it('focuses the next tabbable thing after the trigger if tab is hit inside the menu', async function () {
let tree = (renderers.siblingFocusableElement!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
await menuTester.open();
act(() => {jest.runAllTimers();});
@@ -604,8 +604,8 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
let tree = (renderers.multipleMenus!)();
let [button1, button2] = tree.getAllByRole('button');
// TODO: should i try to open with the tester?
- // let menu1Tester = testUtilUser.createTester('MenuTester', {root: button1});
- // let menu2Tester = testUtilUser.createTester('MenuTester', {root: button2});
+ // let menu1Tester = testUtilUser.createTester('Menu', {root: button1});
+ // let menu2Tester = testUtilUser.createTester('Menu', {root: button2});
await user.click(button1);
act(() => jest.runAllTimers());
@@ -631,7 +631,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('should support a submenu trigger', async () => {
let tree = (renderers.submenus!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container, interactionType});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container, interactionType});
await menuTester.open();
let menu = menuTester.menu;
@@ -655,7 +655,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('should support nested submenu triggers', async () => {
let tree = (renderers.submenus!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container, interactionType});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container, interactionType});
await menuTester.open();
let menu = menuTester.menu;
@@ -690,7 +690,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('should close all submenus if interacting outside root submenu', async () => {
let tree = (renderers.submenus!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
await menuTester.open();
let menu = menuTester.menu;
@@ -722,7 +722,7 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) =>
it('should close nested submenus with Escape', async () => {
let tree = (renderers.submenus!)();
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: tree.container});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container});
await menuTester.open();
let menu = menuTester.menu;
diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js
index d0d29121b55..1ba17971322 100644
--- a/packages/react-aria-components/test/Button.test.js
+++ b/packages/react-aria-components/test/Button.test.js
@@ -10,15 +10,16 @@
* governing permissions and limitations under the License.
*/
-import {Button, ButtonContext} from '../';
+import {Button, ButtonContext, ProgressBar, Text} from '../';
import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
-import React from 'react';
+import React, {useState} from 'react';
import userEvent from '@testing-library/user-event';
describe('Button', () => {
let user;
beforeAll(() => {
user = userEvent.setup({delay: null, pointerMap});
+ jest.useFakeTimers();
});
it('should render a button with default class', () => {
@@ -137,4 +138,63 @@ describe('Button', () => {
fireEvent.mouseUp(button);
expect(button).toHaveTextContent('Test');
});
+
+ // isPending state
+ it('displays a spinner when isPending prop is true', async function () {
+ let onPressSpy = jest.fn();
+ function TestComponent() {
+ let [pending, setPending] = useState(false);
+ return (
+ {
+ setPending(true);
+ onPressSpy();
+ }}
+ isPending={pending}>
+ {({isPending}) => (
+ <>
+ Test
+
+ loading
+
+ >
+ )}
+
+ );
+ }
+ let {getByRole} = render( );
+ let button = getByRole('button');
+ expect(button).not.toHaveAttribute('aria-disabled');
+ await user.click(button);
+ // Button is disabled immediately, but spinner visibility is delayed
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ // Multiple clicks shouldn't call onPressSpy
+ await user.click(button);
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(onPressSpy).toHaveBeenCalledTimes(1);
+ });
+
+ // isPending anchor element
+ it('removes href attribute from anchor element when isPending is true', () => {
+ let {getByRole} = render(
+
+ {({isPending}) => (
+ <>
+ Click me
+
+ loading
+
+ >
+ )}
+
+ );
+ let button = getByRole('button');
+ expect(button).not.toHaveAttribute('href');
+ });
});
diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js
index a673f6328fd..2c0d8bdff39 100644
--- a/packages/react-aria-components/test/ComboBox.test.js
+++ b/packages/react-aria-components/test/ComboBox.test.js
@@ -117,7 +117,7 @@ describe('ComboBox', () => {
);
- let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container});
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container});
act(() => {
comboboxTester.combobox.focus();
});
@@ -153,7 +153,7 @@ describe('ComboBox', () => {
);
- let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container});
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container});
act(() => {
comboboxTester.combobox.focus();
});
@@ -231,7 +231,7 @@ describe('ComboBox', () => {
);
- let comboboxTester = testUtilUser.createTester('ComboBoxTester', {root: tree.container});
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container});
let combobox = comboboxTester.combobox;
expect(combobox).toHaveAttribute('required');
diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js
index 0407898acaa..f5f65bb0ef9 100644
--- a/packages/react-aria-components/test/GridList.test.js
+++ b/packages/react-aria-components/test/GridList.test.js
@@ -71,7 +71,7 @@ describe('GridList', () => {
it('should render with default classes', () => {
let {getByRole} = renderGridList();
- let gridListTester = testUtilUser.createTester('GridListTester', {root: getByRole('grid')});
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
expect(gridListTester.gridlist).toHaveAttribute('class', 'react-aria-GridList');
@@ -219,7 +219,7 @@ describe('GridList', () => {
it('should support selection state', async () => {
let {getByRole} = renderGridList({selectionMode: 'multiple'}, {className: ({isSelected}) => isSelected ? 'selected' : ''});
- let gridListTester = testUtilUser.createTester('GridListTester', {root: getByRole('grid')});
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
let row = gridListTester.rows[0];
expect(row).not.toHaveAttribute('aria-selected', 'true');
@@ -256,7 +256,7 @@ describe('GridList', () => {
);
- let gridListTester = testUtilUser.createTester('GridListTester', {root: getByRole('grid')});
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
let rows = gridListTester.rows;
expect(rows[1]).toHaveAttribute('aria-disabled', 'true');
expect(within(rows[1]).getByRole('button')).toBeDisabled();
diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx
index 27ec6f09a6a..5e3c031c5b0 100644
--- a/packages/react-aria-components/test/Menu.test.tsx
+++ b/packages/react-aria-components/test/Menu.test.tsx
@@ -995,7 +995,7 @@ describe('Menu', () => {
let button = getByRole('button');
expect(button).not.toHaveAttribute('data-pressed');
- let menuTester = testUtilUser.createTester('MenuTester', {user, root: button});
+ let menuTester = testUtilUser.createTester('Menu', {user, root: button});
await menuTester.open();
expect(button).toHaveAttribute('data-pressed');
diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js
index dfea7c93941..1d620f85f40 100644
--- a/packages/react-aria-components/test/Select.test.js
+++ b/packages/react-aria-components/test/Select.test.js
@@ -45,7 +45,7 @@ describe('Select', () => {
it('provides slots', async () => {
let {getByTestId} = render( );
let wrapper = getByTestId('select');
- let selectTester = testUtilUser.createTester('SelectTester', {root: wrapper});
+ let selectTester = testUtilUser.createTester('Select', {root: wrapper});
let trigger = selectTester.trigger;
expect(trigger).toHaveTextContent('Select an item');
@@ -87,7 +87,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.closest('.react-aria-Select')).toHaveAttribute('slot', 'test');
expect(trigger).toHaveAttribute('aria-label', 'test');
@@ -121,7 +121,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('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"