diff --git a/package.json b/package.json index 0c11c845ce0..48dece8de52 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1460 kB", + "limit": "1470 kB", "ignore": "react-dom" } ], diff --git a/pages/flashbar/style-api-v2.page.tsx b/pages/flashbar/style-api-v2.page.tsx new file mode 100644 index 00000000000..d17def9ee57 --- /dev/null +++ b/pages/flashbar/style-api-v2.page.tsx @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { Flashbar, FlashbarProps } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +const noop = () => void 0; + +// One class per message type through the item callback; the collapsed stack applies it to the placeholders too. +const itemClassName = ({ item }: { item: FlashbarProps.MessageDefinition }) => + item.loading ? styles['item-loading'] : styles[`item-${item.type ?? 'info'}`]; + +const dismissible = { dismissible: true, dismissLabel: 'Dismiss', onDismiss: noop }; + +const itemsByType: FlashbarProps.MessageDefinition[] = [ + { type: 'success', content: 'Instance started.', id: 'success', ...dismissible }, + { type: 'error', content: 'Deployment failed.', id: 'error', ...dismissible }, + { type: 'warning', content: 'Certificate expires soon.', id: 'warning', ...dismissible }, + { type: 'info', content: 'New features available.', id: 'info', ...dismissible }, + { loading: true, content: 'Creating snapshot.', id: 'loading' }, +]; + +export default function () { + return ( + + + + + + ); +} diff --git a/pages/flashbar/style-api-v2.scss b/pages/flashbar/style-api-v2.scss new file mode 100644 index 00000000000..711a220659b --- /dev/null +++ b/pages/flashbar/style-api-v2.scss @@ -0,0 +1,97 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/flashbar'; +@use '~components/styling/button'; +@use '../style-api/palette' as palette; + +// Every item row and the focus-ring tokens in one class. +@mixin item($accent, $background, $color: palette.$text) { + @include flashbar.item( + $color: $color, + $background: $background, + $border-color: $accent, + $border-width: 2px, + $border-style: solid, + $border-radius: 8px, + $box-shadow: 0 4px 12px palette.$subtle-active, + $padding-block: 12px, + $padding-inline: 20px, + $focus-ring-color: $accent, + $focus-ring-width: 3px, + $focus-ring-radius: 10px + ); +} + +.item { + @include item(palette.$genai, palette.$genai-surface, palette.$genai-text); +} + +.icon { + @include flashbar.icon($color: palette.$genai); +} + +.dismiss-button { + @include button.root($color: palette.$genai-text, $focus-ring-color: palette.$genai); + + &:hover, + &:focus { + @include button.root($color: palette.$genai); + } +} + +// One class per message type, resolved through the item callback. +.item-success { + @include item(palette.$success, palette.$success-surface); +} + +.item-error { + @include item(palette.$error, palette.$error-surface); +} + +.item-warning { + @include item(palette.$warning, palette.$warning-surface); +} + +.item-info { + @include item(palette.$info, palette.$info-surface); +} + +.item-loading { + @include item(palette.$text-muted, palette.$surface-alt); +} + +.notification-bar { + @include flashbar.notificationBar( + $color: palette.$genai-text, + $background: palette.$genai-surface, + $border-color: palette.$genai, + $border-width: 2px, + $border-style: solid, + $border-radius: 8px, + $box-shadow: 0 4px 12px palette.$subtle-active, + $padding-block: 6px, + $padding-inline: 24px, + $focus-ring-color: palette.$genai, + $focus-ring-width: 3px, + $focus-ring-radius: 10px + ); + + &:hover { + @include flashbar.notificationBar( + $color: palette.$text-on-accent, + $background: palette.$genai-hover, + $border-color: palette.$genai-hover + ); + } + + &:active { + @include flashbar.notificationBar( + $color: palette.$text-on-accent, + $background: palette.$genai-active, + $border-color: palette.$genai-active + ); + } +} diff --git a/src/flashbar/__tests__/style-api.test.tsx b/src/flashbar/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..0b3ccd8eb59 --- /dev/null +++ b/src/flashbar/__tests__/style-api.test.tsx @@ -0,0 +1,62 @@ +// 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 Flashbar, { FlashbarProps } from '../../../lib/components/flashbar'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/flashbar/styles.css.js'; + +function renderFlashbar(props: FlashbarProps & { styleClassNames?: Record }) { + const { container } = render(); + return { container, wrapper: createWrapper(container).findFlashbar()! }; +} + +const noop = () => void 0; + +describe('Flashbar Style API v2', () => { + test('applies each slot class to its element and ignores unknown keys', () => { + const { container, wrapper } = renderFlashbar({ + items: [ + { type: 'success', content: 'Content', dismissible: true, dismissLabel: 'Dismiss', onDismiss: noop, id: '1' }, + ], + styleClassNames: { item: 'a', icon: 'b', dismissButton: 'c', unknown: 'd' }, + }); + const item = wrapper.findItems()[0]; + expect(wrapper.findItemById('1')!.getElement()).toHaveClass('a'); + expect(item.findByClassName(styles['flash-icon'])!.getElement()).toHaveClass('b'); + expect(item.findDismissButton()!.getElement()).toHaveClass('c'); + expect(container.querySelectorAll('.a, .b, .c')).toHaveLength(3); + expect(container.querySelector('.d')).toBeNull(); + }); + + test('resolves the item callback for the full item and the collapsed placeholders of a stacked flashbar', () => { + const { container, wrapper } = renderFlashbar({ + stackItems: true, + items: [ + { type: 'success', content: 'Content', id: '1' }, + { type: 'error', content: 'Content', id: '2' }, + { type: 'info', content: 'Content', id: '3' }, + ], + styleClassNames: { + item: ({ item }: { item: FlashbarProps.MessageDefinition }) => `item-${item.type}`, + notificationBar: 'bar', + }, + }); + expect(wrapper.findItemById('1')!.getElement()).toHaveClass('item-success'); + const placeholders = container.querySelectorAll('li[aria-hidden="true"]'); + expect(placeholders).toHaveLength(2); + expect(placeholders[0]).toHaveClass('item-error'); + expect(placeholders[1]).toHaveClass('item-info'); + expect(wrapper.findByClassName(styles['notification-bar'])!.getElement()).toHaveClass('bar'); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + const { container } = renderFlashbar({ + items: [{ type: 'info', content: 'Content', id: '1' }], + styleClassNames: { item: 'a' }, + }); + expect(container.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/flashbar/collapsible-flashbar.tsx b/src/flashbar/collapsible-flashbar.tsx index 71e408913ea..ba5425dd974 100644 --- a/src/flashbar/collapsible-flashbar.tsx +++ b/src/flashbar/collapsible-flashbar.tsx @@ -50,7 +50,7 @@ const maxNonCollapsibleItems = 1; const resizeListenerThrottleDelay = 100; -export default function CollapsibleFlashbar({ items, style, ...restProps }: InternalFlashbarProps) { +export default function CollapsibleFlashbar({ items, style, styleClassNames, ...restProps }: InternalFlashbarProps) { const visibleItems = useFlashbarVisibility(items); const [enteringItems, setEnteringItems] = useState>([]); const [exitingItems, setExitingItems] = useState>([]); @@ -228,6 +228,10 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte const getAnimationElementId = (item: StackableItem) => `flash-${getItemId(item)}`; + // Style API v2 + const getItemClass = (item: FlashbarProps.MessageDefinition) => + typeof styleClassNames?.item === 'function' ? styleClassNames.item({ item }) : styleClassNames?.item; + const renderList = () => (
    { if (isFlashbarStackExpanded) { @@ -314,6 +318,11 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte i18nStrings={iconAriaLabels} style={style} onDismissed={handleFlashDismissed} + styleClassNames={{ + item: getItemClass(item), + icon: styleClassNames?.icon, + dismissButton: styleClassNames?.dismissButton, + }} {...item} /> )} @@ -346,6 +355,7 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte {isCollapsible && (
    .notification-bar { + @include style-api.carriers($_bar-tokens); @include styles.text-wrapping; background: var(#{custom-props.$styleBackgroundDefault}, awsui.$color-background-notification-stack-bar); border-color: var(#{custom-props.$styleBorderColorDefault}, awsui.$color-border-notification-stack-bar); @@ -203,7 +217,7 @@ the grid layout will be: > .status > .item-count, > .button { @include typography.default-text-style; - color: awsui.$color-text-notification-stack-bar; + color: inherit; cursor: pointer; margin-block: 0; padding-block: 0; @@ -278,9 +292,6 @@ the grid layout will be: @include theming.one-theme-only { padding-inline: 0; } - #{custom-props.$styleFocusRingBoxShadow}: 0 0 0 - var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width) - var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-notification-stack-bar-focused); > .icon { @include styles.with-motion { @@ -296,13 +307,12 @@ the grid layout will be: } @include focus-visible.when-visible { - @include styles.focus-highlight( - $gutter: 0px, - $border-radius: var( - #{custom-props.$styleFocusRingBorderRadius}, - awsui.$border-radius-control-default-focus-ring - ), - $box-shadow: var(#{custom-props.$styleFocusRingBoxShadow}) + @include style-api.focus-highlight( + $gutter-block: 0px, + $gutter-inline: 0px, + $radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring), + $width: var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width), + $color: var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-notification-stack-bar-focused) ); } } diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index c97eb43cc9c..e4c0ccde159 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -54,7 +54,8 @@ function dismissButton( ref?: React.Ref, id?: string, onDismissed?: (id?: string, persistenceConfig?: FlashbarProps.PersistenceConfig) => void, - persistenceConfig?: FlashbarProps.PersistenceConfig + persistenceConfig?: FlashbarProps.PersistenceConfig, + dismissButtonStyleClass?: string ) { return (
    ); @@ -109,6 +111,13 @@ export function focusFlashById(element: HTMLElement | null, itemId: string) { focusContainer?.focus(); } +// Style API v2 +interface FlashStyleClassNames { + item?: string; + icon?: string; + dismissButton?: string; +} + interface FlashProps extends FlashbarProps.MessageDefinition { className: string; transitionState?: string; @@ -116,6 +125,7 @@ interface FlashProps extends FlashbarProps.MessageDefinition { style?: FlashbarProps.Style; rootRef?: React.Ref; onDismissed?: (id?: string) => void; + styleClassNames?: FlashStyleClassNames; } export const Flash = React.forwardRef( @@ -141,6 +151,7 @@ export const Flash = React.forwardRef( rootRef, onDismissed, persistenceConfig, + styleClassNames, ...props }: FlashProps, ref: React.Ref @@ -213,6 +224,7 @@ export const Flash = React.forwardRef( ref={mergedRef} data-itemid={id} className={clsx( + styleClassNames?.item, styles.flash, styles[`flash-type-${effectiveType}`], className, @@ -243,7 +255,7 @@ export const Flash = React.forwardRef( aria-labelledby={`${flashIconId} ${flashMessageId}`} >
    @@ -298,7 +310,8 @@ export const Flash = React.forwardRef( dismissButtonRefObject, id, onDismissed, - persistenceConfig + persistenceConfig, + styleClassNames?.dismissButton )} {ariaRole === 'status' && ( diff --git a/src/flashbar/internal-interfaces.ts b/src/flashbar/internal-interfaces.ts index 33026d66512..1e077f202f7 100644 --- a/src/flashbar/internal-interfaces.ts +++ b/src/flashbar/internal-interfaces.ts @@ -3,4 +3,13 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { FlashbarProps } from './interfaces'; -export type InternalFlashbarProps = FlashbarProps & InternalBaseComponentProps; +// Style API v2 +export interface FlashbarStyleClassNames { + item?: string | ((props: { item: FlashbarProps.MessageDefinition }) => string); + icon?: string; + dismissButton?: string; + notificationBar?: string; +} + +export type InternalFlashbarProps = FlashbarProps & + InternalBaseComponentProps & { styleClassNames?: FlashbarStyleClassNames }; diff --git a/src/flashbar/non-collapsible-flashbar.tsx b/src/flashbar/non-collapsible-flashbar.tsx index af15293b436..2302d8622cb 100644 --- a/src/flashbar/non-collapsible-flashbar.tsx +++ b/src/flashbar/non-collapsible-flashbar.tsx @@ -19,6 +19,7 @@ import { isRefObject } from './utils'; import styles from './styles.css.js'; export default function NonCollapsibleFlashbar({ items, i18nStrings, style, ...restProps }: InternalFlashbarProps) { + const { styleClassNames } = restProps; const visibleItems = useFlashbarVisibility(items); const { allItemsHaveId, baseProps, isReducedMotion, isVisualRefresh, mergedRef, flashRefs, handleFlashDismissed } = @@ -46,6 +47,10 @@ export default function NonCollapsibleFlashbar({ items, i18nStrings, style, ...r const animateFlash = !isReducedMotion && isVisualRefresh; + // Style API v2 + const getItemClass = (item: FlashbarProps.MessageDefinition) => + typeof styleClassNames?.item === 'function' ? styleClassNames.item({ item }) : styleClassNames?.item; + /** * If the flashbar is flat and motion is `enabled` then the adding and removing of items * from the flashbar will render with visual transitions. @@ -132,6 +137,11 @@ export default function NonCollapsibleFlashbar({ items, i18nStrings, style, ...r i18nStrings={iconAriaLabels} style={style} onDismissed={handleFlashDismissed} + styleClassNames={{ + item: getItemClass(item), + icon: styleClassNames?.icon, + dismissButton: styleClassNames?.dismissButton, + }} {...item} /> ); diff --git a/src/flashbar/styles.scss b/src/flashbar/styles.scss index 788854ea259..b21d274ed73 100644 --- a/src/flashbar/styles.scss +++ b/src/flashbar/styles.scss @@ -8,9 +8,30 @@ @use '../internal/styles' as styles; @use '../internal/styles/typography' as typography; @use '../internal/styles/foundation' as foundation; +@use '../internal/styles/style-api' as style-api; @use './motion'; @use './collapsible'; +// Style API v2. The focus-ring tokens are registered once here, for the item slot and the notificationBar slot +// (collapsible.scss). +$_item-tokens: style-api.resolve(style-api.$focus-ring, carrier); +@include style-api.register($_item-tokens); +@include style-api.docs( + 'item', + $_item-tokens, + $properties: (color, background, border, border-radius, box-shadow, padding), + $descriptions: ( + slot: + 'One flash message: its box and focus ring. Accepts a class name, or a function of `{ item }` that returns one, so each `type` can take its own class.' + ) +); +@include style-api.docs( + 'icon', + $properties: (color), + $descriptions: (slot: 'The status icon, or the spinner for `loading`.') +); +@include style-api.docs-forward('dismissButton', 'button', 'root'); + .flashbar { position: relative; /* stylelint-disable-next-line selector-max-type */ @@ -26,6 +47,7 @@ } .flash { + @include style-api.carriers($_item-tokens); position: relative; display: flex; justify-content: flex-start; @@ -41,10 +63,6 @@ overflow-wrap: break-word; word-wrap: break-word; box-shadow: awsui.$shadow-flash-sticky; - - #{custom-props.$styleFocusRingBoxShadow}: 0 0 0 - var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width) - var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused); } .initial-hidden { @@ -83,10 +101,10 @@ outline: none; } @include focus-visible.when-visible { - @include styles.focus-highlight( - $gutter: awsui.$space-button-focus-outline-gutter, - $border-radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring), - $box-shadow: var(#{custom-props.$styleFocusRingBoxShadow}) + @include style-api.focus-highlight( + $radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring), + $width: var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width), + $color: var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused) ); } }