@ionic/core 7.5.4-dev.11700602203.1e7155a1 → 7.5.4-dev.11701112913.1ea61220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/components/input.utils.js +3 -2
  2. package/components/ion-infinite-scroll.js +3 -11
  3. package/components/ion-input.js +29 -8
  4. package/components/ion-select.js +41 -6
  5. package/components/ion-textarea.js +28 -7
  6. package/dist/cjs/{input.utils-590f0f18.js → input.utils-bdb6953c.js} +3 -2
  7. package/dist/cjs/ion-infinite-scroll_2.cjs.entry.js +3 -11
  8. package/dist/cjs/ion-input.cjs.entry.js +30 -9
  9. package/dist/cjs/ion-select_3.cjs.entry.js +41 -6
  10. package/dist/cjs/ion-textarea.cjs.entry.js +29 -8
  11. package/dist/collection/components/infinite-scroll/infinite-scroll.js +3 -11
  12. package/dist/collection/components/input/input.ios.css +10 -3
  13. package/dist/collection/components/input/input.js +29 -6
  14. package/dist/collection/components/input/input.md.css +13 -12
  15. package/dist/collection/components/input/test/slot/input.e2e.js +51 -0
  16. package/dist/collection/components/select/select.ios.css +56 -7
  17. package/dist/collection/components/select/select.js +41 -4
  18. package/dist/collection/components/select/select.md.css +59 -19
  19. package/dist/collection/components/select/test/label/select.e2e.js +1 -1
  20. package/dist/collection/components/select/test/slot/select.e2e.js +67 -0
  21. package/dist/collection/components/textarea/test/slot/textarea.e2e.js +51 -0
  22. package/dist/collection/components/textarea/textarea.ios.css +37 -5
  23. package/dist/collection/components/textarea/textarea.js +28 -5
  24. package/dist/collection/components/textarea/textarea.md.css +47 -14
  25. package/dist/collection/utils/slot-mutation-controller.js +3 -2
  26. package/dist/docs.json +49 -1
  27. package/dist/esm/{input.utils-d04a7212.js → input.utils-2cf5e372.js} +3 -2
  28. package/dist/esm/ion-infinite-scroll_2.entry.js +3 -11
  29. package/dist/esm/ion-input.entry.js +30 -9
  30. package/dist/esm/ion-select_3.entry.js +41 -6
  31. package/dist/esm/ion-textarea.entry.js +29 -8
  32. package/dist/esm-es5/input.utils-2cf5e372.js +4 -0
  33. package/dist/esm-es5/ion-infinite-scroll_2.entry.js +1 -1
  34. package/dist/esm-es5/ion-input.entry.js +1 -1
  35. package/dist/esm-es5/ion-select_3.entry.js +1 -1
  36. package/dist/esm-es5/ion-textarea.entry.js +1 -1
  37. package/dist/ionic/ionic.esm.js +1 -1
  38. package/dist/ionic/p-02663394.system.js +1 -1
  39. package/dist/ionic/p-0fa05d7a.system.entry.js +4 -0
  40. package/dist/ionic/p-33c650c5.entry.js +4 -0
  41. package/dist/ionic/p-37afecd5.entry.js +4 -0
  42. package/dist/ionic/p-79841aa8.entry.js +4 -0
  43. package/dist/ionic/p-7b44e419.js +4 -0
  44. package/dist/ionic/p-ad6ed7ab.system.entry.js +4 -0
  45. package/dist/ionic/p-c4381a06.entry.js +4 -0
  46. package/dist/ionic/p-c700acef.system.js +4 -0
  47. package/dist/ionic/p-cd73cfd1.system.entry.js +4 -0
  48. package/dist/ionic/p-f8febce9.system.entry.js +4 -0
  49. package/dist/types/components/infinite-scroll/infinite-scroll.d.ts +0 -7
  50. package/dist/types/components/input/input.d.ts +2 -0
  51. package/dist/types/components/select/select.d.ts +2 -0
  52. package/dist/types/components/textarea/textarea.d.ts +2 -0
  53. package/dist/types/components/toast/toast-interface.d.ts +1 -0
  54. package/dist/types/utils/slot-mutation-controller.d.ts +2 -2
  55. package/hydrate/index.js +110 -34
  56. package/package.json +1 -1
  57. package/dist/collection/components/infinite-scroll/test/small-dom-update/infinite-scroll.e2e.js +0 -31
  58. package/dist/esm-es5/input.utils-d04a7212.js +0 -4
  59. package/dist/ionic/p-0becf7b5.system.entry.js +0 -4
  60. package/dist/ionic/p-1667596c.js +0 -4
  61. package/dist/ionic/p-265fec8a.entry.js +0 -4
  62. package/dist/ionic/p-2ae9a434.system.entry.js +0 -4
  63. package/dist/ionic/p-791ac0a8.system.entry.js +0 -4
  64. package/dist/ionic/p-7def197f.system.js +0 -4
  65. package/dist/ionic/p-9b34caa0.system.entry.js +0 -4
  66. package/dist/ionic/p-e119ac8b.entry.js +0 -4
  67. package/dist/ionic/p-fa67d712.entry.js +0 -4
  68. package/dist/ionic/p-fb415d64.entry.js +0 -4
