Skip to content

Commit 71b0214

Browse files
authored
feat(input): support notch with label slot (#27635)
1 parent 1188234 commit 71b0214

12 files changed

+279
-133
lines changed

core/src/components/input/input.md.outline.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@
172172

173173
opacity: 0;
174174
pointer-events: none;
175+
176+
/**
177+
* The spacer currently inherits
178+
* border-box sizing from the Ionic reset styles.
179+
* However, we do not want to include padding in
180+
* the calculation of the element dimensions.
181+
* This code can be removed if input is updated
182+
* to use the Shadow DOM.
183+
*/
184+
box-sizing: content-box;
175185
}
176186

177187
:host(.input-fill-outline) .input-outline-start {

core/src/components/input/input.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
3-
import type { LegacyFormController } from '@utils/forms';
4-
import { createLegacyFormController } from '@utils/forms';
3+
import type { LegacyFormController, NotchController } from '@utils/forms';
4+
import { createLegacyFormController, createNotchController } from '@utils/forms';
55
import type { Attributes } from '@utils/helpers';
66
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers';
77
import { printIonWarning } from '@utils/logging';
@@ -33,6 +33,9 @@ export class Input implements ComponentInterface {
3333
private inheritedAttributes: Attributes = {};
3434
private isComposing = false;
3535
private legacyFormController!: LegacyFormController;
36+
private notchSpacerEl: HTMLElement | undefined;
37+
38+
private notchController?: NotchController;
3639

3740
// This flag ensures we log the deprecation warning at most once.
3841
private hasLoggedDeprecationWarning = false;
@@ -359,6 +362,11 @@ export class Input implements ComponentInterface {
359362
const { el } = this;
360363

361364
this.legacyFormController = createLegacyFormController(el);
365+
this.notchController = createNotchController(
366+
el,
367+
() => this.notchSpacerEl,
368+
() => this.labelSlot
369+
);
362370

363371
this.emitStyle();
364372
this.debounceChanged();
@@ -375,6 +383,10 @@ export class Input implements ComponentInterface {
375383
this.originalIonInput = this.ionInput;
376384
}
377385

386+
componentDidRender() {
387+
this.notchController?.calculateNotchWidth();
388+
}
389+
378390
disconnectedCallback() {
379391
if (Build.isBrowser) {
380392
document.dispatchEvent(
@@ -383,6 +395,11 @@ export class Input implements ComponentInterface {
383395
})
384396
);
385397
}
398+
399+
if (this.notchController) {
400+
this.notchController.destroy();
401+
this.notchController = undefined;
402+
}
386403
}
387404

388405
/**
@@ -635,7 +652,7 @@ export class Input implements ComponentInterface {
635652
<div class="input-outline-container">
636653
<div class="input-outline-start"></div>
637654
<div class="input-outline-notch">
638-
<div class="notch-spacer" aria-hidden="true">
655+
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
639656
{this.label}
640657
</div>
641658
</div>

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,58 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
180180
});
181181
});
182182
});
183+
184+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
185+
test.describe(title('input: label slot'), () => {
186+
test('should render the notch correctly with a slotted label', async ({ page }) => {
187+
await page.setContent(
188+
`
189+
<style>
190+
.custom-label {
191+
font-size: 30px;
192+
}
193+
</style>
194+
<ion-input
195+
fill="outline"
196+
label-placement="stacked"
197+
value="apple"
198+
>
199+
<div slot="label" class="custom-label">My Label Content</div>
200+
</ion-input>
201+
`,
202+
config
203+
);
204+
205+
const input = page.locator('ion-input');
206+
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-slotted-label`));
207+
});
208+
test('should render the notch correctly with a slotted label after the input was originally hidden', async ({
209+
page,
210+
}) => {
211+
await page.setContent(
212+
`
213+
<style>
214+
.custom-label {
215+
font-size: 30px;
216+
}
217+
</style>
218+
<ion-input
219+
fill="outline"
220+
label-placement="stacked"
221+
value="apple"
222+
style="display: none"
223+
>
224+
<div slot="label" class="custom-label">My Label Content</div>
225+
</ion-input>
226+
`,
227+
config
228+
);
229+
230+
const input = page.locator('ion-input');
231+
232+
await input.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
233+
234+
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-hidden-slotted-label`));
235+
});
236+
});
237+
});

core/src/components/select/select.tsx

Lines changed: 16 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
3-
import { win } from '@utils/browser';
4-
import type { LegacyFormController } from '@utils/forms';
5-
import { createLegacyFormController } from '@utils/forms';
6-
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers';
3+
import type { LegacyFormController, NotchController } from '@utils/forms';
4+
import { createLegacyFormController, createNotchController } from '@utils/forms';
5+
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
76
import type { Attributes } from '@utils/helpers';
87
import { printIonWarning } from '@utils/logging';
98
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
@@ -58,7 +57,8 @@ export class Select implements ComponentInterface {
5857
private inheritedAttributes: Attributes = {};
5958
private nativeWrapperEl: HTMLElement | undefined;
6059
private notchSpacerEl: HTMLElement | undefined;
61-
private notchVisibilityIO: IntersectionObserver | undefined;
60+
61+
private notchController?: NotchController;
6262

6363
// This flag ensures we log the deprecation warning at most once.
6464
private hasLoggedDeprecationWarning = false;
@@ -245,6 +245,11 @@ export class Select implements ComponentInterface {
245245
const { el } = this;
246246

247247
this.legacyFormController = createLegacyFormController(el);
248+
this.notchController = createNotchController(
249+
el,
250+
() => this.notchSpacerEl,
251+
() => this.labelSlot
252+
);
248253

249254
this.updateOverlayOptions();
250255
this.emitStyle();
@@ -267,6 +272,11 @@ export class Select implements ComponentInterface {
267272
this.mutationO.disconnect();
268273
this.mutationO = undefined;
269274
}
275+
276+
if (this.notchController) {
277+
this.notchController.destroy();
278+
this.notchController = undefined;
279+
}
270280
}
271281

272282
/**
@@ -746,17 +756,7 @@ export class Select implements ComponentInterface {
746756
}
747757

748758
componentDidRender() {
749-
if (this.needsExplicitNotchWidth()) {
750-
/**
751-
* Run this the frame after
752-
* the browser has re-painted the select.
753-
* Otherwise, the label element may have a width
754-
* of 0 and the IntersectionObserver will be used.
755-
*/
756-
raf(() => {
757-
this.setNotchWidth();
758-
});
759-
}
759+
this.notchController?.calculateNotchWidth();
760760
}
761761

762762
/**
@@ -777,120 +777,6 @@ export class Select implements ComponentInterface {
777777
return this.label !== undefined || this.labelSlot !== null;
778778
}
779779

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

core/src/utils/forms/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './form-controller';
2+
export * from './notch-controller';

0 commit comments

Comments
 (0)