From bb516f0da7640055ac1f34d084a4132e02100187 Mon Sep 17 00:00:00 2001 From: Amanda Johnston <90629384+amandaejohnston@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:44:13 -0500 Subject: [PATCH] feat(input): add outline appearance for stacked label to ionic theme (#29268) Issue number: Internal --------- ## What is the new behavior? All changes are specific to the `ionic` theme. - Styles added for `fill="outline"` plus `labelPlacement="stacked"`. - Markup rearranged slightly to ensure label sits above outline while still being clickable to focus the input. See code comments for details. - The default `labelPlacement` is now `"stacked"`. - Values for `labelPlacement` besides `"stacked"` and `"floating"` cannot be used. Note that per the ticket, I did not account for any other scope, including styles for helper text, `labelPlacement="floating"`, `shape="round"`, etc. This means that some states will look broken for now, and will be addressed in future tickets. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information --------- Co-authored-by: ionitron Co-authored-by: Maria Hutt --- core/api.txt | 2 +- core/src/components.d.ts | 6 +- .../components/input/input.ionic.outline.scss | 111 ++++++++++++++++++ core/src/components/input/input.ionic.scss | 7 +- .../components/input/input.ionic.vars.scss | 1 - core/src/components/input/input.tsx | 50 +++++++- ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 1079 -> 1088 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 1206 -> 1223 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 911 -> 923 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 1048 -> 1057 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 1105 -> 1121 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 875 -> 887 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 3772 -> 3768 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 4204 -> 4335 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 3140 -> 3113 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 3772 -> 4005 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 4204 -> 4584 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 3140 -> 3330 bytes .../components/input/test/fill/input.e2e.ts | 21 ++++ ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 3534 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 3578 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 2935 bytes ...ionic-md-rtl-light-Mobile-Chrome-linux.png | Bin 0 -> 3513 bytes ...onic-md-rtl-light-Mobile-Firefox-linux.png | Bin 0 -> 3592 bytes ...ionic-md-rtl-light-Mobile-Safari-linux.png | Bin 0 -> 2914 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 2286 -> 2309 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 2603 -> 2845 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 1886 -> 1891 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 2195 -> 2309 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 2532 -> 2845 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 1794 -> 1891 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 2195 -> 2583 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 2532 -> 3060 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 1794 -> 2135 bytes ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 2195 -> 2583 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 2532 -> 3060 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 1794 -> 2135 bytes .../src/components/input/test/slot/index.html | 30 +++++ .../components/input/test/slot/input.e2e.ts | 19 +++ ...ionic-md-ltr-light-Mobile-Chrome-linux.png | Bin 0 -> 2759 bytes ...onic-md-ltr-light-Mobile-Firefox-linux.png | Bin 0 -> 3147 bytes ...ionic-md-ltr-light-Mobile-Safari-linux.png | Bin 0 -> 2255 bytes ...ionic-md-rtl-light-Mobile-Chrome-linux.png | Bin 0 -> 2750 bytes ...onic-md-rtl-light-Mobile-Firefox-linux.png | Bin 0 -> 3198 bytes ...ionic-md-rtl-light-Mobile-Safari-linux.png | Bin 0 -> 2241 bytes 45 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 core/src/components/input/input.ionic.outline.scss create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-ltr-light-Mobile-Chrome-linux.png create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-ltr-light-Mobile-Firefox-linux.png create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-ltr-light-Mobile-Safari-linux.png create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-rtl-light-Mobile-Chrome-linux.png create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-rtl-light-Mobile-Firefox-linux.png create mode 100644 core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-label-stacked-ionic-md-rtl-light-Mobile-Safari-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-ltr-light-Mobile-Chrome-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-ltr-light-Mobile-Firefox-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-ltr-light-Mobile-Safari-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-rtl-light-Mobile-Chrome-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-rtl-light-Mobile-Firefox-linux.png create mode 100644 core/src/components/input/test/slot/input.e2e.ts-snapshots/input-slots-label-stacked-fill-outline-ionic-md-rtl-light-Mobile-Safari-linux.png diff --git a/core/api.txt b/core/api.txt index 26be0122bd..3eadfcd632 100644 --- a/core/api.txt +++ b/core/api.txt @@ -610,7 +610,7 @@ ion-input,prop,fill,"outline" | "solid" | undefined,undefined,false,false ion-input,prop,helperText,string | undefined,undefined,false,false ion-input,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false ion-input,prop,label,string | undefined,undefined,false,false -ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start",'start',false,false +ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start" | undefined,undefined,false,false ion-input,prop,max,number | string | undefined,undefined,false,false ion-input,prop,maxlength,number | undefined,undefined,false,false ion-input,prop,min,number | string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 9cd8c797de..6bcbbc6e03 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1395,9 +1395,9 @@ export namespace Components { */ "label"?: string; /** - * Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). + * Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). Defaults to "stacked" for the ionic theme, or "start" for all other themes. In the ionic theme, only the values "stacked" and "floating" are supported. */ - "labelPlacement": 'start' | 'end' | 'floating' | 'stacked' | 'fixed'; + "labelPlacement"?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed'; /** * The maximum value, which must not be less than its minimum (min attribute) value. */ @@ -6631,7 +6631,7 @@ declare namespace LocalJSX { */ "label"?: string; /** - * Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). + * Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). Defaults to "stacked" for the ionic theme, or "start" for all other themes. In the ionic theme, only the values "stacked" and "floating" are supported. */ "labelPlacement"?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed'; /** diff --git a/core/src/components/input/input.ionic.outline.scss b/core/src/components/input/input.ionic.outline.scss new file mode 100644 index 0000000000..d749926339 --- /dev/null +++ b/core/src/components/input/input.ionic.outline.scss @@ -0,0 +1,111 @@ +@import "./input.vars"; +@import "../../foundations/ionic.vars"; + +// Input Fill: Outline (Ionic Theme) +// ---------------------------------------------------------------- + +:host(.input-fill-outline) { + --border-radius: #{$ionic-border-radius-rounded-small}; + --padding-start: 12px; + --padding-end: 12px; + --placeholder-color: #{$ionic-color-neutral-600}; + --placeholder-opacity: 1; +} + +/** + * The bottom content should never have + * a border with the outline style. + */ +:host(.input-fill-outline) .input-bottom { + border-top: none; +} + +:host(.input-fill-outline) .input-wrapper { + /** + * For the ionic theme, the padding needs to sit on the + * native wrapper instead, so that it sits within the + * outline container but does not affect the label text. + + * For the ionic theme, the horizontal padding needs to + * sit on the native wrapper instead, so that + */ + @include padding(0); + + /** + * Outline inputs do not have a bottom border. + * Instead, they have a border that wraps the + * input + label. + */ + border-bottom: none; +} + +:host(.input-fill-outline.input-label-placement-stacked) .label-text-wrapper { + @include transform-origin(start, top); + + /** + * Label text should not extend + * beyond the bounds of the input. + */ + max-width: calc(100% - var(--padding-start) - var(--padding-end)); +} + +:host(.input-fill-outline) .label-text-wrapper { + /** + * The label should appear on top of an outline + * container that overlaps it so it is always clickable. + */ + position: relative; + + color: #{$ionic-color-neutral-700}; +} + +:host(.input-fill-outline) .native-wrapper { + @include padding(var(--padding-top), var(--padding-end), var(--padding-bottom), var(--padding-start)); + + min-height: 40px; +} + + +// Input Fill: Outline, Outline Container +// ---------------------------------------------------------------- + +:host(.input-fill-outline) .input-outline { + @include position(0, 0, 0, 0); + @include border-radius(var(--border-radius)); + + position: absolute; + + width: 100%; + height: 100%; + + pointer-events: none; + + border: var(--border-width) var(--border-style) var(--border-color); +} + + +// Input Fill: Outline, Label Placement: Stacked +// ---------------------------------------------------------------- + +// This makes the label sit above the input. +:host(.label-floating.input-fill-outline.input-label-placement-stacked:not(.input-shape-round)) .label-text-wrapper { + @include transform(translateY(0), scale(#{$form-control-label-stacked-scale})); + @include margin(0); + + /** + * Label text should not extend + * beyond the bounds of the input. + */ + max-width: calc((100% - var(--padding-start) - var(--padding-end)) / #{$form-control-label-stacked-scale}); +} + +// Start/End Slots +// ---------------------------------------------------------------- + +:host(.input-fill-outline) ::slotted([slot="start"]) { + margin-inline-end: 8px; +} + +:host(.input-fill-outline) ::slotted([slot="end"]) { + margin-inline-start: 8px; +} diff --git a/core/src/components/input/input.ionic.scss b/core/src/components/input/input.ionic.scss index 6dd8c61da0..0f9a59f8b1 100644 --- a/core/src/components/input/input.ionic.scss +++ b/core/src/components/input/input.ionic.scss @@ -1,9 +1,14 @@ @import "./input"; @import "./input.ionic.vars"; +@import "./input.ionic.outline.scss"; // Ionic Input // -------------------------------------------------- + :host { + --border-width: #{$ionic-border-size-small}; + --border-color: #{$ionic-color-neutral-300}; + // TODO(FW-6113): Verify the ionic design token is correct once it's available and remove the hardcoded value. --highlight-color-invalid: var(--ionic-color-error-600, #970606); } @@ -11,7 +16,7 @@ // Ionic Input Sizes // -------------------------------------------------- -:host(.input-size-large) { +:host(.input-size-large) .native-wrapper { min-height: 48px; } diff --git a/core/src/components/input/input.ionic.vars.scss b/core/src/components/input/input.ionic.vars.scss index 7b3c3c6c97..5b8e433a8f 100644 --- a/core/src/components/input/input.ionic.vars.scss +++ b/core/src/components/input/input.ionic.vars.scss @@ -1,4 +1,3 @@ // Ionic Input // -------------------------------------------------- - diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 9208f170f0..9ef48244ea 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -181,8 +181,12 @@ export class Input implements ComponentInterface { * `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. * `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. * `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). + * + * Defaults to "stacked" for the ionic theme, or "start" for all other themes. + * + * In the ionic theme, only the values "stacked" and "floating" are supported. */ - @Prop() labelPlacement: 'start' | 'end' | 'floating' | 'stacked' | 'fixed' = 'start'; + @Prop({ mutable: true }) labelPlacement?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed'; /** * The maximum value, which must not be less than its minimum (min attribute) value. @@ -343,6 +347,10 @@ export class Input implements ComponentInterface { ...inheritAriaAttributes(this.el), ...inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type']), }; + + if (this.labelPlacement === undefined) { + this.labelPlacement = getIonTheme(this) === 'ionic' ? ionicThemeDefaultLabelPlacement : 'start'; + } } connectedCallback() { @@ -471,6 +479,21 @@ export class Input implements ComponentInterface { return typeof this.value === 'number' ? this.value.toString() : (this.value || '').toString(); } + private getLabelPlacement() { + const theme = getIonTheme(this); + const { el, labelPlacement } = this; + + if (theme === 'ionic' && labelPlacement !== 'stacked' && labelPlacement !== 'floating') { + printIonWarning( + `The "${labelPlacement}" label placement is not supported in the ${theme} theme. The default value of "${ionicThemeDefaultLabelPlacement}" will be used instead.`, + el + ); + return ionicThemeDefaultLabelPlacement; + } + + return labelPlacement; + } + private getSize() { const theme = getIonTheme(this); const { size } = this; @@ -663,9 +686,9 @@ export class Input implements ComponentInterface { */ private renderLabelContainer() { const theme = getIonTheme(this); - const hasOutlineFill = theme === 'md' && this.fill === 'outline'; + const hasOutlineFill = this.fill === 'outline'; - if (hasOutlineFill) { + if (hasOutlineFill && theme === 'md') { /** * The outline fill has a special outline * that appears around the input and the label. @@ -693,19 +716,21 @@ export class Input implements ComponentInterface { } /** - * If not using the outline style, - * we can render just the label. + * If not using the outline style, OR if using the + * ionic theme, just render the label. For the ionic + * theme, the outline will be rendered elsewhere. */ return this.renderLabel(); } render() { - const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus } = this; + const { disabled, fill, readonly, shape, inputId, el, hasFocus } = this; const theme = getIonTheme(this); const value = this.getValue(); const size = this.getSize(); const inItem = hostContext('ion-item', this.el); const shouldRenderHighlight = theme === 'md' && fill !== 'outline' && !inItem; + const labelPlacement = this.getLabelPlacement(); const hasValue = this.hasValue(); const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null; @@ -755,6 +780,18 @@ export class Input implements ComponentInterface {