Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions pages/segmented-control/style-api-v2.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import clsx from 'clsx';

import { SegmentedControl, SegmentedControlProps, SpaceBetween } from '~components';

import { SimplePage } from '../app/templates';

import styles from './style-api-v2.scss';

const textOptions: SegmentedControlProps.Option[] = [
{ id: 'list', text: 'List' },
{ id: 'cards', text: 'Cards' },
{ id: 'table', text: 'Table', disabled: true },
];

const iconOptions: SegmentedControlProps.Option[] = [
{ id: 'list', iconName: 'view-full', iconAlt: 'List' },
{ id: 'cards', iconName: 'view-horizontal', iconAlt: 'Cards' },
{ id: 'table', iconName: 'view-vertical', iconAlt: 'Table', disabled: true },
];

interface StyledSegmentedControlProps {
options: SegmentedControlProps.Option[];
label: string;
square?: boolean;
}

function StyledSegmentedControl({ options, label, square }: StyledSegmentedControlProps) {
const [selectedId, setSelectedId] = useState('list');
return (
<SegmentedControl
selectedId={selectedId}
options={options}
label={label}
onChange={({ detail }) => setSelectedId(detail.selectedId)}
{...{
styleClassNames: {
root: styles.root,
segment: clsx(styles.segment, square && styles['segment-square']),
divider: styles.divider,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's include select slot to the demo as well - we can import select styles from select's test page, or use some minimal ones

select: { trigger: styles['select-trigger'], dropdown: styles['select-dropdown'] },
},
}}
/>
);
}

export default function () {
return (
<SimplePage title="SegmentedControl - Style API v2" screenshotArea={{ disableAnimations: true }}>
<SpaceBetween size="m" direction="horizontal">
<StyledSegmentedControl options={textOptions} label="View" />
<StyledSegmentedControl options={textOptions} label="View, square segments" square={true} />
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
<StyledSegmentedControl options={iconOptions} label="Layout" />
</SpaceBetween>
</SimplePage>
);
}
81 changes: 81 additions & 0 deletions pages/segmented-control/style-api-v2.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

@use '~components/styling/segmented-control';
@use '~components/styling/select';
@use '~components/styling/dropdown';
@use '../style-api/palette' as palette;

.root {
@include segmented-control.root(
$background: palette.$info-surface,
$border-color: palette.$info,
$border-width: 2px,
$border-radius: 12px
);
}

.segment {
@include segmented-control.segment(
$color: palette.$text-muted,
$background: palette.$surface,
$padding: 2px 16px,
$font-family: monospace,
$font-size: 12px,
$font-weight: 700,
$focus-ring-color: palette.$accent,
$focus-ring-width: 3px,
$focus-ring-radius: 12px
);

&:hover {
@include segmented-control.segment($background: palette.$subtle-hover, $color: palette.$text);
}

&[aria-pressed='true'] {
@include segmented-control.segment(
$background: palette.$accent,
$color: palette.$text-on-accent,
$box-shadow: inset 0 0 0 1px palette.$accent-active
);
}

&:disabled,
&[aria-disabled='true'] {
@include segmented-control.segment($background: palette.$control-bg-disabled, $color: palette.$text-disabled);
}
}

.segment-square {
@include segmented-control.segment(
$border-radius: 0,
$border-style: solid,
$border-width: 1px,
$border-color: palette.$border-accent
);
}

.divider {
@include segmented-control.divider($color: palette.$border-accent, $width: 2px);
}

// Select fallback, rendered below the x-small breakpoint: the trigger and its dropdown.
.select-trigger {
@include select.trigger(
$background: palette.$info-surface,
$border-color: palette.$info,
$border-width: 2px,
$border-radius: 12px
);
}

.select-dropdown {
@include dropdown.container(
$background: palette.$surface-alt,
$border-radius: 12px,
$outline-color: palette.$info,
$outline-width: 2px
);
}
54 changes: 54 additions & 0 deletions src/segmented-control/__tests__/style-api.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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 SegmentedControl, { SegmentedControlProps } from '../../../lib/components/segmented-control';
import createWrapper from '../../../lib/components/test-utils/dom';

import styles from '../../../lib/components/segmented-control/styles.css.js';

const options: SegmentedControlProps.Option[] = [
{ id: 'list', text: 'List' },
{ id: 'cards', text: 'Cards', disabled: true },
{ id: 'table', iconName: 'settings', iconAlt: 'Table' },
];

