diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 1fca41033c..d26dfc0f61 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -15,7 +15,7 @@ import { useSelectionFocusMove, useSelection, } from '../table/selection'; -import InternalContainer from '../container/internal'; +import { InternalContainerAsSubstep } from '../container/internal'; import InternalStatusIndicator from '../status-indicator/internal'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import stickyScrolling from '../table/sticky-scrolling'; @@ -139,7 +139,7 @@ const Cards = React.forwardRef(function (
- ( /> )}
- +
diff --git a/src/container/__tests__/analytics.test.tsx b/src/container/__tests__/analytics.test.tsx index c5d2c15a38..34dd37b768 100644 --- a/src/container/__tests__/analytics.test.tsx +++ b/src/container/__tests__/analytics.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { render, act } from '@testing-library/react'; import Container from '../../../lib/components/container'; +import Modal from '../../../lib/components/modal'; import { FunnelMetrics } from '../../../lib/components/internal/analytics'; import { DATA_ATTR_FUNNEL_SUBSTEP } from '../../../lib/components/internal/analytics/selectors'; @@ -118,6 +119,34 @@ describe('Funnel Analytics', () => { expect(FunnelMetrics.funnelSubStepComplete).not.toHaveBeenCalled(); }); + test('Modal containers do not send their own events', async () => { + const { getByTestId } = render( + + + + + + + + + + + + ); + act(() => void jest.runAllTimers()); + + expect(FunnelMetrics.funnelSubStepStart).not.toHaveBeenCalled(); + + getByTestId('input-one').focus(); + getByTestId('input-two').focus(); + getByTestId('input-one').focus(); + + await runPendingPromises(); + + expect(FunnelMetrics.funnelSubStepStart).toHaveBeenCalledTimes(1); + expect(FunnelMetrics.funnelSubStepComplete).not.toHaveBeenCalled(); + }); + test('sibling containers send their own events', async () => { const { getByTestId } = render( diff --git a/src/container/index.tsx b/src/container/index.tsx index 2235c80157..b3e363c503 100644 --- a/src/container/index.tsx +++ b/src/container/index.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import InternalContainer from './internal'; +import { InternalContainerAsSubstep } from './internal'; import { ContainerProps } from './interfaces'; import { getExternalProps } from '../internal/utils/external-props'; import { applyDisplayName } from '../internal/utils/apply-display-name'; @@ -18,12 +18,14 @@ export default function Container({ }: ContainerProps) { const baseComponentProps = useBaseComponent('Container'); const externalProps = getExternalProps(props); + return ( - diff --git a/src/container/internal.tsx b/src/container/internal.tsx index 0a2b6bd63c..2921ec14dd 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -31,6 +31,14 @@ export interface InternalContainerProps extends Omit, * * `full-page` – Only for internal use in table, cards and other components */ variant?: ContainerProps['variant'] | 'embedded' | 'full-page' | 'cards'; + + __funnelSubStepProps?: ReturnType['funnelSubStepProps']; + __subStepRef?: ReturnType['subStepRef']; +} + +export function InternalContainerAsSubstep(props: InternalContainerProps) { + const { subStepRef, funnelSubStepProps } = useFunnelSubStep(); + return ; } export default function InternalContainer({ @@ -52,6 +60,8 @@ export default function InternalContainer({ __headerRef, __darkHeader = false, __disableStickyMobile = true, + __funnelSubStepProps, + __subStepRef, ...restProps }: InternalContainerProps) { const isMobile = useMobile(); @@ -68,12 +78,11 @@ export default function InternalContainer({ ); const { setHasStickyBackground } = useAppLayoutContext(); const isRefresh = useVisualRefresh(); - const { subStepRef, funnelSubStepProps } = useFunnelSubStep(); const hasDynamicHeight = isRefresh && variant === 'full-page'; const overlapElement = useDynamicOverlap({ disabled: !hasDynamicHeight || !__darkHeader }); - const mergedRef = useMergeRefs(rootRef, subStepRef, __internalRootRef); + const mergedRef = useMergeRefs(rootRef, __subStepRef, __internalRootRef); const headerMergedRef = useMergeRefs(headerRef, overlapElement, __headerRef); /** @@ -104,7 +113,7 @@ export default function InternalContainer({ return (
{ if (variant === 'container' || variant === 'stacked') { @@ -35,6 +39,8 @@ export const ExpandableSectionContainer = ({ disableHeaderPaddings={true} __hiddenContent={!expanded} __internalRootRef={__internalRootRef} + __funnelSubStepProps={__funnelSubStepProps} + __subStepRef={__subStepRef} > {children} diff --git a/src/expandable-section/index.tsx b/src/expandable-section/index.tsx index 38dddeb2d2..19d1ec58fe 100644 --- a/src/expandable-section/index.tsx +++ b/src/expandable-section/index.tsx @@ -4,22 +4,31 @@ import React from 'react'; import { ExpandableSectionProps } from './interfaces'; import { applyDisplayName } from '../internal/utils/apply-display-name'; -import InternalExpandableSection from './internal'; +import InternalExpandableSection, { InternalExpandableSectionProps } from './internal'; import useBaseComponent from '../internal/hooks/use-base-component'; import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel'; +import { useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; export { ExpandableSectionProps }; export default function ExpandableSection({ variant = 'default', ...props }: ExpandableSectionProps) { const baseComponentProps = useBaseComponent('ExpandableSection'); - const expandableSection = ; - if (variant === 'container' || variant === 'stacked') { - return {expandableSection}; + return ( + + + + ); } else { - return expandableSection; + return ; } } +function InternalExpandableSectionAsSubstep(props: InternalExpandableSectionProps) { + const { subStepRef, funnelSubStepProps } = useFunnelSubStep(); + + return ; +} + applyDisplayName(ExpandableSection, 'ExpandableSection'); diff --git a/src/expandable-section/internal.tsx b/src/expandable-section/internal.tsx index 353780fcae..34ac689202 100644 --- a/src/expandable-section/internal.tsx +++ b/src/expandable-section/internal.tsx @@ -13,12 +13,14 @@ import { fireNonCancelableEvent } from '../internal/events'; import { ExpandableSectionProps } from './interfaces'; import styles from './styles.css.js'; -import { ExpandableSectionContainer } from './expandable-section-container'; +import { ExpandableSectionContainer, ExpandableSectionContainerProps } from './expandable-section-container'; import { ExpandableSectionHeader } from './expandable-section-header'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { variantSupportsDescription } from './utils'; -type InternalExpandableSectionProps = ExpandableSectionProps & InternalBaseComponentProps; +export type InternalExpandableSectionProps = ExpandableSectionProps & + InternalBaseComponentProps & + Pick; export default function InternalExpandableSection({ expanded: controlledExpanded, @@ -36,6 +38,8 @@ export default function InternalExpandableSection({ disableContentPaddings, headerAriaLabel, __internalRootRef, + __funnelSubStepProps, + __subStepRef, ...props }: InternalExpandableSectionProps) { const ref = useRef(null); @@ -98,6 +102,8 @@ export default function InternalExpandableSection({ expanded={expanded} className={clsx(baseProps.className, styles.root)} variant={variant} + __funnelSubStepProps={__funnelSubStepProps} + __subStepRef={__subStepRef} disableContentPaddings={disableContentPaddings} header={ { Step Content - - - - + Container} /> + Cards} items={[]} cardDefinition={{}} /> +
Table} items={[]} columnDefinitions={[]} /> + + @@ -346,19 +349,19 @@ describe('AnalyticsFunnelStep', () => { totalSubSteps: 4, subStepConfiguration: [ { - name: '', + name: 'Container', number: 1, }, { - name: '', + name: 'Cards', number: 2, }, { - name: '', + name: 'Table', number: 3, }, { - name: '', + name: 'ExpandableSection', number: 4, }, ], @@ -434,6 +437,23 @@ describe('AnalyticsFunnelStep', () => { ); }); + test('does not treat Modals as their own substep', () => { + render( + + + + + + ); + act(() => void jest.runAllTimers()); + + expect(FunnelMetrics.funnelStepStart).toHaveBeenCalledWith( + expect.objectContaining({ + totalSubSteps: 0, + }) + ); + }); + test('does not call funnelStepComplete when the funnel unmounts without submitting', () => { const stepNumber = 1; const stepNameSelector = '.step-name-selector'; diff --git a/src/internal/analytics/components/analytics-funnel.tsx b/src/internal/analytics/components/analytics-funnel.tsx index a6a268fbc6..f612008782 100644 --- a/src/internal/analytics/components/analytics-funnel.tsx +++ b/src/internal/analytics/components/analytics-funnel.tsx @@ -29,6 +29,7 @@ import { getSubStepSelector, } from '../selectors'; import { useDebounceCallback } from '../../hooks/use-debounce-callback'; +import { nodeBelongs } from '../../utils/node-belongs'; export const FUNNEL_VERSION = '1.2'; @@ -369,7 +370,7 @@ const InnerAnalyticsFunnelStep = ({ children, stepNumber, stepNameSelector }: An ); }; interface AnalyticsFunnelSubStepProps { - children?: React.ReactNode; + children?: React.ReactNode | ((props: FunnelSubStepContextValue) => React.ReactNode); } export const AnalyticsFunnelSubStep = ({ children }: AnalyticsFunnelSubStepProps) => { @@ -415,6 +416,10 @@ export const AnalyticsFunnelSubStep = ({ children }: AnalyticsFunnelSubStepProps const context = isNested ? inheritedContext : newContext; useEffect(() => { + if (isNested || !subStepRef.current) { + return; + } + const onMouseDown = () => (mousePressed.current = true); const onMouseUp = async () => { @@ -432,7 +437,7 @@ export const AnalyticsFunnelSubStep = ({ children }: AnalyticsFunnelSubStepProps */ await new Promise(r => setTimeout(r, 1)); - if (!subStepRef.current || !subStepRef.current.contains(document.activeElement)) { + if (!subStepRef.current || !document.activeElement || !nodeBelongs(subStepRef.current, document.activeElement)) { isFocusedSubStep.current = false; /* @@ -456,7 +461,13 @@ export const AnalyticsFunnelSubStep = ({ children }: AnalyticsFunnelSubStepProps subStepNameSelector, subStepSelector, focusCleanupFunction, + isNested, + subStepRef, ]); - return {children}; + return ( + + {typeof children === 'function' ? children(context) : children} + + ); }; diff --git a/src/internal/analytics/hooks/use-funnel.ts b/src/internal/analytics/hooks/use-funnel.ts index 442c8b06ce..3972437f47 100644 --- a/src/internal/analytics/hooks/use-funnel.ts +++ b/src/internal/analytics/hooks/use-funnel.ts @@ -10,6 +10,7 @@ import { getSubStepAllSelector, } from '../selectors'; import { FunnelMetrics } from '../'; +import { nodeBelongs } from '../../utils/node-belongs'; /** * Custom React Hook to manage and interact with FunnelSubStep. @@ -121,7 +122,7 @@ export const useFunnelSubStep = () => { return; } - if (!subStepRef.current || !subStepRef.current.contains(event.relatedTarget) || !event.relatedTarget) { + if (!subStepRef.current || !event.relatedTarget || !nodeBelongs(subStepRef.current, event.relatedTarget)) { isFocusedSubStep.current = false; if (funnelInteractionId && subStepId && funnelState.current !== 'cancelled') { diff --git a/src/modal/internal.tsx b/src/modal/internal.tsx index 4cda58cc65..e67f19ffc5 100644 --- a/src/modal/internal.tsx +++ b/src/modal/internal.tsx @@ -29,10 +29,14 @@ import { ButtonContext } from '../internal/context/button-context'; type InternalModalProps = SomeRequired & InternalBaseComponentProps; export default function InternalModal({ modalRoot, ...rest }: InternalModalProps) { + const referrerId = useUniqueId('modal'); + return ( - - - +
+ + + +
); } @@ -47,8 +51,9 @@ function InnerModal({ disableContentPaddings, onDismiss, __internalRootRef = null, + referrerId, ...rest -}: InternalModalProps) { +}: InternalModalProps & { referrerId: string }) { const instanceUniqueId = useUniqueId(); const headerId = `${rest.id || instanceUniqueId}-header`; const lastMouseDownElementRef = useRef(null); @@ -128,6 +133,7 @@ function InnerModal({ onClick={onOverlayClick} ref={mergedRef} style={footerHeight ? { scrollPaddingBottom: footerHeight } : undefined} + data-awsui-referrer-id={referrerId} >
{ const baseComponentProps = useBaseComponent('Table'); - const table = ( - - ); + const tableProps: Parameters>[0] = { + items, + selectedItems, + variant, + contentDensity, + ...props, + ...baseComponentProps, + ref, + }; + if (variant === 'borderless' || variant === 'embedded') { - return table; + return ; } - return {table}; + return ( + + + + ); } ) as TableForwardRefType; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf8e644dc6..6e9dd32e57 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import React, { useCallback, useImperativeHandle, useRef } from 'react'; import { TableForwardRefType, TableProps } from './interfaces'; import { getVisualContextClassname } from '../internal/components/visual-context'; -import InternalContainer from '../container/internal'; +import InternalContainer, { InternalContainerProps } from '../container/internal'; import { getBaseProps } from '../internal/base-component'; import ToolsHeader from './tools-header'; import Thead, { TheadProps } from './thead'; @@ -39,12 +39,30 @@ import { getTableRoleProps, getTableRowRoleProps, getTableWrapperRoleProps } fro import { useCellEditing } from './use-cell-editing'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import { CollectionLabelContext } from '../internal/context/collection-label-context'; +import { useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; const SELECTION_COLUMN_WIDTH = 54; const selectionColumnId = Symbol('selection-column-id'); type InternalTableProps = SomeRequired, 'items' | 'selectedItems' | 'variant'> & - InternalBaseComponentProps; + InternalBaseComponentProps & { + __funnelSubStepProps?: InternalContainerProps['__funnelSubStepProps']; + __subStepRef?: InternalContainerProps['__subStepRef']; + }; + +export const InternalTableAsSubstep = React.forwardRef( + (props: InternalTableProps, ref: React.Ref) => { + const { subStepRef, funnelSubStepProps } = useFunnelSubStep(); + + const tableProps: InternalTableProps = { + ...props, + __subStepRef: subStepRef, + __funnelSubStepProps: funnelSubStepProps, + }; + + return ; + } +) as TableForwardRefType; const InternalTable = React.forwardRef( ( @@ -88,6 +106,8 @@ const InternalTable = React.forwardRef( renderAriaLive, stickyColumns, columnDisplay, + __funnelSubStepProps, + __subStepRef, ...rest }: InternalTableProps, ref: React.Ref @@ -244,6 +264,8 @@ const InternalTable = React.forwardRef( {...baseProps} __internalRootRef={__internalRootRef} className={clsx(baseProps.className, styles.root)} + __funnelSubStepProps={__funnelSubStepProps} + __subStepRef={__subStepRef} header={ <> {hasHeader && (