feat(input, textarea, select): add start and end slots (#28583)
Issue number: Resolves #26297 --------- <!-- 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. --> With the modern form control syntax, it is not possible to add icon buttons or other decorators to the sides of `ion-input`, `ion-textarea`, or `ion-select`, as you can with `ion-item`. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> `start` and `end` slots added to each component. This PR is a combination of several others that were already approved. If needed, it might be easiest to review the PRs individually by looking at the commit history here. ## 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/3271 Dev build: `7.5.4-dev.11701112913.1ea61220` --------- Co-authored-by: ionitron <hi@ionicframework.com> Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com> Co-authored-by: Liam DeBeasi <liamdebeasi@users.noreply.github.com>
@@ -89,9 +89,7 @@
|
||||
/**
|
||||
* This makes the label sit above the input.
|
||||
*/
|
||||
:host(.has-focus.input-fill-outline.input-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.input-fill-outline.input-label-placement-floating) .label-text-wrapper,
|
||||
:host(.input-fill-outline.input-label-placement-stacked) .label-text-wrapper {
|
||||
:host(.label-floating.input-fill-outline) .label-text-wrapper {
|
||||
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include margin(0);
|
||||
|
||||
@@ -216,8 +214,6 @@
|
||||
* the floating/stacked label. We simulate this "cut out"
|
||||
* by removing the top border from the notch fragment.
|
||||
*/
|
||||
:host(.has-focus.input-fill-outline.input-label-placement-floating) .input-outline-notch,
|
||||
:host(.has-value.input-fill-outline.input-label-placement-floating) .input-outline-notch,
|
||||
:host(.input-fill-outline.input-label-placement-stacked) .input-outline-notch {
|
||||
:host(.label-floating.input-fill-outline) .input-outline-notch {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@
|
||||
// Input Label
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
:host(.input-fill-solid.input-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.has-focus.input-fill-solid.input-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.input-fill-solid.input-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating.input-fill-solid.input-label-placement-floating) .label-text-wrapper {
|
||||
/**
|
||||
* Label text should not extend
|
||||
* beyond the bounds of the input.
|
||||
|
||||
@@ -322,6 +322,9 @@
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
// ensure start/end slot content is vertically centered
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -641,9 +644,7 @@
|
||||
/**
|
||||
* This makes the label sit above the input.
|
||||
*/
|
||||
:host(.input-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.has-focus.input-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.input-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating) .label-text-wrapper {
|
||||
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
|
||||
|
||||
/**
|
||||
@@ -652,3 +653,14 @@
|
||||
*/
|
||||
max-width: calc(100% / #{$form-control-label-stacked-scale});
|
||||
}
|
||||
|
||||
// Start/End Slots
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
::slotted([slot="start"]) {
|
||||
margin-inline-end: $form-control-label-margin;
|
||||
}
|
||||
|
||||
::slotted([slot="end"]) {
|
||||
margin-inline-start: $form-control-label-margin;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { getCounterText } from './input.utils';
|
||||
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
|
||||
*
|
||||
* @slot label - The label text to associate with the input. Use the `labelPlacement` property to control where the label is placed relative to the input. Use this if you need to render a label with custom HTML. (EXPERIMENTAL)
|
||||
* @slot start - Content to display at the leading edge of the input. (EXPERIMENTAL)
|
||||
* @slot end - Content to display at the trailing edge of the input. (EXPERIMENTAL)
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-input',
|
||||
@@ -369,7 +371,7 @@ export class Input implements ComponentInterface {
|
||||
const { el } = this;
|
||||
|
||||
this.legacyFormController = createLegacyFormController(el);
|
||||
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
|
||||
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
||||
this.notchController = createNotchController(
|
||||
el,
|
||||
() => this.notchSpacerEl,
|
||||
@@ -697,18 +699,42 @@ export class Input implements ComponentInterface {
|
||||
}
|
||||
|
||||
private renderInput() {
|
||||
const { disabled, fill, readonly, shape, inputId, labelPlacement } = this;
|
||||
const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus } = this;
|
||||
const mode = getIonMode(this);
|
||||
const value = this.getValue();
|
||||
const inItem = hostContext('ion-item', this.el);
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the input.
|
||||
* For floating labels, the label should move above the input if
|
||||
* the input has a value, is focused, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the input is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
class={createColorClasses(this.color, {
|
||||
[mode]: true,
|
||||
'has-value': this.hasValue(),
|
||||
'has-focus': this.hasFocus,
|
||||
'has-value': hasValue,
|
||||
'has-focus': hasFocus,
|
||||
'label-floating': labelShouldFloat,
|
||||
[`input-fill-${fill}`]: fill !== undefined,
|
||||
[`input-shape-${shape}`]: shape !== undefined,
|
||||
[`input-label-placement-${labelPlacement}`]: true,
|
||||
@@ -717,9 +743,16 @@ export class Input implements ComponentInterface {
|
||||
'input-disabled': disabled,
|
||||
})}
|
||||
>
|
||||
<label class="input-wrapper">
|
||||
{/**
|
||||
* htmlFor is needed so that clicking the label always focuses
|
||||
* the input. Otherwise, if the start slot has something
|
||||
* interactable, clicking the label would focus that instead
|
||||
* since it comes before the input in the DOM.
|
||||
*/}
|
||||
<label class="input-wrapper" htmlFor={inputId}>
|
||||
{this.renderLabelContainer()}
|
||||
<div class="native-wrapper">
|
||||
<slot name="start"></slot>
|
||||
<input
|
||||
class="native-input"
|
||||
ref={(input) => (this.nativeInput = input)}
|
||||
@@ -774,6 +807,7 @@ export class Input implements ComponentInterface {
|
||||
<ion-icon aria-hidden="true" icon={mode === 'ios' ? closeCircle : closeSharp}></ion-icon>
|
||||
</button>
|
||||
)}
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
{shouldRenderHighlight && <div class="input-highlight"></div>}
|
||||
</label>
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
<ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br />
|
||||
<ion-input label="Email" label-placement="floating"></ion-input> <br />
|
||||
<ion-input label="Email" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-input> <br />
|
||||
<ion-input label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-input>
|
||||
<ion-input label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-input><br />
|
||||
<ion-input label="Email" fill="solid" value="hi@ionic.io">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button slot="end" aria-label="button">
|
||||
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -49,53 +49,125 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content id="content" class="ion-padding">
|
||||
<h1>Label Slot</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start</h2>
|
||||
<h2>No Fill / Start Label</h2>
|
||||
<ion-input label-placement="start" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Start</h2>
|
||||
<h2>Solid / Start Label</h2>
|
||||
<ion-input label-placement="start" fill="solid" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start</h2>
|
||||
<h2>Outline / Start Label</h2>
|
||||
<ion-input label-placement="start" fill="outline" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Floating</h2>
|
||||
<h2>No Fill / Floating Label</h2>
|
||||
<ion-input label-placement="floating" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Floating</h2>
|
||||
<h2>Solid / Floating Label</h2>
|
||||
<ion-input label-placement="floating" fill="solid" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating</h2>
|
||||
<h2>Outline / Floating Label</h2>
|
||||
<ion-input label-placement="floating" fill="outline" value="hi@ionic.io">
|
||||
<div slot="label">Email <span class="required">*</span></div>
|
||||
</ion-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Start/End Slots</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start Label</h2>
|
||||
<ion-input label-placement="start" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating / Async</h2>
|
||||
<h2>Solid / Start Label</h2>
|
||||
<ion-input label-placement="start" fill="solid" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start Label</h2>
|
||||
<ion-input label-placement="start" fill="outline" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Floating Label</h2>
|
||||
<ion-input label-placement="floating" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Floating Label</h2>
|
||||
<ion-input label-placement="floating" fill="solid" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating Label</h2>
|
||||
<ion-input label-placement="floating" fill="outline" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Async Slot Content</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Async Label</h2>
|
||||
<ion-input id="solid-async" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-input>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Async Decorations</h2>
|
||||
<ion-input id="async-decorations" label-placement="floating" fill="outline" label="Email"></ion-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
|
||||
@@ -106,29 +178,65 @@
|
||||
|
||||
<script>
|
||||
const solidAsync = document.querySelector('#solid-async');
|
||||
const asyncDecos = document.querySelector('#async-decorations');
|
||||
|
||||
const getSlottedContent = () => {
|
||||
const getSlottedLabel = () => {
|
||||
return solidAsync.querySelector('[slot="label"]');
|
||||
};
|
||||
|
||||
const getSlottedStartDeco = () => {
|
||||
return asyncDecos.querySelector('[slot="start"]');
|
||||
};
|
||||
|
||||
const getSlottedEndDeco = () => {
|
||||
return asyncDecos.querySelector('[slot="end"]');
|
||||
};
|
||||
|
||||
const addSlot = () => {
|
||||
if (getSlottedContent() === null) {
|
||||
if (getSlottedLabel() === null) {
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.slot = 'label';
|
||||
labelEl.innerHTML = 'Email <span class="required">*</span>';
|
||||
|
||||
solidAsync.appendChild(labelEl);
|
||||
}
|
||||
|
||||
if (getSlottedStartDeco() === null) {
|
||||
const startEl = document.createElement('div');
|
||||
startEl.slot = 'start';
|
||||
startEl.innerHTML = 'Start';
|
||||
|
||||
asyncDecos.insertAdjacentElement('afterbegin', startEl);
|
||||
}
|
||||
|
||||
if (getSlottedEndDeco() === null) {
|
||||
const endEl = document.createElement('div');
|
||||
endEl.slot = 'end';
|
||||
endEl.innerHTML = 'End';
|
||||
|
||||
asyncDecos.insertAdjacentElement('beforeend', endEl);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSlot = () => {
|
||||
if (getSlottedContent() !== null) {
|
||||
solidAsync.querySelector('[slot="label"]').remove();
|
||||
const slottedLabel = getSlottedLabel();
|
||||
if (slottedLabel !== null) {
|
||||
slottedLabel.remove();
|
||||
}
|
||||
|
||||
const slottedStartDeco = getSlottedStartDeco();
|
||||
if (slottedStartDeco !== null) {
|
||||
slottedStartDeco.remove();
|
||||
}
|
||||
|
||||
const slottedEndDeco = getSlottedEndDeco();
|
||||
if (slottedEndDeco !== null) {
|
||||
slottedEndDeco.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const updateSlot = () => {
|
||||
const slottedContent = getSlottedContent();
|
||||
const slottedContent = getSlottedLabel();
|
||||
|
||||
if (slottedContent !== null) {
|
||||
slottedContent.textContent = 'This is my really really really long text';
|
||||
|
||||
68
core/src/components/input/test/slot/input.e2e.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs().forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('input: start and end slots (visual checks)'), () => {
|
||||
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label-placement="start" fill="solid" value="100" label="Weight" clear-input="true">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
await expect(input).toHaveScreenshot(screenshot(`input-slots-label-start`));
|
||||
});
|
||||
|
||||
test('should not have visual regressions with a floating label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label-placement="floating" fill="solid" value="100" label="Weight" clear-input="true">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
await expect(input).toHaveScreenshot(screenshot(`input-slots-label-floating`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('input: start and end slots (functionality checks)'), () => {
|
||||
test('should raise floating label when there is content in the start slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
await expect(input).toHaveClass(/label-floating/);
|
||||
});
|
||||
|
||||
test('should raise floating label when there is content in the end slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const input = page.locator('ion-input');
|
||||
await expect(input).toHaveClass(/label-floating/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -21,11 +21,11 @@
|
||||
color: #{$text-color-step-350};
|
||||
}
|
||||
|
||||
// Select Native Wrapper
|
||||
// Select Inner Wrapper
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
:host(.select-label-placement-stacked) .native-wrapper,
|
||||
:host(.select-label-placement-floating) .native-wrapper {
|
||||
:host(.select-label-placement-stacked) .select-wrapper-inner,
|
||||
:host(.select-label-placement-floating) .select-wrapper-inner {
|
||||
width: calc(100% - $select-ios-icon-size - $select-icon-margin-start);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,10 +102,7 @@
|
||||
/**
|
||||
* This makes the label sit above the select.
|
||||
*/
|
||||
:host(.select-expanded.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.ion-focused.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.select-fill-outline.select-label-placement-stacked) .label-text-wrapper {
|
||||
:host(.label-floating.select-fill-outline) .label-text-wrapper {
|
||||
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include margin(0);
|
||||
|
||||
@@ -252,9 +249,6 @@
|
||||
* the floating/stacked label. We simulate this "cut out"
|
||||
* by removing the top border from the notch fragment.
|
||||
*/
|
||||
:host(.select-expanded.select-fill-outline.select-label-placement-floating) .select-outline-notch,
|
||||
:host(.ion-focused.select-fill-outline.select-label-placement-floating) .select-outline-notch,
|
||||
:host(.has-value.select-fill-outline.select-label-placement-floating) .select-outline-notch,
|
||||
:host(.select-fill-outline.select-label-placement-stacked) .select-outline-notch {
|
||||
:host(.label-floating.select-fill-outline) .select-outline-notch {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
@@ -138,11 +138,11 @@
|
||||
--border-radius: 16px;
|
||||
}
|
||||
|
||||
// Select Native Wrapper
|
||||
// Select Inner Wrapper
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
:host(.select-label-placement-stacked) .native-wrapper,
|
||||
:host(.select-label-placement-floating) .native-wrapper {
|
||||
:host(.select-label-placement-stacked) .select-wrapper-inner,
|
||||
:host(.select-label-placement-floating) .select-wrapper-inner {
|
||||
width: calc(100% - $select-md-icon-size - $select-icon-margin-start);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +68,7 @@
|
||||
// Select Label
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
:host(.select-fill-solid.select-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.select-expanded.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.ion-focused.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.select-fill-solid.select-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating.select-fill-solid) .label-text-wrapper {
|
||||
/**
|
||||
* Label text should not extend
|
||||
* beyond the bounds of the select.
|
||||
|
||||
@@ -157,6 +157,13 @@ button {
|
||||
@include margin(0, 0, 0, $select-icon-margin-start);
|
||||
|
||||
position: relative;
|
||||
|
||||
/**
|
||||
* Prevent the icon from shrinking when the label and/or
|
||||
* selected item text is long enough to fill the rest of
|
||||
* the container.
|
||||
*/
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,6 +266,25 @@ button {
|
||||
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.select-wrapper-inner {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host(.select-label-placement-stacked) .select-wrapper-inner,
|
||||
:host(.select-label-placement-floating) .select-wrapper-inner {
|
||||
/**
|
||||
* When using a stacked/floating label, the inner wrapper is
|
||||
* stacked vertically under the label container. This line
|
||||
* ensures that the inner wrapper fills all the remaining height
|
||||
* of the component.
|
||||
*/
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
// Select Highlight
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -519,11 +545,23 @@ button {
|
||||
* The placeholder should be hidden when the label
|
||||
* is on top of the select. This prevents the label
|
||||
* from overlapping any placeholder value.
|
||||
*
|
||||
* TODO(FW-5592): Remove :not(.label-floating) piece
|
||||
*/
|
||||
:host(.select-label-placement-floating) .native-wrapper .select-placeholder {
|
||||
:host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't use .label-floating here because that would
|
||||
* also include the case where the label is floating due
|
||||
* to content in the start/end slot. We want the opacity
|
||||
* to remain at the default in this case, since the select
|
||||
* isn't being actively interacted with.
|
||||
*
|
||||
* TODO(FW-5592): Change entire selector to:
|
||||
* :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
|
||||
*/
|
||||
:host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
|
||||
:host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
|
||||
:host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
|
||||
@@ -533,10 +571,7 @@ button {
|
||||
/**
|
||||
* This makes the label sit above the input.
|
||||
*/
|
||||
:host(.select-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.select-expanded.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.ion-focused.select-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.select-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating) .label-text-wrapper {
|
||||
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
|
||||
|
||||
/**
|
||||
@@ -545,3 +580,23 @@ button {
|
||||
*/
|
||||
max-width: calc(100% / #{$form-control-label-stacked-scale});
|
||||
}
|
||||
|
||||
// Start/End Slots
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
::slotted([slot="start"]), ::slotted([slot="end"]) {
|
||||
/**
|
||||
* Prevent the slots from shrinking when the label and/or
|
||||
* selected item text is long enough to fill the rest of
|
||||
* the container.
|
||||
*/
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::slotted([slot="start"]) {
|
||||
margin-inline-end: $form-control-label-margin;
|
||||
}
|
||||
|
||||
::slotted([slot="end"]) {
|
||||
margin-inline-start: $form-control-label-margin;
|
||||
}
|
||||
|
||||
@@ -33,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.
|
||||
* @slot start - Content to display at the leading edge of the select.
|
||||
* @slot end - Content to display at the trailing edge of the select.
|
||||
*
|
||||
* @part placeholder - The text displayed in the select when there is no value.
|
||||
* @part text - The displayed value of the select.
|
||||
@@ -762,8 +764,22 @@ export class Select implements ComponentInterface {
|
||||
}
|
||||
|
||||
private onClick = (ev: UIEvent) => {
|
||||
this.setFocus();
|
||||
this.open(ev);
|
||||
const target = ev.target as HTMLElement;
|
||||
const closestSlot = target.closest('[slot="start"], [slot="end"]');
|
||||
|
||||
if (target === this.el || closestSlot === null) {
|
||||
this.setFocus();
|
||||
this.open(ev);
|
||||
} else {
|
||||
/**
|
||||
* Prevent clicks to the start/end slots from opening the select.
|
||||
* We ensure the target isn't this element in case the select is slotted
|
||||
* in, for example, an item. This would prevent the select from ever
|
||||
* being opened since the element itself has slot="start"/"end".
|
||||
*/
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onFocus = () => {
|
||||
@@ -864,8 +880,31 @@ export class Select implements ComponentInterface {
|
||||
const inItem = hostContext('ion-item', this.el);
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the select.
|
||||
* For floating labels, the label should move above the select if
|
||||
* the select has a value, is open, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the select is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
onClick={this.onClick}
|
||||
@@ -876,7 +915,8 @@ export class Select implements ComponentInterface {
|
||||
'select-disabled': disabled,
|
||||
'select-expanded': isExpanded,
|
||||
'has-expanded-icon': expandedIcon !== undefined,
|
||||
'has-value': this.hasValue(),
|
||||
'has-value': hasValue,
|
||||
'label-floating': labelShouldFloat,
|
||||
'has-placeholder': placeholder !== undefined,
|
||||
'ion-focusable': true,
|
||||
[`select-${rtl}`]: true,
|
||||
@@ -888,17 +928,23 @@ export class Select implements ComponentInterface {
|
||||
>
|
||||
<label class="select-wrapper" id="select-label">
|
||||
{this.renderLabelContainer()}
|
||||
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
|
||||
{this.renderSelectText()}
|
||||
<div class="select-wrapper-inner">
|
||||
<slot name="start"></slot>
|
||||
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
|
||||
{this.renderSelectText()}
|
||||
{this.renderListbox()}
|
||||
</div>
|
||||
<slot name="end"></slot>
|
||||
{!hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
{this.renderListbox()}
|
||||
</div>
|
||||
{/**
|
||||
* The icon in a floating/stacked select
|
||||
* must be centered with the entire select,
|
||||
* not just the native control. As a result,
|
||||
* we need to render the icon outside of
|
||||
* the native wrapper.
|
||||
* while the start/end slots and native control
|
||||
* are vertically offset in the default or
|
||||
* solid fills. As a result, we render the
|
||||
* icon outside the inner wrapper, which holds
|
||||
* those components.
|
||||
*/}
|
||||
{hasFloatingOrStackedLabel && this.renderSelectIcon()}
|
||||
{shouldRenderHighlight && <div class="select-highlight"></div>}
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
</ion-select>
|
||||
|
||||
<ion-select label="My Label" label-placement="floating">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
<ion-select-option value="bananas">Bananas</ion-select-option>
|
||||
<ion-select-option value="oranges">Oranges</ion-select-option>
|
||||
<ion-button slot="end" aria-label="button">
|
||||
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
|
||||
<ion-item>
|
||||
<ion-select label="My Label" value="apples">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
|
||||
@@ -171,7 +171,7 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
test('label should appear on top of the select when the select is expanded', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select class="select-expanded" label="Label" label-placement="floating" placeholder="Select a Fruit">
|
||||
<ion-select class="select-expanded label-floating" label="Label" label-placement="floating" placeholder="Select a Fruit">
|
||||
<ion-select-option value="apples">Apples</ion-select-option>
|
||||
</ion-select>
|
||||
`,
|
||||
|
||||
251
core/src/components/select/test/slot/index.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Select - Slots</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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select - Slots</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content id="content" class="ion-padding">
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start Label / Justify Start</h2>
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="start" justify="start">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Start Label / Justify Start</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="start"
|
||||
fill="solid"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start Label / Justify Start</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="start"
|
||||
fill="outline"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start Label / Justify End</h2>
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="start" justify="end">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Start Label / Justify End</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="end"
|
||||
fill="solid"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start Label / Justify End</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="end"
|
||||
fill="outline"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start Label / Justify Space Between</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="space-between"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Start Label / Justify Space Between</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="space-between"
|
||||
fill="solid"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start Label / Justify Space Between</h2>
|
||||
<ion-select
|
||||
label="Favorite Pizza"
|
||||
placeholder="Select a pizza"
|
||||
label-placement="start"
|
||||
justify="space-between"
|
||||
fill="outline"
|
||||
>
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Floating Label</h2>
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Floating Label</h2>
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating" fill="solid">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating Label</h2>
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating" fill="outline">
|
||||
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
90
core/src/components/select/test/slot/select.e2e.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs().forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('select: start and end slots (visual checks)'), () => {
|
||||
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label-placement="start" fill="solid" placeholder="Select weight" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
await expect(select).toHaveScreenshot(screenshot(`select-slots-label-start`));
|
||||
});
|
||||
|
||||
test('should not have visual regressions with a floating label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label-placement="floating" fill="solid" placeholder="Select weight" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
await expect(select).toHaveScreenshot(screenshot(`select-slots-label-floating`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('select: start and end slots (functionality checks)'), () => {
|
||||
test('should raise floating label when there is content in the start slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
await expect(select).toHaveClass(/label-floating/);
|
||||
});
|
||||
|
||||
test('should raise floating label when there is content in the end slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
await expect(select).toHaveClass(/label-floating/);
|
||||
});
|
||||
|
||||
test('should not open select when slotted buttons are clicked', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-select label="Favorite Pizza" placeholder="Select a pizza">
|
||||
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
|
||||
<ion-select-option value="supreme">Supreme</ion-select-option>
|
||||
<ion-select-option value="chicken">Chicken</ion-select-option>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-select>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await page.click('ion-select ion-button[slot="end"]');
|
||||
await page.waitForChanges();
|
||||
|
||||
const select = page.locator('ion-select');
|
||||
await expect(select).not.toHaveClass(/select-expanded/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -21,7 +21,13 @@
|
||||
<ion-textarea label="Email" label-placement="stacked" value="hi@ionic.io"></ion-textarea>
|
||||
<ion-textarea label="Email" label-placement="floating"></ion-textarea>
|
||||
<ion-textarea label="Email" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-textarea> <br />
|
||||
<ion-textarea label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-textarea>
|
||||
<ion-textarea label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-textarea><br />
|
||||
<ion-textarea label="Email" label-placement="floating" fill="solid" value="hi@ionic.io">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button slot="end" aria-label="button">
|
||||
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -49,53 +49,135 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content id="content" class="ion-padding">
|
||||
<h1>Label Slot</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start</h2>
|
||||
<h2>No Fill / Start Label</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>
|
||||
<h2>Solid / Start Label</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>
|
||||
<h2>Outline / Start Label</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>
|
||||
<h2>No Fill / Floating Label</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>
|
||||
<h2>Solid / Floating Label</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>
|
||||
<h2>Outline / Floating Label</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>
|
||||
|
||||
<h1>Start/End Slots</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Start Label</h2>
|
||||
<ion-textarea label-placement="start" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating / Async</h2>
|
||||
<h2>Solid / Start Label</h2>
|
||||
<ion-textarea label-placement="start" fill="solid" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Start Label</h2>
|
||||
<ion-textarea label-placement="start" fill="outline" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>No Fill / Floating Label</h2>
|
||||
<ion-textarea label-placement="floating" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Solid / Floating Label</h2>
|
||||
<ion-textarea label-placement="floating" fill="solid" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Floating Label</h2>
|
||||
<ion-textarea label-placement="floating" fill="outline" value="hi@ionic.io" label="Email">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Autogrow</h2>
|
||||
<ion-textarea label-placement="start" fill="outline" label="Email" auto-grow="true" value="hi@ionic.io">
|
||||
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
|
||||
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
|
||||
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Async Slot Content</h1>
|
||||
<div class="grid">
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Async Label</h2>
|
||||
<ion-textarea id="solid-async" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-item">
|
||||
<h2>Outline / Async Decorations</h2>
|
||||
<ion-textarea id="async-decorations" label-placement="floating" fill="outline" label="Email"></ion-textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
|
||||
@@ -106,29 +188,65 @@
|
||||
|
||||
<script>
|
||||
const solidAsync = document.querySelector('#solid-async');
|
||||
const asyncDecos = document.querySelector('#async-decorations');
|
||||
|
||||
const getSlottedContent = () => {
|
||||
const getSlottedLabel = () => {
|
||||
return solidAsync.querySelector('[slot="label"]');
|
||||
};
|
||||
|
||||
const getSlottedStartDeco = () => {
|
||||
return asyncDecos.querySelector('[slot="start"]');
|
||||
};
|
||||
|
||||
const getSlottedEndDeco = () => {
|
||||
return asyncDecos.querySelector('[slot="end"]');
|
||||
};
|
||||
|
||||
const addSlot = () => {
|
||||
if (getSlottedContent() === null) {
|
||||
if (getSlottedLabel() === null) {
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.slot = 'label';
|
||||
labelEl.innerHTML = 'Comments <span class="required">*</span>';
|
||||
|
||||
solidAsync.appendChild(labelEl);
|
||||
}
|
||||
|
||||
if (getSlottedStartDeco() === null) {
|
||||
const startEl = document.createElement('div');
|
||||
startEl.slot = 'start';
|
||||
startEl.innerHTML = 'Start';
|
||||
|
||||
asyncDecos.insertAdjacentElement('afterbegin', startEl);
|
||||
}
|
||||
|
||||
if (getSlottedEndDeco() === null) {
|
||||
const endEl = document.createElement('div');
|
||||
endEl.slot = 'end';
|
||||
endEl.innerHTML = 'End';
|
||||
|
||||
asyncDecos.insertAdjacentElement('beforeend', endEl);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSlot = () => {
|
||||
if (getSlottedContent() !== null) {
|
||||
solidAsync.querySelector('[slot="label"]').remove();
|
||||
const slottedLabel = getSlottedLabel();
|
||||
if (slottedLabel !== null) {
|
||||
slottedLabel.remove();
|
||||
}
|
||||
|
||||
const slottedStartDeco = getSlottedStartDeco();
|
||||
if (slottedStartDeco !== null) {
|
||||
slottedStartDeco.remove();
|
||||
}
|
||||
|
||||
const slottedEndDeco = getSlottedEndDeco();
|
||||
if (slottedEndDeco !== null) {
|
||||
slottedEndDeco.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const updateSlot = () => {
|
||||
const slottedContent = getSlottedContent();
|
||||
const slottedContent = getSlottedLabel();
|
||||
|
||||
if (slottedContent !== null) {
|
||||
slottedContent.textContent = 'This is my really really really long text';
|
||||
|
||||
68
core/src/components/textarea/test/slot/textarea.e2e.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs().forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('textarea: start and end slots (visual checks)'), () => {
|
||||
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea label-placement="start" fill="solid" value="100" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-start`));
|
||||
});
|
||||
|
||||
test('should not have visual regressions with a floating label', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea label-placement="floating" fill="solid" value="100" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
<ion-label slot="end">lbs</ion-label>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-floating`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('textarea: start and end slots (functionality checks)'), () => {
|
||||
test('should raise floating label when there is content in the start slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
await expect(textarea).toHaveClass(/label-floating/);
|
||||
});
|
||||
|
||||
test('should raise floating label when there is content in the end slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-textarea label-placement="floating" fill="solid" label="Weight">
|
||||
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
|
||||
</ion-textarea>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const textarea = page.locator('ion-textarea');
|
||||
await expect(textarea).toHaveClass(/label-floating/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -89,9 +89,7 @@
|
||||
/**
|
||||
* This makes the label sit above the textarea.
|
||||
*/
|
||||
:host(.has-focus.textarea-fill-outline.textarea-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.textarea-fill-outline.textarea-label-placement-floating) .label-text-wrapper,
|
||||
:host(.textarea-fill-outline.textarea-label-placement-stacked) .label-text-wrapper {
|
||||
:host(.label-floating.textarea-fill-outline) .label-text-wrapper {
|
||||
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
|
||||
@include margin(0);
|
||||
|
||||
@@ -116,6 +114,13 @@
|
||||
@include margin(12px, 0px, 0px, 0px);
|
||||
}
|
||||
|
||||
:host(.textarea-fill-outline.textarea-label-placement-stacked) ::slotted([slot="start"]),
|
||||
:host(.textarea-fill-outline.textarea-label-placement-stacked) ::slotted([slot="end"]),
|
||||
:host(.textarea-fill-outline.textarea-label-placement-floating) ::slotted([slot="start"]),
|
||||
:host(.textarea-fill-outline.textarea-label-placement-floating) ::slotted([slot="end"]) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
// Textarea Fill: Outline Outline Container
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -220,8 +225,6 @@
|
||||
* the floating/stacked label. We simulate this "cut out"
|
||||
* by removing the top border from the notch fragment.
|
||||
*/
|
||||
:host(.has-focus.textarea-fill-outline.textarea-label-placement-floating) .textarea-outline-notch,
|
||||
:host(.has-value.textarea-fill-outline.textarea-label-placement-floating) .textarea-outline-notch,
|
||||
:host(.textarea-fill-outline.textarea-label-placement-stacked) .textarea-outline-notch {
|
||||
:host(.label-floating.textarea-fill-outline) .textarea-outline-notch {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@
|
||||
// Textarea Label
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
:host(.textarea-fill-solid.textarea-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.has-focus.textarea-fill-solid.textarea-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.textarea-fill-solid.textarea-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating.textarea-fill-solid) .label-text-wrapper {
|
||||
/**
|
||||
* Label text should not extend
|
||||
* beyond the bounds of the textarea.
|
||||
|
||||
@@ -330,12 +330,8 @@
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
.native-wrapper {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
width: 100%;
|
||||
|
||||
height: 100%;
|
||||
@@ -414,6 +410,13 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.textarea-wrapper-inner {
|
||||
display: flex;
|
||||
|
||||
width: 100%;
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
// Textarea Highlight
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -688,6 +691,13 @@
|
||||
@include margin(8px, 0px, 0px, 0px);
|
||||
}
|
||||
|
||||
:host(.textarea-label-placement-stacked) ::slotted([slot="start"]),
|
||||
:host(.textarea-label-placement-stacked) ::slotted([slot="end"]),
|
||||
:host(.textarea-label-placement-floating) ::slotted([slot="start"]),
|
||||
:host(.textarea-label-placement-floating) ::slotted([slot="end"]) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/**
|
||||
* This makes the label sit over the textarea
|
||||
* when the textarea is blurred and has no value.
|
||||
@@ -713,9 +723,7 @@
|
||||
/**
|
||||
* This makes the label sit above the textarea.
|
||||
*/
|
||||
:host(.textarea-label-placement-stacked) .label-text-wrapper,
|
||||
:host(.has-focus.textarea-label-placement-floating) .label-text-wrapper,
|
||||
:host(.has-value.textarea-label-placement-floating) .label-text-wrapper {
|
||||
:host(.label-floating) .label-text-wrapper {
|
||||
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
|
||||
|
||||
/**
|
||||
@@ -724,3 +732,29 @@
|
||||
*/
|
||||
max-width: calc(100% / #{$form-control-label-stacked-scale});
|
||||
}
|
||||
|
||||
// Start/End Slots
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
.start-slot-wrapper, .end-slot-wrapper {
|
||||
@include padding(var(--padding-top), 0, var(--padding-bottom), 0);
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
::slotted([slot="start"]),
|
||||
::slotted([slot="end"]) {
|
||||
margin-top: 0; // ensure slot content is vertically aligned with label
|
||||
}
|
||||
|
||||
::slotted([slot="start"]) {
|
||||
margin-inline-end: $form-control-label-margin;
|
||||
}
|
||||
|
||||
::slotted([slot="end"]) {
|
||||
margin-inline-start: $form-control-label-margin;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,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)
|
||||
* @slot start - Content to display at the leading edge of the textarea. (EXPERIMENTAL)
|
||||
* @slot end - Content to display at the trailing edge of the textarea. (EXPERIMENTAL)
|
||||
*/
|
||||
@Component({
|
||||
tag: 'ion-textarea',
|
||||
@@ -316,7 +318,7 @@ export class Textarea implements ComponentInterface {
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
this.legacyFormController = createLegacyFormController(el);
|
||||
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
|
||||
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
||||
this.notchController = createNotchController(
|
||||
el,
|
||||
() => this.notchSpacerEl,
|
||||
@@ -705,55 +707,100 @@ Developers can use the "legacy" property to continue using the legacy form marku
|
||||
}
|
||||
|
||||
private renderTextarea() {
|
||||
const { inputId, disabled, fill, shape, labelPlacement } = this;
|
||||
const { inputId, disabled, fill, shape, labelPlacement, el, hasFocus } = this;
|
||||
const mode = getIonMode(this);
|
||||
const value = this.getValue();
|
||||
const inItem = hostContext('ion-item', this.el);
|
||||
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
|
||||
|
||||
const hasValue = this.hasValue();
|
||||
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
||||
|
||||
/**
|
||||
* If the label is stacked, it should always sit above the textarea.
|
||||
* For floating labels, the label should move above the textarea if
|
||||
* the textarea has a value, is focused, or has anything in either
|
||||
* the start or end slot.
|
||||
*
|
||||
* If there is content in the start slot, the label would overlap
|
||||
* it if not forced to float. This is also applied to the end slot
|
||||
* because with the default or solid fills, the textarea is not
|
||||
* vertically centered in the container, but the label is. This
|
||||
* causes the slots and label to appear vertically offset from each
|
||||
* other when the label isn't floating above the input. This doesn't
|
||||
* apply to the outline fill, but this was not accounted for to keep
|
||||
* things consistent.
|
||||
*
|
||||
* TODO(FW-5592): Remove hasStartEndSlots condition
|
||||
*/
|
||||
const labelShouldFloat =
|
||||
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
|
||||
|
||||
return (
|
||||
<Host
|
||||
class={createColorClasses(this.color, {
|
||||
[mode]: true,
|
||||
'has-value': this.hasValue(),
|
||||
'has-focus': this.hasFocus,
|
||||
'has-value': hasValue,
|
||||
'has-focus': hasFocus,
|
||||
'label-floating': labelShouldFloat,
|
||||
[`textarea-fill-${fill}`]: fill !== undefined,
|
||||
[`textarea-shape-${shape}`]: shape !== undefined,
|
||||
[`textarea-label-placement-${labelPlacement}`]: true,
|
||||
'textarea-disabled': disabled,
|
||||
})}
|
||||
>
|
||||
<label class="textarea-wrapper">
|
||||
{/**
|
||||
* htmlFor is needed so that clicking the label always focuses
|
||||
* the textarea. Otherwise, if the start slot has something
|
||||
* interactable, clicking the label would focus that instead
|
||||
* since it comes before the textarea in the DOM.
|
||||
*/}
|
||||
<label class="textarea-wrapper" htmlFor={inputId}>
|
||||
{this.renderLabelContainer()}
|
||||
<div class="native-wrapper" ref={(el) => (this.textareaWrapper = el)}>
|
||||
<textarea
|
||||
class="native-textarea"
|
||||
ref={(el) => (this.nativeInput = el)}
|
||||
id={inputId}
|
||||
disabled={disabled}
|
||||
autoCapitalize={this.autocapitalize}
|
||||
autoFocus={this.autofocus}
|
||||
enterKeyHint={this.enterkeyhint}
|
||||
inputMode={this.inputmode}
|
||||
minLength={this.minlength}
|
||||
maxLength={this.maxlength}
|
||||
name={this.name}
|
||||
placeholder={this.placeholder || ''}
|
||||
readOnly={this.readonly}
|
||||
required={this.required}
|
||||
spellcheck={this.spellcheck}
|
||||
cols={this.cols}
|
||||
rows={this.rows}
|
||||
wrap={this.wrap}
|
||||
onInput={this.onInput}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onKeyDown={this.onKeyDown}
|
||||
{...this.inheritedAttributes}
|
||||
>
|
||||
{value}
|
||||
</textarea>
|
||||
<div class="textarea-wrapper-inner">
|
||||
{/**
|
||||
* Some elements have their own padding styles which may
|
||||
* interfere with slot content alignment (such as icon-
|
||||
* only buttons setting --padding-top=0). To avoid this,
|
||||
* we wrap both the start and end slots in separate
|
||||
* elements and apply our padding styles to that instead.
|
||||
*/}
|
||||
<div class="start-slot-wrapper">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
<div class="native-wrapper" ref={(el) => (this.textareaWrapper = el)}>
|
||||
<textarea
|
||||
class="native-textarea"
|
||||
ref={(el) => (this.nativeInput = el)}
|
||||
id={inputId}
|
||||
disabled={disabled}
|
||||
autoCapitalize={this.autocapitalize}
|
||||
autoFocus={this.autofocus}
|
||||
enterKeyHint={this.enterkeyhint}
|
||||
inputMode={this.inputmode}
|
||||
minLength={this.minlength}
|
||||
maxLength={this.maxlength}
|
||||
name={this.name}
|
||||
placeholder={this.placeholder || ''}
|
||||
readOnly={this.readonly}
|
||||
required={this.required}
|
||||
spellcheck={this.spellcheck}
|
||||
cols={this.cols}
|
||||
rows={this.rows}
|
||||
wrap={this.wrap}
|
||||
onInput={this.onInput}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onKeyDown={this.onKeyDown}
|
||||
{...this.inheritedAttributes}
|
||||
>
|
||||
{value}
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="end-slot-wrapper">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
</div>
|
||||
{shouldRenderHighlight && <div class="textarea-highlight"></div>}
|
||||
</label>
|
||||
|
||||
@@ -6,18 +6,20 @@ import { raf } from '@utils/helpers';
|
||||
* This is not needed for components using native slots in the Shadow DOM.
|
||||
* @internal
|
||||
* @param el The host element to observe
|
||||
* @param slotName mutationCallback will fire when nodes on this slot change
|
||||
* @param slotName mutationCallback will fire when nodes on these slot(s) change
|
||||
* @param mutationCallback The callback to fire whenever the slotted content changes
|
||||
*/
|
||||
export const createSlotMutationController = (
|
||||
el: HTMLElement,
|
||||
slotName: string,
|
||||
slotName: string | string[],
|
||||
mutationCallback: () => void
|
||||
): SlotMutationController => {
|
||||
let hostMutationObserver: MutationObserver | undefined;
|
||||
let slottedContentMutationObserver: MutationObserver | undefined;
|
||||
|
||||
if (win !== undefined && 'MutationObserver' in win) {
|
||||
const slots = Array.isArray(slotName) ? slotName : [slotName];
|
||||
|
||||
hostMutationObserver = new MutationObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
for (const node of entry.addedNodes) {
|
||||
@@ -25,7 +27,7 @@ export const createSlotMutationController = (
|
||||
* Check to see if the added node
|
||||
* is our slotted content.
|
||||
*/
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && slots.includes((node as HTMLElement).slot)) {
|
||||
/**
|
||||
* If so, we want to watch the slotted
|
||||
* content itself for changes. This lets us
|
||||
|
||||