function renderSegmentedControl(styleClassNames: Record<string, unknown>) {
const { container } = render(
<SegmentedControl selectedId="list" options={options} label="View" {...{ styleClassNames }} />
);
return { container, wrapper: createWrapper(container).findSegmentedControl()! };
}

describe('SegmentedControl Style API v2', () => {
test('applies the root, segment and divider slots and forwards select', () => {
const { container, wrapper } = renderSegmentedControl({
root: 'r',
segment: 's',
divider: 'd',
select: { trigger: 't' },
});
expect(container.querySelector(`.${styles['segment-part']}`)).toHaveClass('r');
const segments = wrapper.findSegments();
expect(segments).toHaveLength(3);
for (const segment of segments) {
expect(segment.getElement()).toHaveClass('s');
expect(segment.getElement()).toHaveClass('d');
}
expect(wrapper.findSelectedSegment()!.getElement()).toHaveClass('s');
expect(wrapper.findSegmentById('cards')!.getElement()).toBeDisabled();
expect(wrapper.findSegmentById('cards')!.getElement()).toHaveClass('s');
expect(createWrapper(container).findSelect()!.findTrigger().getElement()).toHaveClass('t');
});

test('ignores unknown slots', () => {
const { container } = renderSegmentedControl({ unknown: 'x' });
expect(container.querySelector('.x')).toBeNull();
});

test('does not leak the styleClassNames prop to the DOM', () => {
const { container } = renderSegmentedControl({ segment: 's' });
expect(container.querySelector('[styleClassNames]')).toBeNull();
});
});
18 changes: 14 additions & 4 deletions src/segmented-control/internal-segmented-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { KeyCode } from '../internal/keycode';
import handleKey from '../internal/utils/handle-key';
import { SegmentedControlProps } from './interfaces';
import { StyleClassNames } from './internal';
import { Segment } from './segment';
import { getSegmentedControlRootStyles } from './style';

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

interface InternalSegmentedControlComponentProps extends SegmentedControlProps {
groupedControlProps?: GroupedControlContextProps;
styleClassNames?: StyleClassNames;
}

