Skip to content

Commit 168aad8

Browse files
authored
feat(select): add label slot (#27468)
1 parent 5dd921f commit 168aad8

File tree

9 files changed

+177
-14
lines changed

9 files changed

+177
-14
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
@@ -294,14 +294,24 @@ button {
294294
* works on block-level elements. A flex item is
295295
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
296296
*/
297-
.label-text {
297+
.label-text,
298+
::slotted([slot="label"]) {
298299
text-overflow: ellipsis;
299300

300301
white-space: nowrap;
301302

302303
overflow: hidden;
303304
}
304305

306+
/**
307+
* If no label text is placed into the slot
308+
* then the element should be hidden otherwise
309+
* there will be additional margins added.
310+
*/
311+
.label-text-wrapper-hidden {
312+
display: none;
313+
}
314+
305315
// Select Native Wrapper
306316
// ----------------------------------------------------------------
307317

core/src/components/select/select.tsx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
3232
/**
3333
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
3434
*
35+
* @slot label - 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.
36+
*
3537
* @part placeholder - The text displayed in the select when there is no value.
3638
* @part text - The displayed value of the select.
3739
* @part icon - The select icon container.
40+
*
3841
*/
3942
@Component({
4043
tag: 'ion-select',
@@ -122,6 +125,10 @@ export class Select implements ComponentInterface {
122125

123126
/**
124127
* The visible label associated with the select.
128+
*
129+
* Use this if you need to render a plaintext label.
130+
*
131+
* The `label` property will take priority over the `label` slot if both are used.
125132
*/
126133
@Prop() label?: string;
127134

@@ -566,7 +573,7 @@ export class Select implements ComponentInterface {
566573
* TODO FW-3194
567574
* Remove legacyFormController logic.
568575
* Remove label and labelText vars
569-
* Pass `this.label` instead of `labelText`
576+
* Pass `this.labelText` instead of `labelText`
570577
* when setting the header.
571578
*/
572579
let label: HTMLElement | null;
@@ -576,7 +583,7 @@ export class Select implements ComponentInterface {
576583
label = this.getLabel();
577584
labelText = label ? label.textContent : null;
578585
} else {
579-
labelText = this.label;
586+
labelText = this.labelText;
580587
}
581588

582589
const interfaceOptions = this.interfaceOptions;
@@ -649,6 +656,30 @@ export class Select implements ComponentInterface {
649656
return Array.from(this.el.querySelectorAll('ion-select-option'));
650657
}
651658

659+
/**
660+
* Returns any plaintext associated with
661+
* the label (either prop or slot).
662+
* Note: This will not return any custom
663+
* HTML. Use the `hasLabel` getter if you
664+
* want to know if any slotted label content
665+
* was passed.
666+
*/
667+
private get labelText() {
668+
const { el, label } = this;
669+
670+
if (label !== undefined) {
671+
return label;
672+
}
673+
674+
const labelSlot = el.querySelector('[slot="label"]');
675+
676+
if (labelSlot !== null) {
677+
return labelSlot.textContent;
678+
}
679+
680+
return;
681+
}
682+
652683
private getText(): string {
653684
const selectedText = this.selectedText;
654685
if (selectedText != null && selectedText !== '') {
@@ -696,17 +727,29 @@ export class Select implements ComponentInterface {
696727

697728
private renderLabel() {
698729
const { label } = this;
699-
if (label === undefined) {
700-
return;
701-
}
702730

703731
return (
704-
<div class="label-text-wrapper">
705-
<div class="label-text">{this.label}</div>
732+
<div
733+
class={{
734+
'label-text-wrapper': true,
735+
'label-text-wrapper-hidden': !this.hasLabel,
736+
}}
737+
>
738+
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
706739
</div>
707740
);
708741
}
709742

743+
/**
744+
* Returns `true` if label content is provided
745+
* either by a prop or a content. If you want
746+
* to get the plaintext value of the label use
747+
* the `labelText` getter instead.
748+
*/
749+
private get hasLabel() {
750+
return this.label !== undefined || this.el.querySelector('[slot="label"]') !== null;
751+
}
752+
710753
/**
711754
* Renders the border container
712755
* when fill="outline".
@@ -902,10 +945,10 @@ Developers can use the "legacy" property to continue using the legacy form marku
902945
}
903946

904947
private get ariaLabel() {
905-
const { placeholder, label, el, inputId, inheritedAttributes } = this;
948+
const { placeholder, el, inputId, inheritedAttributes } = this;
906949
const displayValue = this.getText();
907950
const { labelText } = getAriaLabel(el, inputId);
908-
const definedLabel = label ?? inheritedAttributes['aria-label'] ?? labelText;
951+
const definedLabel = this.labelText ?? inheritedAttributes['aria-label'] ?? labelText;
909952

910953
/**
911954
* 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 />

core/src/components/select/test/label/select.e2e.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ configs().forEach(({ title, screenshot, config }) => {
267267

268268
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
269269
test.describe(title('select: label overflow'), () => {
270-
test('label should be truncated with ellipses', async ({ page }) => {
270+
test('label property should be truncated with ellipses', async ({ page }) => {
271271
await page.setContent(
272272
`
273273
<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
278278
const select = page.locator('ion-select');
279279
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-label-truncate`));
280280
});
281+
test('label slot should be truncated with ellipses', async ({ page }) => {
282+
await page.setContent(
283+
`
284+
<ion-select placeholder="Select an Item">
285+
<div slot="label">Label Label Label Label Label</div>
286+
</ion-select>
287+
`,
288+
config
289+
);
290+
291+
const select = page.locator('ion-select');
292+
expect(await select.screenshot()).toMatchSnapshot(screenshot(`select-label-slot-truncate`));
293+
});
281294
});
282295
});
283296
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
284297
test.describe(title('select: alert label'), () => {
285-
test('should use the label to set the default header in an alert', async ({ page }) => {
298+
test('should use the label prop to set the default header in an alert', async ({ page }) => {
286299
await page.setContent(
287300
`
288301
<ion-select label="My Alert" interface="alert">
@@ -301,5 +314,47 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
301314

302315
await expect(alert.locator('.alert-title')).toHaveText('My Alert');
303316
});
317+
test('should use the label slot to set the default header in an alert', async ({ page }) => {
318+
await page.setContent(
319+
`
320+
<ion-select interface="alert">
321+
<div slot="label">My Alert</div>
322+
<ion-select-option value="a">A</ion-select-option>
323+
</ion-select>
324+
`,
325+
config
326+
);
327+
328+
const select = page.locator('ion-select');
329+
const alert = page.locator('ion-alert');
330+
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
331+
332+
await select.click();
333+
await ionAlertDidPresent.next();
334+
335+
await expect(alert.locator('.alert-title')).toHaveText('My Alert');
336+
});
337+
test('should use the label prop to set the default header in an alert if both prop and slot are set', async ({
338+
page,
339+
}) => {
340+
await page.setContent(
341+
`
342+
<ion-select label="My Prop Alert" interface="alert">
343+
<div slot="label">My Slot Alert</div>
344+
<ion-select-option value="a">A</ion-select-option>
345+
</ion-select>
346+
`,
347+
config
348+
);
349+
350+
const select = page.locator('ion-select');
351+
const alert = page.locator('ion-alert');
352+
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
353+
354+
await select.click();
355+
await ionAlertDidPresent.next();
356+
357+
await expect(alert.locator('.alert-title')).toHaveText('My Prop Alert');
358+
});
304359
});
305360
});
9.07 KB
Loading
3.93 KB
Loading
8.77 KB
Loading
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { newSpecPage } from '@stencil/core/testing';
2+
3+
import { Select } from '../select';
4+
5+
describe('ion-select', () => {
6+
it('should render label prop if only prop provided', async () => {
7+
const page = await newSpecPage({
8+
components: [Select],
9+
html: `
10+
<ion-select label="Label Prop Text"></ion-select>
11+
`,
12+
});
13+
14+
const select = page.body.querySelector('ion-select');
15+
16+
const propEl = select.shadowRoot.querySelector('.label-text');
17+
const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
18+
19+
expect(propEl).not.toBe(null);
20+
expect(slotEl).toBe(null);
21+
});
22+
it('should render label slot if only slot provided', async () => {
23+
const page = await newSpecPage({
24+
components: [Select],
25+
html: `
26+
<ion-select><div slot="label">Label Prop Slot</div></ion-select>
27+
`,
28+
});
29+
30+
const select = page.body.querySelector('ion-select');
31+
32+
const propEl = select.shadowRoot.querySelector('.label-text');
33+
const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
34+
35+
expect(propEl).toBe(null);
36+
expect(slotEl).not.toBe(null);
37+
});
38+
it('should render label prop if both prop and slot provided', async () => {
39+
const page = await newSpecPage({
40+
components: [Select],
41+
html: `
42+
<ion-select label="Label Prop Text"><div slot="label">Label Prop Slot</div></ion-select>
43+
`,
44+
});
45+
46+
const select = page.body.querySelector('ion-select');
47+
48+
const propEl = select.shadowRoot.querySelector('.label-text');
49+
const slotEl = select.shadowRoot.querySelector('slot[name="label"]');
50+
51+
expect(propEl).not.toBe(null);
52+
expect(slotEl).toBe(null);
53+
});
54+
});

0 commit comments

Comments
 (0)