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: https://github.com/ionic-team/ionic-docs/pull/3001 --------- Co-authored-by: ionitron <hi@ionicframework.com>
@ -15,6 +15,7 @@
|
||||
<main>
|
||||
<h1>Textarea - a11y</h1>
|
||||
|
||||
<ion-textarea><div slot="label">Slotted Label</div></ion-textarea><br />
|
||||
<ion-textarea label="my label"></ion-textarea><br />
|
||||
<ion-textarea aria-label="my aria label"></ion-textarea><br />
|
||||
<ion-textarea label="Email" label-placement="stacked" value="hi@ionic.io"></ion-textarea>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.5 KiB |
@ -17,7 +17,7 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
|
||||
helper-text="Enter your email"
|
||||
maxlength="20"
|
||||
counter="true"
|
||||
></ion-input>
|
||||
></ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
@ -180,3 +180,74 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('textarea: notch cutout'), () => {
|
||||
test('notch cutout should be hidden when no label is passed', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea fill="outline" label-placement="stacked" aria-label="my textarea"></ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const notchCutout = page.locator('ion-textarea .textarea-outline-notch');
|
||||
await expect(notchCutout).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('textarea: 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-textarea
|
||||
fill="outline"
|
||||
label-placement="stacked"
|
||||
value="apple"
|
||||
>
|
||||
<div slot="label" class="custom-label">My Label Content</div>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-fill-outline-slotted-label`));
|
||||
});
|
||||
test('should render the notch correctly with a slotted label after the textarea was originally hidden', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
.custom-label {
|
||||
font-size: 30px;
|
||||
}
|
||||
</style>
|
||||
<ion-textarea
|
||||
fill="outline"
|
||||
label-placement="stacked"
|
||||
value="apple"
|
||||
style="display: none"
|
||||
>
|
||||
<div slot="label" class="custom-label">My Label Content</div>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
|
||||
await textarea.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
|
||||
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-fill-outline-hidden-slotted-label`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
@ -25,18 +25,6 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-start-multi-line-value`));
|
||||
});
|
||||
|
||||
test('label should be truncated', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<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>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-start-label-truncated`));
|
||||
});
|
||||
});
|
||||
test.describe(title('textarea: label placement end'), () => {
|
||||
test('label should appear on the ending side of the textarea', async ({ page }) => {
|
||||
@ -61,17 +49,6 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-end-multi-line-value`));
|
||||
});
|
||||
test('label should be truncated', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<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>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-placement-end-label-truncated`));
|
||||
});
|
||||
});
|
||||
test.describe(title('textarea: label placement fixed'), () => {
|
||||
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 }) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('textarea: label overflow'), () => {
|
||||
test('label property should be truncated with an ellipsis', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea label="Label Label Label Label Label" placeholder="Text Input"></ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-label-truncate`));
|
||||
});
|
||||
test('label slot should be truncated with an ellipsis', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea placeholder="Text Input">
|
||||
<div slot="label">Label Label Label Label Label</div>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-label-slot-truncate`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('textarea: async label'), () => {
|
||||
test('textarea should re-render when label slot is added async', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea fill="solid" label-placement="stacked" placeholder="Text Input"></ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
|
||||
await textarea.evaluate((el: HTMLIonInputElement) => {
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.slot = 'label';
|
||||
labelEl.innerHTML = 'Comments <span class="required" style="color: red">*</span';
|
||||
|
||||
el.appendChild(labelEl);
|
||||
});
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(await textarea.screenshot()).toMatchSnapshot(screenshot(`textarea-async-label`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
139
core/src/components/textarea/test/slot/index.html
Normal file
@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Textarea - Slot</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(250px, 1fr));
|
||||
grid-row-gap: 20px;
|
||||
grid-column-gap: 20px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
|
||||
color: #6f7378;
|
||||
|
||||
margin-top: 10px;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Textarea - Slot</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content id="content" class="ion-padding">
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start</h2>
|
||||
<ion-textarea label-placement="start" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Start</h2>
|
||||
<ion-textarea label-placement="start" fill="solid" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start</h2>
|
||||
<ion-textarea label-placement="start" fill="outline" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Floating</h2>
|
||||
<ion-textarea label-placement="floating" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Floating</h2>
|
||||
<ion-textarea label-placement="floating" fill="solid" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating</h2>
|
||||
<ion-textarea label-placement="floating" fill="outline" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating / Async</h2>
|
||||
<ion-textarea id="solid-async" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
|
||||
<ion-button onclick="updateSlot()">Update Slotted Content</ion-button>
|
||||
<ion-button onclick="removeSlot()">Remove Slotted Content</ion-button>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const solidAsync = document.querySelector('#solid-async');
|
||||
|
||||
const getSlottedContent = () => {
|
||||
return solidAsync.querySelector('[slot="label"]');
|
||||
};
|
||||
|
||||
const addSlot = () => {
|
||||
if (getSlottedContent() === null) {
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.slot = 'label';
|
||||
labelEl.innerHTML = 'Comments <span class="required">*</span>';
|
||||
|
||||
solidAsync.appendChild(labelEl);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSlot = () => {
|
||||
if (getSlottedContent() !== null) {
|
||||
solidAsync.querySelector('[slot="label"]').remove();
|
||||
}
|
||||
};
|
||||
|
||||
const updateSlot = () => {
|
||||
const slottedContent = getSlottedContent();
|
||||
|
||||
if (slottedContent !== null) {
|
||||
slottedContent.textContent = 'This is my really really really long text';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -12,3 +12,57 @@ it('should inherit attributes', async () => {
|
||||
expect(nativeEl.getAttribute('tabindex')).toBe('-1');
|
||||
expect(nativeEl.getAttribute('data-form-type')).toBe('password');
|
||||
});
|
||||
|
||||
/**
|
||||
* Textarea uses emulated slots, so the internal
|
||||
* behavior will not exactly match IonSelect's slots.
|
||||
* For example, Textarea does not render an actual `<slot>` element
|
||||
* internally, so we do not check for that here. Instead,
|
||||
* we check to see which label text is being used.
|
||||
* If Textarea is updated to use Shadow DOM (and therefore native slots),
|
||||
* then we can update these tests to more closely match the Select tests.
|
||||
**/
|
||||
describe('textarea: label rendering', () => {
|
||||
it('should render label prop if only prop provided', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Textarea],
|
||||
html: `
|
||||
<ion-textarea label="Label Prop Text"></ion-textarea>
|
||||
`,
|
||||
});
|
||||
|
||||
const textarea = page.body.querySelector('ion-textarea');
|
||||
|
||||
const labelText = textarea.querySelector('.label-text-wrapper');
|
||||
|
||||
expect(labelText.textContent).toBe('Label Prop Text');
|
||||
});
|
||||
it('should render label slot if only slot provided', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Textarea],
|
||||
html: `
|
||||
<ion-textarea><div slot="label">Label Prop Slot</div></ion-textarea>
|
||||
`,
|
||||
});
|
||||
|
||||
const textarea = page.body.querySelector('ion-textarea');
|
||||
|
||||
const labelText = textarea.querySelector('.label-text-wrapper');
|
||||
|
||||
expect(labelText.textContent).toBe('Label Prop Slot');
|
||||
});
|
||||
it('should render label prop if both prop and slot provided', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Textarea],
|
||||
html: `
|
||||
<ion-textarea label="Label Prop Text"><div slot="label">Label Prop Slot</div></ion-textarea>
|
||||
`,
|
||||
});
|
||||
|
||||
const textarea = page.body.querySelector('ion-textarea');
|
||||
|
||||
const labelText = textarea.querySelector('.label-text-wrapper');
|
||||
|
||||
expect(labelText.textContent).toBe('Label Prop Text');
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,6 +176,16 @@
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
/**
|
||||
* The spacer currently inherits
|
||||
* border-box sizing from the Ionic reset styles.
|
||||
* However, we do not want to include padding in
|
||||
* the calculation of the element dimensions.
|
||||
* This code can be removed if textarea is updated
|
||||
* to use the Shadow DOM.
|
||||
*/
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
:host(.textarea-fill-outline) .textarea-outline-start {
|
||||
|
||||
@ -62,8 +62,6 @@
|
||||
|
||||
font-family: $font-family-base;
|
||||
|
||||
white-space: pre-wrap;
|
||||
|
||||
z-index: $z-index-item-input;
|
||||
|
||||
box-sizing: border-box;
|
||||
@ -74,6 +72,8 @@
|
||||
flex: 1;
|
||||
|
||||
background: var(--background);
|
||||
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// TODO: FW-2876 - Remove this selector
|
||||
@ -131,9 +131,8 @@
|
||||
outline: none;
|
||||
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
appearance: none;
|
||||
|
||||
white-space: pre-wrap;
|
||||
|
||||
/**
|
||||
* This ensures the textarea
|
||||
@ -145,6 +144,9 @@
|
||||
* contrast of the textarea.
|
||||
*/
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
appearance: none;
|
||||
|
||||
&::placeholder {
|
||||
@include padding(0);
|
||||
@ -159,6 +161,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: FW-2876 - Remove this selector
|
||||
:host(.legacy-textarea) .native-textarea {
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
// TODO: FW-2876 - Remove this selector
|
||||
:host(.legacy-textarea) .native-textarea,
|
||||
:host(.legacy-textarea) .textarea-legacy-wrapper::after {
|
||||
@ -455,7 +462,8 @@
|
||||
* 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;
|
||||
@ -463,6 +471,16 @@
|
||||
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,
|
||||
.textarea-outline-notch-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.textarea-wrapper textarea {
|
||||
/**
|
||||
* When the floating label appears on top of the
|
||||
|
||||
@ -1,10 +1,25 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
|
||||
import type { LegacyFormController } from '@utils/forms';
|
||||
import { createLegacyFormController } from '@utils/forms';
|
||||
import {
|
||||
Build,
|
||||
Component,
|
||||
Element,
|
||||
Event,
|
||||
Host,
|
||||
Method,
|
||||
Prop,
|
||||
State,
|
||||
Watch,
|
||||
forceUpdate,
|
||||
h,
|
||||
writeTask,
|
||||
} from '@stencil/core';
|
||||
import type { LegacyFormController, NotchController } from '@utils/forms';
|
||||
import { createLegacyFormController, createNotchController } from '@utils/forms';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers';
|
||||
import { printIonWarning } from '@utils/logging';
|
||||
import { createSlotMutationController } from '@utils/slot-mutation-controller';
|
||||
import type { SlotMutationController } from '@utils/slot-mutation-controller';
|
||||
import { createColorClasses, hostContext } from '@utils/theme';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
@ -15,6 +30,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
|
||||
|
||||
/**
|
||||
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
|
||||
*
|
||||
* @slot label - The label text to associate with the textarea. Use the `labelPlacement` property to control where the label is placed relative to the textarea. Use this if you need to render a label with custom HTML. (EXPERIMENTAL)
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-textarea',
|
||||
@ -38,6 +55,11 @@ export class Textarea implements ComponentInterface {
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private originalIonInput?: EventEmitter<TextareaInputEventDetail>;
|
||||
private legacyFormController!: LegacyFormController;
|
||||
private notchSpacerEl: HTMLElement | undefined;
|
||||
|
||||
private slotMutationController?: SlotMutationController;
|
||||
|
||||
private notchController?: NotchController;
|
||||
|
||||
// This flag ensures we log the deprecation warning at most once.
|
||||
private hasLoggedDeprecationWarning = false;
|
||||
@ -205,6 +227,10 @@ export class Textarea implements ComponentInterface {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Prop() label?: string;
|
||||
|
||||
@ -286,6 +312,12 @@ export class Textarea implements ComponentInterface {
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
this.legacyFormController = createLegacyFormController(el);
|
||||
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
|
||||
this.notchController = createNotchController(
|
||||
el,
|
||||
() => this.notchSpacerEl,
|
||||
() => this.labelSlot
|
||||
);
|
||||
this.emitStyle();
|
||||
this.debounceChanged();
|
||||
if (Build.isBrowser) {
|
||||
@ -305,6 +337,16 @@ export class Textarea implements ComponentInterface {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this.slotMutationController) {
|
||||
this.slotMutationController.destroy();
|
||||
this.slotMutationController = undefined;
|
||||
}
|
||||
|
||||
if (this.notchController) {
|
||||
this.notchController.destroy();
|
||||
this.notchController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@ -319,6 +361,10 @@ export class Textarea implements ComponentInterface {
|
||||
this.runAutoGrow();
|
||||
}
|
||||
|
||||
componentDidRender() {
|
||||
this.notchController?.calculateNotchWidth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets focus on the native `textarea` in `ion-textarea`. Use this method instead of the global
|
||||
* `textarea.focus()`.
|
||||
@ -530,17 +576,37 @@ Developers can use the "legacy" property to continue using the legacy form marku
|
||||
|
||||
private renderLabel() {
|
||||
const { label } = this;
|
||||
if (label === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="label-text-wrapper">
|
||||
<div class="label-text">{this.label}</div>
|
||||
<div
|
||||
class={{
|
||||
'label-text-wrapper': true,
|
||||
'label-text-wrapper-hidden': !this.hasLabel,
|
||||
}}
|
||||
>
|
||||
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the border container when fill="outline".
|
||||
*/
|
||||
@ -559,8 +625,13 @@ Developers can use the "legacy" property to continue using the legacy form marku
|
||||
return [
|
||||
<div class="textarea-outline-container">
|
||||
<div class="textarea-outline-start"></div>
|
||||
<div class="textarea-outline-notch">
|
||||
<div class="notch-spacer" aria-hidden="true">
|
||||
<div
|
||||
class={{
|
||||
'textarea-outline-notch': true,
|
||||
'textarea-outline-notch-hidden': !this.hasLabel,
|
||||
}}
|
||||
>
|
||||
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
|
||||
{this.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||