export default function InternalSegmentedControl({
Expand All @@ -27,6 +29,7 @@ export default function InternalSegmentedControl({
onChange,
style,
groupedControlProps,
styleClassNames,
}: InternalSegmentedControlComponentProps) {
const segmentByIdRef = useRef<{ [id: string]: HTMLButtonElement }>({});
const selectedOptions = (options || []).filter(option => {
Expand Down Expand Up @@ -61,10 +64,16 @@ export default function InternalSegmentedControl({

return (
<div
className={clsx(styles['segment-part'], styles[`segment-count-${options?.length}`], groupedControlClassNames, {
[styles.refresh]: isVisualRefresh,
[styles['grouped-vertical']]: isGroupedVertical,
})}
className={clsx(
styleClassNames?.root,
styles['segment-part'],
styles[`segment-count-${options?.length}`],
groupedControlClassNames,
{
[styles.refresh]: isVisualRefresh,
[styles['grouped-vertical']]: isGroupedVertical,
}
)}
aria-label={label}
aria-labelledby={ariaLabelledby}
role="toolbar"
Expand Down Expand Up @@ -110,6 +119,7 @@ export default function InternalSegmentedControl({
onKeyDown={event => moveHighlight(event, focusableSegmentIndex)}
style={style}
fullWidth={isGroupedVertical}
styleClassNames={styleClassNames}
/>
);
})}
Expand Down
18 changes: 16 additions & 2 deletions src/segmented-control/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@ import { useGroupedControlContext } from '../internal/context/control-group-cont
import { fireNonCancelableEvent } from '../internal/events';
import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
import InternalSelect, { InternalSelectProps } from '../select/internal';
import InternalSelect, { InternalSelectProps, StyleClassNames as SelectStyleClassNames } from '../select/internal';
import { SegmentedControlProps } from './interfaces';
import InternalSegmentedControlComponent from './internal-segmented-control';

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

type InternalSegmentedControlProps = SegmentedControlProps & InternalBaseComponentProps;
type InternalSegmentedControlProps = SegmentedControlProps &
InternalBaseComponentProps & {
styleClassNames?: StyleClassNames;
};

// Style API v2
export interface StyleClassNames {
root?: string;
segment?: string;
divider?: string;
select?: SelectStyleClassNames;
}

export default function InternalSegmentedControl({
selectedId,
Expand All @@ -25,6 +36,7 @@ export default function InternalSegmentedControl({
onChange,
__internalRootRef,
style,
styleClassNames,
...props
}: InternalSegmentedControlProps) {
const baseProps = getBaseProps(props);
Expand All @@ -45,6 +57,7 @@ export default function InternalSegmentedControl({
selectedOption: currentSelectedOption,
triggerVariant: 'option',
onChange: event => fireNonCancelableEvent(onChange, { selectedId: event.detail.selectedOption.value! }),
styleClassNames: styleClassNames?.select,
};

return (
Expand All @@ -57,6 +70,7 @@ export default function InternalSegmentedControl({
onChange={onChange}
style={style}
groupedControlProps={groupedControlProps}
styleClassNames={styleClassNames}
/>
<div className={clsx(styles.select, getGroupedControlClassNames(styles, groupedControlProps))}>
{ariaLabelledby && <InternalSelect {...selectProps} ariaLabelledby={ariaLabelledby} />}
Expand Down
35 changes: 29 additions & 6 deletions src/segmented-control/segment.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@
@use '../internal/styles/foundation' as foundation;
@use '../internal/generated/custom-css-properties/index.scss' as custom-props;
@use '../internal/styles/utils/theming' as theming;
@use '../internal/styles/style-api' as style-api;
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;

// Style API v2. The root radius is a carrier so the segments can derive their own radius from it; the segment
// focus ring and the divider (the ::after of the segment) read carriers re-anchored on the segment.
$root-tokens: style-api.resolve((border-radius), carrier);
$segment-tokens: style-api.resolve(style-api.$focus-ring, carrier);
$divider-tokens: style-api.resolve((color, width), carrier);
@include style-api.register($segment-tokens, $divider-tokens);

$default-background: var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-segment-default);
$default-color: var(#{custom-props.$styleColorDefault}, awsui.$color-text-segment-default);
$hover-background: var(#{custom-props.$styleBackgroundHover}, awsui.$color-background-segment-hover);
Expand All @@ -23,9 +31,12 @@ $segment-border-width: var(#{$segment-border-width-prop}, 0);
$segment-gap: awsui.$space-static-xxs;
$segment-divider-width: 1px;
$one-theme-segment-gap: calc(#{$segment-gap} - #{$segment-divider-width});
$segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field} + #{awsui.$border-width-button}));
$root-radius: style-api.value($root-tokens, border-radius, awsui.$border-radius-button);
$segment-radius: calc($root-radius - (#{awsui.$border-width-field} + #{awsui.$border-width-button}));

.segment {
@include style-api.carriers($segment-tokens);
@include style-api.carriers($divider-tokens);
@include styles.font-body-m;
@include styles.text-flex-wrapping;
@include styles.font-button;
Expand Down Expand Up @@ -69,13 +80,25 @@ $segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field
}

#{custom-props.$styleFocusRingBoxShadow}: 0 0 0
var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width)
var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused);
style-api.value(
$segment-tokens,
focus-ring-width,
var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width)
)
style-api.value(
$segment-tokens,
focus-ring-color,
var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused)
);

@include focus-visible.when-visible {
@include styles.focus-highlight(
$gutter: awsui.$space-segmented-control-focus-outline-gutter,
$border-radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring),
$border-radius: style-api.value(
$segment-tokens,
focus-ring-radius,
var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring)
),
$box-shadow: var(#{custom-props.$styleFocusRingBoxShadow}),
$z-index: 1 // Prevents adjacent segments from overlapping the focus ring
);
Expand All @@ -87,8 +110,8 @@ $segment-radius: calc(awsui.$border-radius-button - (#{awsui.$border-width-field
inset-inline-end: calc(-1 * ($segment-gap + $segment-divider-width));
block-size: calc(100% - (($segment-gap + awsui.$space-static-xxxs) * 2));
min-block-size: calc(awsui.$line-height-body-m - $segment-gap * 2);
inline-size: $segment-divider-width;
background: awsui.$color-border-segment-divider;
inline-size: style-api.value($divider-tokens, width, $segment-divider-width);
background: style-api.value($divider-tokens, color, awsui.$color-border-segment-divider);
z-index: 1;
}
&:not(.refresh):not(:last-child)::after {
Expand Down
Loading
Loading