@@ -215,6 +215,12 @@ button {
215
215
  margin-top: 0;
216
216
  margin-bottom: 0;
217
217
  position: relative;
218
+ /**
219
+ * Prevent the icon from shrinking when the label and/or
220
+ * selected item text is long enough to fill the rest of
221
+ * the container.
222
+ */
223
+ flex-shrink: 0;
218
224
  }
219
225
 
220
226
  /**
@@ -298,6 +304,23 @@ button {
298
304
  transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
299
305
  }
300
306
 
307
+ .select-wrapper-inner {
308
+ display: flex;
309
+ align-items: center;
310
+ overflow: hidden;
311
+ }
312
+
313
+ :host(.select-label-placement-stacked) .select-wrapper-inner,
314
+ :host(.select-label-placement-floating) .select-wrapper-inner {
315
+ /**
316
+ * When using a stacked/floating label, the inner wrapper is
317
+ * stacked vertically under the label container. This line
318
+ * ensures that the inner wrapper fills all the remaining height
319
+ * of the component.
320
+ */
321
+ flex-grow: 1;
322
+ }
323
+
301
324
  :host(.ion-touched.ion-invalid) {
302
325
  --highlight-color: var(--highlight-color-invalid);
303
326
  }
@@ -538,11 +561,23 @@ button {
538
561
  * The placeholder should be hidden when the label
539
562
  * is on top of the select. This prevents the label
540
563
  * from overlapping any placeholder value.
564
+ *
565
+ * TODO(FW-5592): Remove :not(.label-floating) piece
541
566
  */
542
- :host(.select-label-placement-floating) .native-wrapper .select-placeholder {
567
+ :host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
543
568
  opacity: 0;
544
569
  }
545
570
 
571
+ /**
572
+ * We don't use .label-floating here because that would
573
+ * also include the case where the label is floating due
574
+ * to content in the start/end slot. We want the opacity
575
+ * to remain at the default in this case, since the select
576
+ * isn't being actively interacted with.
577
+ *
578
+ * TODO(FW-5592): Change entire selector to:
579
+ * :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
580
+ */
546
581
  :host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
547
582
  :host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
548
583
  :host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
@@ -552,10 +587,7 @@ button {
552
587
  /**
553
588
  * This makes the label sit above the input.
554
589
  */
555
- :host(.select-label-placement-stacked) .label-text-wrapper,
556
- :host(.select-expanded.select-label-placement-floating) .label-text-wrapper,
557
- :host(.ion-focused.select-label-placement-floating) .label-text-wrapper,
558
- :host(.has-value.select-label-placement-floating) .label-text-wrapper {
590
+ :host(.label-floating) .label-text-wrapper {
559
591
  transform: translateY(50%) scale(0.75);
560
592
  /**
561
593
  * Label text should not extend
@@ -564,6 +596,23 @@ button {
564
596
  max-width: calc(100% / 0.75);
565
597
  }
566
598
 
599
+ ::slotted([slot=start]), ::slotted([slot=end]) {
600
+ /**
601
+ * Prevent the slots from shrinking when the label and/or
602
+ * selected item text is long enough to fill the rest of
603
+ * the container.
604
+ */
605
+ flex-shrink: 0;
606
+ }
607
+
608
+ ::slotted([slot=start]) {
609
+ margin-inline-end: 16px;
610
+ }
611
+
612
+ ::slotted([slot=end]) {
613
+ margin-inline-start: 16px;
614
+ }
615
+
567
616
  /**
568
617
  * Convert a font size to a dynamic font size.
569
618
  * Fonts that participate in Dynamic Type should use
@@ -643,8 +692,8 @@ button {
643
692
  color: var(--ion-color-step-650, #595959);
644
693
  }
645
694
 
646
- :host(.select-label-placement-stacked) .native-wrapper,
647
- :host(.select-label-placement-floating) .native-wrapper {
695
+ :host(.select-label-placement-stacked) .select-wrapper-inner,
696
+ :host(.select-label-placement-floating) .select-wrapper-inner {
648
697
  width: calc(100% - 1.125rem - 4px);
649
698
  }
650
699
 
@@ -16,6 +16,8 @@ import { getIonMode } from "../../global/ionic-global";
16
16
  * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
17
17
  *
18
18
  * @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.
19
+ * @slot start - Content to display at the leading edge of the select.
20
+ * @slot end - Content to display at the trailing edge of the select.
19
21
  *
20
22
  * @part placeholder - The text displayed in the select when there is no value.
21
23
  * @part text - The displayed value of the select.
@@ -30,8 +32,22 @@ export class Select {
30
32
  // This flag ensures we log the deprecation warning at most once.
31
33
  this.hasLoggedDeprecationWarning = false;
32
34
  this.onClick = (ev) => {
33
- this.setFocus();
34
- this.open(ev);
35
+ const target = ev.target;
36
+ const closestSlot = target.closest('[slot="start"], [slot="end"]');
37
+ if (target === this.el || closestSlot === null) {
38
+ this.setFocus();
39
+ this.open(ev);
40
+ }
41
+ else {
42
+ /**
43
+ * Prevent clicks to the start/end slots from opening the select.
44
+ * We ensure the target isn't this element in case the select is slotted
45
+ * in, for example, an item. This would prevent the select from ever
46
+ * being opened since the element itself has slot="start"/"end".
47
+ */
48
+ ev.stopPropagation();
49
+ ev.preventDefault();
50
+ }
35
51
  };
36
52
  this.onFocus = () => {
37
53
  this.ionFocus.emit();
@@ -534,7 +550,27 @@ export class Select {
534
550
  const rtl = isRTL(el) ? 'rtl' : 'ltr';
535
551
  const inItem = hostContext('ion-item', this.el);
536
552
  const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
553
+ const hasValue = this.hasValue();
554
+ const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
537
555
  renderHiddenInput(true, el, name, parseValue(value), disabled);
556
+ /**
557
+ * If the label is stacked, it should always sit above the select.
558
+ * For floating labels, the label should move above the select if
559
+ * the select has a value, is open, or has anything in either
560
+ * the start or end slot.
561
+ *
562
+ * If there is content in the start slot, the label would overlap
563
+ * it if not forced to float. This is also applied to the end slot
564
+ * because with the default or solid fills, the select is not
565
+ * vertically centered in the container, but the label is. This
566
+ * causes the slots and label to appear vertically offset from each
567
+ * other when the label isn't floating above the input. This doesn't
568
+ * apply to the outline fill, but this was not accounted for to keep
569
+ * things consistent.
570
+ *
571
+ * TODO(FW-5592): Remove hasStartEndSlots condition
572
+ */
573
+ const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots));
538
574
  return (h(Host, { onClick: this.onClick, class: createColorClasses(this.color, {
539
575
  [mode]: true,
540
576
  'in-item': inItem,
@@ -542,7 +578,8 @@ export class Select {
542
578
  'select-disabled': disabled,
543
579
  'select-expanded': isExpanded,
544
580
  'has-expanded-icon': expandedIcon !== undefined,
545
- 'has-value': this.hasValue(),
581
+ 'has-value': hasValue,
582
+ 'label-floating': labelShouldFloat,
546
583
  'has-placeholder': placeholder !== undefined,
547
584
  'ion-focusable': true,
548
585
  [`select-${rtl}`]: true,
@@ -550,7 +587,7 @@ export class Select {
550
587
  [`select-justify-${justify}`]: justifyEnabled,
551
588
  [`select-shape-${shape}`]: shape !== undefined,
552
589
  [`select-label-placement-${labelPlacement}`]: true,
553
- }) }, h("label", { class: "select-wrapper", id: "select-label" }, this.renderLabelContainer(), h("div", { class: "native-wrapper", ref: (el) => (this.nativeWrapperEl = el), part: "container" }, this.renderSelectText(), !hasFloatingOrStackedLabel && this.renderSelectIcon(), this.renderListbox()), hasFloatingOrStackedLabel && this.renderSelectIcon(), shouldRenderHighlight && h("div", { class: "select-highlight" }))));
590
+ }) }, h("label", { class: "select-wrapper", id: "select-label" }, this.renderLabelContainer(), h("div", { class: "select-wrapper-inner" }, h("slot", { name: "start" }), h("div", { class: "native-wrapper", ref: (el) => (this.nativeWrapperEl = el), part: "container" }, this.renderSelectText(), this.renderListbox()), h("slot", { name: "end" }), !hasFloatingOrStackedLabel && this.renderSelectIcon()), hasFloatingOrStackedLabel && this.renderSelectIcon(), shouldRenderHighlight && h("div", { class: "select-highlight" }))));
554
591
  }
