Skip to content
Draft
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
74 changes: 74 additions & 0 deletions pages/control-group/action.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import FormField from '~components/form-field';
import Input from '~components/input';
import ControlGroup, { InternalControlGroupProps } from '~components/internal/components/control-group';
import RadioGroup from '~components/radio-group';
import Select, { SelectProps } from '~components/select';

import { useAppContext } from '../app/app-context';
import { PermutationsPage } from '../app/templates';
import createPermutations from '../utils/permutations';
import PermutationsView from '../utils/permutations-view';

type Direction = NonNullable<InternalControlGroupProps['direction']>;

const noop = () => {
/* empty handler to suppress controlled-component warnings */
};

const operators: SelectProps.Option[] = [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
];

const input = <Input ariaLabel="Value" value="service" onChange={noop} />;
const select = <Select ariaLabel="Operator" selectedOption={operators[0]} options={operators} onChange={noop} />;

const permutations = createPermutations<InternalControlGroupProps>([
{
children: [
input,
<>
{input}
{select}
</>,
],
actionButton: [
{ iconName: 'remove', ariaLabel: 'Remove' },
{ iconName: 'close', ariaLabel: 'Close' },
],
},
]);

export default function ControlGroupActionPermutations() {
// The name `direction` is already taken for the LTR/RTL URL parameter
const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>();
const direction: Direction = urlParams.controlGroupDirection === 'vertical' ? 'vertical' : 'horizontal';

return (
<PermutationsPage
title="Control group action slot"
i18n={{}}
settings={
<FormField label="Direction">
<RadioGroup
value={direction}
onChange={({ detail }) => setUrlParams({ controlGroupDirection: detail.value as Direction })}
items={[
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
]}
/>
</FormField>
}
>
<PermutationsView
permutations={permutations}
render={permutation => <ControlGroup {...permutation} direction={direction} />}
/>
</PermutationsPage>
);
}
32 changes: 23 additions & 9 deletions src/button/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
getSubStepAllSelector,
getTextFromSelector,
} from '../internal/analytics/selectors';
import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles';
import { useButtonContext } from '../internal/context/button-context';
import { useGroupedControlContext } from '../internal/context/control-group-context';
import { fireCancelableEvent, isPlainLeftClick } from '../internal/events';
import useForwardFocus from '../internal/hooks/forward-focus';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
Expand Down Expand Up @@ -145,6 +147,11 @@ export const InternalButton = React.forwardRef(
useForwardFocus(ref, buttonRef);

const buttonContext = useButtonContext();
// Read the grouped-control context so a button used as a control group `action`
// fuses onto the controls like the other grouped controls (Input, Select, …):
// the shared corner mixin flattens the facing corners on the button's own root,
// where its border + radius are actually painted.
const groupedControlProps = useGroupedControlContext();
const i18n = useInternalI18n('button');

const uniqueId = useUniqueId('button');
Expand Down Expand Up @@ -199,15 +206,22 @@ export const InternalButton = React.forwardRef(
};

const stylingClassName = styleClassNames?.root;
const buttonClass = clsx(props.className, stylingClassName, styles.button, styles[`variant-${variant}`], {
[styles.disabled]: isNotInteractive,
[styles['disabled-with-reason']]: isDisabledWithReason,
[styles['button-no-wrap']]: !wrapText,
[styles['button-no-text']]: !shouldHaveContent,
[styles['full-width']]: shouldHaveContent && fullWidth,
[styles['button-compact']]: __compact,
[styles.link]: isAnchor,
});
const buttonClass = clsx(
props.className,
stylingClassName,
styles.button,
styles[`variant-${variant}`],
...getGroupedControlClassNames(styles, groupedControlProps),
{
[styles.disabled]: isNotInteractive,
[styles['disabled-with-reason']]: isDisabledWithReason,
[styles['button-no-wrap']]: !wrapText,
[styles['button-no-text']]: !shouldHaveContent,
[styles['full-width']]: shouldHaveContent && fullWidth,
[styles['button-compact']]: __compact,
[styles.link]: isAnchor,
}
);

const explicitTabIndex = nativeButtonAttributes?.tabIndex ?? nativeAnchorAttributes?.tabIndex;
const { tabIndex } = useSingleTabStopNavigation(buttonRef, {
Expand Down
51 changes: 51 additions & 0 deletions src/button/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,57 @@ $tokens: style-api.resolve($props, carrier);
}
}

// Fuse the button onto a surrounding control group's controls when it is used as the
// group `action`. The button reads the grouped-control context like the other grouped
// controls (Input, Select, …) and applies the shared corner mixin to its OWN root,
// where the border and radius are painted — so the facing corners flatten correctly
// regardless of variant.
&.grouped {
// The seam overlap (negative inline-start margin) lives on the control-group
// `.action-slot`, mirroring how the other controls get their seam from the
// control-group slot rather than from the control itself.

// Reproduce the control field's block-height recipe so the fused action lines up
// with its neighbours under the row's bottom-edge alignment (`align-items:
// flex-end` on the control-group root), where a shorter box would leave the top
// border a couple pixels low — the offset the icon action originally showed.
// The field box (input/styles.scss `.input`) is, under `box-sizing: border-box`:
// field = max($size-vertical-input, lineHeight + 2·$space-field-vertical + 2·$border-width-field)
// The button shares the same `border-box` reset and the same `font-body-m`
// line-height (styles-reset; `font-button` only adds weight), so adopting the
// field's own padding and floor tokens below makes the two recipes share every
// term EXCEPT the border width. For the icon variant the border is recolored to
// `$border-width-field` (see `&.variant-icon`), so there every term matches the
// field token-for-token and the border-box heights are provably equal, not merely
// 32px by coincidence. normal/primary keep `$border-width-button` per spec, so
// their height matches only while `$border-width-button == $border-width-field`
// (true in the default theme); that single-token divergence is intentional.
padding-block: awsui.$space-field-vertical;
min-block-size: awsui.$size-vertical-input;

// A fused text (normal/primary) action must stay on a single line so the strip
// keeps the control row's height; override the base `.button` `white-space: normal`
// (set by styles-reset). Standalone buttons (no `.grouped`) keep wrapping.
white-space: nowrap;

@include styles.grouped-control;

// The icon variant is visually borderless (transparent border). As a fused control
// it needs the SAME gray control border the other controls draw, so the fused strip
// reads as one continuous outline. Both tokens are inside the border-box, so this
// recolor keeps the height recipe above intact. Normal/primary keep their own border.
&.variant-icon {
border-block: awsui.$border-width-field solid awsui.$color-border-input-default;
border-inline: awsui.$border-width-field solid awsui.$color-border-input-default;

// Stock icon-only inline padding ($space-xxs) looks cramped beside the roomier
// control fields; use the controls' own horizontal field padding so the icon cell
// matches its neighbor. Scoped to grouped+icon, so standalone icon buttons keep
// $space-xxs. Inline-only, so the height recipe above is unaffected.
padding-inline: awsui.$space-field-horizontal;
}
}

&:focus {
outline: none;
text-decoration: none;
Expand Down
5 changes: 5 additions & 0 deletions src/internal/components/control-group/__tests__/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ export function DirectionProbe({ testId = 'direction-probe' }: { testId?: string
const { direction } = useGroupedControlContext();
return <span data-testid={testId}>{direction}</span>;
}

export function EndActionProbe({ testId = 'end-action-probe' }: { testId?: string }) {
const { endAction } = useGroupedControlContext();
return <span data-testid={testId}>{endAction ? 'yes' : 'no'}</span>;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// 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 { fireEvent, render } from '@testing-library/react';

import ControlGroup from '../../../../../lib/components/internal/components/control-group';
import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context';
import { DirectionProbe, PositionProbe } from './common';
import { DirectionProbe, EndActionProbe, PositionProbe } from './common';

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

describe('Control group', () => {
test('keeps focus on a control when the children are reordered', () => {
Expand Down Expand Up @@ -66,6 +68,90 @@ describe('Control group', () => {
});
});

describe('actionButton', () => {
test('renders a button inside an action-slot element when actionButton is provided', () => {
const { container, getByRole } = render(
<ControlGroup actionButton={{ iconName: 'remove', ariaLabel: 'Remove' }}>
<input data-testid="control" />
</ControlGroup>
);

const actionSlot = container.querySelector(`.${styles['action-slot']}`);
expect(actionSlot).not.toBeNull();
expect(actionSlot).toContainElement(getByRole('button', { name: 'Remove' }));
});

test('renders no action-slot element when actionButton is absent', () => {
const { container } = render(
<ControlGroup>
<input data-testid="control" />
</ControlGroup>
);

expect(container.querySelector(`.${styles['action-slot']}`)).toBeNull();
});

test('does not count the action as a control for a single-control group', () => {
const { getByTestId } = render(
<ControlGroup actionButton={{ iconName: 'remove', ariaLabel: 'Remove' }}>
<PositionProbe />
</ControlGroup>
);

expect(getByTestId('probe')).toHaveTextContent('only');
});

test('does not count the action as a control when positions are assigned', () => {
const { getByTestId } = render(
<ControlGroup actionButton={{ iconName: 'remove', ariaLabel: 'Remove' }}>
<PositionProbe testId="a" />
<PositionProbe testId="b" />
<PositionProbe testId="c" />
</ControlGroup>
);

expect(getByTestId('a')).toHaveTextContent('first');
expect(getByTestId('b')).toHaveTextContent('middle');
expect(getByTestId('c')).toHaveTextContent('last');
});

test('calls onClick when the action button is clicked', () => {
const onClick = jest.fn();
const { getByRole } = render(
<ControlGroup actionButton={{ iconName: 'remove', ariaLabel: 'Remove', onClick }}>
<input data-testid="control" />
</ControlGroup>
);

fireEvent.click(getByRole('button', { name: 'Remove' }));
expect(onClick).toHaveBeenCalledTimes(1);
});

test('signals endAction to the controls so they square the corners facing the action', () => {
const { getByTestId } = render(
<ControlGroup actionButton={{ iconName: 'remove', ariaLabel: 'Remove' }}>
<EndActionProbe testId="a" />
<EndActionProbe testId="b" />
</ControlGroup>
);

expect(getByTestId('a')).toHaveTextContent('yes');
expect(getByTestId('b')).toHaveTextContent('yes');
});

test('does not signal endAction to the controls when no action is present', () => {
const { getByTestId } = render(
<ControlGroup>
<EndActionProbe testId="a" />
<EndActionProbe testId="b" />
</ControlGroup>
);

expect(getByTestId('a')).toHaveTextContent('no');
expect(getByTestId('b')).toHaveTextContent('no');
});
});

describe('direction', () => {
test('defaults the direction to "horizontal" and exposes it to each child', () => {
const { getByTestId } = render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import { GroupedControlContextProps } from '../../context/control-group-context'
*/
export function getGroupedControlClassNames(
styles: Record<string, string>,
{ position, direction }: GroupedControlContextProps
{ position, direction, endAction }: GroupedControlContextProps
): string[] {
if (!position) {
return [];
}
return [styles.grouped, styles[`grouped-${direction}-${position}`]];
const classNames = [styles.grouped, styles[`grouped-${direction}-${position}`]];
// When an action is fused onto the group's inline-end, square the control's corners
// that face the action (see the grouped-control `-end-action` rules in the mixin).
if (endAction) {
classNames.push(styles[`grouped-end-action-${direction}-${position}`]);
}
return classNames;
}
Loading
Loading