Skip to content

Commit 436320b

Browse files
authored
fix(select): slotted label content works with outline notch (#27483)
1 parent b81e659 commit 436320b

8 files changed

+207
-5
lines changed

core/src/components/select/select.tsx

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
33
import type { LegacyFormController } from '@utils/forms';
44
import { createLegacyFormController } from '@utils/forms';
5-
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
5+
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers';
66
import type { Attributes } from '@utils/helpers';
77
import { printIonWarning } from '@utils/logging';
88
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
99
import type { OverlaySelect } from '@utils/overlays-interface';
1010
import { isRTL } from '@utils/rtl';
1111
import { createColorClasses, hostContext } from '@utils/theme';
1212
import { watchForOptions } from '@utils/watch-options';
13+
import { win } from '@utils/window';
1314
import { caretDownSharp, chevronExpand } from 'ionicons/icons';
1415

1516
import { getIonMode } from '../../global/ionic-global';
@@ -55,6 +56,8 @@ export class Select implements ComponentInterface {
5556
private legacyFormController!: LegacyFormController;
5657
private inheritedAttributes: Attributes = {};
5758
private nativeWrapperEl: HTMLElement | undefined;
59+
private notchSpacerEl: HTMLElement | undefined;
60+
private notchVisibilityIO: IntersectionObserver | undefined;
5861

5962
// This flag ensures we log the deprecation warning at most once.
6063
private hasLoggedDeprecationWarning = false;
@@ -665,13 +668,13 @@ export class Select implements ComponentInterface {
665668
* was passed.
666669
*/
667670
private get labelText() {
668-
const { el, label } = this;
671+
const { label } = this;
669672

670673
if (label !== undefined) {
671674
return label;
672675
}
673676

674-
const labelSlot = el.querySelector('[slot="label"]');
677+
const { labelSlot } = this;
675678

676679
if (labelSlot !== null) {
677680
return labelSlot.textContent;
@@ -740,14 +743,155 @@ export class Select implements ComponentInterface {
740743
);
741744
}
742745

746+
componentDidRender() {
747+
if (this.needsExplicitNotchWidth()) {
748+
/**
749+
* Run this the frame after
750+
* the browser has re-painted the select.
751+
* Otherwise, the label element may have a width
752+
* of 0 and the IntersectionObserver will be used.
753+
*/
754+
raf(() => {
755+
this.setNotchWidth();
756+
});
757+
}
758+
}
759+
760+
/**
761+
* Gets any content passed into the `label` slot,
762+
* not the <slot> definition.
763+
*/
764+
private get labelSlot() {
765+
return this.el.querySelector('[slot="label"]');
766+
}
767+
743768
/**
744769
* Returns `true` if label content is provided
745770
* either by a prop or a content. If you want
746771
* to get the plaintext value of the label use
747772
* the `labelText` getter instead.
748773
*/
749774
private get hasLabel() {
750-
return this.label !== undefined || this.el.querySelector('[slot="label"]') !== null;
775+
return this.label !== undefined || this.labelSlot !== null;
776+
}
777+
778+
private needsExplicitNotchWidth() {
779+
if (
780+
/**
781+
* If the notch is not being used
782+
* then we do not need to set the notch width.
783+
*/
784+
this.notchSpacerEl === undefined ||
785+
/**
786+
* If no label is being used, then we
787+
* do not need to estimate the notch width.
788+
*/
789+
!this.hasLabel ||
790+
/**
791+
* If the label property is being used
792+
* then we can render the label text inside
793+
* of the notch and let the browser
794+
* determine the notch size for us.
795+
*/
796+
this.label !== undefined
797+
) {
798+
return false;
799+
}
800+
801+
return true;
802+
}
803+
804+
/**
805+
* When using a label prop we can render
806+
* the label value inside of the notch and
807+
* let the browser calculate the size of the notch.
808+
* However, we cannot render the label slot in multiple
809+
* places so we need to manually calculate the notch dimension
810+
* based on the size of the slotted content.
811+
*
812+
* This function should only be used to set the notch width
813+
* on slotted label content. The notch width for label prop
814+
* content is automatically calculated based on the
815+
* intrinsic size of the label text.
816+
*/
817+
private setNotchWidth() {
818+
const { el, notchSpacerEl } = this;
819+
820+
if (notchSpacerEl === undefined) {
821+
return;
822+
}
823+
824+
if (!this.needsExplicitNotchWidth()) {
825+
notchSpacerEl.style.removeProperty('width');
826+
return;
827+
}
828+
829+
const width = this.labelSlot!.scrollWidth;
830+
if (
831+
/**
832+
* If the computed width of the label is 0
833+
* and notchSpacerEl's offsetParent is null
834+
* then that means the element is hidden.
835+
* As a result, we need to wait for the element
836+
* to become visible before setting the notch width.
837+
*
838+
* We do not check el.offsetParent because
839+
* that can be null if ion-select has
840+
* position: fixed applied to it.
841+
* notchSpacerEl does not have position: fixed.
842+
*/
843+
width === 0 &&
844+
notchSpacerEl.offsetParent === null &&
845+
win !== undefined &&
846+
'IntersectionObserver' in win
847+
) {
848+
/**
849+
* If there is an IO already attached
850+
* then that will update the notch
851+
* once the element becomes visible.
852+
* As a result, there is no need to create
853+
* another one.
854+
*/
855+
if (this.notchVisibilityIO !== undefined) {
856+
return;
857+
}
858+
859+
const io = (this.notchVisibilityIO = new IntersectionObserver(
860+
(ev) => {
861+
/**
862+
* If the element is visible then we
863+
* can try setting the notch width again.
864+
*/
865+
if (ev[0].intersectionRatio === 1) {
866+
this.setNotchWidth();
867+
io.disconnect();
868+
this.notchVisibilityIO = undefined;
869+
}
870+
},
871+
/**
872+
* Set the root to be the select
873+
* This causes the IO callback
874+
* to be fired in WebKit as soon as the element
875+
* is visible. If we used the default root value
876+
* then WebKit would only fire the IO callback
877+
* after any animations (such as a modal transition)
878+
* finished, and there would potentially be a flicker.
879+
*/
880+
{ threshold: 0.01, root: el }
881+
));
882+
883+
io.observe(notchSpacerEl);
884+
return;
885+
}
886+
887+
/**
888+
* If the element is visible then we can set the notch width.
889+
* The notch is only visible when the label is scaled,
890+
* which is why we multiply the width by 0.75 as this is
891+
* the same amount the label element is scaled by in the
892+
* select CSS (See $select-floating-label-scale in select.vars.scss).
893+
*/
894+
notchSpacerEl.style.setProperty('width', `${width * 0.75}px`);
751895
}
752896

753897
/**
@@ -770,7 +914,7 @@ export class Select implements ComponentInterface {
770914
<div class="select-outline-container">
771915
<div class="select-outline-start"></div>
772916
<div class="select-outline-notch">
773-
<div class="notch-spacer" aria-hidden="true">
917+
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
774918
{this.label}
775919
</div>
776920
</div>

core/src/components/select/test/fill/select.e2e.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
120120
const select = page.locator('ion-select');
121121
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-label-floating`));
122122
});
123+
123124
test('should not have visual regressions with shaped outline', async ({ page }) => {
124125
await page.setContent(
125126
`
@@ -167,3 +168,60 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
167168
});
168169
});
169170
});
171+
172+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
173+
test.describe(title('select: label slot'), () => {
174+
test('should render the notch correctly with a slotted label', async ({ page }) => {
175+
await page.setContent(
176+
`
177+
<style>
178+
.custom-label {
179+
font-size: 30px;
180+
}
181+
</style>
182+
<ion-select
183+
fill="outline"
184+
label-placement="stacked"
185+
value="apple"
186+
>
187+
<div slot="label" class="custom-label">My Label Content</div>
188+
<ion-select-option value="apple">Apple</ion-select-option>
189+
</ion-select>
190+
`,
191+
config
192+
);
193+
194+
const select = page.locator('ion-select');
195+
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-slotted-label`));
196+
});
197+
test('should render the notch correctly with a slotted label after the select was originally hidden', async ({
198+
page,
199+
}) => {
200+
await page.setContent(
201+
`
202+
<style>
203+
.custom-label {
204+
font-size: 30px;
205+
}
206+
</style>
207+
<ion-select
208+
fill="outline"
209+
label-placement="stacked"
210+
value="apple"
211+
style="display: none"
212+
>
213+
<div slot="label" class="custom-label">My Label Content</div>
214+
<ion-select-option value="apple">Apple</ion-select-option>
215+
</ion-select>
216+
`,
217+
config
218+
);
219+
220+
const select = page.locator('ion-select');
221+
222+
await select.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
223+
224+
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-hidden-slotted-label`));
225+
});
226+
});
227+
});

0 commit comments

Comments
 (0)