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 {
- @@ -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); + }); });