From 6d19d70fa8fdcead698f1671c00fddabdb43b3a5 Mon Sep 17 00:00:00 2001 From: Connor Lanigan Date: Thu, 14 Sep 2023 15:33:13 +0200 Subject: [PATCH 1/3] chore: Fix funnel support for modals (#1494) --- src/cards/index.tsx | 6 ++-- src/container/__tests__/analytics.test.tsx | 29 +++++++++++++++ src/container/index.tsx | 8 +++-- src/container/internal.tsx | 15 ++++++-- .../expandable-section-container.tsx | 10 ++++-- src/expandable-section/index.tsx | 19 +++++++--- src/expandable-section/internal.tsx | 10 ++++-- .../components/analytics-funnel.test.tsx | 36 ++++++++++++++----- .../analytics/components/analytics-funnel.tsx | 17 +++++++-- src/internal/analytics/hooks/use-funnel.ts | 3 +- src/modal/internal.tsx | 14 +++++--- src/table/index.tsx | 31 ++++++++-------- src/table/internal.tsx | 26 ++++++++++++-- 13 files changed, 174 insertions(+), 50 deletions(-) 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 && ( From 8fe25aa2166123b92cad4b50ddbe48e83130b69c Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 19 Sep 2023 11:34:28 +0200 Subject: [PATCH 2/3] fix: Improve text wrapping in dropdown error messages (#1469) --- .../page-objects/async-dropdown-page.ts | 2 +- .../__tests__/dropdown-status.test.tsx | 2 +- src/internal/styles/utils/mixins.scss | 10 ++-------- src/status-indicator/internal.tsx | 1 + src/status-indicator/styles.scss | 18 +++++++++++------- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/__integ__/page-objects/async-dropdown-page.ts b/src/__integ__/page-objects/async-dropdown-page.ts index 7d7979d90e..a76c9ecce7 100644 --- a/src/__integ__/page-objects/async-dropdown-page.ts +++ b/src/__integ__/page-objects/async-dropdown-page.ts @@ -93,7 +93,7 @@ export default class AsyncDropdownComponentPage extends AsyncDropdownPage { if (!exists) { return null; } - return this.getText(statusIndicatorSelector); + return this.getText(statusIndicatorSelector).then(value => value.trim()); } async assertStatusText(expected: string | null) { diff --git a/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx b/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx index 53933d48bb..9f7bb8b00f 100644 --- a/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx +++ b/src/internal/components/dropdown-status/__tests__/dropdown-status.test.tsx @@ -23,7 +23,7 @@ function renderComponent(props: DropdownStatusPropsExtended) { const wrapper = createWrapper(container!); return { getStickyState: () => getByTestId('sticky-state').textContent, - getContent: () => getByTestId('content').textContent, + getContent: () => getByTestId('content').textContent?.trim(), getIcon: () => wrapper.findStatusIndicator()!.findByClassName(statusIconStyles.icon)!.getElement(), }; } diff --git a/src/internal/styles/utils/mixins.scss b/src/internal/styles/utils/mixins.scss index 3700a510be..f595ebdd32 100644 --- a/src/internal/styles/utils/mixins.scss +++ b/src/internal/styles/utils/mixins.scss @@ -17,14 +17,8 @@ // prevents the text wrapping. We need to override the min-width by setting it to "0" min-width: 0; - // IE does not support `word-break: break-word`. - // We use `-ms-word-break: break-all` as a compromise for IE. - -ms-word-break: break-all; - - // From docs: - // > To prevent overflow, an otherwise unbreakable string of characters — like a long word or URL — may be broken at - // > any point if there are no otherwise-acceptable break points in the line. - // Also, this overrides any usage of `overflow-wrap` (and its alias `word-wrap`). Therefore, such rule is not used. + // `word-break: break-word` is deprecated. + // But its replacement, `overflow-wrap: anywhere`, is not supported in Safari 14.0 and 15.0. word-break: break-word; } diff --git a/src/status-indicator/internal.tsx b/src/status-indicator/internal.tsx index e8e966ff45..e96cb2a8a3 100644 --- a/src/status-indicator/internal.tsx +++ b/src/status-indicator/internal.tsx @@ -112,6 +112,7 @@ export default function StatusIndicator({ role={iconAriaLabel ? 'img' : undefined} > {typeToIcon(__size)[type]} + {__display === 'inline' && <> } {children} diff --git a/src/status-indicator/styles.scss b/src/status-indicator/styles.scss index dc5e832c0c..6eff82e21e 100644 --- a/src/status-indicator/styles.scss +++ b/src/status-indicator/styles.scss @@ -41,20 +41,24 @@ $_color-overrides: ( } } -.icon { - padding-right: awsui.$space-xxs; -} - .container { - word-break: break-all; - word-wrap: break-word; - &.display-inline { + @include styles.text-wrapping; display: inline; + + > .icon { + white-space: nowrap; + } } &.display-inline-block { display: inline-block; + word-wrap: break-word; + word-break: break-all; + + > .icon { + padding-right: awsui.$space-xxs; + } } } From 8766148a65411c5328c7b88fb0245dc36c09ee7f Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 19 Sep 2023 16:10:25 +0200 Subject: [PATCH 3/3] fix: Fix table resizer issues with screen-reader (#1565) --- .../__tests__/resizable-columns.test.tsx | 18 ++- src/table/header-cell/index.tsx | 6 +- .../resizer/__tests__/resizer-lookup.test.tsx | 131 ++++++++++++++++++ src/table/resizer/index.tsx | 128 +++++++---------- src/table/resizer/resizer-lookup.ts | 46 ++++++ 5 files changed, 246 insertions(+), 83 deletions(-) create mode 100644 src/table/resizer/__tests__/resizer-lookup.test.tsx create mode 100644 src/table/resizer/resizer-lookup.ts diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index d2fbd79500..1db3082563 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -295,18 +295,21 @@ test('should not trigger if the previous and the current widths are the same', ( }); describe('resize with keyboard', () => { + let mockWidth = 150; + const originalBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; beforeEach(() => { HTMLElement.prototype.getBoundingClientRect = function () { const rect = originalBoundingClientRect.apply(this); if (this.tagName === 'TH') { - rect.width = 150; + rect.width = mockWidth; } return rect; }; }); afterEach(() => { + mockWidth = 150; HTMLElement.prototype.getBoundingClientRect = originalBoundingClientRect; }); @@ -330,6 +333,19 @@ describe('resize with keyboard', () => { expect(onChange).toHaveBeenCalledWith({ widths: [150 + 10, 300] }); }); }); + + test('cannot resize below minsize', () => { + mockWidth = 80; + const onChange = jest.fn(); + const { wrapper } = renderTable(
onChange(event.detail)} />); + const columnResizerWrapper = wrapper.findColumnResizer(1)!; + + columnResizerWrapper.focus(); + columnResizerWrapper.keydown(KeyCode.left); + + expect(onChange).toHaveBeenCalledTimes(0); + expect(columnResizerWrapper.getElement()!).toHaveAttribute('aria-valuenow', '80'); + }); }); describe('column header content', () => { diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index bfa78bdcd5..eab39a2953 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -28,8 +28,6 @@ interface TableHeaderCellProps { onResizeFinish: () => void; colIndex: number; updateColumn: (columnId: PropertyKey, newWidth: number) => void; - onFocus?: () => void; - onBlur?: () => void; resizableColumns?: boolean; isEditable?: boolean; columnId: PropertyKey; @@ -144,8 +142,8 @@ export function TableHeaderCell({ tabIndex={tabIndex} focusId={`resize-control-${String(columnId)}`} showFocusRing={focusedComponent === `resize-control-${String(columnId)}`} - onDragMove={newWidth => updateColumn(columnId, newWidth)} - onFinish={onResizeFinish} + onWidthUpdate={newWidth => updateColumn(columnId, newWidth)} + onWidthUpdateCommit={onResizeFinish} ariaLabelledby={headerId} minWidth={typeof column.minWidth === 'string' ? parseInt(column.minWidth) : column.minWidth} /> diff --git a/src/table/resizer/__tests__/resizer-lookup.test.tsx b/src/table/resizer/__tests__/resizer-lookup.test.tsx new file mode 100644 index 0000000000..dd27d5ea0b --- /dev/null +++ b/src/table/resizer/__tests__/resizer-lookup.test.tsx @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { render } from '@testing-library/react'; +import { getResizerElements, getHeaderWidth } from '../../../../lib/components/table/resizer/resizer-lookup'; +import tableStyles from '../../../../lib/components/table/styles.css.js'; +import resizerStyles from '../../../../lib/components/table/resizer/styles.css.js'; +import React from 'react'; + +jest.mock('../../../../lib/components/internal/utils/scrollable-containers', () => ({ + getOverflowParents: jest.fn(() => { + const overflowParent = document.querySelector('[data-testid="scroll-parent"]')!; + return [overflowParent]; + }), +})); + +test('getHeaderWidth returns header bounding rect width', () => { + const header = document.createElement('th'); + const originalGetBoundingClientRect = header.getBoundingClientRect; + header.getBoundingClientRect = () => ({ ...originalGetBoundingClientRect.apply(header), width: 100 }); + const resizer = document.createElement('span'); + header.append(resizer); + + expect(getHeaderWidth(resizer)).toBe(100); +}); + +test('getHeaderWidth returns 0 when no header provided', () => { + expect(getHeaderWidth(null)).toBe(0); +}); + +test('getHeaderWidth returns 0 when no header found', () => { + const resizer = document.createElement('span'); + expect(getHeaderWidth(resizer)).toBe(0); +}); + +test('getResizerElements returns elements required for resizer computations', () => { + const { container } = render( +
+
+ +
+ +
+
+
+
+ ); + + const elements = getResizerElements(container.querySelector('[data-testid="resizer"]')!); + expect(elements?.header).toBe(document.querySelector('th')!); + expect(elements?.table).toBe(document.querySelector('table')!); + expect(elements?.tracker).toBe(document.querySelector('[data-testid="tracker"]')!); + expect(elements?.scrollParent).toBe(document.querySelector('[data-testid="scroll-parent"]')!); +}); + +test('getResizerElements return null when no resizer', () => { + expect(getResizerElements(null)).toBe(null); +}); + +test('getResizerElements return null when no header', () => { + const { container } = render( +
+ + +
+ +
+
+
+
+ ); + expect(getResizerElements(container.querySelector('[data-testid="resizer"]')!)).toBe(null); +}); + +test('getResizerElements return null when no table root', () => { + const { container } = render( +
+ + +
+ +
+
+
+
+ ); + expect(getResizerElements(container.querySelector('[data-testid="resizer"]')!)).toBe(null); +}); + +test('getResizerElements return null when no table', () => { + const { container } = render( +
+
+ + + +
+
+
+
+ ); + expect(getResizerElements(container.querySelector('[data-testid="resizer"]')!)).toBe(null); +}); + +test('getResizerElements return null when no tracker', () => { + const { container } = render( +
+ + +
+ +
+
+
+ ); + expect(getResizerElements(container.querySelector('[data-testid="resizer"]')!)).toBe(null); +}); + +test('getResizerElements return null when no overflow parent', () => { + const { container } = render( +
+ + +
+ +
+
+
+ ); + expect(getResizerElements(container.querySelector('[data-testid="resizer"]')!)).toBe(null); +}); diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 79bded48ed..b578bce5e9 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -1,25 +1,21 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import clsx from 'clsx'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { getOverflowParents } from '../../internal/utils/scrollable-containers'; -import { findUpUntil } from '../../internal/utils/dom'; -import tableStyles from '../styles.css.js'; +import React, { useEffect, useRef, useState } from 'react'; import styles from './styles.css.js'; import { KeyCode } from '../../internal/keycode'; import { DEFAULT_COLUMN_WIDTH } from '../use-column-widths'; import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; +import { getHeaderWidth, getResizerElements } from './resizer-lookup'; interface ResizerProps { - onDragMove: (newWidth: number) => void; - onFinish: () => void; + onWidthUpdate: (newWidth: number) => void; + onWidthUpdateCommit: () => void; ariaLabelledby?: string; minWidth?: number; tabIndex?: number; focusId?: string; showFocusRing?: boolean; - onFocus?: () => void; - onBlur?: () => void; } const AUTO_GROW_START_TIME = 10; @@ -27,60 +23,57 @@ const AUTO_GROW_INTERVAL = 10; const AUTO_GROW_INCREMENT = 5; export function Resizer({ - onDragMove, - onFinish, + onWidthUpdate, + onWidthUpdateCommit, ariaLabelledby, minWidth = DEFAULT_COLUMN_WIDTH, tabIndex, showFocusRing, focusId, - onFocus, - onBlur, }: ResizerProps) { + onWidthUpdate = useStableCallback(onWidthUpdate); + onWidthUpdateCommit = useStableCallback(onWidthUpdateCommit); + + const resizerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [headerCell, setHeaderCell] = useState(null); const autoGrowTimeout = useRef | undefined>(); - const onFinishStable = useStableCallback(onFinish); - const onDragStable = useStableCallback(onDragMove); const [resizerHasFocus, setResizerHasFocus] = useState(false); const [headerCellWidth, setHeaderCellWidth] = useState(0); - const originalHeaderCellWidthRef = useRef(0); - const handlers = useMemo(() => { - if (!headerCell) { - return null; + // Read header width after mounting for it to be available in the element's ARIA label before it gets focused. + useEffect(() => { + setHeaderCellWidth(getHeaderWidth(resizerRef.current)); + }, []); + + useEffect(() => { + const elements = getResizerElements(resizerRef.current); + if ((!isDragging && !resizerHasFocus) || !elements) { + return; } - const rootElement = findUpUntil(headerCell, element => element.className.indexOf(tableStyles.root) > -1)!; - const tableElement = rootElement.querySelector(`table`)!; - // tracker is rendered inside table wrapper to align with its size - const trackerElement = rootElement.querySelector(`.${styles.tracker}`)!; - const scrollParent = getOverflowParents(headerCell)[0]; - const { left: leftEdge, right: rightEdge } = scrollParent.getBoundingClientRect(); + const { left: leftEdge, right: rightEdge } = elements.scrollParent.getBoundingClientRect(); const updateTrackerPosition = (newOffset: number) => { - const { left: scrollParentLeft } = tableElement.getBoundingClientRect(); - trackerElement.style.top = headerCell.getBoundingClientRect().height + 'px'; + const { left: scrollParentLeft } = elements.table.getBoundingClientRect(); + elements.tracker.style.top = elements.header.getBoundingClientRect().height + 'px'; // minus one pixel to offset the cell border - trackerElement.style.left = newOffset - scrollParentLeft - 1 + 'px'; + elements.tracker.style.left = newOffset - scrollParentLeft - 1 + 'px'; }; const updateColumnWidth = (newWidth: number) => { - const { right, width } = headerCell.getBoundingClientRect(); + const { right, width } = elements.header.getBoundingClientRect(); const updatedWidth = newWidth < minWidth ? minWidth : newWidth; updateTrackerPosition(right + updatedWidth - width); - setHeaderCellWidth(newWidth); + if (newWidth >= minWidth) { + setHeaderCellWidth(newWidth); + } // callbacks must be the last calls in the handler, because they may cause an extra update - onDragStable(newWidth); - }; - - const resetColumnWidth = () => { - updateColumnWidth(originalHeaderCellWidthRef.current); + onWidthUpdate(newWidth); }; const resizeColumn = (offset: number) => { if (offset > leftEdge) { - const cellLeft = headerCell.getBoundingClientRect().left; + const cellLeft = elements.header.getBoundingClientRect().left; const newWidth = offset - cellLeft; // callbacks must be the last calls in the handler, because they may cause an extra update updateColumnWidth(newWidth); @@ -88,11 +81,11 @@ export function Resizer({ }; const onAutoGrow = () => { - const width = headerCell.getBoundingClientRect().width; + const width = elements.header.getBoundingClientRect().width; autoGrowTimeout.current = setTimeout(onAutoGrow, AUTO_GROW_INTERVAL); // callbacks must be the last calls in the handler, because they may cause an extra update updateColumnWidth(width + AUTO_GROW_INCREMENT); - scrollParent.scrollLeft += AUTO_GROW_INCREMENT; + elements.scrollParent.scrollLeft += AUTO_GROW_INCREMENT; }; const onMouseMove = (event: MouseEvent) => { @@ -108,55 +101,45 @@ export function Resizer({ const onMouseUp = (event: MouseEvent) => { resizeColumn(event.pageX); setIsDragging(false); - onFinishStable(); + onWidthUpdateCommit(); clearTimeout(autoGrowTimeout.current); }; const onKeyDown = (event: KeyboardEvent) => { if (event.keyCode === KeyCode.left) { event.preventDefault(); - updateColumnWidth(headerCell.getBoundingClientRect().width - 10); - setTimeout(() => onFinishStable(), 0); + updateColumnWidth(elements.header.getBoundingClientRect().width - 10); + setTimeout(() => onWidthUpdateCommit(), 0); } if (event.keyCode === KeyCode.right) { event.preventDefault(); - updateColumnWidth(headerCell.getBoundingClientRect().width + 10); - setTimeout(() => onFinishStable(), 0); + updateColumnWidth(elements.header.getBoundingClientRect().width + 10); + setTimeout(() => onWidthUpdateCommit(), 0); } }; - return { updateTrackerPosition, updateColumnWidth, resetColumnWidth, onMouseMove, onMouseUp, onKeyDown }; - }, [headerCell, minWidth, onDragStable, onFinishStable]); - - useEffect(() => { - if ((!isDragging && !resizerHasFocus) || !headerCell || !handlers) { - return; - } - - originalHeaderCellWidthRef.current = headerCell.getBoundingClientRect().width; - - handlers.updateTrackerPosition(headerCell.getBoundingClientRect().right); + updateTrackerPosition(elements.header.getBoundingClientRect().right); if (isDragging) { document.body.classList.add(styles['resize-active']); - document.addEventListener('mousemove', handlers.onMouseMove); - document.addEventListener('mouseup', handlers.onMouseUp); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); } if (resizerHasFocus) { document.body.classList.add(styles['resize-active']); document.body.classList.add(styles['resize-active-with-focus']); - headerCell.addEventListener('keydown', handlers.onKeyDown); + elements.header.addEventListener('keydown', onKeyDown); } return () => { clearTimeout(autoGrowTimeout.current); document.body.classList.remove(styles['resize-active']); document.body.classList.remove(styles['resize-active-with-focus']); - document.removeEventListener('mousemove', handlers.onMouseMove); - document.removeEventListener('mouseup', handlers.onMouseUp); - headerCell.removeEventListener('keydown', handlers.onKeyDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + elements.header.removeEventListener('keydown', onKeyDown); }; - }, [headerCell, isDragging, onFinishStable, resizerHasFocus, handlers]); + }, [isDragging, resizerHasFocus, minWidth, onWidthUpdate, onWidthUpdateCommit]); const headerCellWidthString = headerCellWidth.toFixed(0); const resizerAriaProps = { @@ -169,15 +152,6 @@ export function Resizer({ 'aria-valuemin': minWidth, }; - // Read header width after mounting for it to be available in the element's ARIA label before it gets focused. - const resizerRef = useRef(null); - useEffect(() => { - if (resizerRef.current) { - const headerCell = findUpUntil(resizerRef.current, element => element.tagName.toLowerCase() === 'th')!; - setHeaderCellWidth(headerCell.getBoundingClientRect().width); - } - }, []); - return ( <> element.tagName.toLowerCase() === 'th')!; setIsDragging(true); - setHeaderCell(headerCell); }} - onFocus={event => { - const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!; - setHeaderCellWidth(headerCell.getBoundingClientRect().width); + onClick={() => { + // Prevents dragging mode activation for VO+Space click. + setIsDragging(false); + }} + onFocus={() => { + setHeaderCellWidth(getHeaderWidth(resizerRef.current)); setResizerHasFocus(true); - setHeaderCell(headerCell); - onFocus?.(); }} onBlur={() => { setResizerHasFocus(false); - onBlur?.(); }} {...resizerAriaProps} tabIndex={tabIndex} diff --git a/src/table/resizer/resizer-lookup.ts b/src/table/resizer/resizer-lookup.ts new file mode 100644 index 0000000000..c18053ab5f --- /dev/null +++ b/src/table/resizer/resizer-lookup.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; +import tableStyles from '../styles.css.js'; +import resizerStyles from './styles.css.js'; +import { getOverflowParents } from '../../internal/utils/scrollable-containers.js'; + +export function getResizerElements(resizerElement: null | HTMLElement) { + if (!resizerElement) { + return null; + } + + const header = findUpUntil(resizerElement, element => element.tagName.toLowerCase() === 'th'); + if (!header) { + return null; + } + + const tableRoot = findUpUntil(header, element => element.className.indexOf(tableStyles.root) > -1); + if (!tableRoot) { + return null; + } + + const table = tableRoot.querySelector(`table`); + if (!table) { + return null; + } + + // tracker is rendered inside table wrapper to align with its size + const tracker = tableRoot.querySelector(`.${resizerStyles.tracker}`); + if (!tracker) { + return null; + } + + const scrollParent = getOverflowParents(header)[0]; + if (!scrollParent) { + return null; + } + + return { header, table, tracker, scrollParent }; +} + +export function getHeaderWidth(resizerElement: null | HTMLElement): number { + const header = resizerElement && findUpUntil(resizerElement, element => element.tagName.toLowerCase() === 'th'); + return header?.getBoundingClientRect().width ?? 0; +}