diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index 2883d8eb438..cbe42da6d33 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -2698,7 +2698,7 @@ export namespace Components {
*/
"justify": 'start' | 'end' | 'space-between';
/**
- * The visible label associated with the select.
+ * 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.
*/
"label"?: string;
/**
@@ -6772,7 +6772,7 @@ declare namespace LocalJSX {
*/
"justify"?: 'start' | 'end' | 'space-between';
/**
- * The visible label associated with the select.
+ * 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.
*/
"label"?: string;
/**
diff --git a/core/src/components/select/select.scss b/core/src/components/select/select.scss
index a33b4cb837d..e223e1ca674 100644
--- a/core/src/components/select/select.scss
+++ b/core/src/components/select/select.scss
@@ -303,7 +303,8 @@ button {
* works on block-level elements. A flex item is
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
*/
-.label-text {
+.label-text,
+::slotted([slot="label"]) {
text-overflow: ellipsis;
white-space: nowrap;
@@ -311,6 +312,15 @@ button {
overflow: hidden;
}
+/**
+ * If no label text is placed into the slot
+ * then the element should be hidden otherwise
+ * there will be additional margins added.
+ */
+.label-text-wrapper-hidden {
+ display: none;
+}
+
// Select Native Wrapper
// ----------------------------------------------------------------
diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx
index 1050749ca77..0592e5f50f5 100644
--- a/core/src/components/select/select.tsx
+++ b/core/src/components/select/select.tsx
@@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
import type { LegacyFormController } from '@utils/forms';
import { createLegacyFormController } from '@utils/forms';
-import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
+import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
@@ -10,6 +10,7 @@ import type { OverlaySelect } from '@utils/overlays-interface';
import { isRTL } from '@utils/rtl';
import { createColorClasses, hostContext } from '@utils/theme';
import { watchForOptions } from '@utils/watch-options';
+import { win } from '@utils/window';
import { caretDownSharp, chevronExpand } from 'ionicons/icons';
import { getIonMode } from '../../global/ionic-global';
@@ -32,6 +33,8 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
+ * @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.
+ *
* @part placeholder - The text displayed in the select when there is no value.
* @part text - The displayed value of the select.
* @part icon - The select icon container.
@@ -54,6 +57,8 @@ export class Select implements ComponentInterface {
private legacyFormController!: LegacyFormController;
private inheritedAttributes: Attributes = {};
private nativeWrapperEl: HTMLElement | undefined;
+ private notchSpacerEl: HTMLElement | undefined;
+ private notchVisibilityIO: IntersectionObserver | undefined;
// This flag ensures we log the deprecation warning at most once.
private hasLoggedDeprecationWarning = false;
@@ -124,6 +129,10 @@ export class Select implements ComponentInterface {
/**
* 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.
*/
@Prop() label?: string;
@@ -568,7 +577,7 @@ export class Select implements ComponentInterface {
* TODO FW-3194
* Remove legacyFormController logic.
* Remove label and labelText vars
- * Pass `this.label` instead of `labelText`
+ * Pass `this.labelText` instead of `labelText`
* when setting the header.
*/
let label: HTMLElement | null;
@@ -578,7 +587,7 @@ export class Select implements ComponentInterface {
label = this.getLabel();
labelText = label ? label.textContent : null;
} else {
- labelText = this.label;
+ labelText = this.labelText;
}
const interfaceOptions = this.interfaceOptions;
@@ -651,6 +660,30 @@ export class Select implements ComponentInterface {
return Array.from(this.el.querySelectorAll('ion-select-option'));
}
+ /**
+ * Returns any plaintext associated with
+ * the label (either prop or slot).
+ * Note: This will not return any custom
+ * HTML. Use the `hasLabel` getter if you
+ * want to know if any slotted label content
+ * was passed.
+ */
+ private get labelText() {
+ const { label } = this;
+
+ if (label !== undefined) {
+ return label;
+ }
+
+ const { labelSlot } = this;
+
+ if (labelSlot !== null) {
+ return labelSlot.textContent;
+ }
+
+ return;
+ }
+
private getText(): string {
const selectedText = this.selectedText;
if (selectedText != null && selectedText !== '') {
@@ -698,17 +731,166 @@ export class Select implements ComponentInterface {
private renderLabel() {
const { label } = this;
- if (label === undefined) {
- return;
- }
return (
-
-
{this.label}
+
+ {label === undefined ?
:
{label}
}
);
}
+ componentDidRender() {
+ if (this.needsExplicitNotchWidth()) {
+ /**
+ * Run this the frame after
+ * the browser has re-painted the select.
+ * Otherwise, the label element may have a width
+ * of 0 and the IntersectionObserver will be used.
+ */
+ raf(() => {
+ this.setNotchWidth();
+ });
+ }
+ }
+
+ /**
+ * Gets any content passed into the `label` slot,
+ * not the
definition.
+ */
+ private get labelSlot() {
+ return this.el.querySelector('[slot="label"]');
+ }
+
+ /**
+ * Returns `true` if label content is provided
+ * either by a prop or a content. If you want
+ * to get the plaintext value of the label use
+ * the `labelText` getter instead.
+ */
+ private get hasLabel() {
+ return this.label !== undefined || this.labelSlot !== null;
+ }
+
+ private needsExplicitNotchWidth() {
+ if (
+ /**
+ * If the notch is not being used
+ * then we do not need to set the notch width.
+ */
+ this.notchSpacerEl === undefined ||
+ /**
+ * If either the label property is being
+ * used or the label slot is not defined,
+ * then we do not need to estimate the notch width.
+ */
+ this.label !== undefined ||
+ this.labelSlot === null
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * When using a label prop we can render
+ * the label value inside of the notch and
+ * let the browser calculate the size of the notch.
+ * However, we cannot render the label slot in multiple
+ * places so we need to manually calculate the notch dimension
+ * based on the size of the slotted content.
+ *
+ * This function should only be used to set the notch width
+ * on slotted label content. The notch width for label prop
+ * content is automatically calculated based on the
+ * intrinsic size of the label text.
+ */
+ private setNotchWidth() {
+ const { el, notchSpacerEl } = this;
+
+ if (notchSpacerEl === undefined) {
+ return;
+ }
+
+ if (!this.needsExplicitNotchWidth()) {
+ notchSpacerEl.style.removeProperty('width');
+ return;
+ }
+
+ const width = this.labelSlot!.scrollWidth;
+ if (
+ /**
+ * If the computed width of the label is 0
+ * and notchSpacerEl's offsetParent is null
+ * then that means the element is hidden.
+ * As a result, we need to wait for the element
+ * to become visible before setting the notch width.
+ *
+ * We do not check el.offsetParent because
+ * that can be null if ion-select has
+ * position: fixed applied to it.
+ * notchSpacerEl does not have position: fixed.
+ */
+ width === 0 &&
+ notchSpacerEl.offsetParent === null &&
+ win !== undefined &&
+ 'IntersectionObserver' in win
+ ) {
+ /**
+ * If there is an IO already attached
+ * then that will update the notch
+ * once the element becomes visible.
+ * As a result, there is no need to create
+ * another one.
+ */
+ if (this.notchVisibilityIO !== undefined) {
+ return;
+ }
+
+ const io = (this.notchVisibilityIO = new IntersectionObserver(
+ (ev) => {
+ /**
+ * If the element is visible then we
+ * can try setting the notch width again.
+ */
+ if (ev[0].intersectionRatio === 1) {
+ this.setNotchWidth();
+ io.disconnect();
+ this.notchVisibilityIO = undefined;
+ }
+ },
+ /**
+ * Set the root to be the select
+ * This causes the IO callback
+ * to be fired in WebKit as soon as the element
+ * is visible. If we used the default root value
+ * then WebKit would only fire the IO callback
+ * after any animations (such as a modal transition)
+ * finished, and there would potentially be a flicker.
+ */
+ { threshold: 0.01, root: el }
+ ));
+
+ io.observe(notchSpacerEl);
+ return;
+ }
+
+ /**
+ * If the element is visible then we can set the notch width.
+ * The notch is only visible when the label is scaled,
+ * which is why we multiply the width by 0.75 as this is
+ * the same amount the label element is scaled by in the
+ * select CSS (See $select-floating-label-scale in select.vars.scss).
+ */
+ notchSpacerEl.style.setProperty('width', `${width * 0.75}px`);
+ }
+
/**
* Renders the border container
* when fill="outline".
@@ -729,7 +911,7 @@ export class Select implements ComponentInterface {
-
+
(this.notchSpacerEl = el)}>
{this.label}
@@ -906,10 +1088,10 @@ Developers can use the "legacy" property to continue using the legacy form marku
}
private get ariaLabel() {
- const { placeholder, label, el, inputId, inheritedAttributes } = this;
+ const { placeholder, el, inputId, inheritedAttributes } = this;
const displayValue = this.getText();
const { labelText } = getAriaLabel(el, inputId);
- const definedLabel = label ?? inheritedAttributes['aria-label'] ?? labelText;
+ const definedLabel = this.labelText ?? inheritedAttributes['aria-label'] ?? labelText;
/**
* If developer has specified a placeholder
diff --git a/core/src/components/select/test/a11y/index.html b/core/src/components/select/test/a11y/index.html
index fd7b9bd55f8..cd859593888 100644
--- a/core/src/components/select/test/a11y/index.html
+++ b/core/src/components/select/test/a11y/index.html
@@ -15,6 +15,7 @@
Select - a11y
+ Slotted Label
diff --git a/core/src/components/select/test/fill/select.e2e.ts b/core/src/components/select/test/fill/select.e2e.ts
index 767191fbdfa..cfd282d7854 100644
--- a/core/src/components/select/test/fill/select.e2e.ts
+++ b/core/src/components/select/test/fill/select.e2e.ts
@@ -120,6 +120,7 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
const select = page.locator('ion-select');
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-label-floating`));
});
+
test('should not have visual regressions with shaped outline', async ({ page }) => {
await page.setContent(
`
@@ -167,3 +168,60 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
});
});
});
+
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
+ test.describe(title('select: label slot'), () => {
+ test('should render the notch correctly with a slotted label', async ({ page }) => {
+ await page.setContent(
+ `
+
+
+ My Label Content
+ Apple
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select');
+ expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-slotted-label`));
+ });
+ test('should render the notch correctly with a slotted label after the select was originally hidden', async ({
+ page,
+ }) => {
+ await page.setContent(
+ `
+
+
+ My Label Content
+ Apple
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select');
+
+ await select.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
+
+ expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-fill-outline-hidden-slotted-label`));
+ });
+ });
+});
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..e189c85fe0e
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..79d572bee45
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..0344c584fe7
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..e189c85fe0e
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..79d572bee45
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..0344c584fe7
Binary files /dev/null and b/core/src/components/select/test/fill/select.e2e.ts-snapshots/select-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/select/test/label/select.e2e.ts b/core/src/components/select/test/label/select.e2e.ts
index 1989ffab301..f9c0c570f0a 100644
--- a/core/src/components/select/test/label/select.e2e.ts
+++ b/core/src/components/select/test/label/select.e2e.ts
@@ -267,7 +267,7 @@ configs().forEach(({ title, screenshot, config }) => {
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('select: label overflow'), () => {
- test('label should be truncated with ellipses', async ({ page }) => {
+ test('label property should be truncated with ellipses', async ({ page }) => {
await page.setContent(
`
@@ -278,11 +278,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
const select = page.locator('ion-select');
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-label-truncate`));
});
+ test('label slot should be truncated with ellipses', async ({ page }) => {
+ await page.setContent(
+ `
+
+ Label Label Label Label Label
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select');
+ expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-label-slot-truncate`));
+ });
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select: alert label'), () => {
- test('should use the label to set the default header in an alert', async ({ page }) => {
+ test('should use the label prop to set the default header in an alert', async ({ page }) => {
await page.setContent(
`
@@ -301,5 +314,47 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(alert.locator('.alert-title')).toHaveText('My Alert');
});
+ test('should use the label slot to set the default header in an alert', async ({ page }) => {
+ await page.setContent(
+ `
+
+ My Alert
+ A
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select');
+ const alert = page.locator('ion-alert');
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+
+ await select.click();
+ await ionAlertDidPresent.next();
+
+ await expect(alert.locator('.alert-title')).toHaveText('My Alert');
+ });
+ test('should use the label prop to set the default header in an alert if both prop and slot are set', async ({
+ page,
+ }) => {
+ await page.setContent(
+ `
+
+ My Slot Alert
+ A
+
+ `,
+ config
+ );
+
+ const select = page.locator('ion-select');
+ const alert = page.locator('ion-alert');
+ const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
+
+ await select.click();
+ await ionAlertDidPresent.next();
+
+ await expect(alert.locator('.alert-title')).toHaveText('My Prop Alert');
+ });
});
});
diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..5acafac7d00
Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..6d9f6a7c909
Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..60b208fee5e
Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-slot-truncate-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx
index 3d4620571a9..aa6436f3380 100644
--- a/core/src/components/select/test/select.spec.tsx
+++ b/core/src/components/select/test/select.spec.tsx
@@ -19,4 +19,53 @@ describe('ion-select', () => {
expect(hiddenInput.disabled).toBe(true);
expect(hiddenInput.name).toBe('my name');
});
+
+ it('should render label prop if only prop provided', async () => {
+ const page = await newSpecPage({
+ components: [Select],
+ html: `
+
+ `,
+ });
+
+ const select = page.body.querySelector('ion-select');
+
+ const propEl = select.shadowRoot.querySelector('.label-text');
+ const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
+
+ expect(propEl).not.toBe(null);
+ expect(slotEl).toBe(null);
+ });
+ it('should render label slot if only slot provided', async () => {
+ const page = await newSpecPage({
+ components: [Select],
+ html: `
+ Label Prop Slot
+ `,
+ });
+
+ const select = page.body.querySelector('ion-select');
+
+ const propEl = select.shadowRoot.querySelector('.label-text');
+ const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
+
+ expect(propEl).toBe(null);
+ expect(slotEl).not.toBe(null);
+ });
+ it('should render label prop if both prop and slot provided', async () => {
+ const page = await newSpecPage({
+ components: [Select],
+ html: `
+ Label Prop Slot
+ `,
+ });
+
+ const select = page.body.querySelector('ion-select');
+
+ const propEl = select.shadowRoot.querySelector('.label-text');
+ const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
+
+ expect(propEl).not.toBe(null);
+ expect(slotEl).toBe(null);
+ });
});