fix(select): auto-scroll to selected item for all interfaces (#30202)

Issue number: resolves #19296

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
- when the ion-select is with the interface action-sheet or alert is not
scrolling to the selected item on open

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->
- change test page so all select have scroll;
- guarantee focusVisibleElement is called on all interfaces;

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

---------

Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
This commit is contained in:
João Ferreira
2025-02-28 12:06:58 +00:00
committed by GitHub
parent cd5c27a12a
commit 8eaeb22e7a
27 changed files with 239 additions and 12 deletions

View File

@ -310,19 +310,10 @@ export class Select implements ComponentInterface {
}
this.isExpanded = true;
const overlay = (this.overlay = await this.createOverlay(event));
overlay.onDidDismiss().then(() => {
this.overlay = undefined;
this.isExpanded = false;
this.ionDismiss.emit();
this.setFocus();
});
await overlay.present();
// focus selected option for popovers and modals
if (this.interface === 'popover' || this.interface === 'modal') {
// Add logic to scroll selected item into view before presenting
const scrollSelectedIntoView = () => {
const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value);
if (indexOfSelected > -1) {
const selectedItem = overlay.querySelector<HTMLElement>(
`.select-interface-option:nth-child(${indexOfSelected + 1})`
@ -345,6 +336,7 @@ export class Select implements ComponentInterface {
| HTMLIonCheckboxElement
| null;
if (interactiveEl) {
selectedItem.scrollIntoView({ block: 'nearest' });
// Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
// and removing `ion-focused` style
interactiveEl.setFocus();
@ -372,8 +364,40 @@ export class Select implements ComponentInterface {
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
}
}
};
// For modals and popovers, we can scroll before they're visible
if (this.interface === 'modal') {
overlay.addEventListener('ionModalWillPresent', scrollSelectedIntoView, { once: true });
} else if (this.interface === 'popover') {
overlay.addEventListener('ionPopoverWillPresent', scrollSelectedIntoView, { once: true });
} else {
/**
* For alerts and action sheets, we need to wait a frame after willPresent
* because these overlays don't have their content in the DOM immediately
* when willPresent fires. By waiting a frame, we ensure the content is
* rendered and can be properly scrolled into view.
*/
const scrollAfterRender = () => {
requestAnimationFrame(() => {
scrollSelectedIntoView();
});
};
if (this.interface === 'alert') {
overlay.addEventListener('ionAlertWillPresent', scrollAfterRender, { once: true });
} else if (this.interface === 'action-sheet') {
overlay.addEventListener('ionActionSheetWillPresent', scrollAfterRender, { once: true });
}
}
overlay.onDidDismiss().then(() => {
this.overlay = undefined;
this.isExpanded = false;
this.ionDismiss.emit();
this.setFocus();
});
await overlay.present();
return overlay;
}

View File

@ -61,6 +61,169 @@
</ion-item>
</ion-list>
<ion-list>
<ion-list-header>
<ion-label>Single Value - Overflowing Options</ion-label>
</ion-list-header>
<ion-item>
<ion-select id="alert-select-scroll-to-selected" label="Alert" interface="alert" value="watermelon">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="apricot">Apricot</ion-select-option>
<ion-select-option value="avocado">Avocado</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
<ion-select-option value="blackberry">Blackberry</ion-select-option>
<ion-select-option value="blueberry">Blueberry</ion-select-option>
<ion-select-option value="cantaloupe">Cantaloupe</ion-select-option>
<ion-select-option value="cherry">Cherry</ion-select-option>
<ion-select-option value="coconut">Coconut</ion-select-option>
<ion-select-option value="cranberry">Cranberry</ion-select-option>
<ion-select-option value="dragonfruit">Dragonfruit</ion-select-option>
<ion-select-option value="fig">Fig</ion-select-option>
<ion-select-option value="grape">Grape</ion-select-option>
<ion-select-option value="grapefruit">Grapefruit</ion-select-option>
<ion-select-option value="guava">Guava</ion-select-option>
<ion-select-option value="kiwi">Kiwi</ion-select-option>
<ion-select-option value="lemon">Lemon</ion-select-option>
<ion-select-option value="lime">Lime</ion-select-option>
<ion-select-option value="lychee">Lychee</ion-select-option>
<ion-select-option value="mango">Mango</ion-select-option>
<ion-select-option value="nectarine">Nectarine</ion-select-option>
<ion-select-option value="orange">Orange</ion-select-option>
<ion-select-option value="papaya">Papaya</ion-select-option>
<ion-select-option value="passion-fruit">Passion Fruit</ion-select-option>
<ion-select-option value="peach">Peach</ion-select-option>
<ion-select-option value="pear">Pear</ion-select-option>
<ion-select-option value="pineapple">Pineapple</ion-select-option>
<ion-select-option value="plum">Plum</ion-select-option>
<ion-select-option value="pomegranate">Pomegranate</ion-select-option>
<ion-select-option value="raspberry">Raspberry</ion-select-option>
<ion-select-option value="strawberry">Strawberry</ion-select-option>
<ion-select-option value="tangerine">Tangerine</ion-select-option>
<ion-select-option value="watermelon">Watermelon</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select
id="action-sheet-select-scroll-to-selected"
label="Action Sheet"
interface="action-sheet"
value="watermelon"
>
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="apricot">Apricot</ion-select-option>
<ion-select-option value="avocado">Avocado</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
<ion-select-option value="blackberry">Blackberry</ion-select-option>
<ion-select-option value="blueberry">Blueberry</ion-select-option>
<ion-select-option value="cantaloupe">Cantaloupe</ion-select-option>
<ion-select-option value="cherry">Cherry</ion-select-option>
<ion-select-option value="coconut">Coconut</ion-select-option>
<ion-select-option value="cranberry">Cranberry</ion-select-option>
<ion-select-option value="dragonfruit">Dragonfruit</ion-select-option>
<ion-select-option value="fig">Fig</ion-select-option>
<ion-select-option value="grape">Grape</ion-select-option>
<ion-select-option value="grapefruit">Grapefruit</ion-select-option>
<ion-select-option value="guava">Guava</ion-select-option>
<ion-select-option value="kiwi">Kiwi</ion-select-option>
<ion-select-option value="lemon">Lemon</ion-select-option>
<ion-select-option value="lime">Lime</ion-select-option>
<ion-select-option value="lychee">Lychee</ion-select-option>
<ion-select-option value="mango">Mango</ion-select-option>
<ion-select-option value="nectarine">Nectarine</ion-select-option>
<ion-select-option value="orange">Orange</ion-select-option>
<ion-select-option value="papaya">Papaya</ion-select-option>
<ion-select-option value="passion-fruit">Passion Fruit</ion-select-option>
<ion-select-option value="peach">Peach</ion-select-option>
<ion-select-option value="pear">Pear</ion-select-option>
<ion-select-option value="pineapple">Pineapple</ion-select-option>
<ion-select-option value="plum">Plum</ion-select-option>
<ion-select-option value="pomegranate">Pomegranate</ion-select-option>
<ion-select-option value="raspberry">Raspberry</ion-select-option>
<ion-select-option value="strawberry">Strawberry</ion-select-option>
<ion-select-option value="tangerine">Tangerine</ion-select-option>
<ion-select-option value="watermelon">Watermelon</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select id="popover-select-scroll-to-selected" label="Popover" interface="popover" value="watermelon">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="apricot">Apricot</ion-select-option>
<ion-select-option value="avocado">Avocado</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
<ion-select-option value="blackberry">Blackberry</ion-select-option>
<ion-select-option value="blueberry">Blueberry</ion-select-option>
<ion-select-option value="cantaloupe">Cantaloupe</ion-select-option>
<ion-select-option value="cherry">Cherry</ion-select-option>
<ion-select-option value="coconut">Coconut</ion-select-option>
<ion-select-option value="cranberry">Cranberry</ion-select-option>
<ion-select-option value="dragonfruit">Dragonfruit</ion-select-option>
<ion-select-option value="fig">Fig</ion-select-option>
<ion-select-option value="grape">Grape</ion-select-option>
<ion-select-option value="grapefruit">Grapefruit</ion-select-option>
<ion-select-option value="guava">Guava</ion-select-option>
<ion-select-option value="kiwi">Kiwi</ion-select-option>
<ion-select-option value="lemon">Lemon</ion-select-option>
<ion-select-option value="lime">Lime</ion-select-option>
<ion-select-option value="lychee">Lychee</ion-select-option>
<ion-select-option value="mango">Mango</ion-select-option>
<ion-select-option value="nectarine">Nectarine</ion-select-option>
<ion-select-option value="orange">Orange</ion-select-option>
<ion-select-option value="papaya">Papaya</ion-select-option>
<ion-select-option value="passion-fruit">Passion Fruit</ion-select-option>
<ion-select-option value="peach">Peach</ion-select-option>
<ion-select-option value="pear">Pear</ion-select-option>
<ion-select-option value="pineapple">Pineapple</ion-select-option>
<ion-select-option value="plum">Plum</ion-select-option>
<ion-select-option value="pomegranate">Pomegranate</ion-select-option>
<ion-select-option value="raspberry">Raspberry</ion-select-option>
<ion-select-option value="strawberry">Strawberry</ion-select-option>
<ion-select-option value="tangerine">Tangerine</ion-select-option>
<ion-select-option value="watermelon">Watermelon</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select id="modal-select-scroll-to-selected" label="Modal" interface="modal" value="watermelon">
<ion-select-option value="apple">Apple</ion-select-option>
<ion-select-option value="apricot">Apricot</ion-select-option>
<ion-select-option value="avocado">Avocado</ion-select-option>
<ion-select-option value="banana">Banana</ion-select-option>
<ion-select-option value="blackberry">Blackberry</ion-select-option>
<ion-select-option value="blueberry">Blueberry</ion-select-option>
<ion-select-option value="cantaloupe">Cantaloupe</ion-select-option>
<ion-select-option value="cherry">Cherry</ion-select-option>
<ion-select-option value="coconut">Coconut</ion-select-option>
<ion-select-option value="cranberry">Cranberry</ion-select-option>
<ion-select-option value="dragonfruit">Dragonfruit</ion-select-option>
<ion-select-option value="fig">Fig</ion-select-option>
<ion-select-option value="grape">Grape</ion-select-option>
<ion-select-option value="grapefruit">Grapefruit</ion-select-option>
<ion-select-option value="guava">Guava</ion-select-option>
<ion-select-option value="kiwi">Kiwi</ion-select-option>
<ion-select-option value="lemon">Lemon</ion-select-option>
<ion-select-option value="lime">Lime</ion-select-option>
<ion-select-option value="lychee">Lychee</ion-select-option>
<ion-select-option value="mango">Mango</ion-select-option>
<ion-select-option value="nectarine">Nectarine</ion-select-option>
<ion-select-option value="orange">Orange</ion-select-option>
<ion-select-option value="papaya">Papaya</ion-select-option>
<ion-select-option value="passion-fruit">Passion Fruit</ion-select-option>
<ion-select-option value="peach">Peach</ion-select-option>
<ion-select-option value="pear">Pear</ion-select-option>
<ion-select-option value="pineapple">Pineapple</ion-select-option>
<ion-select-option value="plum">Plum</ion-select-option>
<ion-select-option value="pomegranate">Pomegranate</ion-select-option>
<ion-select-option value="raspberry">Raspberry</ion-select-option>
<ion-select-option value="strawberry">Strawberry</ion-select-option>
<ion-select-option value="tangerine">Tangerine</ion-select-option>
<ion-select-option value="watermelon">Watermelon</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<ion-list>
<ion-list-header>
<ion-label>Multiple Value Select</ion-label>

View File

@ -8,7 +8,7 @@ import type { E2ELocator } from '@utils/test/playwright';
* does not. The overlay rendering is already tested in the respective
* test files.
*/
configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
test.describe(title('select: basic'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/select/test/basic', config);
@ -24,6 +24,16 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(page.locator('ion-alert')).toBeVisible();
});
test('it should scroll to selected option when opened', async ({ page }) => {
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
await page.click('#alert-select-scroll-to-selected');
await ionAlertDidPresent.next();
const alert = page.locator('ion-alert');
await expect(alert).toHaveScreenshot(screenshot(`select-basic-alert-scroll-to-selected`));
});
});
test.describe('select: action sheet', () => {
@ -36,6 +46,16 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(page.locator('ion-action-sheet')).toBeVisible();
});
test('it should scroll to selected option when opened', async ({ page }) => {
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
await page.click('#action-sheet-select-scroll-to-selected');
await ionActionSheetDidPresent.next();
const actionSheet = page.locator('ion-action-sheet');
await expect(actionSheet).toHaveScreenshot(screenshot(`select-basic-action-sheet-scroll-to-selected`));
});
});
test.describe('select: popover', () => {
@ -57,6 +77,16 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(popover).toBeVisible();
});
test('it should scroll to selected option when opened', async ({ page }) => {
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#popover-select-scroll-to-selected');
await ionPopoverDidPresent.next();
const popover = page.locator('ion-popover');
await expect(popover).toHaveScreenshot(screenshot(`select-basic-popover-scroll-to-selected`));
});
});
test.describe('select: modal', () => {
@ -75,6 +105,16 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(modal).toBeVisible();
});
test('it should scroll to selected option when opened', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#modal-select-scroll-to-selected');
await ionModalDidPresent.next();
const modal = page.locator('ion-modal');
await expect(modal).toHaveScreenshot(screenshot(`select-basic-modal-scroll-to-selected`));
});
});
});
});