diff --git a/pages/slider/style-api-v2.page.tsx b/pages/slider/style-api-v2.page.tsx new file mode 100644 index 0000000000..7a79fa7310 --- /dev/null +++ b/pages/slider/style-api-v2.page.tsx @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { KeyValuePairs, Slider, SliderProps } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +const styleClassNames = { root: styles.slider, track: styles.track, range: styles.range, handle: styles.handle }; + +export default function () { + const [value, setValue] = useState(40); + const slider = (props: Partial & { ariaLabel: string }) => ( + setValue(detail.value)} + min={0} + max={100} + {...props} + {...{ styleClassNames }} + /> + ); + return ( + + + + ); +} diff --git a/pages/slider/style-api-v2.scss b/pages/slider/style-api-v2.scss new file mode 100644 index 0000000000..aa31c633fa --- /dev/null +++ b/pages/slider/style-api-v2.scss @@ -0,0 +1,56 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '@cloudscape-design/component-toolkit/styling' as styling; +@use '../style-api/palette' as palette; + +.track { + @include styling.override { + background-color: palette.$surface-alt; + } +} + +.range { + @include styling.override { + background-color: palette.$accent; + } +} + +// The handle is the native , so its states are regular selectors. +.handle { + --awsui-style-handle-color: #{palette.$accent}; + --awsui-style-border-radius: 4px; + + &:hover { + --awsui-style-handle-color: #{palette.$accent-hover}; + } + + &:is(:focus, :active) { + --awsui-style-handle-color: #{palette.$accent-active}; + } + + &:disabled, + &[aria-disabled='true'] { + --awsui-style-handle-color: #{palette.$control-bg-disabled}; + } +} + +// The track and range don't contain the input, so they take its state through :has() on the root slot. +/* stylelint-disable selector-combinator-disallowed-list */ +.slider { + &:has(.handle:is(:focus, :active)) .range { + @include styling.override { + background-color: palette.$accent-active; + } + } + + // After the focus rule: a read-only slider stays focusable, and both rules have the same specificity. + &:has(.handle:disabled, .handle[aria-disabled='true']) .range { + @include styling.override { + background-color: palette.$control-bg-disabled; + } + } +} +/* stylelint-enable selector-combinator-disallowed-list */ diff --git a/src/__tests__/snapshot-tests/__snapshots__/style-docs.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/style-docs.test.ts.snap index 0ec1499e58..98b233c04e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/style-docs.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/style-docs.test.ts.snap @@ -197,6 +197,48 @@ exports[`Style API docs definition for radio-button matches the snapshot: radio- } `; +exports[`Style API docs definition for slider matches the snapshot: slider 1`] = ` +{ + "slots": [ + { + "name": "root", + "properties": [], + "propertyDescriptions": {}, + "tokenDescriptions": {}, + "tokens": [], + }, + { + "name": "track", + "properties": [ + "background-color", + ], + "propertyDescriptions": {}, + "tokenDescriptions": {}, + "tokens": [], + }, + { + "name": "range", + "properties": [ + "background-color", + ], + "propertyDescriptions": {}, + "tokenDescriptions": {}, + "tokens": [], + }, + { + "name": "handle", + "properties": [], + "propertyDescriptions": {}, + "tokenDescriptions": {}, + "tokens": [ + "handle-color", + "border-radius", + ], + }, + ], +} +`; + exports[`Style API docs definition for toggle matches the snapshot: toggle 1`] = ` { "slots": [ @@ -246,6 +288,7 @@ exports[`Style API docs list of components with a Style API doc matches the snap "dropdown", "link", "radio-button", + "slider", "toggle", ] `; diff --git a/src/internal/styles/style-api.scss b/src/internal/styles/style-api.scss index a245639edf..8e3a8eddfd 100644 --- a/src/internal/styles/style-api.scss +++ b/src/internal/styles/style-api.scss @@ -51,9 +51,9 @@ $_focus-ring-carriers: toolkit.resolve($focus-ring, carrier); } } -@mixin docs($name, $map) { +@mixin docs($name, $tokens: null, $properties: null, $descriptions: null) { @if enabled() { - @include toolkit.docs($name, $map); + @include toolkit.docs($name, $tokens, $properties, $descriptions); } } diff --git a/src/slider/__tests__/style-api.test.tsx b/src/slider/__tests__/style-api.test.tsx new file mode 100644 index 0000000000..b53a8b69cd --- /dev/null +++ b/src/slider/__tests__/style-api.test.tsx @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Slider from '../../../lib/components/slider'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/slider/styles.css.js'; + +function renderSlider(element: React.ReactElement) { + const { container } = render(element); + return { container, wrapper: createWrapper(container).findSlider()! }; +} + +const slots = { root: 'e', track: 'a', range: 'b', handle: 'c', unknown: 'd' }; + +describe('Slider Style API v2 (implicit styleClassNames)', () => { + test('applies styleClassNames to the root, track, range, and handle slots', () => { + const { container, wrapper } = renderSlider( + {}} {...({ styleClassNames: slots } as any)} /> + ); + + expect(wrapper.getElement()).toHaveClass('e'); + expect(container.querySelector(`.${styles['slider-track']}`)).toHaveClass('a'); + expect(container.querySelector(`.${styles['slider-range']}`)).toHaveClass('b'); + expect(container.querySelector(`.${styles.thumb}`)).toHaveClass('c'); + expect(container.querySelector('.d')).toBeNull(); + }); + + test('applies the range slot to filled tick marks and the track slot to the remaining ones', () => { + const { container } = renderSlider( + {}} + {...({ styleClassNames: slots } as any)} + /> + ); + + const ticks = Array.from(container.querySelectorAll(`.${styles.tick}`)); + expect(ticks.length).toBeGreaterThan(0); + expect( + ticks.filter(tick => tick.classList.contains(styles.filled)).every(tick => tick.classList.contains('b')) + ).toBe(true); + expect( + ticks.filter(tick => !tick.classList.contains(styles.filled)).every(tick => tick.classList.contains('a')) + ).toBe(true); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + const { container } = renderSlider( + {}} {...({ styleClassNames: slots } as any)} /> + ); + expect(container.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/slider/index.tsx b/src/slider/index.tsx index bbdee1bb1a..39958ddee0 100644 --- a/src/slider/index.tsx +++ b/src/slider/index.tsx @@ -6,7 +6,7 @@ import React from 'react'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { SliderProps } from './interfaces'; -import InternalSlider from './internal'; +import InternalSlider, { StyleClassNames } from './internal'; export { SliderProps }; @@ -19,6 +19,7 @@ export default function Slider({ tickMarks, hideFillLine, style, ...props }: Sli tickMarks={tickMarks} hideFillLine={hideFillLine} style={style} + styleClassNames={(props as { styleClassNames?: StyleClassNames }).styleClassNames} {...props} {...baseComponentProps} /> diff --git a/src/slider/internal.tsx b/src/slider/internal.tsx index e1de93e7e9..e81fde9eaa 100644 --- a/src/slider/internal.tsx +++ b/src/slider/internal.tsx @@ -27,7 +27,16 @@ import { import styles from './styles.css.js'; +// Style API v2 +export interface StyleClassNames { + root?: string; + track?: string; + range?: string; + handle?: string; +} + interface InternalSliderProps extends SliderProps, InternalBaseComponentProps { + styleClassNames?: StyleClassNames; style?: SliderProps['style']; } @@ -47,6 +56,7 @@ export default function InternalSlider({ valueFormatter, i18nStrings, style, + styleClassNames, __internalRootRef, ...rest }: InternalSliderProps) { @@ -132,7 +142,7 @@ export default function InternalSlider({
{!!step && tickMarks && ( getType(); + return (
getType(), + className={clsx(styles.tick, isFilled ? styleClassNames?.range : styleClassNames?.track, { + [styles.filled]: isFilled, [styles.active]: !hideFillLine && isActive && value > getType(), [styles.error]: invalid && !hideFillLine && value > getType(), [styles.warning]: showWarning && !hideFillLine && value > getType(),