diff --git a/build-tools/utils/custom-css-properties.js b/build-tools/utils/custom-css-properties.js index a3ed844125..3812801cfb 100644 --- a/build-tools/utils/custom-css-properties.js +++ b/build-tools/utils/custom-css-properties.js @@ -105,6 +105,11 @@ const customCssPropertiesList = [ 'styleBorderColorFocus', 'styleBoxShadowFocus', 'styleColorFocus', + // Pressed state + 'styleBackgroundPressed', + 'styleBorderColorPressed', + 'styleBoxShadowPressed', + 'styleColorPressed', // Placeholder style properties 'stylePlaceholderColor', 'stylePlaceholderFontSize', diff --git a/pages/toggle-button/style-permutations.page.tsx b/pages/toggle-button/style-permutations.page.tsx new file mode 100644 index 0000000000..1697e7d701 --- /dev/null +++ b/pages/toggle-button/style-permutations.page.tsx @@ -0,0 +1,164 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import ToggleButton, { ToggleButtonProps } from '~components/toggle-button'; + +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; +import ScreenshotArea from '../utils/screenshot-area'; + +const blueSquareStyle: ToggleButtonProps['style'] = { + root: { + background: { + default: '#2563eb', + hover: '#1d4ed8', + active: '#1e40af', + disabled: '#bfdbfe', + pressed: '#1e3a8a', + }, + borderColor: { + default: '#2563eb', + hover: '#1d4ed8', + active: '#1e3a8a', + disabled: '#60a5fa', + pressed: '#1e3a8a', + }, + borderWidth: '2px', + borderRadius: '0', + boxShadow: { + default: '0 1px 2px rgba(0, 0, 0, 0.05)', + hover: '0 2px 4px rgba(59, 130, 246, 0.15)', + active: '0 1px 2px rgba(0, 0, 0, 0.05)', + disabled: 'none', + pressed: '0 0 0 4px rgba(59, 130, 246, 0.2)', + }, + color: { + default: '#ffffff', + hover: '#ffffff', + active: '#ffffff', + disabled: '#0c4a6e', + pressed: '#ffffff', + }, + paddingBlock: '10px', + paddingInline: '16px', + focusRing: { + borderColor: '#3b82f6', + borderRadius: '2px', + borderWidth: '3px', + }, + }, +}; + +const purpleRoundedStyle: ToggleButtonProps['style'] = { + root: { + background: { + default: '#7c3aed', + hover: '#6d28d9', + active: '#5b21b6', + disabled: '#ddd6fe', + pressed: '#4c1d95', + }, + borderColor: { + default: '#7c3aed', + hover: '#6d28d9', + active: '#5b21b6', + disabled: '#c4b5fd', + pressed: '#5b21b6', + }, + borderWidth: '2px', + borderRadius: '12px', + boxShadow: { + default: '0 1px 3px rgba(0, 0, 0, 0.1)', + hover: '0 4px 6px rgba(139, 92, 246, 0.2)', + active: '0 2px 4px rgba(0, 0, 0, 0.1)', + disabled: 'none', + pressed: '0 0 0 4px rgba(139, 92, 246, 0.3)', + }, + color: { + default: '#ffffff', + hover: '#ffffff', + active: '#ffffff', + disabled: '#2e1065', + pressed: '#ffffff', + }, + paddingBlock: '12px', + paddingInline: '20px', + focusRing: { + borderColor: '#8b5cf6', + borderRadius: '14px', + borderWidth: '3px', + }, + }, +}; + +const blueIconStyle: ToggleButtonProps['style'] = { + root: { + color: { + default: 'light-dark(#3b82f6, #93c5fd)', + hover: 'light-dark(#2563eb, #60a5fa)', + active: 'light-dark(#1d4ed8, #3b82f6)', + disabled: 'light-dark(#93c5fd, #94a3b8)', + pressed: 'light-dark(#1e40af, #2563eb)', + }, + focusRing: { + borderColor: '#3b82f6', + borderRadius: '50%', + borderWidth: '3px', + }, + }, +}; + +const purpleIconStyle: ToggleButtonProps['style'] = { + root: { + color: { + default: 'light-dark(#8b5cf6, #c4b5fd)', + hover: 'light-dark(#7c3aed, #a78bfa)', + active: 'light-dark(#6d28d9, #8b5cf6)', + disabled: 'light-dark(#c4b5fd, #94a3b8)', + pressed: 'light-dark(#5b21b6, #7c3aed)', + }, + focusRing: { + borderColor: '#8b5cf6', + borderRadius: '10px', + borderWidth: '3px', + }, + }, +}; + +const permutations = createPermutations([ + { + variant: ['normal'], + children: ['Subscribe'], + pressed: [false, true], + disabled: [false, true], + iconName: ['thumbs-up'], + pressedIconName: ['thumbs-up-filled'], + onChange: [() => {}], + style: [blueSquareStyle, purpleRoundedStyle], + }, + { + variant: ['icon'], + pressed: [false, true], + disabled: [false, true], + iconName: ['thumbs-up'], + pressedIconName: ['thumbs-up-filled'], + onChange: [() => {}], + ariaLabel: ['Toggle'], + style: [blueIconStyle, purpleIconStyle], + }, +]); + +export default function ToggleButtonStylePermutations() { + return ( + <> +

Toggle Button Style Permutations

+ + } + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index fb6bd7c226..5a9b0c92bc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -27172,6 +27172,221 @@ We do not support using this attribute to apply custom styling.", "optional": true, "type": "string", }, + { + "inlineType": { + "name": "ToggleButtonProps.Style", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "active", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "hover", + "optional": true, + "type": "string", + }, + { + "name": "pressed", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "background", + "optional": true, + "type": "{ active?: string | undefined; default?: string | undefined; disabled?: string | undefined; hover?: string | undefined; pressed?: string | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "active", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "hover", + "optional": true, + "type": "string", + }, + { + "name": "pressed", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "borderColor", + "optional": true, + "type": "{ active?: string | undefined; default?: string | undefined; disabled?: string | undefined; hover?: string | undefined; pressed?: string | undefined; }", + }, + { + "name": "borderRadius", + "optional": true, + "type": "string", + }, + { + "name": "borderWidth", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "active", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "hover", + "optional": true, + "type": "string", + }, + { + "name": "pressed", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "boxShadow", + "optional": true, + "type": "{ active?: string | undefined; default?: string | undefined; disabled?: string | undefined; hover?: string | undefined; pressed?: string | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "active", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "hover", + "optional": true, + "type": "string", + }, + { + "name": "pressed", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "color", + "optional": true, + "type": "{ active?: string | undefined; default?: string | undefined; disabled?: string | undefined; hover?: string | undefined; pressed?: string | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "borderColor", + "optional": true, + "type": "string", + }, + { + "name": "borderRadius", + "optional": true, + "type": "string", + }, + { + "name": "borderWidth", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "focusRing", + "optional": true, + "type": "{ borderColor?: string | undefined; borderRadius?: string | undefined; borderWidth?: string | undefined; }", + }, + { + "name": "paddingBlock", + "optional": true, + "type": "string", + }, + { + "name": "paddingInline", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "root", + "optional": true, + "type": "{ background?: { active?: string | undefined; default?: string | undefined; disabled?: string | undefined; hover?: string | undefined; pressed?: string | undefined; } | undefined; ... 7 more ...; paddingInline?: string | undefined; }", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "systemTags": [ + "core", + ], + "type": "ToggleButtonProps.Style", + }, { "defaultValue": "'normal'", "description": "Determines the general styling of the toggle button as follows: diff --git a/src/toggle-button/__tests__/styles.test.tsx b/src/toggle-button/__tests__/styles.test.tsx new file mode 100644 index 0000000000..be7f53282b --- /dev/null +++ b/src/toggle-button/__tests__/styles.test.tsx @@ -0,0 +1,277 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import customCssProps from '../../internal/generated/custom-css-properties'; +import { getToggleButtonStyles } from '../style'; + +// Mock the environment module +jest.mock('../../internal/environment', () => ({ + SYSTEM: 'core', +})); + +const STATES = ['default', 'disabled', 'hover', 'active', 'pressed'] as const; +const STATE_PROPERTIES = ['background', 'borderColor', 'boxShadow', 'color'] as const; + +const CSS_PROPERTY_MAP = { + background: { + default: customCssProps.styleBackgroundDefault, + disabled: customCssProps.styleBackgroundDisabled, + hover: customCssProps.styleBackgroundHover, + active: customCssProps.styleBackgroundActive, + pressed: customCssProps.styleBackgroundPressed, + }, + borderColor: { + default: customCssProps.styleBorderColorDefault, + disabled: customCssProps.styleBorderColorDisabled, + hover: customCssProps.styleBorderColorHover, + active: customCssProps.styleBorderColorActive, + pressed: customCssProps.styleBorderColorPressed, + }, + boxShadow: { + default: customCssProps.styleBoxShadowDefault, + disabled: customCssProps.styleBoxShadowDisabled, + hover: customCssProps.styleBoxShadowHover, + active: customCssProps.styleBoxShadowActive, + pressed: customCssProps.styleBoxShadowPressed, + }, + color: { + default: customCssProps.styleColorDefault, + disabled: customCssProps.styleColorDisabled, + hover: customCssProps.styleColorHover, + active: customCssProps.styleColorActive, + pressed: customCssProps.styleColorPressed, + }, +} as const; + +describe('getToggleButtonStyles', () => { + afterEach(() => { + jest.resetModules(); + }); + + test('returns empty nativeButtonStyles for undefined or empty style objects', () => { + expect(getToggleButtonStyles(undefined)).toEqual({ nativeButtonStyles: {} }); + expect(getToggleButtonStyles({})).toEqual({ nativeButtonStyles: {} }); + }); + + test('extracts only pressed states to nativeButtonStyles', () => { + const allStyles = { + root: { + borderRadius: '4px', + borderWidth: '1px', + paddingBlock: '8px', + paddingInline: '12px', + background: { + default: '#ffffff', + disabled: '#f0f0f0', + hover: '#fafafa', + active: '#eeeeee', + pressed: '#e0e0e0', + }, + borderColor: { + default: '#cccccc', + disabled: '#e0e0e0', + hover: '#999999', + active: '#666666', + pressed: '#0073bb', + }, + boxShadow: { + default: 'none', + disabled: 'none', + hover: '0 1px 2px rgba(0,0,0,0.1)', + active: '0 1px 2px rgba(0,0,0,0.2)', + pressed: '0 0 0 2px #0073bb', + }, + color: { + default: '#000000', + disabled: '#999999', + hover: '#000000', + active: '#000000', + pressed: '#0073bb', + }, + focusRing: { + borderColor: '#0073bb', + borderRadius: '6px', + borderWidth: '2px', + }, + }, + }; + + const result = getToggleButtonStyles(allStyles); + + // Should only contain pressed states + expect(result).toEqual({ + nativeButtonStyles: { + [customCssProps.styleBackgroundPressed]: '#e0e0e0', + [customCssProps.styleBorderColorPressed]: '#0073bb', + [customCssProps.styleBoxShadowPressed]: '0 0 0 2px #0073bb', + [customCssProps.styleColorPressed]: '#0073bb', + }, + }); + + // Should NOT contain non-pressed states (these are handled by InternalButton) + expect(result.nativeButtonStyles).not.toHaveProperty('borderRadius'); + expect(result.nativeButtonStyles).not.toHaveProperty('borderWidth'); + expect(result.nativeButtonStyles).not.toHaveProperty(customCssProps.styleBackgroundDefault); + expect(result.nativeButtonStyles).not.toHaveProperty(customCssProps.styleBackgroundHover); + }); + + test('returns empty nativeButtonStyles when SYSTEM is not core', async () => { + jest.resetModules(); + jest.doMock('../../internal/environment', () => ({ + SYSTEM: 'visual-refresh', + })); + + const { getToggleButtonStyles: getToggleButtonStylesNonCore } = await import('../style'); + + const result = getToggleButtonStylesNonCore({ root: { borderRadius: '4px' } }); + + expect(result).toEqual({ nativeButtonStyles: {} }); + }); + + describe('individual root properties', () => { + const rootProperties = { + borderRadius: '8px', + borderWidth: '2px', + paddingBlock: '10px', + paddingInline: '16px', + }; + + Object.entries(rootProperties).forEach(([property, value]) => { + test(`${property} only - not extracted (handled by InternalButton)`, () => { + const result = getToggleButtonStyles({ root: { [property]: value } }); + + // These properties are passed to InternalButton via the style prop + // and are not extracted to nativeButtonStyles + expect(result).toEqual({ + nativeButtonStyles: {}, + }); + }); + }); + }); + + describe('state properties', () => { + STATE_PROPERTIES.forEach(property => { + describe(`${property}`, () => { + test('pressed state only', () => { + const testValue = `test-${property}-pressed`; + const style = { + root: { + [property]: { pressed: testValue }, + }, + }; + + const result = getToggleButtonStyles(style); + + // Pressed state should be extracted to nativeButtonStyles + expect(result).toEqual({ + nativeButtonStyles: { + [CSS_PROPERTY_MAP[property].pressed]: testValue, + }, + }); + }); + + ['default', 'disabled', 'hover', 'active'].forEach(state => { + test(`${state} only - not extracted (handled by InternalButton)`, () => { + const testValue = `test-${property}-${state}`; + const style = { + root: { + [property]: { [state]: testValue }, + }, + }; + + const result = getToggleButtonStyles(style); + + // Non-pressed states are not extracted + expect(result).toEqual({ + nativeButtonStyles: {}, + }); + }); + }); + + test('all states together - only pressed extracted', () => { + const allStateValues = STATES.reduce( + (acc, state) => ({ + ...acc, + [state]: `test-${property}-${state}`, + }), + {} + ); + + const style = { root: { [property]: allStateValues } }; + const result = getToggleButtonStyles(style); + + // Only pressed state should be in nativeButtonStyles + expect(result).toEqual({ + nativeButtonStyles: { + [CSS_PROPERTY_MAP[property].pressed]: `test-${property}-pressed`, + }, + }); + }); + }); + }); + }); + + describe('focusRing properties', () => { + const focusRingProperties = { + borderColor: { prop: customCssProps.styleFocusRingBorderColor, value: '#10b981' }, + borderRadius: { prop: customCssProps.styleFocusRingBorderRadius, value: '10px' }, + borderWidth: { prop: customCssProps.styleFocusRingBorderWidth, value: '3px' }, + }; + + Object.entries(focusRingProperties).forEach(([property, { value }]) => { + test(`${property} only - not extracted (handled by InternalButton)`, () => { + const result = getToggleButtonStyles({ + root: { focusRing: { [property]: value } }, + }); + + // focusRing properties are passed to InternalButton via the style prop + expect(result).toEqual({ + nativeButtonStyles: {}, + }); + }); + }); + + test('all focusRing properties together - not extracted (handled by InternalButton)', () => { + const result = getToggleButtonStyles({ + root: { + focusRing: { + borderColor: '#10b981', + borderRadius: '10px', + borderWidth: '3px', + }, + }, + }); + + // focusRing properties are passed to InternalButton via the style prop + expect(result).toEqual({ + nativeButtonStyles: {}, + }); + }); + }); + + test('handles mixed property types - only pressed states extracted', () => { + const result = getToggleButtonStyles({ + root: { + borderRadius: '8px', + paddingBlock: '10px', + background: { default: '#3b82f6', pressed: '#1e40af' }, + color: { hover: '#ffffff', pressed: '#f0f0f0' }, + focusRing: { borderColor: '#3b82f6' }, + }, + }); + + // Only pressed states should be extracted + expect(result).toEqual({ + nativeButtonStyles: { + [customCssProps.styleBackgroundPressed]: '#1e40af', + [customCssProps.styleColorPressed]: '#f0f0f0', + }, + }); + + // Other properties are passed to InternalButton via the style prop + expect(result.nativeButtonStyles).not.toHaveProperty('borderRadius'); + expect(result.nativeButtonStyles).not.toHaveProperty('paddingBlock'); + expect(result.nativeButtonStyles).not.toHaveProperty(customCssProps.styleBackgroundDefault); + expect(result.nativeButtonStyles).not.toHaveProperty(customCssProps.styleColorHover); + expect(result.nativeButtonStyles).not.toHaveProperty(customCssProps.styleFocusRingBorderColor); + }); +}); diff --git a/src/toggle-button/index.tsx b/src/toggle-button/index.tsx index bdfd2bdd3d..2eae84d17a 100644 --- a/src/toggle-button/index.tsx +++ b/src/toggle-button/index.tsx @@ -33,6 +33,7 @@ const ToggleButton = React.forwardRef( pressed = false, nativeButtonAttributes, onChange, + style, ...props }: ToggleButtonProps, ref: React.Ref @@ -68,6 +69,7 @@ const ToggleButton = React.forwardRef( pressed={pressed} nativeButtonAttributes={nativeButtonAttributes} onChange={onChange} + style={style} > {children} diff --git a/src/toggle-button/interfaces.ts b/src/toggle-button/interfaces.ts index 485a9ca753..b343b0ee5d 100644 --- a/src/toggle-button/interfaces.ts +++ b/src/toggle-button/interfaces.ts @@ -72,6 +72,11 @@ export interface ToggleButtonProps extends BaseComponentProps, Omit; + + /** + * @awsuiSystem core + */ + style?: ToggleButtonProps.Style; } export namespace ToggleButtonProps { @@ -87,4 +92,46 @@ export namespace ToggleButtonProps { */ focus(options?: FocusOptions): void; } + + export interface Style { + root?: { + background?: { + active?: string; + default?: string; + disabled?: string; + hover?: string; + pressed?: string; + }; + borderColor?: { + active?: string; + default?: string; + disabled?: string; + hover?: string; + pressed?: string; + }; + borderRadius?: string; + borderWidth?: string; + boxShadow?: { + active?: string; + default?: string; + disabled?: string; + hover?: string; + pressed?: string; + }; + color?: { + active?: string; + default?: string; + disabled?: string; + hover?: string; + pressed?: string; + }; + focusRing?: { + borderColor?: string; + borderRadius?: string; + borderWidth?: string; + }; + paddingBlock?: string; + paddingInline?: string; + }; + } } diff --git a/src/toggle-button/internal.tsx b/src/toggle-button/internal.tsx index e23865bf56..549e0a376d 100644 --- a/src/toggle-button/internal.tsx +++ b/src/toggle-button/internal.tsx @@ -9,6 +9,7 @@ import InternalButton from '../button/internal'; import { fireNonCancelableEvent } from '../internal/events'; import { isDevelopment } from '../internal/is-development'; import { ToggleButtonProps } from './interfaces'; +import { getToggleButtonStyles } from './style'; import { getToggleIcon } from './util'; import styles from './styles.css.js'; @@ -28,6 +29,7 @@ export const InternalToggleButton = React.forwardRef( onChange, className, analyticsAction = 'click', + style, ...rest }: ToggleButtonProps & { __title?: string; analyticsAction?: string }, ref: React.Ref @@ -46,6 +48,8 @@ export const InternalToggleButton = React.forwardRef( } } + const { nativeButtonStyles } = getToggleButtonStyles(style); + return ( ); diff --git a/src/toggle-button/style.tsx b/src/toggle-button/style.tsx new file mode 100644 index 0000000000..50b636b30f --- /dev/null +++ b/src/toggle-button/style.tsx @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { SYSTEM } from '../internal/environment'; +import customCssProps from '../internal/generated/custom-css-properties'; +import { ToggleButtonProps } from './interfaces'; + +export function getToggleButtonStyles(style: ToggleButtonProps['style']) { + if (SYSTEM !== 'core') { + return { nativeButtonStyles: {} }; + } + + // Styles NOT supported by InternalButton (pressed states only) + // InternalButton will handle all other styles when the original style object is passed to it + const nativeButtonStyles = { + [customCssProps.styleBackgroundPressed]: style?.root?.background?.pressed, + [customCssProps.styleBorderColorPressed]: style?.root?.borderColor?.pressed, + [customCssProps.styleBoxShadowPressed]: style?.root?.boxShadow?.pressed, + [customCssProps.styleColorPressed]: style?.root?.color?.pressed, + }; + + return { + nativeButtonStyles, + }; +} diff --git a/src/toggle-button/styles.scss b/src/toggle-button/styles.scss index eacd937eb4..a51431e052 100644 --- a/src/toggle-button/styles.scss +++ b/src/toggle-button/styles.scss @@ -4,15 +4,18 @@ */ @use '../internal/styles/tokens' as awsui; +@use '../internal/generated/custom-css-properties/index.scss' as custom-props; .variant-normal.pressed { - background: awsui.$color-background-toggle-button-normal-pressed; - border-color: awsui.$color-border-toggle-button-normal-pressed; - color: awsui.$color-text-toggle-button-normal-pressed; + background: var(#{custom-props.$styleBackgroundPressed}, awsui.$color-background-toggle-button-normal-pressed); + border-color: var(#{custom-props.$styleBorderColorPressed}, awsui.$color-border-toggle-button-normal-pressed); + color: var(#{custom-props.$styleColorPressed}, awsui.$color-text-toggle-button-normal-pressed); + box-shadow: var(#{custom-props.$styleBoxShadowPressed}); } .variant-icon.pressed { - background: transparent; - border-color: transparent; - color: awsui.$color-text-toggle-button-icon-pressed; + background: var(#{custom-props.$styleBackgroundPressed}, transparent); + border-color: var(#{custom-props.$styleBorderColorPressed}, transparent); + color: var(#{custom-props.$styleColorPressed}, awsui.$color-text-toggle-button-icon-pressed); + box-shadow: var(#{custom-props.$styleBoxShadowPressed}); }