diff --git a/pages/control-group/action.page.tsx b/pages/control-group/action.page.tsx new file mode 100644 index 0000000000..eb1eabcc46 --- /dev/null +++ b/pages/control-group/action.page.tsx @@ -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; + +const noop = () => { + /* empty handler to suppress controlled-component warnings */ +}; + +const operators: SelectProps.Option[] = [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, +]; + +const input = ; +const select = + + ); + + 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( + + + + ); + + expect(container.querySelector(`.${styles['action-slot']}`)).toBeNull(); + }); + + test('does not count the action as a control for a single-control group', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('probe')).toHaveTextContent('only'); + }); + + test('does not count the action as a control when positions are assigned', () => { + const { getByTestId } = render( + + + + + + ); + + 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( + + + + ); + + 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( + + + + + ); + + 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( + + + + + ); + + 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( diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index aed1187a07..0de6cb5bb4 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -11,10 +11,16 @@ import { GroupedControlContextProps } from '../../context/control-group-context' */ export function getGroupedControlClassNames( styles: Record, - { 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; } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 1ee3ed8122..6d81992f98 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { InternalButton } from '../../../button/internal'; +import { IconProps } from '../../../icon/interfaces'; import { BaseComponentProps } from '../../../types/base-component'; import { getBaseProps } from '../../base-component'; import { @@ -10,6 +12,7 @@ import { GroupedControlDirection, GroupedControlPosition, } from '../../context/control-group-context'; +import { fireNonCancelableEvent, NonCancelableEventHandler } from '../../events'; import { flattenChildren } from '../../utils/flatten-children'; import styles from './styles.css.js'; @@ -17,11 +20,35 @@ import styles from './styles.css.js'; export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; direction?: GroupedControlDirection; + /** + * Specifies an action button rendered next to the grouped controls. + */ + actionButton?: InternalControlGroupProps.ActionButton; +} + +export namespace InternalControlGroupProps { + export interface ActionButton { + /** Called when the user clicks the button. */ + onClick?: NonCancelableEventHandler; + /** Accessible name for the action. */ + ariaLabel?: string; + /** Displays an icon in the action (for example `remove` or `close`). */ + iconName?: IconProps.Name; + /** Alternate text for a custom icon, recommended for accessibility. */ + iconAlt?: string; + /** Disables the action and prevents clicks. */ + disabled?: boolean; + /** Reason the action is disabled (keeps it focusable). */ + disabledReason?: string; + /** Adds `aria-describedby` to point the action at extra descriptive text. */ + ariaDescribedby?: string; + } } export default function InternalControlGroup({ children, direction = 'horizontal', + actionButton, ...props }: InternalControlGroupProps) { const baseProps = getBaseProps(props); @@ -29,8 +56,12 @@ export default function InternalControlGroup({ const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; - return ( -
+ const controls = ( +
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; const position: GroupedControlPosition = @@ -40,10 +71,52 @@ export default function InternalControlGroup({ key={key ? String(key) : index} className={clsx(styles.control, styles[`control-${position}-${direction}`])} > - {child} + {/* + `position` is unchanged whether or not an action is present, so the + action is never counted as a control. `endAction` only tells the + control which facing corners to square so it fuses with the action. + */} + + {child} +
); })}
); + + if (!actionButton) { + return controls; + } + + return ( +
+ {controls} + {/* + The action fuses onto the controls as the trailing element. It reads the + grouped-control context like any other control, so it owns its own border and + corner flattening (see button/styles.scss `.grouped`). The direction is always + `horizontal` for the action: in a vertical group the action sits BESIDE the + stacked column spanning its full height, so relative to the column it is the + trailing element of a horizontal pairing (flat inline-start corners, rounded + inline-end corners top and bottom). `endAction` is omitted (false): the action + rounds its inline-end rather than squaring it. It is NOT counted as a control + and NOT a member of the role="group". + */} +
+ + fireNonCancelableEvent(actionButton.onClick)} + /> + +
+
+ ); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 54c7f93695..5f2e8109aa 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -26,6 +26,67 @@ } } +.layout { + display: flex; + flex-direction: row; + // The controls group and the action always sit side-by-side on the inline axis, + // so in vertical direction the action sits beside the stacked column. + // No column-gap: the action fuses onto the controls as if it were the last + // control, with its inline-start border forming a single seam with the row/column. +} + +.action-slot { + // The action's own root (the Button) owns the fused border and corner radius: it + // reads the grouped-control context and applies the shared corner mixin on itself + // (see button/styles.scss `.grouped`). The slot is just a layout wrapper that lets + // the button fill it so it spans the controls' height (and the full column height in + // vertical direction). Not flex: 1 — the slot sizes to the action, not the row. + display: flex; + // Establish a stacking context and raise the slot on focus so the button's focus + // ring paints over the seam border, mirroring the `.control` treatment below. + isolation: isolate; + &:focus-within { + z-index: 1; + } +} + +.layout-horizontal { + // Line the action's bottom edge up with the controls' field boxes, matching the + // horizontal root's bottom-edge alignment. + align-items: flex-end; + + // Overlap the action onto the control row by one border width so the action's + // inline-start border coincides with the last control's inline-end border as a + // single seam, reusing the control seam-overlap pattern. Scoped under the layout + // with a child combinator (the repo forbids the descendant space combinator). + > .action-slot { + margin-inline-start: calc(-1 * #{awsui.$border-width-field}); + } +} + +.layout-vertical { + // The action spans the full block height of the stacked column, so stretch it to + // match the column's height rather than aligning to a single edge. + align-items: stretch; + + // Seam the action onto the column's inline-end edge (same overlap pattern) and let + // the inner button fill the slot vertically so the border/radius spans the full + // column height. The base rule already gives flat inline-start / rounded inline-end + // corners, which is correct for the full-height action. + > .action-slot { + margin-inline-start: calc(-1 * #{awsui.$border-width-field}); + align-items: stretch; + } +} + +// The stacked column's inline-end corners (and the horizontal strip's trailing +// control corners) are squared where they meet the action by the shared +// grouped-control mixin via the `endAction` context flag, NOT from here — the mixin +// runs on each control's own painted element, so it reaches the visible corner for +// every control type (Input, Select, Multiselect, Autosuggest, SegmentedControl, +// Button-trigger) without this component targeting control-internal DOM. See +// src/internal/styles/forms/mixins.scss (`grouped-control` -> `-end-action`). + .control { // Let slots shrink below their content so a narrow group compresses, not overflows. min-inline-size: 0; diff --git a/src/internal/context/control-group-context.tsx b/src/internal/context/control-group-context.tsx index f06b78421f..ac1855c576 100644 --- a/src/internal/context/control-group-context.tsx +++ b/src/internal/context/control-group-context.tsx @@ -17,11 +17,22 @@ export interface GroupedControlContextProps { * controls. */ direction: GroupedControlDirection; + /** + * Whether an action is fused onto the group's inline-end (spanning the full + * block height in vertical direction). Controls read this to square the + * inline-end corners that face the action so the column's trailing edge reads + * flat and the action owns the only rounded inline-end corners. + * + * This does NOT change `position`, so the action is still not counted as a + * control; it only tells a control which facing corners to square. + */ + endAction?: boolean; } export const GroupedControlContext = createContext({ position: null, direction: 'horizontal', + endAction: false, }); export function useGroupedControlContext() { diff --git a/src/internal/styles/forms/mixins.scss b/src/internal/styles/forms/mixins.scss index 5bb4af20f0..ff7e441653 100644 --- a/src/internal/styles/forms/mixins.scss +++ b/src/internal/styles/forms/mixins.scss @@ -402,4 +402,32 @@ $inline-label-border-radius: 2px; border-start-end-radius: 0; } } + + // When an action is fused onto the group's inline-end, the controls' corners that + // face the action become interior to the fused strip and must be squared so the + // action owns the only rounded inline-end corners. These rules run on the SAME + // element the base grouped-control radii paint on (each control applies this mixin + // to its own painted element), so they reach the visible corner for every control + // type without any component reaching into another's DOM. + &-end-action { + // Horizontal: only the trailing control (last, or the single `only`) sits against + // the action; first/middle already have their inline-end corners squared. + &-horizontal { + &-last, + &-only { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + // Vertical: the action spans the full block height on the inline-end, so every + // control's inline-end corners face it. (middle controls are already fully squared.) + &-vertical { + &-first, + &-last, + &-only { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + } }