Skip to content

Commit af92cb2

Browse files
authored
feat(select): add label slot (#27545)
resolves #26838
1 parent 448e63f commit af92cb2

16 files changed

+371
-16
lines changed

core/src/components.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,7 +2698,7 @@ export namespace Components {
26982698
*/
26992699
"justify": 'start' | 'end' | 'space-between';
27002700
/**
2701-
* The visible label associated with the select.
2701+
* The visible label associated with the select. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
27022702
*/
27032703
"label"?: string;
27042704
/**
@@ -6772,7 +6772,7 @@ declare namespace LocalJSX {
67726772
*/
67736773
"justify"?: 'start' | 'end' | 'space-between';
67746774
/**
6775-
* The visible label associated with the select.
6775+
* The visible label associated with the select. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
67766776
*/
67776777
"label"?: string;
67786778
/**

core/src/components/select/select.scss

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,24 @@ button {
303303
* works on block-level elements. A flex item is
304304
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
305305
*/
306-
.label-text {
306+
.label-text,
307+
::slotted([slot="label"]) {
307308
text-overflow: ellipsis;
308309

309310
white-space: nowrap;
310311

311312
overflow: hidden;
312313
}
313314

315+
/**
316+
* If no label text is placed into the slot
317+
* then the element should be hidden otherwise
318+
* there will be additional margins added.
319+
*/
320+
.label-text-wrapper-hidden {
321+
display: none;
322+
}
323+
314324
// Select Native Wrapper
315325
// ----------------------------------------------------------------
316326

core/src/components/select/select.tsx

Lines changed: 193 additions & 11 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';
@@ -32,6 +33,8 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
3233
/**
3334
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
3435
*
36+
* @slot label - The label text to associate with the select. Use the "labelPlacement" property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML.
37+
*
3538
* @part placeholder - The text displayed in the select when there is no value.
3639
* @part text - The displayed value of the select.
3740
* @part icon - The select icon container.
@@ -54,6 +57,8 @@ export class Select implements ComponentInterface {
5457
private legacyFormController!: LegacyFormController;
5558
private inheritedAttributes: Attributes = {};
5659
private nativeWrapperEl: HTMLElement | undefined;
60+
private notchSpacerEl: HTMLElement | undefined;
61+
private notchVisibilityIO: IntersectionObserver | undefined;
5762

5863
// This flag ensures we log the deprecation warning at most once.
5964
private hasLoggedDeprecationWarning = false;
@@ -124,6 +129,10 @@ export class Select implements ComponentInterface {
124129

125130
/**
126131
* The visible label associated with the select.
132+
*
133+
* Use this if you need to render a plaintext label.
134+
*
135+
* The `label` property will take priority over the `label` slot if both are used.
127136
*/
128137
@Prop() label?: string;
129138

@@ -568,7 +577,7 @@ export class Select implements ComponentInterface {
568577
* TODO FW-3194
569578
* Remove legacyFormController logic.
570579
* Remove label and labelText vars
571-
* Pass `this.label` instead of `labelText`
580+
* Pass `this.labelText` instead of `labelText`
572581
* when setting the header.
573582
*/
574583
let label: HTMLElement | null;
@@ -578,7 +587,7 @@ export class Select implements ComponentInterface {
578587
label = this.getLabel();
579588
labelText = label ? label.textContent : null;
580589
} else {
581-
labelText = this.label;
590+
labelText = this.labelText;
582591
}
583592

584593
const interfaceOptions = this.interfaceOptions;
@@ -651,6 +660,30 @@ export class Select implements ComponentInterface {
651660
return Array.from(this.el.querySelectorAll('ion-select-option'));
652661
}
653662

663+
/**
664+
* Returns any plaintext associated with
665+
* the label (either prop or slot).
666+
* Note: This will not return any custom
667+
* HTML. Use the `hasLabel` getter if you
668+
* want to know if any slotted label content
669+
* was passed.
670+
*/
671+
private get labelText() {
672+
const { label } = this;
673+
674+
if (label !== undefined) {
675+
return label;
676+
}
677+
678+
const { labelSlot } = this;
679+
680+
if (labelSlot !== null) {
681+
return labelSlot.textContent;
682+
}
683+
684+
return;
685+
}
686+
654687
private getText(): string {
655688
const selectedText = this.selectedText;
656689
if (selectedText != null && selectedText !== '') {
@@ -698,17 +731,166 @@ export class Select implements ComponentInterface {
698731

699732
private renderLabel() {
700733
const { label } = this;
701-
if (label === undefined) {
702-
return;
703-
}
704734

705735
return (
706-
<div class="label-text-wrapper" part="label">
707-
<div class="label-text">{this.label}</div>
736+
<div
737+
class={{
738+
'label-text-wrapper': true,
739+
'label-text-wrapper-hidden': !this.hasLabel,
740+
}}
741+
part="label"
742+
>
743+
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
708744
</div>
709745
);
710746
}
711747

748+
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+
}
760+
}
761+
762+
/**
763+
* Gets any content passed into the `label` slot,
764+
* not the <slot> definition.
765+
*/
766+
private get labelSlot() {
767+
return this.el.querySelector('[slot="label"]');
768+
}
769+
770+
/**
771+
* Returns `true` if label content is provided
772+
* either by a prop or a content. If you want
773+
* to get the plaintext value of the label use
774+
* the `labelText` getter instead.
775+
*/
776+
private get hasLabel() {
777+
return this.label !== undefined || this.labelSlot !== null;
778+
}
779+
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+
712894
/**
713895
* Renders the border container
714896
* when fill="outline".
@@ -729,7 +911,7 @@ export class Select implements ComponentInterface {
729911
<div class="select-outline-container">
730912
<div class="select-outline-start"></div>
731913
<div class="select-outline-notch">
732-
<div class="notch-spacer" aria-hidden="true">
914+
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
733915
{this.label}
734916
</div>
735917
</div>
@@ -906,10 +1088,10 @@ Developers can use the "legacy" property to continue using the legacy form marku
9061088
}
9071089

9081090
private get ariaLabel() {
909-
const { placeholder, label, el, inputId, inheritedAttributes } = this;
1091+
const { placeholder, el, inputId, inheritedAttributes } = this;
9101092
const displayValue = this.getText();
9111093
const { labelText } = getAriaLabel(el, inputId);
912-
const definedLabel = label ?? inheritedAttributes['aria-label'] ?? labelText;
1094+
const definedLabel = this.labelText ?? inheritedAttributes['aria-label'] ?? labelText;
9131095

9141096
/**
9151097
* If developer has specified a placeholder

core/src/components/select/test/a11y/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<main>
1616
<h1>Select - a11y</h1>
1717

18+
<ion-select> <div slot="label">Slotted Label</div> </ion-select><br />
1819
<ion-select label="My Visible Label"></ion-select><br />
1920
<ion-select aria-label="My Aria Label"></ion-select><br />
2021
<ion-select label="My Label" placeholder="Placeholder"></ion-select><br />

0 commit comments

Comments
 (0)