From 344c46b491260d1a3bf3a717859d2d6aa886ba7b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 16 Oct 2024 10:12:22 +0200 Subject: [PATCH 1/5] chore: Add `initialCheck` capability to alert/flash content API --- pages/alert/runtime-content.page.tsx | 11 ++++- src/alert/internal.tsx | 2 +- src/flashbar/flash.tsx | 2 +- .../controllers/alert-flash-content.ts | 44 +++++++++++++++++++ .../helpers/use-discovered-content.tsx | 20 +++++---- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/pages/alert/runtime-content.page.tsx b/pages/alert/runtime-content.page.tsx index 589d2b552b..557393cdbc 100644 --- a/pages/alert/runtime-content.page.tsx +++ b/pages/alert/runtime-content.page.tsx @@ -60,6 +60,13 @@ awsuiPlugins.alertContent.registerContentReplacer({ }, }; }, + initialCheck(context) { + const found = context.type === 'error' && context.contentText?.match('Access denied'); + return { + header: found ? 'remove' : 'original', + content: found ? 'replaced' : 'original', + }; + }, }); const alertTypeOptions = ['error', 'warning', 'info', 'success'].map(type => ({ value: type })); @@ -106,7 +113,7 @@ export default function () { {hidden ? null : ( - Action} > {!contentSwapped ? content1 : content2} - + */} = { type InternalAlertProps = SomeRequired & InternalBaseComponentProps; const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.alert.onActionRegistered); -const useDiscoveredContent = createUseDiscoveredContent('alert', awsuiPluginsInternal.alertContent.onContentRegistered); +const useDiscoveredContent = createUseDiscoveredContent('alert', awsuiPluginsInternal.alertContent); const InternalAlert = React.forwardRef( ( diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index ecd0cca1d5..62dc845cd7 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -38,7 +38,7 @@ const ICON_TYPES = { } as const; const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered); -const useDiscoveredContent = createUseDiscoveredContent('flash', awsuiPluginsInternal.flashContent.onContentRegistered); +const useDiscoveredContent = createUseDiscoveredContent('flash', awsuiPluginsInternal.flashContent); function dismissButton( dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'], diff --git a/src/internal/plugins/controllers/alert-flash-content.ts b/src/internal/plugins/controllers/alert-flash-content.ts index ea9722ece1..a9264d8339 100644 --- a/src/internal/plugins/controllers/alert-flash-content.ts +++ b/src/internal/plugins/controllers/alert-flash-content.ts @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { ReactNode } from 'react'; +import flattenChildren from 'react-keyed-flatten-children'; + import debounce from '../../debounce'; // this code should not depend on React typings, because it is portable between major versions @@ -14,6 +17,18 @@ export interface AlertFlashContentContext { contentRef: RefShim; } +interface AlertFlashContentInitialContextRaw { + type: string; + header?: ReactNode; + content?: ReactNode; +} + +export interface AlertFlashContentInitialContext { + type: string; + headerText?: string; + contentText?: string; +} + export type ReplacementType = 'original' | 'remove' | 'replaced'; export interface ReplacementApi { @@ -30,9 +45,15 @@ export interface AlertFlashContentResult { unmount: (containers: { replacementHeaderContainer: HTMLElement; replacementContentContainer: HTMLElement }) => void; } +export interface AlertFlashContentInitialResult { + header: ReplacementType; + content: ReplacementType; +} + export interface AlertFlashContentConfig { id: string; runReplacer: (context: AlertFlashContentContext, replacementApi: ReplacementApi) => AlertFlashContentResult; + initialCheck?: (context: AlertFlashContentInitialContext) => AlertFlashContentInitialResult; } export type AlertFlashContentRegistrationListener = (provider: AlertFlashContentConfig) => () => void; @@ -44,8 +65,15 @@ export interface AlertFlashContentApiPublic { export interface AlertFlashContentApiInternal { clearRegisteredReplacer(): void; onContentRegistered(listener: AlertFlashContentRegistrationListener): () => void; + initialSyncRender(context: AlertFlashContentInitialContextRaw): AlertFlashContentInitialResult; } +const nodeAsString = (node: ReactNode) => + flattenChildren(node) + .map(node => (typeof node === 'object' ? node.props.children : node)) + .filter(node => typeof node === 'string') + .join(''); + export class AlertFlashContentController { #listeners: Array = []; #cleanups = new Map void>(); @@ -79,6 +107,21 @@ export class AlertFlashContentController { this.#provider = undefined; }; + initialSyncRender = (context: AlertFlashContentInitialContextRaw): AlertFlashContentInitialResult => { + if (this.#provider?.initialCheck) { + const processedContext: AlertFlashContentInitialContext = { + type: context.type, + headerText: nodeAsString(context.header), + contentText: nodeAsString(context.content), + }; + return this.#provider.initialCheck(processedContext); + } + return { + header: 'original', + content: 'original', + }; + }; + onContentRegistered = (listener: AlertFlashContentRegistrationListener) => { if (this.#provider) { const cleanup = listener(this.#provider); @@ -102,6 +145,7 @@ export class AlertFlashContentController { installInternal(internalApi: Partial = {}): AlertFlashContentApiInternal { internalApi.clearRegisteredReplacer ??= this.clearRegisteredReplacer; internalApi.onContentRegistered ??= this.onContentRegistered; + internalApi.initialSyncRender ??= this.initialSyncRender; return internalApi as AlertFlashContentApiInternal; } } diff --git a/src/internal/plugins/helpers/use-discovered-content.tsx b/src/internal/plugins/helpers/use-discovered-content.tsx index 3b46c4d7af..09dd618beb 100644 --- a/src/internal/plugins/helpers/use-discovered-content.tsx +++ b/src/internal/plugins/helpers/use-discovered-content.tsx @@ -3,15 +3,12 @@ import { ReactNode, useEffect, useRef, useState } from 'react'; import { - AlertFlashContentController, + AlertFlashContentApiInternal, AlertFlashContentResult, ReplacementType, } from '../controllers/alert-flash-content'; -export function createUseDiscoveredContent( - componentName: string, - onContentRegistered: AlertFlashContentController['onContentRegistered'] -) { +export function createUseDiscoveredContent(componentName: string, controller: AlertFlashContentApiInternal) { return function useDiscoveredContent({ type, header, @@ -25,14 +22,21 @@ export function createUseDiscoveredContent( const contentRef = useRef(null); const replacementHeaderRef = useRef(null); const replacementContentRef = useRef(null); - const [headerReplacementType, setFoundHeaderReplacement] = useState('original'); - const [contentReplacementType, setFoundContentReplacement] = useState('original'); + const [initialState] = useState(() => + controller.initialSyncRender({ + type, + header, + content: children, + }) + ); + const [headerReplacementType, setFoundHeaderReplacement] = useState(initialState.header); + const [contentReplacementType, setFoundContentReplacement] = useState(initialState.content); const mountedProvider = useRef(); useEffect(() => { const context = { type, headerRef, contentRef }; - return onContentRegistered(provider => { + return controller.onContentRegistered(provider => { let mounted = true; function checkMounted(methodName: string) { From 75e3857e6f0495d8218d17fa155de470b9243f13 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 16 Oct 2024 12:12:09 +0200 Subject: [PATCH 2/5] Hide alerts during initial render --- pages/alert/runtime-content.page.tsx | 30 ++++++++++++------- src/alert/internal.tsx | 7 ++++- src/alert/styles.scss | 8 +++++ .../controllers/alert-flash-content.ts | 18 ++++------- .../helpers/use-discovered-content.tsx | 11 ++++--- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/pages/alert/runtime-content.page.tsx b/pages/alert/runtime-content.page.tsx index 557393cdbc..39da4a09b6 100644 --- a/pages/alert/runtime-content.page.tsx +++ b/pages/alert/runtime-content.page.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useContext, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { @@ -20,7 +20,9 @@ import awsuiPlugins from '~components/internal/plugins'; import AppContext, { AppContextType } from '../app/app-context'; import ScreenshotArea from '../utils/screenshot-area'; -type PageContext = React.Context>; +type PageContext = React.Context< + AppContextType<{ loading: boolean; hidden: boolean; type: AlertProps.Type; autofocus: boolean }> +>; awsuiPlugins.alertContent.registerContentReplacer({ id: 'awsui/alert-test-action', @@ -61,11 +63,7 @@ awsuiPlugins.alertContent.registerContentReplacer({ }; }, initialCheck(context) { - const found = context.type === 'error' && context.contentText?.match('Access denied'); - return { - header: found ? 'remove' : 'original', - content: found ? 'replaced' : 'original', - }; + return context.type === 'error' && !!context.contentText?.match('Access denied'); }, }); @@ -73,7 +71,7 @@ const alertTypeOptions = ['error', 'warning', 'info', 'success'].map(type => ({ export default function () { const { - urlParams: { loading = false, hidden = false, type = 'error' }, + urlParams: { loading = false, hidden = false, type = 'error', autofocus = false }, setUrlParams, } = useContext(AppContext as PageContext); const [unrelatedState, setUnrelatedState] = useState(false); @@ -82,6 +80,14 @@ export default function () { const content1 = useMemo(() => (loading ? Loading... : Content), [loading]); const content2 = loading ? Loading... : There was an error: Access denied because of XYZ; + const alertRef = useRef(null); + + useEffect(() => { + if (autofocus && !hidden) { + alertRef.current?.focus(); + } + }, [autofocus, hidden]); + return (

Alert runtime actions

@@ -99,6 +105,9 @@ export default function () { setContentSwapped(e.detail.checked)} checked={contentSwapped}> Swap content + setUrlParams({ autofocus: e.detail.checked })} checked={autofocus}> + Auto-focus alert +