Skip to content

Commit d5dfac0

Browse files
committed
chore: Adds internal style api v2 to segmented control
1 parent d8d3fa2 commit d5dfac0

8 files changed

Lines changed: 288 additions & 16 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
import clsx from 'clsx';
5+
6+
import { SegmentedControl, SegmentedControlProps, SpaceBetween } from '~components';
7+
8+
import { SimplePage } from '../app/templates';
9+
10+
import styles from './style-api-v2.scss';
11+
12+
const textOptions: SegmentedControlProps.Option[] = [
13+
{ id: 'list', text: 'List' },
14+
{ id: 'cards', text: 'Cards' },
15+
{ id: 'table', text: 'Table', disabled: true },
16+
];
17+
18+
const iconOptions: SegmentedControlProps.Option[] = [
19+
{ id: 'list', iconName: 'view-full', iconAlt: 'List' },
20+
{ id: 'cards', iconName: 'view-horizontal', iconAlt: 'Cards' },
21+
{ id: 'table', iconName: 'view-vertical', iconAlt: 'Table', disabled: true },
22+
];
23+
24+
interface StyledSegmentedControlProps {
25+
options: SegmentedControlProps.Option[];
26+
label: string;
27+
square?: boolean;
28+
}
29+
30+
function StyledSegmentedControl({ options, label, square }: StyledSegmentedControlProps) {
31+
const [selectedId, setSelectedId] = useState('list');
32+
return (
33+
<SegmentedControl
34+
selectedId={selectedId}
35+
options={options}
36+
label={label}
37+
onChange={({ detail }) => setSelectedId(detail.selectedId)}
38+
{...{
39+
styleClassNames: {
40+
root: styles.root,
41+
segment: clsx(styles.segment, square && styles['segment-square']),
42+
divider: styles.divider,
43+
select: { trigger: styles['select-trigger'], dropdown: styles['select-dropdown'] },
44+
},
45+
}}
46+
/>
47+
);
48+
}
49+
50+
export default function () {
51+
return (
52+
<SimplePage title="SegmentedControl - Style API v2" screenshotArea={{ disableAnimations: true }}>
53+
<SpaceBetween size="m" direction="horizontal">
54+
<StyledSegmentedControl options={textOptions} label="View" />
55+
<StyledSegmentedControl options={textOptions} label="View, square segments" square={true} />
56+
</SpaceBetween>
57+
<SpaceBetween size="m" direction="horizontal">
58+
<StyledSegmentedControl options={iconOptions} label="Layout" />
59+
</SpaceBetween>
60+
</SimplePage>
61+
);
62+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
@use '~components/styling/segmented-control';
7+
@use '~components/styling/select';
8+
@use '~components/styling/dropdown';
9+
@use '../style-api/palette' as palette;
10+
11+
.root {
12+
@include segmented-control.root(
13+
$background: palette.$info-surface,
14+
$border-color: palette.$info,
15+
$border-width: 2px,
16+
$border-radius: 12px
17+
);
18+
}
19+
20+
.segment {
21+
@include segmented-control.segment(
22+
$color: palette.$text-muted,
23+
$background: palette.$surface,
24+
$padding: 2px 16px,
25+
$font-family: monospace,
26+
$font-size: 12px,
27+
$font-weight: 700,
28+
$focus-ring-color: palette.$accent,
29+
$focus-ring-width: 3px,
30+
$focus-ring-radius: 12px
31+
);
32+
33+
&:hover {
34+
@include segmented-control.segment($background: palette.$subtle-hover, $color: palette.$text);
35+
}
36+
37+
&[aria-pressed='true'] {
38+
@include segmented-control.segment(
39+
$background: palette.$accent,
40+
$color: palette.$text-on-accent,
41+
$box-shadow: inset 0 0 0 1px palette.$accent-active
42+
);
43+
}
44+
45+
&:disabled,
46+
&[aria-disabled='true'] {
47+
@include segmented-control.segment($background: palette.$control-bg-disabled, $color: palette.$text-disabled);
48+
}
49+
}
50+
51+
.segment-square {
52+
@include segmented-control.segment(
53+
$border-radius: 0,
54+
$border-style: solid,
55+
$border-width: 1px,
56+
$border-color: palette.$border-accent
57+
);
58+
}
59+
60+
.divider {
61+
@include segmented-control.divider($color: palette.$border-accent, $width: 2px);
62+
}
63+
64+
// Select fallback, rendered below the x-small breakpoint: the trigger and its dropdown.
65+
.select-trigger {
66+
@include select.trigger(
67+
$background: palette.$info-surface,
68+
$border-color: palette.$info,
69+
$border-width: 2px,
70+
$border-radius: 12px
71+
);
72+
}
73+
74+
.select-dropdown {
75+
@include dropdown.container(
76+
$background: palette.$surface-alt,
77+
$border-radius: 12px,
78+
$outline-color: palette.$info,
79+
$outline-width: 2px
80+
);
81+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { render } from '@testing-library/react';
5+
6+
import SegmentedControl, { SegmentedControlProps } from '../../../lib/components/segmented-control';
7+
import createWrapper from '../../../lib/components/test-utils/dom';
8+
9+
import styles from '../../../lib/components/segmented-control/styles.css.js';
10+
11+
const options: SegmentedControlProps.Option[] = [
12+
{ id: 'list', text: 'List' },
13+
{ id: 'cards', text: 'Cards', disabled: true },
14+
{ id: 'table', iconName: 'settings', iconAlt: 'Table' },
15+
];
16+
17+
function renderSegmentedControl(styleClassNames: Record<string, unknown>) {
18+
const { container } = render(
19+
<SegmentedControl selectedId="list" options={options} label="View" {...{ styleClassNames }} />
20+
);
21+
return { container, wrapper: createWrapper(container).findSegmentedControl()! };
22+
}
23+
24+
describe('SegmentedControl Style API v2', () => {
25+
test('applies the root, segment and divider slots and forwards select', () => {
26+
const { container, wrapper } = renderSegmentedControl({
27+
root: 'r',
28+
segment: 's',
29+
divider: 'd',
30+
select: { trigger: 't' },
31+
});
32+
expect(container.querySelector(`.${styles['segment-part']}`)).toHaveClass('r');
33+
const segments = wrapper.findSegments();
34+
expect(segments).toHaveLength(3);
35+
for (const segment of segments) {
36+
expect(segment.getElement()).toHaveClass('s');
37+
expect(segment.getElement()).toHaveClass('d');
38+
}
39+
expect(wrapper.findSelectedSegment()!.getElement()).toHaveClass('s');
40+
expect(wrapper.findSegmentById('cards')!.getElement()).toBeDisabled();
41+
expect(wrapper.findSegmentById('cards')!.getElement()).toHaveClass('s');
42+
expect(createWrapper(container).findSelect()!.findTrigger().getElement()).toHaveClass('t');
43+
});
44+
45+
test('ignores unknown slots', () => {
46+
const { container } = renderSegmentedControl({ unknown: 'x' });
47+
expect(container.querySelector('.x')).toBeNull();
48+
});
49+
50+
test('does not leak the styleClassNames prop to the DOM', () => {
51+
const { container } = renderSegmentedControl({ segment: 's' });
52+
expect(container.querySelector('[styleClassNames]')).toBeNull();
53+
});
54+
});

‎src/segmented-control/internal-segmented-control.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
1010
import { KeyCode } from '../internal/keycode';
1111
import handleKey from '../internal/utils/handle-key';
1212
import { SegmentedControlProps } from './interfaces';
13+
import { StyleClassNames } from './internal';
1314
import { Segment } from './segment';
1415
import { getSegmentedControlRootStyles } from './style';
1516

1617
import styles from './styles.css.js';
1718

1819
interface InternalSegmentedControlComponentProps extends SegmentedControlProps {
1920
groupedControlProps?: GroupedControlContextProps;
21+
styleClassNames?: StyleClassNames;
2022
}
2123

2224
export default function InternalSegmentedControl({
@@ -27,6 +29,7 @@ export default function InternalSegmentedControl({
2729
onChange,
2830
style,
2931
groupedControlProps,
32+
styleClassNames,
3033
}: InternalSegmentedControlComponentProps) {
3134
const segmentByIdRef = useRef<{ [id: string]: HTMLButtonElement }>({});
3235
const selectedOptions = (options || []).filter(option => {
@@ -61,10 +64,16 @@ export default function InternalSegmentedControl({
6164

6265
return (
6366
<div
64-
className={clsx(styles['segment-part'], styles[`segment-count-${options?.length}`], groupedControlClassNames, {
65-
[styles.refresh]: isVisualRefresh,
66-
[styles['grouped-vertical']]: isGroupedVertical,
67-
})}
67+
className={clsx(
68+
styleClassNames?.root,
69+
styles['segment-part'],
70+
styles[`segment-count-${options?.length}`],
71+
groupedControlClassNames,
72+
{
73+
[styles.refresh]: isVisualRefresh,
74+
[styles['grouped-vertical']]: isGroupedVertical,
75+
}
76+
)}
6877
aria-label={label}
6978
aria-labelledby={ariaLabelledby}
7079
role="toolbar"
@@ -110,6 +119,7 @@ export default function InternalSegmentedControl({
110119
onKeyDown={event => moveHighlight(event, focusableSegmentIndex)}
111120
style={style}
112121
fullWidth={isGroupedVertical}
122+
styleClassNames={styleClassNames}
113123
/>
114124
);
115125
})}

‎src/segmented-control/internal.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@ import { useGroupedControlContext } from '../internal/context/control-group-cont
99
import { fireNonCancelableEvent } from '../internal/events';
1010
import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles';
1111
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
12-
import InternalSelect, { InternalSelectProps } from '../select/internal';
12+
import InternalSelect, { InternalSelectProps, StyleClassNames as SelectStyleClassNames } from '../select/internal';
1313
import { SegmentedControlProps } from './interfaces';
1414
import InternalSegmentedControlComponent from './internal-segmented-control';
1515

1616
import styles from './styles.css.js';
1717

18-
type InternalSegmentedControlProps = SegmentedControlProps & InternalBaseComponentProps;
18+
type InternalSegmentedControlProps = SegmentedControlProps &
19+
InternalBaseComponentProps & {
20+
styleClassNames?: StyleClassNames;
21+
};
22+
23+
// Style API v2
24+
export interface StyleClassNames {
25+
root?: string;
26+
segment?: string;
27+
divider?: string;
28+
select?: SelectStyleClassNames;
29+
}
1930

2031
export default function InternalSegmentedControl({
2132
selectedId,
@@ -25,6 +36,7 @@ export default function InternalSegmentedControl({
2536
onChange,
2637
__internalRootRef,
2738
style,
39+
styleClassNames,
2840
...props
2941
}: InternalSegmentedControlProps) {
3042
const baseProps = getBaseProps(props);
@@ -45,6 +57,7 @@ export default function InternalSegmentedControl({
4557
selectedOption: currentSelectedOption,
4658
triggerVariant: 'option',
4759
onChange: event => fireNonCancelableEvent(onChange, { selectedId: event.detail.selectedOption.value! }),
60+
styleClassNames: styleClassNames?.select,
4861
};
4962

5063
return (
@@ -57,6 +70,7 @@ export default function InternalSegmentedControl({
5770
onChange={onChange}
5871
style={style}
5972
groupedControlProps={groupedControlProps}
73+
styleClassNames={styleClassNames}
6074
/>
6175
<div className={clsx(styles.select, getGroupedControlClassNames(styles, groupedControlProps))}>
6276
{ariaLabelledby && <InternalSelect {...selectProps} ariaLabelledby={ariaLabelledby} />}

‎src/segmented-control/segment.scss‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
@use '../internal/styles/foundation' as foundation;
99
@use '../internal/generated/custom-css-properties/index.scss' as custom-props;
1010
@use '../internal/styles/utils/theming' as theming;
11+
@use '../internal/styles/style-api' as style-api;
1112
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
1213

14+
// Style API v2. The root radius is a carrier so the segments can derive their own radius from it; the segment
15+
// focus ring and the divider (the ::after of the segment) read carriers re-anchored on the segment.
16+
$root-tokens: style-api.resolve((border-radius), carrier);
17+
$segment-tokens: style-api.resolve(style-api.$focus-ring, carrier);
18+
$divider-tokens: style-api.resolve((color, width), carrier);
19+
@include style-api.register($segment-tokens, $divider-tokens);
20+
1321
$default-background: var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-segment-default);
1422
$default-color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-segment-default);
1523
$hover-background: var(#{custom-props.$styleBackgroundHover}, awsui.$color-background-segment-hover);
@@ -23,9 +31,12 @@ $segment-border-width: var(#{$segment-border-width-prop}, 0);
2331
$segment-gap: awsui.$space-static-xxs;
2432
$segment-divider-width: 1px;
2533
$one-theme-segment-gap: calc(#{$segment-gap} - #{$segment-divider-width});
26-
$segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field} + #{awsui.$border-width-button}));
34+
$root-radius: style-api.value($root-tokens, border-radius, awsui.$border-radius-button);
35+
$segment-radius: calc($root-radius - (#{awsui.$border-width-field} + #{awsui.$border-width-button}));
2736

2837
.segment {
38+
@include style-api.carriers($segment-tokens);
39+
@include style-api.carriers($divider-tokens);
2940
@include styles.font-body-m;
3041
@include styles.text-flex-wrapping;
3142
@include styles.font-button;
@@ -69,13 +80,25 @@ $segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field
6980
}
7081

7182
#{custom-props.$styleFocusRingBoxShadow}: 0 0 0
72-
var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width)
73-
var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused);
83+
style-api.value(
84+
$segment-tokens,
85+
focus-ring-width,
86+
var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width)
87+
)
88+
style-api.value(
89+
$segment-tokens,
90+
focus-ring-color,
91+
var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused)
92+
);
7493

7594
@include focus-visible.when-visible {
7695
@include styles.focus-highlight(
7796
$gutter: awsui.$space-segmented-control-focus-outline-gutter,
78-
$border-radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring),
97+
$border-radius: style-api.value(
98+
$segment-tokens,
99+
focus-ring-radius,
100+
var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring)
101+
),
79102
$box-shadow: var(#{custom-props.$styleFocusRingBoxShadow}),
80103
$z-index: 1 // Prevents adjacent segments from overlapping the focus ring
81104
);
@@ -87,8 +110,8 @@ $segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field
87110
inset-inline-end: calc(-1 * ($segment-gap + $segment-divider-width));
88111
block-size: calc(100% - (($segment-gap + awsui.$space-static-xxxs) * 2));
89112
min-block-size: calc(awsui.$line-height-body-m - $segment-gap * 2);
90-
inline-size: $segment-divider-width;
91-
background: awsui.$color-border-segment-divider;
113+
inline-size: style-api.value($divider-tokens, width, $segment-divider-width);
114+
background: style-api.value($divider-tokens, color, awsui.$color-border-segment-divider);
92115
z-index: 1;
93116
}
94117
&:not(.refresh):not(:last-child)::after {

0 commit comments

Comments
 (0)