From ba4dcdd2727b96c1c251dbee32b9c0d52cfc594d Mon Sep 17 00:00:00 2001 From: Jory Cunningham Date: Wed, 28 Oct 2026 18:29:17 +0000 Subject: [PATCH] add loading state for Toggle --- pages/toggle/permutations.page.tsx | 14 ++ pages/toggle/simple.page.tsx | 42 ++++++ src/i18n/messages-types.ts | 3 + src/i18n/messages/all.en.json | 3 + src/toggle/__tests__/toggle.test.tsx | 206 ++++++++++++++++++++++++++- src/toggle/interfaces.ts | 18 +++ src/toggle/internal.tsx | 172 ++++++++++++++-------- src/toggle/styles.scss | 24 ++++ 8 files changed, 422 insertions(+), 60 deletions(-) diff --git a/pages/toggle/permutations.page.tsx b/pages/toggle/permutations.page.tsx index 41d3e4faf9b..0eb6fcb9fd6 100644 --- a/pages/toggle/permutations.page.tsx +++ b/pages/toggle/permutations.page.tsx @@ -46,6 +46,20 @@ const permutations = createPermutations([ children: ['Some label'], description: ['', 'Short description'], }, + { + loading: [true], + checked: [false, true], + children: ['Some label'], + description: ['', 'Short description'], + i18nStrings: [{ loadingText: 'Saving' }], + }, + { + loading: [true], + disabled: [true], + checked: [false, true], + children: ['Some label'], + i18nStrings: [{ loadingText: 'Saving' }], + }, ]); export default function TogglePermutations() { diff --git a/pages/toggle/simple.page.tsx b/pages/toggle/simple.page.tsx index 1adf07d97a9..ec58eb434c9 100644 --- a/pages/toggle/simple.page.tsx +++ b/pages/toggle/simple.page.tsx @@ -7,6 +7,23 @@ import Toggle from '~components/toggle'; export default function ToggleSimpleScenario() { const [checked, setChecked] = useState(false); + const [loadingChecked, setLoadingChecked] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const SIMULATED_ASYNC_DELAY_MS = 2000; + + function handleLoadingChange(event: { detail: { checked: boolean } }) { + if (isLoading) { + return; + } + const newChecked = event.detail.checked; + setIsLoading(true); + setTimeout(() => { + setLoadingChecked(newChecked); + setIsLoading(false); + }, SIMULATED_ASYNC_DELAY_MS); + } + return ( <>

Toggle demo

@@ -20,6 +37,31 @@ export default function ToggleSimpleScenario() { Toggle within Formfield +

Loading state

+ + Toggle with async loading (2s delay) + +

Loading states (static)

+ {}}> + Loading, unchecked + + {}}> + Loading, checked + + {}} + > + Loading + disabled (loading takes precedence) + ); } diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 69fbaf64af8..fba3ca60712 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -634,6 +634,9 @@ export interface I18nFormatArgTypes { "i18nStrings.valuesSuggestionError": never; "i18nStrings.valuesSuggestionLoading": never; } + "toggle": { + "i18nStrings.loadingText": never; + } "token-group": { "i18nStrings.limitShowFewer": never; "i18nStrings.limitShowMore": never; diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index cc5df478d45..71a4c0e98cc 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -476,6 +476,9 @@ "i18nStrings.valuesSuggestionError": "Tag values could not be retrieved", "i18nStrings.valuesSuggestionLoading": "Loading tag values" }, + "toggle": { + "i18nStrings.loadingText": "Loading" + }, "token-group": { "i18nStrings.limitShowFewer": "Show fewer", "i18nStrings.limitShowMore": "Show more" diff --git a/src/toggle/__tests__/toggle.test.tsx b/src/toggle/__tests__/toggle.test.tsx index 1b576c7e087..c63969f4630 100644 --- a/src/toggle/__tests__/toggle.test.tsx +++ b/src/toggle/__tests__/toggle.test.tsx @@ -1,22 +1,26 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import { clearMessageCache } from '@cloudscape-design/component-toolkit/internal'; import '../../__a11y__/to-validate-a11y'; import FormField from '../../../lib/components/form-field'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; import createWrapper, { ToggleWrapper } from '../../../lib/components/test-utils/dom'; import Toggle, { ToggleProps } from '../../../lib/components/toggle'; import { createCommonTests } from '../../checkbox/__tests__/common-tests'; import customCssProps from '../../internal/generated/custom-css-properties'; import abstractSwitchStyles from '../../../lib/components/internal/components/abstract-switch/styles.css.js'; +import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js'; import styles from '../../../lib/components/toggle/styles.selectors.js'; function renderToggle(jsx: React.ReactElement) { const { container, rerender } = render(jsx); const wrapper = createWrapper(container).findToggle()!; - return { wrapper, rerender }; + return { wrapper, rerender, container }; } function findStyledElement(wrapper: ToggleWrapper) { @@ -269,3 +273,201 @@ describe('handle follow-through animation', () => { expect(findStyledElement(wrapper)).toHaveAttribute(readyAttribute); }); }); + +describe('loading state', () => { + test('renders a loading handle with spinner when loading', () => { + const { wrapper, container } = renderToggle(); + expect(wrapper.findByClassName(styles['toggle-handle-loading'])).not.toBeNull(); + expect(createWrapper(container).findSpinner()).not.toBeNull(); + }); + + test('does not render the standard toggle handle when loading', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findByClassName(styles['toggle-handle'])).toBeNull(); + }); + + test('applies loading-checked class to loading handle when loading and checked', () => { + const { wrapper } = renderToggle(); + const loadingHandle = wrapper.findByClassName(styles['toggle-handle-loading'])!.getElement(); + expect(loadingHandle).toHaveClass(styles['toggle-handle-loading-checked']); + }); + + test('does not apply toggle-control-checked class when loading and checked', () => { + const { wrapper } = renderToggle(); + const control = wrapper.findByClassName(styles['toggle-control'])!.getElement(); + expect(control).not.toHaveClass(styles['toggle-control-checked']); + }); + + test('applies toggle-control-disabled class when loading', () => { + const { wrapper } = renderToggle(); + const control = wrapper.findByClassName(styles['toggle-control'])!.getElement(); + expect(control).toHaveClass(styles['toggle-control-disabled']); + }); + + test('does not fire onChange on label click when loading', () => { + const onChange = jest.fn(); + const { wrapper } = renderToggle(); + act(() => wrapper.findLabel().click()); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('does not fire onChange on input click when loading', () => { + const onChange = jest.fn(); + const { wrapper } = renderToggle(); + act(() => wrapper.findNativeInput().click()); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('prevents Space key default on input when loading', () => { + const { wrapper } = renderToggle(); + const input = wrapper.findNativeInput().getElement(); + const prevented = fireEvent.keyDown(input, { key: ' ' }); + expect(prevented).toBe(false); + }); + + test('does not prevent Space key default when not loading', () => { + const { wrapper } = renderToggle(); + const input = wrapper.findNativeInput().getElement(); + const prevented = fireEvent.keyDown(input, { key: ' ' }); + expect(prevented).toBe(true); + }); + + test('input is not natively disabled when loading (remains keyboard-focusable)', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findNativeInput().getElement()).not.toHaveAttribute('disabled'); + }); + + test('sets aria-disabled on input when loading', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findNativeInput().getElement()).toHaveAttribute('aria-disabled', 'true'); + }); + + test('sets aria-busy on input when loading', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findNativeInput().getElement()).toHaveAttribute('aria-busy', 'true'); + }); + + test('does not set aria-busy when not loading', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findNativeInput().getElement()).not.toHaveAttribute('aria-busy'); + }); + + test('does not apply label-disabled style when loading without disabled', () => { + const { wrapper } = renderToggle( + + Toggle label + + ); + const label = wrapper.findByClassName(abstractSwitchStyles.label)!.getElement(); + expect(label).not.toHaveClass(abstractSwitchStyles['label-disabled']); + }); + + test('applies label-disabled style when disabled without loading', () => { + const { wrapper } = renderToggle( + + Toggle label + + ); + const label = wrapper.findByClassName(abstractSwitchStyles.label)!.getElement(); + expect(label).toHaveClass(abstractSwitchStyles['label-disabled']); + }); + + test('loading takes precedence over disabled: input is not natively disabled', () => { + const { wrapper } = renderToggle(); + expect(wrapper.findNativeInput().getElement()).not.toHaveAttribute('disabled'); + }); + + test('loading takes precedence over disabled: renders spinner not standard handle', () => { + const { wrapper, container } = renderToggle(); + expect(createWrapper(container).findSpinner()).not.toBeNull(); + expect(wrapper.findByClassName(styles['toggle-handle'])).toBeNull(); + }); + + describe('live region', () => { + test('links input to loading live region via aria-describedby', () => { + const { wrapper, container } = renderToggle( + + ); + const input = wrapper.findNativeInput().getElement(); + const ariaDescribedby = input.getAttribute('aria-describedby') ?? ''; + const liveRegionId = ariaDescribedby.split(' ').find(id => id.startsWith('toggle-loading')); + expect(liveRegionId).toBeTruthy(); + expect(container.querySelector(`#${liveRegionId!}`)).not.toBeNull(); + }); + + test('live region contains loadingText when loading', () => { + const { container } = renderToggle( + + ); + const liveRegion = container.querySelector(`.${liveRegionStyles.root}`); + expect(liveRegion).toHaveTextContent('Saving preference'); + }); + + test('live region is empty when not loading', () => { + const { container } = renderToggle( + + ); + const liveRegion = container.querySelector(`.${liveRegionStyles.root}`); + expect(liveRegion).toHaveTextContent(''); + }); + + test('live region text comes from i18n provider when i18nStrings prop is absent', () => { + const { container } = render( + + + + ); + const liveRegion = container.querySelector(`.${liveRegionStyles.root}`); + expect(liveRegion).toHaveTextContent('Provider saving text'); + }); + + test('i18nStrings prop takes precedence over i18n provider', () => { + const { container } = render( + + + + ); + const liveRegion = container.querySelector(`.${liveRegionStyles.root}`); + expect(liveRegion).toHaveTextContent('Prop text'); + }); + }); + + describe('warnOnce', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + clearMessageCache(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + test('warns when loading=true and loadingText is absent', () => { + renderToggle(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('`loadingText` is required when `loading` is true for accessibility.') + ); + }); + + test('does not warn when loading=true and i18nStrings.loadingText is provided', () => { + renderToggle(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + test('does not warn when not loading', () => { + renderToggle(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + test('check a11y when loading', async () => { + const { wrapper } = renderToggle( + + Toggle label + + ); + await expect(wrapper.getElement()).toValidateA11y(); + }); +}); diff --git a/src/toggle/interfaces.ts b/src/toggle/interfaces.ts index aa48db28455..ecc025ba7e8 100644 --- a/src/toggle/interfaces.ts +++ b/src/toggle/interfaces.ts @@ -29,6 +29,16 @@ export interface ToggleProps extends BaseCheckboxProps { */ style?: ToggleProps.Style; + /** + * Renders the toggle as being in a loading state. It takes precedence over the `disabled` property if both are set to `true`. + * It prevents users from changing the toggle value (pointer and keyboard), but the toggle can still be focused. + * The checked indicator is hidden while loading, and `onChange` will not fire while `loading` is true. + */ + loading?: boolean; + /** + * An object containing all the necessary localized strings required by the component. + */ + i18nStrings?: ToggleProps.I18nStrings; /** * Attributes to add to the native `input` element. * Some attributes will be automatically combined with internal attribute values: @@ -47,6 +57,14 @@ export interface ToggleProps extends BaseCheckboxProps { } export namespace ToggleProps { + export interface I18nStrings { + /** + * Text announced by screen readers when the toggle is in a loading state. + * Use a descriptive string that names the operation, for example "Saving preference" rather than a generic "Loading". + */ + loadingText?: string; + } + export interface Ref { /** * Sets input focus onto the UI control. diff --git a/src/toggle/internal.tsx b/src/toggle/internal.tsx index 0a3b6ee8717..c706c993896 100644 --- a/src/toggle/internal.tsx +++ b/src/toggle/internal.tsx @@ -1,13 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; +import { useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { GeneratedAnalyticsMetadataFragment, getAnalyticsMetadataAttribute, } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; +import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import AbstractSwitch from '../internal/components/abstract-switch'; import { useFormFieldContext } from '../internal/context/form-field-context'; @@ -15,6 +17,8 @@ import { fireNonCancelableEvent } from '../internal/events'; import useForwardFocus from '../internal/hooks/forward-focus'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; +import InternalLiveRegion from '../live-region/internal'; +import InternalSpinner from '../spinner/internal'; import { GeneratedAnalyticsMetadataToggleComponent } from './analytics-metadata/interfaces'; import { ToggleProps } from './interfaces'; import { getAbstractSwitchStyles, getStyledControlStyle } from './style'; @@ -39,6 +43,8 @@ const InternalToggle = React.forwardRef( name, disabled, readOnly, + loading, + i18nStrings, children, description, ariaLabel, @@ -58,6 +64,11 @@ const InternalToggle = React.forwardRef( const { ariaDescribedby, ariaLabelledby } = useFormFieldContext(rest); const baseProps = getBaseProps(rest); const checkboxRef = useRef(null); + const i18n = useInternalI18n('toggle'); + + const isLoading = !!loading; + const effectiveDisabled = isLoading || !!disabled; + const resolvedLoadingText = i18n('i18nStrings.loadingText', i18nStrings?.loadingText); // The handle's follow-through animation only plays once the state has changed since mount. const [initialChecked] = useState(!!checked); @@ -66,6 +77,12 @@ const InternalToggle = React.forwardRef( setHasChanged(true); } + const loadingRegionId = useUniqueId('toggle-loading-'); + const [liveMessage, setLiveMessage] = useState(''); + useEffect(() => { + setLiveMessage(isLoading ? (resolvedLoadingText ?? '') : ''); + }, [isLoading, resolvedLoadingText]); + const analyticsMetadata: GeneratedAnalyticsMetadataFragment = {}; const analyticsComponentMetadata: GeneratedAnalyticsMetadataToggleComponent = { name: 'awsui.Toggle', @@ -79,68 +96,107 @@ const InternalToggle = React.forwardRef( analyticsMetadata.component = analyticsComponentMetadata; } - if (!disabled && !readOnly) { + if (!effectiveDisabled && !readOnly) { analyticsMetadata.action = !checked ? 'select' : 'deselect'; } + + if (isLoading && !resolvedLoadingText) { + warnOnce('Toggle', '`loadingText` is required when `loading` is true for accessibility.'); + } + useForwardFocus(ref, checkboxRef); return ( - ( - fireNonCancelableEvent(onFocus)} - onBlur={() => fireNonCancelableEvent(onBlur)} - // empty handler to suppress React controllability warning - onChange={() => {}} - /> - )} - onClick={() => { - checkboxRef.current?.focus(); - fireNonCancelableEvent(onChange, { checked: !checked }); - }} - styledControl={ - /*Using span, not div for HTML validity*/ - - } - style={getAbstractSwitchStyles(style, checked, disabled, readOnly)} - __internalRootRef={__internalRootRef} - {...getAnalyticsMetadataAttribute(analyticsMetadata)} - /> + <> + ( + fireNonCancelableEvent(onFocus)} + onBlur={() => fireNonCancelableEvent(onBlur)} + onKeyDown={ + isLoading + ? e => { + if (e.key === ' ') { + e.preventDefault(); + } + } + : undefined + } + // empty handler to suppress React controllability warning + onChange={() => {}} + /> + )} + onClick={() => { + if (isLoading) { + return; + } + checkboxRef.current?.focus(); + fireNonCancelableEvent(onChange, { checked: !checked }); + }} + styledControl={ + isLoading ? ( + /*Using span, not div for HTML validity*/ + + + + ) : ( + /*Using span, not div for HTML validity*/ + + ) + } + style={getAbstractSwitchStyles(style, checked, effectiveDisabled, readOnly)} + __internalRootRef={__internalRootRef} + {...getAnalyticsMetadataAttribute(analyticsMetadata)} + /> + + ); } ); diff --git a/src/toggle/styles.scss b/src/toggle/styles.scss index f83c154eb6e..686f0c9a92c 100644 --- a/src/toggle/styles.scss +++ b/src/toggle/styles.scss @@ -108,3 +108,27 @@ $control-tokens: style-api.resolve($control-props, carrier); @include motion.handle($handle-size, $handle-travel); } + +.toggle-handle-loading { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + border-start-start-radius: 0.6 * styles.$base-size; + border-start-end-radius: 0.6 * styles.$base-size; + border-end-start-radius: 0.6 * styles.$base-size; + border-end-end-radius: 0.6 * styles.$base-size; + background: style-api.value($control-tokens, indicator-color, awsui.$color-foreground-control-disabled); + inline-size: $handle-size; + block-size: $handle-size; + inset-block-start: $handle-offset; + inset-inline-start: $handle-offset; + + &-checked { + transform: translateX($handle-travel); + + @include styles.with-direction('rtl') { + transform: translateX(-$handle-travel); + } + } +}