555
592
  // TODO FW-3194 - Remove this
556
593
  renderLegacySelect() {
@@ -215,6 +215,12 @@ button {
215
215
  margin-top: 0;
216
216
  margin-bottom: 0;
217
217
  position: relative;
218
+ /**
219
+ * Prevent the icon from shrinking when the label and/or
220
+ * selected item text is long enough to fill the rest of
221
+ * the container.
222
+ */
223
+ flex-shrink: 0;
218
224
  }
219
225
 
220
226
  /**
@@ -298,6 +304,23 @@ button {
298
304
  transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
299
305
  }
300
306
 
307
+ .select-wrapper-inner {
308
+ display: flex;
309
+ align-items: center;
310
+ overflow: hidden;
311
+ }
312
+
313
+ :host(.select-label-placement-stacked) .select-wrapper-inner,
314
+ :host(.select-label-placement-floating) .select-wrapper-inner {
315
+ /**
316
+ * When using a stacked/floating label, the inner wrapper is
317
+ * stacked vertically under the label container. This line
318
+ * ensures that the inner wrapper fills all the remaining height
319
+ * of the component.
320
+ */
321
+ flex-grow: 1;
322
+ }
323
+
301
324
  :host(.ion-touched.ion-invalid) {
302
325
  --highlight-color: var(--highlight-color-invalid);
303
326
  }
