diff --git a/pages/copy-to-clipboard/style-api-v2.page.tsx b/pages/copy-to-clipboard/style-api-v2.page.tsx new file mode 100644 index 00000000000..5befec5f477 --- /dev/null +++ b/pages/copy-to-clipboard/style-api-v2.page.tsx @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { CopyToClipboard, KeyValuePairs } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +const styleClassNames = { + copyButton: styles['copy-brand'], + statusPopover: { container: styles['copy-status-popover'], arrow: styles['copy-status-popover-arrow'] }, +}; + +export default function () { + const shared = { + copyErrorText: 'Failed to copy', + copySuccessText: 'Copied', + textToCopy: 'arn:aws:iam::123456789012:role/example', + ...{ styleClassNames }, + }; + return ( + + , + }, + { + type: 'pair', + label: 'Icon variant', + value: , + }, + { + type: 'pair', + label: 'Inline variant', + value: , + }, + ]} + /> + + ); +} diff --git a/pages/copy-to-clipboard/style-api-v2.scss b/pages/copy-to-clipboard/style-api-v2.scss new file mode 100644 index 00000000000..483b1123d53 --- /dev/null +++ b/pages/copy-to-clipboard/style-api-v2.scss @@ -0,0 +1,36 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/button'; +@use '~components/styling/popover'; +@use '../style-api/palette' as palette; + +// The copy button forwards to the button root slot. +.copy-brand { + @include button.root( + $background: palette.$control-bg, + $color: palette.$accent, + $border-color: palette.$border-accent, + $border-radius: 4px, + $focus-ring-color: palette.$accent + ); + + &:hover { + @include button.root($color: palette.$accent-hover, $border-color: palette.$accent-hover); + } + + &:active { + @include button.root($color: palette.$accent-active); + } +} + +// The status popover forwards to the popover container and arrow slots. +.copy-status-popover { + @include popover.container($border-color: palette.$border-accent, $border-radius: 4px); +} + +.copy-status-popover-arrow { + @include popover.arrow($border-color: palette.$border-accent); +} diff --git a/pages/popover/style-api-v2.page.tsx b/pages/popover/style-api-v2.page.tsx new file mode 100644 index 00000000000..95e33764c9b --- /dev/null +++ b/pages/popover/style-api-v2.page.tsx @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import { Checkbox, KeyValuePairs, Popover } from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +type PageContext = React.Context< + AppContextType<{ + renderWithPortal: boolean; + }> +>; + +export default function () { + const { + urlParams: { renderWithPortal = true }, + setUrlParams, + } = useContext(AppContext as PageContext); + const shared = { header: 'Header', content: 'Popover content with a styled surface and arrow.', renderWithPortal }; + return ( + setUrlParams({ renderWithPortal: detail.checked })} + > + renderWithPortal + + } + > + + Open popover + + ), + }, + { + type: 'pair', + label: 'Info', + value: ( + + Open popover + + ), + }, + ]} + /> + + ); +} diff --git a/pages/popover/style-api-v2.scss b/pages/popover/style-api-v2.scss new file mode 100644 index 00000000000..5d11ad8ec0c --- /dev/null +++ b/pages/popover/style-api-v2.scss @@ -0,0 +1,42 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/popover'; +@use '~components/styling/button'; +@use '../style-api/palette' as palette; + +.popover-brand { + @include popover.container( + $background: palette.$genai-surface, + $border-color: palette.$genai, + $border-width: 3px, + $border-radius: 8px + ); +} + +.popover-brand-arrow { + @include popover.arrow($fill-color: palette.$genai-surface, $border-color: palette.$genai, $border-width: 3px); +} + +.popover-borderless { + @include popover.container( + $background: palette.$info-surface, + $border-width: 0px, + $border-radius: 4px, + $box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2) + ); +} + +.popover-borderless-arrow { + @include popover.arrow($fill-color: palette.$info, $border-width: 0px); +} + +.popover-brand-dismiss { + @include button.root($color: palette.$genai, $focus-ring-color: palette.$genai); + + &:hover { + @include button.root($color: palette.$genai-hover); + } +} diff --git a/pages/tooltip/style-api-v2.page.tsx b/pages/tooltip/style-api-v2.page.tsx new file mode 100644 index 00000000000..091aa556211 --- /dev/null +++ b/pages/tooltip/style-api-v2.page.tsx @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import { Button, Tooltip } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +export default function () { + const triggerRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + return ( + +
+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
+
+ ); +} diff --git a/pages/tooltip/style-api-v2.scss b/pages/tooltip/style-api-v2.scss new file mode 100644 index 00000000000..19eedf70ee9 --- /dev/null +++ b/pages/tooltip/style-api-v2.scss @@ -0,0 +1,20 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/tooltip'; +@use '../style-api/palette' as palette; + +.tooltip-info { + @include tooltip.container( + $background: palette.$info-surface, + $border-color: palette.$info-surface, + $border-radius: 8px, + $box-shadow: 0px 4px 20px 1px palette.$info + ); +} + +.tooltip-info-arrow { + @include tooltip.arrow($fill-color: palette.$info-surface, $border-color: palette.$info-surface); +} diff --git a/src/copy-to-clipboard/__tests__/style-api.test.tsx b/src/copy-to-clipboard/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..9255f83b492 --- /dev/null +++ b/src/copy-to-clipboard/__tests__/style-api.test.tsx @@ -0,0 +1,67 @@ +// 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 CopyToClipboard, { CopyToClipboardProps } from '../../../lib/components/copy-to-clipboard'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import popoverStyles from '../../../lib/components/popover/styles.css.js'; + +function renderCopyToClipboard(props: Partial & Record = {}) { + const { container } = render( + + ); + return { container, wrapper: createWrapper(container).findCopyToClipboard()! }; +} + +describe('CopyToClipboard Style API v2', () => { + test.each(['button', 'icon', 'inline'])( + 'applies the copyButton slot (variant=%s)', + variant => { + const { container, wrapper } = renderCopyToClipboard({ + variant, + styleClassNames: { copyButton: 'a', unknown: 'b' }, + }); + expect(wrapper.findCopyButton().getElement()).toHaveClass('a'); + expect(container.querySelector('.b')).toBeNull(); + } + ); + + test('forwards the statusPopover slot to the status popover', () => { + const { container, wrapper } = renderCopyToClipboard({ + styleClassNames: { statusPopover: { container: 'a', arrow: 'b' } }, + }); + wrapper.findCopyButton().click(); + expect(wrapper.findStatusText()).not.toBeNull(); + expect(container.querySelector(`.${popoverStyles['container-body']}`)).toHaveClass('a'); + expect(container.querySelector(`.${popoverStyles.arrow}`)).toHaveClass('b'); + expect(wrapper.findCopyButton().getElement()).not.toHaveClass('a'); + }); + + test('forwards the statusPopover slot when the popover is rendered in a portal', () => { + const { wrapper } = renderCopyToClipboard({ + popoverRenderWithPortal: true, + styleClassNames: { statusPopover: { container: 'a' } }, + }); + wrapper.findCopyButton().click(); + expect(wrapper.findStatusText({ popoverRenderWithPortal: true })).not.toBeNull(); + expect(document.body.querySelector(`.${popoverStyles['container-body']}`)).toHaveClass('a'); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + const { wrapper } = renderCopyToClipboard({ + popoverRenderWithPortal: true, + styleClassNames: { copyButton: 'a', statusPopover: { container: 'b' } }, + }); + wrapper.findCopyButton().click(); + expect(document.body.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/copy-to-clipboard/index.tsx b/src/copy-to-clipboard/index.tsx index dc2a7898125..2d13fb4fde7 100644 --- a/src/copy-to-clipboard/index.tsx +++ b/src/copy-to-clipboard/index.tsx @@ -7,7 +7,7 @@ import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { getExternalProps } from '../internal/utils/external-props'; import { CopyToClipboardProps } from './interfaces'; -import InternalCopyToClipboard from './internal'; +import InternalCopyToClipboard, { StyleClassNames } from './internal'; export { CopyToClipboardProps }; @@ -29,6 +29,7 @@ export default function CopyToClipboard({ wrapText={wrapText} {...baseProps} {...filteredProps} + styleClassNames={(filteredProps as { styleClassNames?: StyleClassNames }).styleClassNames} /> ); } diff --git a/src/copy-to-clipboard/internal.tsx b/src/copy-to-clipboard/internal.tsx index cf222a92eff..7faa9ca90a8 100644 --- a/src/copy-to-clipboard/internal.tsx +++ b/src/copy-to-clipboard/internal.tsx @@ -9,14 +9,22 @@ import { getBaseProps } from '../internal/base-component'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useOneTheme } from '../internal/hooks/use-visual-mode'; -import InternalPopover from '../popover/internal'; +import InternalPopover, { StyleClassNames as PopoverStyleClassNames } from '../popover/internal'; import InternalStatusIndicator from '../status-indicator/internal'; import { CopyToClipboardProps } from './interfaces'; import styles from './styles.css.js'; import testStyles from './test-classes/styles.css.js'; -interface InternalCopyToClipboardProps extends CopyToClipboardProps, InternalBaseComponentProps {} +interface InternalCopyToClipboardProps extends CopyToClipboardProps, InternalBaseComponentProps { + styleClassNames?: StyleClassNames; +} + +// Style API v2 +export interface StyleClassNames { + copyButton?: string; + statusPopover?: PopoverStyleClassNames; +} type CopyStatus = 'success' | 'error'; @@ -72,6 +80,7 @@ export default function InternalCopyToClipboard({ disabledReason, onCopySuccess, onCopyFailure, + styleClassNames, __internalRootRef, ...restProps }: InternalCopyToClipboardProps) { @@ -139,6 +148,7 @@ export default function InternalCopyToClipboard({ formAction="none" disabled={disabled} disabledReason={disabledReason} + styleClassNames={{ root: styleClassNames?.copyButton }} > {copyButtonText} @@ -166,6 +176,7 @@ export default function InternalCopyToClipboard({ dismissButton={false} renderWithPortal={popoverRenderWithPortal} {...oneThemePopoverProps} + styleClassNames={styleClassNames?.statusPopover} content={ isOneTheme ? ( {statusText} diff --git a/src/copy-to-clipboard/styles.scss b/src/copy-to-clipboard/styles.scss index 30887c8d344..fbcdd702dd9 100644 --- a/src/copy-to-clipboard/styles.scss +++ b/src/copy-to-clipboard/styles.scss @@ -5,6 +5,10 @@ @use '../internal/styles/tokens' as awsui; @use './motion'; +@use '../internal/styles/style-api' as style-api; + +@include style-api.docs-forward('copyButton', 'button', 'root'); +@include style-api.docs-forward('statusPopover', 'popover'); .root { display: contents; diff --git a/src/popover/__tests__/style-api.test.tsx b/src/popover/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..e8f817f84e1 --- /dev/null +++ b/src/popover/__tests__/style-api.test.tsx @@ -0,0 +1,41 @@ +// 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 Popover from '../../../lib/components/popover'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/popover/styles.css.js'; + +function renderOpenPopover(props: Record) { + const { container } = render(); + const wrapper = createWrapper(container).findPopover()!; + wrapper.findTrigger().click(); + return wrapper; +} + +describe('Popover Style API v2', () => { + test('applies styleClassNames to the container and arrow slots', () => { + const wrapper = renderOpenPopover({ styleClassNames: { container: 'a', arrow: 'b', unknown: 'c' } }); + expect(wrapper.findByClassName(styles['container-body'])!.getElement()).toHaveClass('a'); + expect(wrapper.findByClassName(styles.arrow)!.getElement()).toHaveClass('b'); + expect(wrapper.find('.c')).toBeNull(); + }); + + test('forwards the dismissButton slot to the dismiss button', () => { + const wrapper = renderOpenPopover({ styleClassNames: { dismissButton: 'a' } }); + expect(wrapper.findDismissButton()!.getElement()).toHaveClass('a'); + }); + + test('applies styleClassNames to the popover container when rendered in a portal', () => { + const wrapper = renderOpenPopover({ renderWithPortal: true, styleClassNames: { container: 'a' } }); + expect(wrapper.findContent({ renderWithPortal: true })).not.toBeNull(); + expect(document.body.querySelector(`.${styles['container-body']}`)).toHaveClass('a'); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + renderOpenPopover({ renderWithPortal: true, styleClassNames: { container: 'a' } }); + expect(document.body.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/popover/arrow.scss b/src/popover/arrow.scss index 15b3f0a42c6..824db9a46f5 100644 --- a/src/popover/arrow.scss +++ b/src/popover/arrow.scss @@ -3,8 +3,11 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +@use '../internal/styles/style-api' as style-api; +@use './style-api' as popover-style-api; .arrow { + @include style-api.carriers(popover-style-api.$arrow-tokens); // Arrow design: // - A wrapper component for positioning. // - A background triangle with border color and box-shadow @@ -64,19 +67,22 @@ &-outer { &::after { - background-color: awsui.$color-border-popover; + background-color: style-api.value(popover-style-api.$arrow-tokens, border-color, awsui.$color-border-popover); } } &-inner { - inset-block-start: calc(awsui.$border-width-popover + 1px); + // The inner triangle is shifted by the border width plus up to 1px so that the uncovered part of the + // outer triangle renders as the border; min() collapses the shift to 0 for a borderless popover. + $border-width: style-api.value(popover-style-api.$arrow-tokens, border-width, awsui.$border-width-popover); + inset-block-start: calc(#{$border-width} + min(#{$border-width}, 1px)); &::after { border-start-start-radius: 1px; border-start-end-radius: 0; border-end-start-radius: 0; border-end-end-radius: 0; - background-color: awsui.$color-background-popover; + background-color: style-api.value(popover-style-api.$arrow-tokens, fill-color, awsui.$color-background-popover); } } diff --git a/src/popover/arrow.tsx b/src/popover/arrow.tsx index 77f628e26c6..3a5c9c01419 100644 --- a/src/popover/arrow.tsx +++ b/src/popover/arrow.tsx @@ -11,12 +11,20 @@ import styles from './styles.css.js'; interface ArrowProps { position: InternalPosition | null; variant?: 'default' | 'info'; + className?: string; } -const Arrow = ({ position, variant }: ArrowProps) => { +const Arrow = ({ position, variant, className }: ArrowProps) => { const isVisualRefresh = useVisualRefresh(); return ( -
+
diff --git a/src/popover/body.tsx b/src/popover/body.tsx index 8bca2d3cf19..69e1c35582b 100644 --- a/src/popover/body.tsx +++ b/src/popover/body.tsx @@ -28,6 +28,7 @@ export interface PopoverBodyProps { overflowVisible?: 'content' | 'both'; className?: string; + dismissButtonClassName?: string; ariaLabelledby?: string; closeAnalyticsAction?: string; @@ -46,6 +47,7 @@ const PopoverBody = React.forwardRef( variant, overflowVisible, className, + dismissButtonClassName, ariaLabelledby, closeAnalyticsAction, }: PopoverBodyProps, @@ -90,6 +92,7 @@ const PopoverBody = React.forwardRef( ariaLabel={i18n('dismissAriaLabel', dismissAriaLabel)} onClick={() => onDismiss?.('close-button')} ref={dismissButtonRef} + styleClassNames={{ root: dismissButtonClassName }} />
); diff --git a/src/popover/container.tsx b/src/popover/container.tsx index 6106749c0bf..00004e44222 100644 --- a/src/popover/container.tsx +++ b/src/popover/container.tsx @@ -48,6 +48,7 @@ interface PopoverContainerProps { hideOnOverscroll?: boolean; hoverArea?: boolean; className?: string; + bodyClassName?: string; } export default function PopoverContainer({ @@ -70,6 +71,7 @@ export default function PopoverContainer({ hideOnOverscroll, hoverArea, className, + bodyClassName, }: PopoverContainerProps) { const bodyRef = useRef(null); const contentRef = useRef(null); @@ -158,7 +160,7 @@ export default function PopoverContainer({
; + styleClassNames?: StyleClassNames; +} + +// Style API v2 +export interface StyleClassNames { + container?: string; + arrow?: string; + dismissButton?: string; } export default React.forwardRef(InternalPopover); @@ -59,6 +67,7 @@ function InternalPopover( __closeAnalyticsAction, __visible: controlledVisible, __onVisibleChange: onVisibleChange, + styleClassNames, ...restProps }: InternalPopoverProps, @@ -177,9 +186,10 @@ function InternalPopover( fixedWidth={fixedWidth} position={position} trackRef={triggerRef} - arrow={position => } + arrow={position => } renderWithPortal={renderWithPortal} zIndex={renderWithPortal ? 7000 : undefined} + bodyClassName={styleClassNames?.container} > diff --git a/src/popover/style-api.scss b/src/popover/style-api.scss new file mode 100644 index 00000000000..a6abb1cd6c5 --- /dev/null +++ b/src/popover/style-api.scss @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@use '../internal/styles/style-api' as style-api; + +// Shared by popover and tooltip. +$arrow-tokens: style-api.resolve((fill-color, border-color, border-width), carrier); +$container-properties: (background, border, border-radius, box-shadow); diff --git a/src/popover/styles.scss b/src/popover/styles.scss index 89a179b17c0..53560507700 100644 --- a/src/popover/styles.scss +++ b/src/popover/styles.scss @@ -7,11 +7,19 @@ @use '../internal/styles/tokens' as awsui; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; +@use '../internal/styles/style-api' as style-api; +@use './style-api' as popover-style-api; + @use './arrow'; @use './body'; @use './container'; @use './motion'; +@include style-api.register(popover-style-api.$arrow-tokens); +@include style-api.docs('container', $properties: popover-style-api.$container-properties); +@include style-api.docs('arrow', popover-style-api.$arrow-tokens); +@include style-api.docs-forward('dismissButton', 'button', 'root'); + $trigger-underline-offset: 0.25em; .root { diff --git a/src/tooltip/__tests__/style-api.test.tsx b/src/tooltip/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..2cf95e1d65a --- /dev/null +++ b/src/tooltip/__tests__/style-api.test.tsx @@ -0,0 +1,28 @@ +// 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 Tooltip from '../../../lib/components/tooltip'; + +import popoverStyles from '../../../lib/components/popover/styles.css.js'; + +function renderTooltip(props: Record) { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + return render( trigger} {...props} />); +} + +describe('Tooltip Style API v2', () => { + test('applies styleClassNames to the container and arrow slots', () => { + renderTooltip({ styleClassNames: { container: 'a', arrow: 'b', unknown: 'c' } }); + expect(document.body.querySelector(`.${popoverStyles['container-body']}`)).toHaveClass('a'); + expect(document.body.querySelector(`.${popoverStyles.arrow}`)).toHaveClass('b'); + expect(document.body.querySelector('.c')).toBeNull(); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + renderTooltip({ styleClassNames: { container: 'a' } }); + expect(document.body.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/tooltip/internal.tsx b/src/tooltip/internal.tsx index 7556612d1dd..5110acd591a 100644 --- a/src/tooltip/internal.tsx +++ b/src/tooltip/internal.tsx @@ -22,8 +22,15 @@ type InternalTooltipComponentProps = InternalTooltipProps & // ID of the associated trigger. When the trigger and the tooltip are connected via referrer ID, the // nodeBelongs(triggerNode, tooltipNode) returns true. referrerId?: string; + styleClassNames?: StyleClassNames; }; +// Style API v2 +interface StyleClassNames { + container?: string; + arrow?: string; +} + export default function InternalTooltip({ content, getTrack, @@ -31,6 +38,7 @@ export default function InternalTooltip({ position = 'top', onEscape, referrerId, + styleClassNames, __internalRootRef, ...restProps }: InternalTooltipComponentProps) { @@ -84,9 +92,10 @@ export default function InternalTooltip({ fixedWidth={false} position={position} zIndex={7000} - arrow={position => } + arrow={position => } hideOnOverscroll={true} className={className} + bodyClassName={styleClassNames?.container} > {content} diff --git a/src/tooltip/styles.scss b/src/tooltip/styles.scss index 65b7808010b..ac2de99873a 100644 --- a/src/tooltip/styles.scss +++ b/src/tooltip/styles.scss @@ -5,6 +5,11 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +@use '../internal/styles/style-api' as style-api; +@use '../popover/style-api' as popover-style-api; + +@include style-api.docs('container', $properties: popover-style-api.$container-properties); +@include style-api.docs('arrow', popover-style-api.$arrow-tokens); .root { @include styles.with-motion {