Skip to content

Commit 8bcd9e8

Browse files
feat(textarea): add experimental label slot (#27677)
Issue number: resolves #27061 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Textarea does not accept custom HTML labels ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Textarea accepts custom HTML labels as an experimental feature. We marked this as experimental because it makes use of "scoped slots" which is an emulated version of Web Component slots. As a result, there may be instances where the slot behavior does not exactly match the native slot behavior. Note to reviewers: This is a combination of previously reviewed PRs. The implementation is complete, so feel free to bikeshed. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Docs PR: ionic-team/ionic-docs#3001 --------- Co-authored-by: ionitron <[email protected]>
1 parent 95e28b6 commit 8bcd9e8

File tree

60 files changed

+440
-43
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+440
-43
lines changed

core/src/components.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2992,7 +2992,7 @@ export namespace Components {
29922992
*/
29932993
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
29942994
/**
2995-
* The visible label associated with the textarea.
2995+
* The visible label associated with the textarea. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
29962996
*/
29972997
"label"?: string;
29982998
/**
@@ -7090,7 +7090,7 @@ declare namespace LocalJSX {
70907090
*/
70917091
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
70927092
/**
7093-
* The visible label associated with the textarea.
7093+
* The visible label associated with the textarea. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
70947094
*/
70957095
"label"?: string;
70967096
/**

core/src/components/textarea/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>Textarea - a11y</h1>
1717

18+
<ion-textarea><div slot="label">Slotted Label</div></ion-textarea><br />
1819
<ion-textarea label="my label"></ion-textarea><br />
1920
<ion-textarea aria-label="my aria label"></ion-textarea><br />
2021
<ion-textarea label="Email" label-placement="stacked" value="[email protected]"></ion-textarea>

core/src/components/textarea/test/fill/textarea.e2e.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
1717
helper-text="Enter your email"
1818
maxlength="20"
1919
counter="true"
20-
></ion-input>
20+
></ion-textarea>
2121
`,
2222
config
2323
);
@@ -180,3 +180,74 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
180180
});
181181
});
182182
});
183+
184+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
185+
test.describe(title('textarea: notch cutout'), () => {
186+
test('notch cutout should be hidden when no label is passed', async ({ page }) => {
187+
await page.setContent(
188+
`
189+
<ion-textarea fill="outline" label-placement="stacked" aria-label="my textarea"></ion-textarea>
190+
`,
191+
config
192+
);
193+
194+
const notchCutout = page.locator('ion-textarea .textarea-outline-notch');
195+
await expect(notchCutout).toBeHidden();
196+
});
197+
});
198+
});
199+
200+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
201+
test.describe(title('textarea: label slot'), () => {
202+
test('should render the notch correctly with a slotted label', async ({ page }) => {
203+
await page.setContent(
204+
`
205+
<style>
206+
.custom-label {
207+
font-size: 30px;
208+
}
209+
</style>
210+
<ion-textarea
211+
fill="outline"
212+
label-placement="stacked"
213+
value="apple"
214+
>
215+
<div slot="label" class="custom-label">My Label Content</div>
216+
</ion-textarea>
217+
`,
218+
config
219+
);
220+
221+
const textarea = page.locator('ion-textarea');
222+
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-fill-outline-slotted-label`));
223+
});
224+
test('should render the notch correctly with a slotted label after the textarea was originally hidden', async ({
225+
page,
226+
}) => {
227+
await page.setContent(
228+
`
229+
<style>
230+
.custom-label {
231+
font-size: 30px;
232+
}
233+
</style>
234+
<ion-textarea
235+
fill="outline"
236+
label-placement="stacked"
237+
value="apple"
238+
style="display: none"
239+
>
240+
<div slot="label" class="custom-label">My Label Content</div>
241+
</ion-textarea>
242+
`,
243+
config
244+
);
245+
246+
const textarea = page.locator('ion-textarea');
247+
248+
await textarea.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
249+
250+
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-fill-outline-hidden-slotted-label`));
251+
});
252+
});
253+
});

core/src/components/textarea/test/label-placement/textarea.e2e.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,6 @@ configs().forEach(({ title, screenshot, config }) => {
2525
const textarea = page.locator('ion-textarea');
2626
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-start-multi-line-value`));
2727
});
28-
29-
test('label should be truncated', async ({ page }) => {
30-
await page.setContent(
31-
`
32-
<ion-textarea label="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." label-placement="start"></ion-textarea>
33-
`,
34-
config
35-
);
36-
37-
const textarea = page.locator('ion-textarea');
38-
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-start-label-truncated`));
39-
});
4028
});
4129
test.describe(title('textarea: label placement end'), () => {
4230
test('label should appear on the ending side of the textarea', async ({ page }) => {
@@ -61,17 +49,6 @@ configs().forEach(({ title, screenshot, config }) => {
6149
const textarea = page.locator('ion-textarea');
6250
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-end-multi-line-value`));
6351
});
64-
test('label should be truncated', async ({ page }) => {
65-
await page.setContent(
66-
`
67-
<ion-textarea label="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." label-placement="end"></ion-textarea>
68-
`,
69-
config
70-
);
71-
72-
const textarea = page.locator('ion-textarea');
73-
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-end-label-truncated`));
74-
});
7552
});
7653
test.describe(title('textarea: label placement fixed'), () => {
7754
test('label should appear on the starting side of the textarea and have a fixed width', async ({ page }) => {
@@ -234,3 +211,59 @@ configs().forEach(({ title, screenshot, config }) => {
234211
});
235212
});
236213
});
214+
215+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
216+
test.describe(title('textarea: label overflow'), () => {
217+
test('label property should be truncated with an ellipsis', async ({ page }) => {
218+
await page.setContent(
219+
`
220+
<ion-textarea label="Label Label Label Label Label" placeholder="Text Input"></ion-textarea>
221+
`,
222+
config
223+
);
224+
225+
const textarea = page.locator('ion-textarea');
226+
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-label-truncate`));
227+
});
228+
test('label slot should be truncated with an ellipsis', async ({ page }) => {
229+
await page.setContent(
230+
`
231+
<ion-textarea placeholder="Text Input">
232+
<div slot="label">Label Label Label Label Label</div>
233+
</ion-textarea>
234+
`,
235+
config
236+
);
237+
238+
const textarea = page.locator('ion-textarea');
239+
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-label-slot-truncate`));
240+
});
241+
});
242+
});
243+
244+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
245+
test.describe(title('textarea: async label'), () => {
246+
test('textarea should re-render when label slot is added async', async ({ page }) => {
247+
await page.setContent(
248+
`
249+
<ion-textarea fill="solid" label-placement="stacked" placeholder="Text Input"></ion-textarea>
250+
`,
251+
config
252+
);
253+
254+
const textarea = page.locator('ion-textarea');
255+
256+
await textarea.evaluate((el: HTMLIonInputElement) => {
257+
const labelEl = document.createElement('div');
258+
labelEl.slot = 'label';
259+
labelEl.innerHTML = 'Comments <span class="required" style="color: red">*</span';
260+
261+
el.appendChild(labelEl);
262+
});
263+
264+
await page.waitForChanges();
265+
266+
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-async-label`));
267+
});
268+
});
269+
});
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Textarea - Slot</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
.grid {
17+
display: grid;
18+
grid-template-columns: repeat(3, minmax(250px, 1fr));
19+
grid-row-gap: 20px;
20+
grid-column-gap: 20px;
21+
}
22+
h2 {
23+
font-size: 12px;
24+
font-weight: normal;
25+
26+
color: #6f7378;
27+
28+
margin-top: 10px;
29+
}
30+
@media screen and (max-width: 800px) {
31+
.grid {
32+
grid-template-columns: 1fr;
33+
padding: 0;
34+
}
35+
}
36+
37+
.required {
38+
color: red;
39+
}
40+
</style>
41+
</head>
42+
43+
<body>
44+
<ion-app>
45+
<ion-header>
46+
<ion-toolbar>
47+
<ion-title>Textarea - Slot</ion-title>
48+
</ion-toolbar>
49+
</ion-header>
50+
51+
<ion-content id="content" class="ion-padding">
52+
<div class="grid">
53+
<div class="grid-item">
54+
<h2>No Fill / Start</h2>
55+
<ion-textarea label-placement="start" value="[email protected]">
56+
<div slot="label">Email <span class="required">*</span></div>
57+
</ion-textarea>
58+
</div>
59+
60+
<div class="grid-item">
61+
<h2>Solid / Start</h2>
62+
<ion-textarea label-placement="start" fill="solid" value="[email protected]">
63+
<div slot="label">Email <span class="required">*</span></div>
64+
</ion-textarea>
65+
</div>
66+
67+
<div class="grid-item">
68+
<h2>Outline / Start</h2>
69+
<ion-textarea label-placement="start" fill="outline" value="[email protected]">
70+
<div slot="label">Email <span class="required">*</span></div>
71+
</ion-textarea>
72+
</div>
73+
74+
<div class="grid-item">
75+
<h2>No Fill / Floating</h2>
76+
<ion-textarea label-placement="floating" value="[email protected]">
77+
<div slot="label">Email <span class="required">*</span></div>
78+
</ion-textarea>
79+
</div>
80+
81+
<div class="grid-item">
82+
<h2>Solid / Floating</h2>
83+
<ion-textarea label-placement="floating" fill="solid" value="[email protected]">
84+
<div slot="label">Email <span class="required">*</span></div>
85+
</ion-textarea>
86+
</div>
87+
88+
<div class="grid-item">
89+
<h2>Outline / Floating</h2>
90+
<ion-textarea label-placement="floating" fill="outline" value="[email protected]">
91+
<div slot="label">Email <span class="required">*</span></div>
92+
</ion-textarea>
93+
</div>
94+
95+
<div class="grid-item">
96+
<h2>Outline / Floating / Async</h2>
97+
<ion-textarea id="solid-async" label-placement="floating" fill="outline" value="[email protected]"></ion-textarea>
98+
</div>
99+
</div>
100+
101+
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
102+
<ion-button onclick="updateSlot()">Update Slotted Content</ion-button>
103+
<ion-button onclick="removeSlot()">Remove Slotted Content</ion-button>
104+
</ion-content>
105+
</ion-app>
106+
107+
<script>
108+
const solidAsync = document.querySelector('#solid-async');
109+
110+
const getSlottedContent = () => {
111+
return solidAsync.querySelector('[slot="label"]');
112+
};
113+
114+
const addSlot = () => {
115+
if (getSlottedContent() === null) {
116+
const labelEl = document.createElement('div');
117+
labelEl.slot = 'label';
118+
labelEl.innerHTML = 'Comments <span class="required">*</span>';
119+
120+
solidAsync.appendChild(labelEl);
121+
}
122+
};
123+
124+
const removeSlot = () => {
125+
if (getSlottedContent() !== null) {
126+
solidAsync.querySelector('[slot="label"]').remove();
127+
}
128+
};
129+
130+
const updateSlot = () => {
131+
const slottedContent = getSlottedContent();
132+
133+
if (slottedContent !== null) {
134+
slottedContent.textContent = 'This is my really really really long text';
135+
}
136+
};
137+
</script>
138+
</body>
139+
</html>

0 commit comments

Comments
 (0)