4
core/src/components.d.ts
vendored
@ -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;
|
||||
/**
|
||||
|
@ -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
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
|
@ -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,15 +731,164 @@ export class Select implements ComponentInterface {
|
||||
|
||||
private renderLabel() {
|
||||
const { label } = this;
|
||||
if (label === undefined) {
|
||||
|
||||
return (
|
||||
<div
|
||||
class={{
|
||||
'label-text-wrapper': true,
|
||||
'label-text-wrapper-hidden': !this.hasLabel,
|
||||
}}
|
||||
part="label"
|
||||
>
|
||||
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <slot> 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="label-text-wrapper" part="label">
|
||||
<div class="label-text">{this.label}</div>
|
||||
</div>
|
||||
);
|
||||
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`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -729,7 +911,7 @@ export class Select implements ComponentInterface {
|
||||
<div class="select-outline-container">
|
||||
<div class="select-outline-start"></div>
|
||||
<div class="select-outline-notch">
|
||||
<div class="notch-spacer" aria-hidden="true">
|
||||
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
|
||||
{this.label}
|
||||
</div>
|
||||
</div>
|
||||
@ -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
|
||||
|
@ -15,6 +15,7 @@
|
||||
<main>
|
||||
<h1>Select - a11y</h1>
|
||||
|
||||
<ion-select> <div slot="label">Slotted Label</div> </ion-select><br />
|
||||
<ion-select label="My Visible Label"></ion-select><br />
|
||||
<ion-select aria-label="My Aria Label"></ion-select><br />
|
||||
<ion-select label="My Label" placeholder="Placeholder"></ion-select><br />
|
||||
|
@ -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(
|
||||
`
|
||||
<style>
|
||||
.custom-label {
|
||||
font-size: 30px;
|
||||
}
|
||||
</style>
|
||||
<ion-select
|
||||
fill="outline"
|
||||
label-placement="stacked"
|
||||
value="apple"
|
||||
>
|
||||
<div slot="label" class="custom-label">My Label Content</div>
|
||||
<ion-select-option value="apple">Apple</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<style>
|
||||
.custom-label {
|
||||
font-size: 30px;
|
||||
}
|
||||
</style>
|
||||
<ion-select
|
||||
fill="outline"
|
||||
label-placement="stacked"
|
||||
value="apple"
|
||||
style="display: none"
|
||||
>
|
||||
<div slot="label" class="custom-label">My Label Content</div>
|
||||
<ion-select-option value="apple">Apple</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 11 KiB |
@ -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(
|
||||
`
|
||||
<ion-select label="Label Label Label Label Label" placeholder="Select an Item"></ion-select>
|
||||
@ -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(
|
||||
`
|
||||
<ion-select placeholder="Select an Item">
|
||||
<div slot="label">Label Label Label Label Label</div>
|
||||
</ion-select>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-select label="My Alert" interface="alert">
|
||||
@ -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(
|
||||
`
|
||||
<ion-select interface="alert">
|
||||
<div slot="label">My Alert</div>
|
||||
<ion-select-option value="a">A</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
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(
|
||||
`
|
||||
<ion-select label="My Prop Alert" interface="alert">
|
||||
<div slot="label">My Slot Alert</div>
|
||||
<ion-select-option value="a">A</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 8.8 KiB |
@ -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: `
|
||||
<ion-select label="Label Prop Text"></ion-select>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<ion-select><div slot="label">Label Prop Slot</div></ion-select>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<ion-select label="Label Prop Text"><div slot="label">Label Prop Slot</div></ion-select>
|
||||
`,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|