@@ -538,11 +561,23 @@ button {
538
561
  * The placeholder should be hidden when the label
539
562
  * is on top of the select. This prevents the label
540
563
  * from overlapping any placeholder value.
564
+ *
565
+ * TODO(FW-5592): Remove :not(.label-floating) piece
541
566
  */
542
- :host(.select-label-placement-floating) .native-wrapper .select-placeholder {
567
+ :host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
543
568
  opacity: 0;
544
569
  }
545
570
 
571
+ /**
572
+ * We don't use .label-floating here because that would
573
+ * also include the case where the label is floating due
574
+ * to content in the start/end slot. We want the opacity
575
+ * to remain at the default in this case, since the select
576
+ * isn't being actively interacted with.
577
+ *
578
+ * TODO(FW-5592): Change entire selector to:
579
+ * :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
580
+ */
546
581
  :host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
547
582
  :host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
548
583
  :host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
@@ -552,10 +587,7 @@ button {
552
587
  /**
553
588
  * This makes the label sit above the input.
554
589
  */
555
- :host(.select-label-placement-stacked) .label-text-wrapper,
556
- :host(.select-expanded.select-label-placement-floating) .label-text-wrapper,
557
- :host(.ion-focused.select-label-placement-floating) .label-text-wrapper,
558
- :host(.has-value.select-label-placement-floating) .label-text-wrapper {
590
+ :host(.label-floating) .label-text-wrapper {
559
591
  transform: translateY(50%) scale(0.75);
560
592
  /**
561
593
  * Label text should not extend
@@ -564,6 +596,23 @@ button {
564
596
  max-width: calc(100% / 0.75);
565
597
  }
566
598
 
599
+ ::slotted([slot=start]), ::slotted([slot=end]) {
600
+ /**
601
+ * Prevent the slots from shrinking when the label and/or
602
+ * selected item text is long enough to fill the rest of
603
+ * the container.
604
+ */
605
+ flex-shrink: 0;
606
+ }
607
+
608
+ ::slotted([slot=start]) {
609
+ margin-inline-end: 16px;
610
+ }
611
+
612
+ ::slotted([slot=end]) {
613
+ margin-inline-start: 16px;
614
+ }
615
+
567
616
  /**
568
617
  * Convert a font size to a dynamic font size.
569
618
  * Fonts that participate in Dynamic Type should use
@@ -741,10 +790,7 @@ button {
741
790
  }
742
791
  }
743
792
 
744
- :host(.select-fill-solid.select-label-placement-stacked) .label-text-wrapper,
745
- :host(.select-expanded.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
746
- :host(.ion-focused.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
747
- :host(.has-value.select-fill-solid.select-label-placement-floating) .label-text-wrapper {
793
+ :host(.label-floating.select-fill-solid) .label-text-wrapper {
748
794
  /**
749
795
  * Label text should not extend
750
796
  * beyond the bounds of the select.
@@ -878,10 +924,7 @@ button {
878
924
  /**
879
925
  * This makes the label sit above the select.
880
926
  */
881
- :host(.select-expanded.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
882
- :host(.ion-focused.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
883
- :host(.has-value.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
884
- :host(.select-fill-outline.select-label-placement-stacked) .label-text-wrapper {
927
+ :host(.label-floating.select-fill-outline) .label-text-wrapper {
885
928
  transform: translateY(-32%) scale(0.75);
886
929
  margin-left: 0;
887
930
  margin-right: 0;
@@ -1026,10 +1069,7 @@ button {
1026
1069
  * the floating/stacked label. We simulate this "cut out"
1027
1070
  * by removing the top border from the notch fragment.
1028
1071
  */
1029
- :host(.select-expanded.select-fill-outline.select-label-placement-floating) .select-outline-notch,
1030
- :host(.ion-focused.select-fill-outline.select-label-placement-floating) .select-outline-notch,
1031
- :host(.has-value.select-fill-outline.select-label-placement-floating) .select-outline-notch,
1032
- :host(.select-fill-outline.select-label-placement-stacked) .select-outline-notch {
1072
+ :host(.label-floating.select-fill-outline) .select-outline-notch {
1033
1073
  border-top: none;
1034
1074
  }
1035
1075
 
@@ -1194,8 +1234,8 @@ button {
1194
1234
  --border-radius: 16px;
1195
1235
  }
1196
1236
 
1197
- :host(.select-label-placement-stacked) .native-wrapper,
1198
- :host(.select-label-placement-floating) .native-wrapper {
1237
+ :host(.select-label-placement-stacked) .select-wrapper-inner,
1238
+ :host(.select-label-placement-floating) .select-wrapper-inner {
1199
1239
  width: calc(100% - 0.8125rem - 4px);
1200
1240
  }
1201
1241
 
@@ -121,7 +121,7 @@ configs().forEach(({ title, screenshot, config }) => {
121
121
  });
122
122
  test('label should appear on top of the select when the select is expanded', async ({ page }) => {
123
123
  await page.setContent(`
124
- <ion-select class="select-expanded" label="Label" label-placement="floating" placeholder="Select a Fruit">
124
+ <ion-select class="select-expanded label-floating" label="Label" label-placement="floating" placeholder="Select a Fruit">
125
125
  <ion-select-option value="apples">Apples</ion-select-option>
126
126
  </ion-select>
127
127
  `, config);
@@ -0,0 +1,67 @@
1
+ /*!
2
+ * (C) Ionic http://ionicframework.com - MIT License
3
+ */
4
+ import { expect } from "@playwright/test";
5
+ import { configs, test } from "../../../../utils/test/playwright/index";
6
+ configs().forEach(({ title, screenshot, config }) => {
7
+ test.describe(title('select: start and end slots (visual checks)'), () => {
8
+ test('should not have visual regressions with a start-positioned label', async ({ page }) => {
9
+ await page.setContent(`
10
+ <ion-select label-placement="start" fill="solid" placeholder="Select weight" label="Weight">
11
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
12
+ <ion-label slot="end">lbs</ion-label>
13
+ </ion-select>
14
+ `, config);
15
+ const select = page.locator('ion-select');
16
+ await expect(select).toHaveScreenshot(screenshot(`select-slots-label-start`));
17
+ });
18
+ test('should not have visual regressions with a floating label', async ({ page }) => {
19
+ await page.setContent(`
20
+ <ion-select label-placement="floating" fill="solid" placeholder="Select weight" label="Weight">
21
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
22
+ <ion-label slot="end">lbs</ion-label>
23
+ </ion-select>
24
+ `, config);
25
+ const select = page.locator('ion-select');
26
+ await expect(select).toHaveScreenshot(screenshot(`select-slots-label-floating`));
27
+ });
28
+ });
29
+ });
30
+ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
31
+ test.describe(title('select: start and end slots (functionality checks)'), () => {
32
+ test('should raise floating label when there is content in the start slot', async ({ page }) => {
33
+ await page.setContent(`
34
+ <ion-select label-placement="floating" fill="solid" label="Weight">
35
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
36
+ </ion-select>
37
+ `, config);
38
+ const select = page.locator('ion-select');
39
+ await expect(select).toHaveClass(/label-floating/);
40
+ });
41
+ test('should raise floating label when there is content in the end slot', async ({ page }) => {
42
+ await page.setContent(`
43
+ <ion-select label-placement="floating" fill="solid" label="Weight">
44
+ <ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
45
+ </ion-select>
46
+ `, config);
47
+ const select = page.locator('ion-select');
48
+ await expect(select).toHaveClass(/label-floating/);
49
+ });
50
+ test('should not open select when slotted buttons are clicked', async ({ page }) => {
51
+ await page.setContent(`
52
+ <ion-select label="Favorite Pizza" placeholder="Select a pizza">
53
+ <ion-select-option value="pepperoni">Pepperoni</ion-select-option>
54
+ <ion-select-option value="supreme">Supreme</ion-select-option>
55
+ <ion-select-option value="chicken">Chicken</ion-select-option>
56
+ <ion-button fill="clear" slot="end" aria-label="Show/hide password">
57
+ <ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
58
+ </ion-button>
59
+ </ion-select>
60
+ `, config);
61
+ await page.click('ion-select ion-button[slot="end"]');
62
+ await page.waitForChanges();
63
+ const select = page.locator('ion-select');
64
+ await expect(select).not.toHaveClass(/select-expanded/);
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,51 @@
1
+ /*!
2
+ * (C) Ionic http://ionicframework.com - MIT License
3
+ */
4
+ import { expect } from "@playwright/test";
5
+ import { configs, test } from "../../../../utils/test/playwright/index";
6
+ configs().forEach(({ title, screenshot, config }) => {
7
+ test.describe(title('textarea: start and end slots (visual checks)'), () => {
8
+ test('should not have visual regressions with a start-positioned label', async ({ page }) => {
9
+ await page.setContent(`
10
+ <ion-textarea label-placement="start" fill="solid" value="100" label="Weight">
11
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
12
+ <ion-label slot="end">lbs</ion-label>
13
+ </ion-textarea>
14
+ `, config);
15
+ const textarea = page.locator('ion-textarea');
16
+ await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-start`));
17
+ });
18
+ test('should not have visual regressions with a floating label', async ({ page }) => {
19
+ await page.setContent(`
20
+ <ion-textarea label-placement="floating" fill="solid" value="100" label="Weight">
21
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
22
+ <ion-label slot="end">lbs</ion-label>
23
+ </ion-textarea>
24
+ `, config);
25
+ const textarea = page.locator('ion-textarea');
26
+ await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-floating`));
27
+ });
28
+ });
29
+ });
30
+ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
31
+ test.describe(title('textarea: start and end slots (functionality checks)'), () => {
32
+ test('should raise floating label when there is content in the start slot', async ({ page }) => {
33
+ await page.setContent(`
34
+ <ion-textarea label-placement="floating" fill="solid" label="Weight">
35
+ <ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
36
+ </ion-textarea>
37
+ `, config);
38
+ const textarea = page.locator('ion-textarea');
39
+ await expect(textarea).toHaveClass(/label-floating/);
40
+ });
41
+ test('should raise floating label when there is content in the end slot', async ({ page }) => {
42
+ await page.setContent(`
43
+ <ion-textarea label-placement="floating" fill="solid" label="Weight">
44
+ <ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
45
+ </ion-textarea>
46
+ `, config);
47
+ const textarea = page.locator('ion-textarea');
48
+ await expect(textarea).toHaveClass(/label-floating/);
49
+ });
50
+ });
51
+ });
@@ -334,9 +334,7 @@
334
334
  }
335
335
 
336
336
  .native-wrapper {
337
- display: flex;
338
337
  position: relative;
339
- flex-grow: 1;
340
338
  width: 100%;
341
339
  height: 100%;
342
340
  }
@@ -407,6 +405,12 @@
407
405
  word-break: break-word;
408
406
  }
409
407
 
408
+ .textarea-wrapper-inner {
409
+ display: flex;
410
+ width: 100%;
411
+ min-height: inherit;
412
+ }
413
+
410
414
  :host(.ion-touched.ion-invalid) {
411
415
  --highlight-color: var(--highlight-color-invalid);
412
416
  }
@@ -682,6 +686,13 @@
682
686
  margin-bottom: 0px;
683
687
  }
684
688
 
689
+ :host(.textarea-label-placement-stacked) ::slotted([slot=start]),
690
+ :host(.textarea-label-placement-stacked) ::slotted([slot=end]),
691
+ :host(.textarea-label-placement-floating) ::slotted([slot=start]),
692
+ :host(.textarea-label-placement-floating) ::slotted([slot=end]) {
693
+ margin-top: 8px;
694
+ }
695
+
685
696
  /**
686
697
  * This makes the label sit over the textarea
687
698
  * when the textarea is blurred and has no value.
@@ -707,9 +718,7 @@
707
718
  /**
708
719
  * This makes the label sit above the textarea.
709
720
  */
710
- :host(.textarea-label-placement-stacked) .label-text-wrapper,
711
- :host(.has-focus.textarea-label-placement-floating) .label-text-wrapper,
712
- :host(.has-value.textarea-label-placement-floating) .label-text-wrapper {
721
+ :host(.label-floating) .label-text-wrapper {
713
722
  transform: translateY(50%) scale(0.75);
714
723
  /**
715
724
  * Label text should not extend
@@ -718,6 +727,29 @@
718
727
  max-width: calc(100% / 0.75);
719
728
  }
720
729
 
730
+ .start-slot-wrapper, .end-slot-wrapper {
731
+ padding-left: 0;
732
+ padding-right: 0;
733
+ padding-top: var(--padding-top);
734
+ padding-bottom: var(--padding-bottom);
735
+ display: flex;
736
+ flex-shrink: 0;
737
+ align-self: start;
738
+ }
739
+
740
+ ::slotted([slot=start]),
741
+ ::slotted([slot=end]) {
742
+ margin-top: 0;
743
+ }
744
+
745
+ ::slotted([slot=start]) {
746
+ margin-inline-end: 16px;
747
+ }
748
+
749
+ ::slotted([slot=end]) {
750
+ margin-inline-start: 16px;
751
+ }
752
+
721
753
  /**
722
754
  * Convert a font size to a dynamic font size.
723
755
  * Fonts that participate in Dynamic Type should use
@@ -13,6 +13,8 @@ import { getCounterText } from "../input/input.utils";
13
13
  * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
14
14
  *
15
15
  * @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)
16
+ * @slot start - Content to display at the leading edge of the textarea. (EXPERIMENTAL)
17
+ * @slot end - Content to display at the trailing edge of the textarea. (EXPERIMENTAL)
16
18
  */
17
19
  export class Textarea {
18
20
  constructor() {
@@ -121,7 +123,7 @@ export class Textarea {
121
123
  connectedCallback() {
122
124
  const { el } = this;
123
125
  this.legacyFormController = createLegacyFormController(el);
124
- this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
126
+ this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
125
127
  this.notchController = createNotchController(el, () => this.notchSpacerEl, () => this.labelSlot);
126
128
  this.emitStyle();
127
129
  this.debounceChanged();
@@ -362,20 +364,41 @@ Developers can use the "legacy" property to continue using the legacy form marku
362
364
  return (h("div", { class: "textarea-bottom" }, this.renderHintText(), this.renderCounter()));
363
365
  }
364
366
  renderTextarea() {
365
- const { inputId, disabled, fill, shape, labelPlacement } = this;
367
+ const { inputId, disabled, fill, shape, labelPlacement, el, hasFocus } = this;
366
368
  const mode = getIonMode(this);
367
369
  const value = this.getValue();
368
370
  const inItem = hostContext('ion-item', this.el);
369
371
  const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
372
+ const hasValue = this.hasValue();
373
+ const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
374
+ /**
375
+ * If the label is stacked, it should always sit above the textarea.
376
+ * For floating labels, the label should move above the textarea if
377
+ * the textarea has a value, is focused, or has anything in either
378
+ * the start or end slot.
379
+ *
380
+ * If there is content in the start slot, the label would overlap
381
+ * it if not forced to float. This is also applied to the end slot
382
+ * because with the default or solid fills, the textarea is not
383
+ * vertically centered in the container, but the label is. This
384
+ * causes the slots and label to appear vertically offset from each
385
+ * other when the label isn't floating above the input. This doesn't
386
+ * apply to the outline fill, but this was not accounted for to keep
387
+ * things consistent.
388
+ *
389
+ * TODO(FW-5592): Remove hasStartEndSlots condition
390
+ */
391
+ const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
370
392
  return (h(Host, { class: createColorClasses(this.color, {
371
393
  [mode]: true,
372
- 'has-value': this.hasValue(),
373
- 'has-focus': this.hasFocus,
394
+ 'has-value': hasValue,
395
+ 'has-focus': hasFocus,
396
+ 'label-floating': labelShouldFloat,
374
397
  [`textarea-fill-${fill}`]: fill !== undefined,
375
398
  [`textarea-shape-${shape}`]: shape !== undefined,
376
399
  [`textarea-label-placement-${labelPlacement}`]: true,
377
400
  'textarea-disabled': disabled,
378
- }) }, h("label", { class: "textarea-wrapper" }, this.renderLabelContainer(), h("div", { class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, h("textarea", Object.assign({ 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)), shouldRenderHighlight && h("div", { class: "textarea-highlight" })), this.renderBottomContent()));
401
+ }) }, h("label", { class: "textarea-wrapper", htmlFor: inputId }, this.renderLabelContainer(), h("div", { class: "textarea-wrapper-inner" }, h("div", { class: "start-slot-wrapper" }, h("slot", { name: "start" })), h("div", { class: "native-wrapper", ref: (el) => (this.textareaWrapper = el) }, h("textarea", Object.assign({ 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)), h("div", { class: "end-slot-wrapper" }, h("slot", { name: "end" }))), shouldRenderHighlight && h("div", { class: "textarea-highlight" })), this.renderBottomContent()));
379
402
  }
380
403
  render() {
381
404
  const { legacyFormController } = this;