diff --git a/pages/select/disabled-reason.page.tsx b/pages/select/disabled-reason.page.tsx index 386632780e..6b1c203b1e 100644 --- a/pages/select/disabled-reason.page.tsx +++ b/pages/select/disabled-reason.page.tsx @@ -1,32 +1,47 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import * as React from 'react'; +import React, { useContext } from 'react'; import Box from '~components/box'; import Select from '~components/select'; +import AppContext, { AppContextType } from '../app/app-context'; import ScreenshotArea from '../utils/screenshot-area'; -const options = [ - { - value: '1', - label: 'Option 1', - disabled: true, - disabledReason: 'disabled reason', - }, - { - value: '2', - label: 'Option 2', - }, -]; +type PageContext = React.Context< + AppContextType<{ + expandToViewport: boolean; + virtualScroll: boolean; + }> +>; + +const options = [...Array(50).keys()].map(n => { + const numberToDisplay = (n + 1).toString(); + const baseOption = { + value: numberToDisplay, + label: `Option ${numberToDisplay}`, + }; + if (n === 0 || n === 24 || n === 49) { + return { ...baseOption, disabled: true, disabledReason: 'disabled reason' }; + } + return baseOption; +}); export default function SelectPage() { + const { urlParams } = useContext(AppContext as PageContext); + return ( Select with disabled reason
-
diff --git a/src/internal/components/tooltip/index.tsx b/src/internal/components/tooltip/index.tsx index 95d790b44c..4414a66185 100644 --- a/src/internal/components/tooltip/index.tsx +++ b/src/internal/components/tooltip/index.tsx @@ -20,6 +20,7 @@ export interface TooltipProps { className?: string; contentAttributes?: React.HTMLAttributes; size?: PopoverProps['size']; + hideOnTriggerOverflow?: boolean; } export default function Tooltip({ @@ -30,6 +31,7 @@ export default function Tooltip({ contentAttributes = {}, position = 'top', size = 'small', + hideOnTriggerOverflow, }: TooltipProps) { if (!trackKey && (typeof value === 'string' || typeof value === 'number')) { trackKey = value; @@ -48,6 +50,7 @@ export default function Tooltip({ position={position} zIndex={7000} arrow={position => } + hideOnTriggerOverflow={hideOnTriggerOverflow} > {value} diff --git a/src/internal/utils/scrollable-containers.ts b/src/internal/utils/scrollable-containers.ts index 9cb2a3b69e..7e6f5505de 100644 --- a/src/internal/utils/scrollable-containers.ts +++ b/src/internal/utils/scrollable-containers.ts @@ -123,3 +123,17 @@ export function getFirstScrollableParent(element: HTMLElement): HTMLElement | un }) || undefined ); } + +export function isInScroll(element: HTMLElement | SVGElement) { + if (element instanceof SVGElement) { + return true; + } + const scrollableParent = getFirstScrollableParent(element); + if (!scrollableParent) { + return true; + } + const elementCenter = element.offsetTop + element.offsetHeight / 2; + const isAbove = elementCenter < scrollableParent.scrollTop; + const isBelow = elementCenter > scrollableParent.clientHeight - scrollableParent.scrollTop; + return !isAbove && !isBelow; +} diff --git a/src/popover/container.tsx b/src/popover/container.tsx index fd86aebf54..4542d676d3 100644 --- a/src/popover/container.tsx +++ b/src/popover/container.tsx @@ -1,12 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useLayoutEffect, useRef } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { nodeContains } from '@cloudscape-design/component-toolkit/dom'; import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { isInScroll } from '../internal/utils/scrollable-containers'; import { InternalPosition, PopoverProps } from './interfaces'; import usePopoverPosition from './use-popover-position.js'; @@ -39,6 +40,7 @@ export interface PopoverContainerProps { // Do not use this if the popover is open on hover, in order to avoid unexpected movement. allowScrollToFit?: boolean; allowVerticalOverflow?: boolean; + hideOnTriggerOverflow?: boolean; } export default function PopoverContainer({ @@ -55,12 +57,15 @@ export default function PopoverContainer({ keepPosition, allowScrollToFit, allowVerticalOverflow, + hideOnTriggerOverflow, }: PopoverContainerProps) { const bodyRef = useRef(null); const contentRef = useRef(null); const popoverRef = useRef(null); const arrowRef = useRef(null); + const [hideDueToOverScroll, setHideDueToOverscroll] = useState(false); + const isRefresh = useVisualRefresh(); // Updates the position handler. @@ -113,8 +118,13 @@ export default function PopoverContainer({ }; const updatePositionOnResize = () => requestAnimationFrame(() => updatePositionHandler()); - const refreshPosition = () => requestAnimationFrame(() => positionHandlerRef.current()); - + const refreshPosition = () => { + const hide = !!hideOnTriggerOverflow && !!trackRef.current && !isInScroll(trackRef.current); + setHideDueToOverscroll(hide); + if (!hide) { + requestAnimationFrame(() => positionHandlerRef.current()); + } + }; window.addEventListener('click', onClick); window.addEventListener('resize', updatePositionOnResize); window.addEventListener('scroll', refreshPosition, true); @@ -124,9 +134,9 @@ export default function PopoverContainer({ window.removeEventListener('resize', updatePositionOnResize); window.removeEventListener('scroll', refreshPosition, true); }; - }, [keepPosition, positionHandlerRef, trackRef, updatePositionHandler]); + }, [hideOnTriggerOverflow, keepPosition, positionHandlerRef, trackRef, updatePositionHandler]); - return ( + return hideDueToOverScroll ? null : (
Promise +) { + return useBrowser(async browser => { + await browser.url( + `/#/light/select/disabled-reason?expandToViewport=${expandToViewport}&virtualScroll=${virtualScroll}` + ); + const select = createWrapper().findSelect(); + const page = new SelectPageObject(browser, select); + await testFn(page); + }); +} + +describe('Disabled reasons', () => { + describe.each([false, true])('expandToViewport=%s', expandToViewport => { + describe.each([false, true])('virtualScroll=%s', virtualScroll => { + test( + 'shows disabled reason on hover', + setupTest({ expandToViewport, virtualScroll }, async page => { + await page.pause(100); + const select = createWrapper().findSelect(); + await page.clickSelect(); + await page.assertDropdownOpen(true, expandToViewport); + const firstDisabledOption = select.findDropdown({ expandToViewport }).findOption(1); + await page.hoverElement(firstDisabledOption.toSelector()); + const disabledTooltip = firstDisabledOption.findDisabledReason(); + expect(await page.isDisplayed(disabledTooltip.toSelector())).toBe(true); + }) + ); + + test( + 'hides disabled reason when the disabled option is scrolled out of view', + setupTest({ expandToViewport, virtualScroll }, async page => { + await page.pause(100); + const select = createWrapper().findSelect(); + await page.clickSelect(); + await page.assertDropdownOpen(true, expandToViewport); + const dropdown = select.findDropdown({ expandToViewport }); + const firstDisabledOption = select.findDropdown({ expandToViewport }).findOption(1); + await page.hoverElement(firstDisabledOption.toSelector()); + const disabledTooltipSelector = firstDisabledOption.findDisabledReason().toSelector(); + expect(await page.isDisplayed(disabledTooltipSelector)).toBe(true); + await page.elementScrollTo(dropdown.findOptionsContainer().toSelector(), { top: 500 }); + await page.waitForJsTimers(); + expect(await page.isDisplayed(disabledTooltipSelector)).toBe(false); + }) + ); + }); + }); +}); diff --git a/src/select/__integ__/page-objects/select-page.ts b/src/select/__integ__/page-objects/select-page.ts index 2ccedbe52e..0874b1caad 100644 --- a/src/select/__integ__/page-objects/select-page.ts +++ b/src/select/__integ__/page-objects/select-page.ts @@ -57,12 +57,16 @@ export default class SelectPageObject< await this.browser.releaseActions(); } - isDropdownOpen() { - return this.isExisting(this.wrapper.findDropdown().findOpenDropdown().toSelector()); + isDropdownOpen(expandToViewport = false) { + return this.isExisting(this.wrapper.findDropdown({ expandToViewport }).findOpenDropdown().toSelector()); } - async assertDropdownOpen(isOpen: boolean) { - await assert.equal(await this.isDropdownOpen(), isOpen, `Select dropdown should ${isOpen ? '' : 'not '} be open`); + async assertDropdownOpen(isOpen: boolean, expandToViewport?: boolean) { + await assert.equal( + await this.isDropdownOpen(expandToViewport), + isOpen, + `Select dropdown should ${isOpen ? '' : 'not '} be open` + ); } async ensureDropdownOpen() { diff --git a/src/select/parts/item.tsx b/src/select/parts/item.tsx index 926dbb09bc..159f995212 100644 --- a/src/select/parts/item.tsx +++ b/src/select/parts/item.tsx @@ -108,6 +108,7 @@ const Item = ( trackRef={internalRef} value={disabledReason!} position="right" + hideOnTriggerOverflow={true} /> )} diff --git a/src/select/parts/multiselect-item.tsx b/src/select/parts/multiselect-item.tsx index af0a31044c..c9b8110a75 100644 --- a/src/select/parts/multiselect-item.tsx +++ b/src/select/parts/multiselect-item.tsx @@ -96,6 +96,7 @@ const MultiSelectItem = ( trackRef={internalRef} value={disabledReason!} position="right" + hideOnTriggerOverflow={true} /> )}