diff --git a/.changeset/fix-picker-overlay-scroll.md b/.changeset/fix-picker-overlay-scroll.md new file mode 100644 index 0000000000..a58fe4c88d --- /dev/null +++ b/.changeset/fix-picker-overlay-scroll.md @@ -0,0 +1,8 @@ +--- +'@spectrum-web-components/overlay': patch +'@spectrum-web-components/picker': patch +--- + +**Fixed** issue where picker menus inside overlays could not scroll to the bottom after selecting an item and reopening. The problem was caused by the overlay's placement calculation happening before the menu fully rendered, resulting in incorrect height measurements. + +This fix ensures picker menus maintain proper scrollable height when reopened, regardless of the selected item's position. diff --git a/1st-gen/packages/menu/src/Menu.ts b/1st-gen/packages/menu/src/Menu.ts index 28cbfb6f74..0073ee6f9b 100644 --- a/1st-gen/packages/menu/src/Menu.ts +++ b/1st-gen/packages/menu/src/Menu.ts @@ -103,30 +103,30 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { /** * Minimum vertical movement (in pixels) required to trigger scrolling detection. - * + * * This threshold is consistent with other components in the design system: * - Card component uses 10px for click vs. drag detection * - Menu component uses 10px for scroll vs. selection detection - * + * * The 10px threshold is carefully chosen to: * - Allow for natural finger tremor and accidental touches * - Distinguish between intentional scroll gestures and taps * - Provide consistent behavior across the platform - * + * * @see {@link packages/card/src/Card.ts} for similar threshold usage */ private scrollThreshold = 10; // pixels /** * Maximum time (in milliseconds) for a movement to be considered scrolling. - * + * * This threshold is consistent with other timing values in the design system: * - Longpress duration: 300ms (ActionButton, LongpressController) * - Scroll detection: 300ms (Menu component) - * + * * Quick movements within this timeframe are likely intentional scrolls, * while slower movements are more likely taps or selections. - * + * * @see {@link packages/action-button/src/ActionButton.ts} for longpress duration * @see {@link packages/overlay/src/LongpressController.ts} for longpress duration */ @@ -576,7 +576,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { * Resets the scrolling state after a short delay (100ms) to allow for * any final touch events to be processed. This delay prevents immediate * state changes that could interfere with the selection logic. - * + * * The 100ms delay is consistent with the design system's approach to * touch event handling and ensures that any final touch events or * gesture recognition can complete before the scrolling state is reset. @@ -720,7 +720,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { ): MenuItem { const diff = before ? -1 : 1; const elements = this.rovingTabindexController?.elements || []; - const index = !!menuItem ? elements.indexOf(menuItem) : -1; + const index = menuItem ? elements.indexOf(menuItem) : -1; let newIndex = Math.min(Math.max(0, index + diff), elements.length - 1); while ( !this.isFocusableElement(elements[newIndex]) && @@ -729,7 +729,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { ) { newIndex += diff; } - return !!this.isFocusableElement(elements[newIndex]) + return this.isFocusableElement(elements[newIndex]) ? (elements[newIndex] as MenuItem) : menuItem || elements[0]; } @@ -1010,7 +1010,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { } }); } - if (!!this._updateFocus) { + if (this._updateFocus) { this.rovingTabindexController?.focusOnItem(this._updateFocus); this._updateFocus = undefined; } diff --git a/1st-gen/packages/overlay/src/PlacementController.ts b/1st-gen/packages/overlay/src/PlacementController.ts index 2dfe32ea36..b57e485439 100644 --- a/1st-gen/packages/overlay/src/PlacementController.ts +++ b/1st-gen/packages/overlay/src/PlacementController.ts @@ -24,6 +24,7 @@ import { shift, size, } from '@floating-ui/dom'; +import { isWebKit } from '@spectrum-web-components/shared'; import type { VirtualTrigger } from './VirtualTrigger.js'; import type { OpenableElement } from './overlay-types.js'; import type { Overlay } from './Overlay.js'; @@ -278,6 +279,28 @@ export class PlacementController implements ReactiveController { // Wait for document fonts to be ready before computing placement. await (document.fonts ? document.fonts.ready : Promise.resolve()); + // Safari/iOS-specific fix: Add small delay for picker menus to allow scrollIntoView to complete + // Check if this is a submenu overlay (slot="submenu") + // Submenus need immediate positioning for hover responsiveness + const isSubmenu = Array.from(this.host.elements).some( + (el) => el.getAttribute?.('slot') === 'submenu' + ); + + // Safari-specific timing fix covered by cross-browser integration tests + /* c8 ignore start */ + if (isWebKit() && !isSubmenu) { + const hasMenu = Array.from(this.host.elements).some( + (el) => + el.tagName === 'SP-MENU' || el.querySelector?.('sp-menu') + ); + + if (hasMenu) { + // Wait 1 frame for Safari layout to settle after scrollIntoView + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + } + /* c8 ignore stop */ + // Determine the flip middleware based on the type of trigger element. const flipMiddleware = !(options.trigger instanceof HTMLElement) ? flip({ diff --git a/1st-gen/packages/overlay/test/overlay.test.ts b/1st-gen/packages/overlay/test/overlay.test.ts index 41c188e0f3..e864fe341f 100644 --- a/1st-gen/packages/overlay/test/overlay.test.ts +++ b/1st-gen/packages/overlay/test/overlay.test.ts @@ -35,6 +35,8 @@ import { import { render, TemplateResult } from '@spectrum-web-components/base'; import { Button } from '@spectrum-web-components/button'; import { Menu } from '@spectrum-web-components/menu'; +import '@spectrum-web-components/menu/sp-menu.js'; +import '@spectrum-web-components/menu/sp-menu-item.js'; import { Theme } from '@spectrum-web-components/theme'; import '@spectrum-web-components/theme/sp-theme.js'; import '@spectrum-web-components/theme/src/themes.js'; @@ -54,7 +56,7 @@ import { clickAndHoverTarget, definedOverlayElement, virtualElement, -} from '../stories/overlay.stories'; +} from '../stories/overlay.stories.js'; // import { isWebKit } from '@spectrum-web-components/shared'; async function styledFixture( @@ -476,6 +478,7 @@ describe('Overlays', () => { const initial = el.getBoundingClientRect(); trigger.updateBoundingClientRect(500, 500); + // Wait for placement computation to complete await nextFrame(); await nextFrame(); const final = el.getBoundingClientRect(); diff --git a/1st-gen/packages/picker/src/Picker.ts b/1st-gen/packages/picker/src/Picker.ts index c652c88d94..7ae9a4ccf3 100644 --- a/1st-gen/packages/picker/src/Picker.ts +++ b/1st-gen/packages/picker/src/Picker.ts @@ -1000,7 +1000,7 @@ export class Picker extends PickerBase { ); if (!this.value || nextItem !== this.selectedItem) { // updates picker text but does not fire change event until action is completed - if (!!nextItem) this.setValueFromItem(nextItem as MenuItem); + if (nextItem) this.setValueFromItem(nextItem as MenuItem); } }; } diff --git a/1st-gen/packages/picker/stories/picker.stories.ts b/1st-gen/packages/picker/stories/picker.stories.ts index ad76843a40..3a30c49c20 100644 --- a/1st-gen/packages/picker/stories/picker.stories.ts +++ b/1st-gen/packages/picker/stories/picker.stories.ts @@ -884,3 +884,341 @@ export const PickerInModalOverlay = (): TemplateResult => { PickerInModalOverlay.swc_vrt = { skip: true, }; + +export const PickerIniPadSafari = (): TemplateResult => { + return html` + + + + Save + + + + Finish + + + + Review + + + + Extra item 1 + + + + Extra item 2 + + + + Extra item 3 + + + + Extra item 4 + + + + Extra item 5 + + + + Extra item 6 + + + + Extra item 7 + + + + Extra item 8 + + + + Extra item 9 + + + + Extra item 10 + + + + Extra item 11 + + + + Extra item 12 + + + + Extra item 13 + + + + Extra item 14 + + + + Extra item 15 + + + + Extra item 16 + + + + Extra item 17 + + + + Extra item 18 + + + + Extra item 19 + + + + Extra item 20 + + + + Extra item 21 + + + + Extra item 22 + + + + Extra item 23 + + + + Extra item 24 + + + + Extra item 25 + + + + Extra item 26 + + + + Extra item 27 + + + + Open overlay + + + + + + + Save + + + + Finish + + + + Review + + + + Extra item 1 + + + + Extra item 2 + + + + Extra item 3 + + + + Extra item 4 + + + + Extra item 5 + + + + Extra item 6 + + + + Extra item 7 + + + + Extra item 8 + + + + Extra item 9 + + + + Extra item 10 + + + + Extra item 11 + + + + Extra item 12 + + + + Extra item 13 + + + + Extra item 14 + + + + Extra item 15 + + + + Extra item 16 + + + + Extra item 17 + + + + Extra item 18 + + + + Extra item 19 + + + + Extra item 20 + + + + Extra item 21 + + + + Extra item 22 + + + + Extra item 23 + + + + Extra item 24 + + + + Extra item 25 + + + + Extra item 26 + + + + Extra item 27 + + + + + `; +}; + +PickerIniPadSafari.swc_vrt = { + skip: true, +}; + +PickerIniPadSafari.parameters = { + tags: ['!dev'], +}; + +PickerIniPadSafari.parameters = { + // Disables Chromatic's snapshotting on a global level + chromatic: { disableSnapshot: true }, +};