diff --git a/pages/control-group/common.tsx b/pages/control-group/common.tsx new file mode 100644 index 0000000000..0bcba1b070 --- /dev/null +++ b/pages/control-group/common.tsx @@ -0,0 +1,57 @@ +// 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 { InternalControlGroupProps } from '~components/internal/components/control-group'; +import { MultiselectProps } from '~components/multiselect'; +import RadioGroup from '~components/radio-group'; +import { SelectProps } from '~components/select'; + +import { useAppContext } from '../app/app-context'; + +export const noop = () => { + /* empty handler to suppress controlled-component warnings */ +}; + +export const operators: SelectProps.Option[] = [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, +]; + +export const multiOptions: MultiselectProps.Option[] = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, +]; + +export const enteredTextLabel = (value: string) => `Use: ${value}`; + +export type Direction = NonNullable; + +export function useControlGroupDirection() { + const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>(); // The name `direction` is already taken for the LTR/RTL URL parameter + const direction: Direction = urlParams.controlGroupDirection === 'vertical' ? 'vertical' : 'horizontal'; + const setDirection = (value: Direction) => setUrlParams({ controlGroupDirection: value }); + return { direction, setDirection }; +} + +export function DirectionSettings({ + direction, + setDirection, +}: { + direction: Direction; + setDirection: (value: Direction) => void; +}) { + return ( + + setDirection(detail.value as Direction)} + items={[ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' }, + ]} + /> + + ); +} diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx new file mode 100644 index 0000000000..84117d86f9 --- /dev/null +++ b/pages/control-group/labels.page.tsx @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { SpaceBetween } from '~components'; +import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import Input from '~components/input'; +import ControlGroup from '~components/internal/components/control-group'; +import Multiselect, { MultiselectProps } from '~components/multiselect'; +import Select, { SelectProps } from '~components/select'; + +import { SimplePage } from '../app/templates'; +import FocusTarget from '../common/focus-target'; +import { DirectionSettings, enteredTextLabel, multiOptions, noop, operators, useControlGroupDirection } from './common'; + +const suggestions: AutosuggestProps.Option[] = Array.from({ length: 20 }, (_, i) => ({ + value: `Metric ${i + 1}`, +})); + +// Long option lists so the control groups' open dropdowns extend far enough down to overlap the standalone controls below, +// to test that dropdowns are rendered over labels and not the other way around. +const longOperators: SelectProps.Option[] = Array.from({ length: 20 }, (_, i) => ({ + value: `op-${i + 1}`, + label: `Operator ${i + 1}`, +})); + +const longMultiOptions: MultiselectProps.Option[] = Array.from({ length: 20 }, (_, i) => ({ + value: `${i + 1}`, + label: `Option ${i + 1}`, +})); + +export default function ControlGroupLabels() { + const { direction, setDirection } = useControlGroupDirection(); + + return ( + } + i18n={{}} + screenshotArea={{}} + > + + + + + + + + + + + + + + + + + + + ; const select = ; @@ -105,4 +111,32 @@ describe('Control group', () => { expect(getByTestId('direction')).toHaveTextContent('vertical'); }); }); + + test('renders the inline label and wires it to the group via aria-labelledby', () => { + const { container, getByRole } = render( + + + + + ); + + const group = getByRole('group'); + const label = findControlGroup(container)!.findInlineLabel()!.getElement(); + + expect(label).toHaveTextContent('Threshold'); + expect(label.id).toBeTruthy(); + expect(group.getAttribute('aria-labelledby')).toBe(label.id); + }); + + test('omits the inline label and aria-labelledby when inlineLabelText is not set', () => { + const { container, getByRole } = render( + + + + + ); + + expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); + expect(findControlGroup(container)).toBeNull(); + }); }); diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 1ee3ed8122..ea7bc2a1ed 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 { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + import { BaseComponentProps } from '../../../types/base-component'; import { getBaseProps } from '../../base-component'; import { @@ -17,33 +19,50 @@ import styles from './styles.css.js'; export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; direction?: GroupedControlDirection; + inlineLabelText?: string; } export default function InternalControlGroup({ children, direction = 'horizontal', + inlineLabelText, ...props }: InternalControlGroupProps) { const baseProps = getBaseProps(props); + const labelId = useUniqueId('control-group-label'); const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; + const controls = flattenedChildren.map((child, index) => { + const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; + const position: GroupedControlPosition = + controlCount === 1 ? 'only' : index === 0 ? 'first' : index === controlCount - 1 ? 'last' : 'middle'; + return ( +
+ {child} +
+ ); + }); + + if (inlineLabelText) { + return ( +
+ +
+
+ {controls} +
+
+
+ ); + } + return (
- {flattenedChildren.map((child, index) => { - const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; - const position: GroupedControlPosition = - controlCount === 1 ? 'only' : index === 0 ? 'first' : index === controlCount - 1 ? 'last' : 'middle'; - return ( -
- {child} -
- ); - })} + {controls}
); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 54c7f93695..106fe9f2e4 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -5,6 +5,7 @@ @use '../../styles' as styles; @use '../../styles/tokens' as awsui; +@use '../../styles/forms/mixins' as forms; .root { @include styles.styles-reset; @@ -51,3 +52,25 @@ } } } + +.inline-label { + @include forms.inline-label; + // A grouped Multiselect always renders inline tokens, whose row sits higher + // than normal trigger text, so the group label always clears that row the same + // way the Multiselect's own inline label does. + @include forms.inline-label-inline-tokens; + // Raise the label above the focused slot (which sits at z-index 1) so a + // control's focus ring paints under the label notch instead of over it. + z-index: 2; +} + +.inline-label-wrapper { + @include forms.inline-label-wrapper; + // Contain the label and the control slots in one stacking context so their + // z-indexes resolve against each other (label 2 over focused slot 1). + isolation: isolate; +} + +.inline-label-trigger-wrapper { + @include forms.inline-label-trigger-wrapper; +} diff --git a/src/internal/styles/forms/mixins.scss b/src/internal/styles/forms/mixins.scss index 5bb4af20f0..6eccb24f7d 100644 --- a/src/internal/styles/forms/mixins.scss +++ b/src/internal/styles/forms/mixins.scss @@ -353,6 +353,12 @@ $inline-label-border-radius: 2px; z-index: 1; } +// Adjusts an inline label's vertical position so it does not overlap inline tokens. +@mixin inline-label-inline-tokens { + padding-block-end: 0; + transform: translateY(-1.5px); +} + @mixin inline-label-disabled { background: linear-gradient( to bottom, diff --git a/src/multiselect/__tests__/control-group.test.tsx b/src/multiselect/__tests__/control-group.test.tsx index 06cff025f7..2bc7c9fb35 100644 --- a/src/multiselect/__tests__/control-group.test.tsx +++ b/src/multiselect/__tests__/control-group.test.tsx @@ -42,4 +42,18 @@ describe('Multiselect in control group', () => { expect(inlineTokens[0].findLabel().getElement()).toHaveTextContent('One'); expect(multiselect.findTokens()).toHaveLength(0); }); + + it('renders the dropdown in a portal even if `expandToViewport` is not set', () => { + const { container } = render( + + + + ); + + const multiselect = createWrapper(container).findMultiselect()!; + multiselect.openDropdown(); + + expect(multiselect.findDropdown({ expandToViewport: true }).findOpenDropdown()).not.toBeNull(); + expect(multiselect.findDropdown().findOpenDropdown()).toBeNull(); + }); }); diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index c46589bae7..d72ad0a38b 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -78,10 +78,17 @@ const InternalMultiselect = React.forwardRef( const groupedControlProps = useGroupedControlContext(); const i18n = useInternalI18n('multiselect'); + const isGrouped = !!groupedControlProps.position; + // When rendered inside a control group, tokens are always shown inline in the // trigger regardless of the `inlineTokens` prop, since there is no room to // display tokens below the control within a group. - const showTokensInline = inlineTokens || !!groupedControlProps.position; + const showTokensInline = inlineTokens || isGrouped; + + // When grouped, the dropdown is portaled regardless of the public + // `expandToViewport` prop so it escapes the control slot's stacking context + // and paints over the group's inline label (which the focus ring stays under). + const effectiveExpandToViewport = expandToViewport || isGrouped; const selfControlId = useUniqueId('trigger'); const controlId = formFieldContext.controlId ?? selfControlId; @@ -214,12 +221,12 @@ const InternalMultiselect = React.forwardRef( : undefined } open={multiselectProps.isOpen} - minWidth={getDropdownMinWidth({ expandToViewport, triggerWidth })} + minWidth={getDropdownMinWidth({ expandToViewport: effectiveExpandToViewport, triggerWidth })} maxWidth={getBreakpointValue('xxs')} // AWSUI-19898 trigger={trigger} header={dropdownHeader} footer={dropdownFooter} - expandToViewport={expandToViewport} + expandToViewport={effectiveExpandToViewport} // Forces dropdown position recalculation when new options are loaded contentKey={hasOptions.current.toString()} content={ diff --git a/src/select/__tests__/control-group.test.tsx b/src/select/__tests__/control-group.test.tsx index c0c03fb4f8..b2467173ec 100644 --- a/src/select/__tests__/control-group.test.tsx +++ b/src/select/__tests__/control-group.test.tsx @@ -19,4 +19,16 @@ describe('Select control in control group', () => { expect(getByTestId('probe')).toHaveTextContent('none'); }); + + test('renders the dropdown in a portal even if `expandToViewport` is not set', () => { + const { container } = render( + +