diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 19a4e9f9f7a..add31fa527e 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -207,6 +207,7 @@ export namespace Components { "translucent": boolean; } interface IonApp { + "setFocus": (elements: HTMLElement[]) => Promise; } interface IonAvatar { } @@ -2349,19 +2350,23 @@ export namespace Components { } interface IonSelectPopover { /** - * Header text for the popover + * The header text of the popover */ "header"?: string; /** - * Text for popover body + * The text content of the popover body */ "message"?: string; /** - * Array of options for the popover + * If true, the select accepts multiple values + */ + "multiple"?: boolean; + /** + * An array of options for the popover */ "options": SelectPopoverOption[]; /** - * Subheader text for the popover + * The subheader text of the popover */ "subHeader"?: string; } @@ -5929,19 +5934,23 @@ declare namespace LocalJSX { } interface IonSelectPopover { /** - * Header text for the popover + * The header text of the popover */ "header"?: string; /** - * Text for popover body + * The text content of the popover body */ "message"?: string; /** - * Array of options for the popover + * If true, the select accepts multiple values + */ + "multiple"?: boolean; + /** + * An array of options for the popover */ "options"?: SelectPopoverOption[]; /** - * Subheader text for the popover + * The subheader text of the popover */ "subHeader"?: string; } diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index e84ffbbaca1..809116d41ec 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,4 +1,4 @@ -import { Build, Component, ComponentInterface, Element, Host, h } from '@stencil/core'; +import { Build, Component, ComponentInterface, Element, Host, Method, h } from '@stencil/core'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -9,6 +9,8 @@ import { isPlatform } from '../../utils/platform'; styleUrl: 'app.scss', }) export class App implements ComponentInterface { + private focusVisible?: any; + @Element() el!: HTMLElement; componentDidLoad() { @@ -33,11 +35,28 @@ export class App implements ComponentInterface { if (typeof (window as any) !== 'undefined') { import('../../utils/keyboard/keyboard').then(module => module.startKeyboardAssist(window)); } - import('../../utils/focus-visible').then(module => module.startFocusVisible()); + import('../../utils/focus-visible').then(module => this.focusVisible = module.startFocusVisible()); }); } } + /** + * @internal + * Used to set focus on an element that uses `ion-focusable`. + * Do not use this if focusing the element as a result of a keyboard + * event as the focus utility should handle this for us. This method + * should be used when we want to programmatically focus an element as + * a result of another user action. (Ex: We focus the first element + * inside of a popover when the user presents it, but the popover is not always + * presented as a result of keyboard action.) + */ + @Method() + async setFocus(elements: HTMLElement[]) { + if (this.focusVisible) { + this.focusVisible.setFocus(elements); + } + } + render() { const mode = getIonMode(this); return ( diff --git a/core/src/components/checkbox/readme.md b/core/src/components/checkbox/readme.md index eeb6daa7fe9..5f644ecaf81 100644 --- a/core/src/components/checkbox/readme.md +++ b/core/src/components/checkbox/readme.md @@ -303,6 +303,19 @@ export default defineComponent({ | `--transition` | Transition of the checkbox icon | +## Dependencies + +### Used by + + - ion-select-popover + +### Graph +```mermaid +graph TD; + ion-select-popover --> ion-checkbox + style ion-checkbox fill:#f9f,stroke:#333,stroke-width:4px +``` + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index 2e28491f3ae..f07be06238d 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -747,7 +747,7 @@ export class Datetime implements ComponentInterface { } connectedCallback() { - this.clearFocusVisible = startFocusVisible(this.el); + this.clearFocusVisible = startFocusVisible(this.el).destroy; } disconnectedCallback() { diff --git a/core/src/components/item/item.ios.scss b/core/src/components/item/item.ios.scss index 89239023644..27c244a4a61 100644 --- a/core/src/components/item/item.ios.scss +++ b/core/src/components/item/item.ios.scss @@ -24,6 +24,7 @@ --highlight-color-valid: #{$item-ios-input-highlight-color-valid}; --highlight-color-invalid: #{$item-ios-input-highlight-color-invalid}; --bottom-padding-start: 0px; + font-size: $item-ios-font-size; } diff --git a/core/src/components/item/item.md.scss b/core/src/components/item/item.md.scss index 900d263bb16..ff7ea706b72 100644 --- a/core/src/components/item/item.md.scss +++ b/core/src/components/item/item.md.scss @@ -99,6 +99,7 @@ transition: none; } +:host(.item-fill-outline.ion-focused) .item-native, :host(.item-fill-outline.item-has-focus) .item-native { border-color: transparent; } @@ -308,6 +309,8 @@ --padding-start: 0; } +:host(.ion-focused:not(.ion-color)) ::slotted(.label-stacked), +:host(.ion-focused:not(.ion-color)) ::slotted(.label-floating), :host(.item-has-focus:not(.ion-color)) ::slotted(.label-stacked), :host(.item-has-focus:not(.ion-color)) ::slotted(.label-floating) { color: $label-md-text-color-focused; @@ -347,13 +350,10 @@ --border-color: #{$item-md-input-fill-border-color}; } -:host(.item-fill-solid) .item-native:hover { - --background: var(--background-hover); - --border-color: #{$item-md-input-fill-border-color-hover}; -} - +:host(.item-fill-solid.ion-focused) .item-native, :host(.item-fill-solid.item-has-focus) .item-native { --background: var(--background-focused); + border-bottom-color: var(--highlight-color-focused); } @@ -361,10 +361,20 @@ @include border-radius(16px, 16px, 0, 0); } +@media (any-hover: hover) { + :host(.item-fill-solid:hover) .item-native { + --background: var(--background-hover); + --border-color: #{$item-md-input-fill-border-color-hover}; + } +} + // Material Design Item: Fill Outline // -------------------------------------------------- :host(.item-fill-outline) { + --ripple-color: transparent; + --background-focused: transparent; + --background-hover: transparent; --border-color: #{$item-md-input-fill-border-color}; --border-width: #{$item-md-border-bottom-width}; @@ -379,10 +389,6 @@ @include border-radius(4px); } -:host(.item-fill-outline) .item-native:hover { - --border-color: #{$item-md-input-fill-border-color-hover}; -} - :host(.item-fill-outline.item-shape-round) .item-native { --inner-padding-start: 16px; @@ -393,14 +399,22 @@ @include padding-horizontal(32px, null); } - +:host(.item-fill-outline.item-label-floating.ion-focused) .item-native ::slotted(ion-input:not(:first-child)), +:host(.item-fill-outline.item-label-floating.ion-focused) .item-native ::slotted(ion-textarea:not(:first-child)), :host(.item-fill-outline.item-label-floating.item-has-focus) .item-native ::slotted(ion-input:not(:first-child)), +:host(.item-fill-outline.item-label-floating.item-has-focus) .item-native ::slotted(ion-textarea:not(:first-child)), :host(.item-fill-outline.item-label-floating.item-has-value) .item-native ::slotted(ion-input:not(:first-child)), -:host(.item-fill-outline.item-label-floating.item-has-focus) .item-native ::slotted(ion-textarea:not(:first-child)), :host(.item-fill-outline.item-label-floating.item-has-value) .item-native ::slotted(ion-textarea:not(:first-child)) { transform: translateY(-25%); } +@media (any-hover: hover) { + :host(.item-fill-outline:hover) .item-native { + --border-color: #{$item-md-input-fill-border-color-hover}; + } +} + + // Material Design Item: Invalid // -------------------------------------------------- @@ -416,4 +430,4 @@ :host(.item-fill-solid.ion-invalid:not(.ion-color)) .item-native, :host(.item-fill-solid.ion-invalid:not(.ion-color)) .item-highlight { border-color: var(--highlight-color-invalid); -} \ No newline at end of file +} diff --git a/core/src/components/item/item.scss b/core/src/components/item/item.scss index a0c7fb3eb74..1ebe1bc1014 100644 --- a/core/src/components/item/item.scss +++ b/core/src/components/item/item.scss @@ -154,7 +154,7 @@ // -------------------------------------------------- @media (any-hover: hover) { - :host(.ion-activatable:hover) .item-native { + :host(.ion-activatable:not(.ion-focused):hover) .item-native { color: var(--color-hover); &::after { @@ -164,7 +164,7 @@ } } - :host(.ion-color.ion-activatable:hover) .item-native { + :host(.ion-color.ion-activatable:not(.ion-focused):hover) .item-native { color: #{current-color(contrast)}; &::after { @@ -173,6 +173,7 @@ } } + // Item: Disabled // -------------------------------------------------- @@ -308,7 +309,11 @@ button, a { z-index: 1; } +// Setting pointer-events to none allows the label +// to be clicked to open the select interface ::slotted(ion-label) { + pointer-events: none; + flex: 1; } @@ -359,7 +364,7 @@ button, a { width: 100%; height: 100%; - + transform: scaleX(0); transition: transform 200ms, border-bottom-width 200ms; @@ -370,6 +375,8 @@ button, a { pointer-events: none; } +:host(.ion-focused) .item-highlight, +:host(.ion-focused) .item-inner-highlight, :host(.item-has-focus) .item-highlight, :host(.item-has-focus) .item-inner-highlight { transform: scaleX(1); @@ -378,22 +385,27 @@ button, a { border-color: var(--highlight-background); } +:host(.ion-focused) .item-highlight, :host(.item-has-focus) .item-highlight { border-width: var(--full-highlight-height); opacity: var(--show-full-highlight); } +:host(.ion-focused) .item-inner-highlight, :host(.item-has-focus) .item-inner-highlight { border-bottom-width: var(--inset-highlight-height); opacity: var(--show-inset-highlight); } +:host(.ion-focused.item-fill-solid) .item-highlight, :host(.item-has-focus.item-fill-solid) .item-highlight { border-width: calc(var(--full-highlight-height) - 1px); } +:host(.ion-focused) .item-inner-highlight, +:host(.ion-focused:not(.item-fill-outline)) .item-highlight, :host(.item-has-focus) .item-inner-highlight, :host(.item-has-focus:not(.item-fill-outline)) .item-highlight { border-top: none; @@ -405,6 +417,7 @@ button, a { // Item Input Focused // -------------------------------------------------- +:host(.item-interactive.ion-focused), :host(.item-interactive.item-has-focus), :host(.item-interactive.ion-touched.ion-invalid) { // If the item has a full border and highlight is enabled, show the full item highlight @@ -417,6 +430,7 @@ button, a { // Item Input Focus // -------------------------------------------------- +:host(.item-interactive.ion-focused), :host(.item-interactive.item-has-focus) { --highlight-background: var(--highlight-color-focused); } @@ -553,4 +567,4 @@ ion-ripple-effect { display: none; color: var(--highlight-color-invalid); -} \ No newline at end of file +} diff --git a/core/src/components/label/label.md.scss b/core/src/components/label/label.md.scss index 05bf329cef5..b163d60e39b 100644 --- a/core/src/components/label/label.md.scss +++ b/core/src/components/label/label.md.scss @@ -47,6 +47,7 @@ transform 150ms $label-md-transition-timing-function; } +:host-context(.ion-focused).label-floating, :host-context(.item-has-focus).label-floating, :host-context(.item-has-placeholder:not(.item-input)).label-floating, :host-context(.item-has-value).label-floating { @@ -54,9 +55,10 @@ } /** - * When translating the label inside of an ion-item with `fill="outline"`, + * When translating the label inside of an ion-item with `fill="outline"`, * add pseudo-elements to imitate fieldset-like padding without shifting the label */ +:host-context(.item-fill-outline.ion-focused).label-floating, :host-context(.item-fill-outline.item-has-focus).label-floating, :host-context(.item-fill-outline.item-has-placeholder:not(.item-input)).label-floating, :host-context(.item-fill-outline.item-has-value).label-floating { @@ -96,28 +98,38 @@ } } +:host-context(.item-fill-outline.ion-focused.item-has-start-slot).label-floating, :host-context(.item-fill-outline.item-has-focus.item-has-start-slot).label-floating, :host-context(.item-fill-outline.item-has-placeholder:not(.item-input).item-has-start-slot).label-floating, :host-context(.item-fill-outline.item-has-value.item-has-start-slot).label-floating { @include transform(translateX(#{$item-md-fill-outline-label-translate-x}), translateY(-6px), scale(.75)); } +:host-context(.item-fill-outline.ion-focused.item-has-start-slot).label-floating.label-rtl, :host-context(.item-fill-outline.item-has-focus.item-has-start-slot).label-floating.label-rtl, :host-context(.item-fill-outline.item-has-placeholder:not(.item-input).item-has-start-slot).label-floating.label-rtl, :host-context(.item-fill-outline.item-has-value.item-has-start-slot).label-floating.label-rtl { @include transform(translateX(calc(-1 * #{$item-md-fill-outline-label-translate-x})), translateY(-6px), scale(.75)); } +:host-context(.ion-focused).label-stacked:not(.ion-color), +:host-context(.ion-focused).label-floating:not(.ion-color), :host-context(.item-has-focus).label-stacked:not(.ion-color), :host-context(.item-has-focus).label-floating:not(.ion-color) { color: $label-md-text-color-focused; } +:host-context(.ion-focused.ion-color).label-stacked:not(.ion-color), +:host-context(.ion-focused.ion-color).label-floating:not(.ion-color), :host-context(.item-has-focus.ion-color).label-stacked:not(.ion-color), :host-context(.item-has-focus.ion-color).label-floating:not(.ion-color) { color: #{current-color(contrast)}; } +:host-context(.item-fill-solid.ion-focused.ion-color).label-stacked:not(.ion-color), +:host-context(.item-fill-solid.ion-focused.ion-color).label-floating:not(.ion-color), +:host-context(.item-fill-outline.ion-focused.ion-color).label-stacked:not(.ion-color), +:host-context(.item-fill-outline.ion-focused.ion-color).label-floating:not(.ion-color), :host-context(.item-fill-solid.item-has-focus.ion-color).label-stacked:not(.ion-color), :host-context(.item-fill-solid.item-has-focus.ion-color).label-floating:not(.ion-color), :host-context(.item-fill-outline.item-has-focus.ion-color).label-stacked:not(.ion-color), diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index ccbe34f481a..f30364b7a4d 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -32,7 +32,10 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => const results = getPopoverPosition(isRTL, contentWidth, contentHeight, arrowWidth, arrowHeight, reference, side, align, defaultPosition, trigger, ev); - const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass } = calculateWindowAdjustment(side, results.top, results.left, POPOVER_IOS_BODY_PADDING, bodyWidth, bodyHeight, contentWidth, contentHeight, 25, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight); + const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING; + const margin = size === 'cover' ? 0 : 25; + + const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, margin, results.originX, results.originY, results.referenceCoordinates, results.arrowTop, results.arrowLeft, arrowHeight); const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index 985d2af030b..04c503676a8 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -31,7 +31,9 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => const results = getPopoverPosition(isRTL, contentWidth, contentHeight, 0, 0, reference, side, align, defaultPosition, trigger, ev); - const { originX, originY, top, left, bottom } = calculateWindowAdjustment(side, results.top, results.left, POPOVER_MD_BODY_PADDING, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates); + const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING; + + const { originX, originY, top, left, bottom } = calculateWindowAdjustment(side, results.top, results.left, padding, bodyWidth, bodyHeight, contentWidth, contentHeight, 0, results.originX, results.originY, results.referenceCoordinates); const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); diff --git a/core/src/components/select-popover/readme.md b/core/src/components/select-popover/readme.md index 3480e9d0d66..00fbc1f19f5 100644 --- a/core/src/components/select-popover/readme.md +++ b/core/src/components/select-popover/readme.md @@ -1,6 +1,6 @@ # ion-select-popover -SelectPopover is an internal component that is used for create the popover interface, from a Select component. +The select popover is an internal component that is used to create the popover interface from a select component. diff --git a/core/src/components/select-popover/select-popover-interface.ts b/core/src/components/select-popover/select-popover-interface.ts index 194f1e06452..7bc59315492 100644 --- a/core/src/components/select-popover/select-popover-interface.ts +++ b/core/src/components/select-popover/select-popover-interface.ts @@ -5,5 +5,5 @@ export interface SelectPopoverOption { disabled: boolean; checked: boolean; cssClass?: string | string[]; - handler?: () => void; + handler?: (value: any) => boolean | void | {[key: string]: any}; } diff --git a/core/src/components/select-popover/select-popover.ios.scss b/core/src/components/select-popover/select-popover.ios.scss new file mode 100644 index 00000000000..22dc63cc3a2 --- /dev/null +++ b/core/src/components/select-popover/select-popover.ios.scss @@ -0,0 +1,3 @@ +@import "./select-popover"; +@import "./select-popover.ios.vars"; + diff --git a/core/src/components/select-popover/select-popover.ios.vars.scss b/core/src/components/select-popover/select-popover.ios.vars.scss new file mode 100644 index 00000000000..05ef1b1b63e --- /dev/null +++ b/core/src/components/select-popover/select-popover.ios.vars.scss @@ -0,0 +1,5 @@ +@import "../../themes/ionic.globals.ios"; +@import "../item/item.ios.vars"; + +// iOS Select Popover +// -------------------------------------------------- diff --git a/core/src/components/select-popover/select-popover.md.scss b/core/src/components/select-popover/select-popover.md.scss new file mode 100644 index 00000000000..feba89b0fe4 --- /dev/null +++ b/core/src/components/select-popover/select-popover.md.scss @@ -0,0 +1,25 @@ +@import "./select-popover"; +@import "./select-popover.md.vars"; + +ion-list ion-radio { + opacity: 0; +} + +ion-item { + --inner-border-width: 0; +} + +.item-radio-checked { + --background: #{ion-color(primary, base, 0.08)}; + --background-focused: #{ion-color(primary, base)}; + --background-focused-opacity: 0.2; + --background-hover: #{ion-color(primary, base)}; + --background-hover-opacity: 0.12; +} + +.item-checkbox-checked { + --background-activated: #{$item-md-color}; + --background-focused: #{$item-md-color}; + --background-hover: #{$item-md-color}; + --color: #{ion-color(primary, base)}; +} diff --git a/core/src/components/select-popover/select-popover.md.vars.scss b/core/src/components/select-popover/select-popover.md.vars.scss new file mode 100644 index 00000000000..a0ea2826b90 --- /dev/null +++ b/core/src/components/select-popover/select-popover.md.vars.scss @@ -0,0 +1,5 @@ +@import "../../themes/ionic.globals.md"; +@import "../item/item.md.vars"; + +// Material Design Select Popover +// -------------------------------------------------- diff --git a/core/src/components/select-popover/select-popover.scss b/core/src/components/select-popover/select-popover.scss index 5b76901dbf3..0c4d9782b4b 100644 --- a/core/src/components/select-popover/select-popover.scss +++ b/core/src/components/select-popover/select-popover.scss @@ -1,10 +1,10 @@ @import "./select-popover.vars"; -:host ion-list { +ion-list { @include margin($select-popover-list-margin-top, $select-popover-list-margin-end, $select-popover-list-margin-bottom, $select-popover-list-margin-start); } -:host ion-list-header, -:host ion-label { +ion-list-header, +ion-label { @include margin(0); } diff --git a/core/src/components/select-popover/select-popover.tsx b/core/src/components/select-popover/select-popover.tsx index 4ff4b70a6f5..0f123f0d5c3 100644 --- a/core/src/components/select-popover/select-popover.tsx +++ b/core/src/components/select-popover/select-popover.tsx @@ -10,60 +10,166 @@ import { getClassMap } from '../../utils/theme'; */ @Component({ tag: 'ion-select-popover', - styleUrl: 'select-popover.scss', + styleUrls: { + ios: 'select-popover.ios.scss', + md: 'select-popover.md.scss' + }, scoped: true }) export class SelectPopover implements ComponentInterface { - - /** Header text for the popover */ + /** + * The header text of the popover + */ @Prop() header?: string; - /** Subheader text for the popover */ + /** + * The subheader text of the popover + */ @Prop() subHeader?: string; - /** Text for popover body */ + /** + * The text content of the popover body + */ @Prop() message?: string; - /** Array of options for the popover */ + /** + * If true, the select accepts multiple values + */ + @Prop() multiple?: boolean; + + /** + * An array of options for the popover + */ @Prop() options: SelectPopoverOption[] = []; @Listen('ionChange') onSelect(ev: any) { - const option = this.options.find(o => o.value === ev.target.value); - if (option) { - safeCall(option.handler); + this.setChecked(ev); + this.callOptionHandler(ev); + } + + /** + * When an option is selected we need to get the value(s) + * of the selected option(s) and return it in the option + * handler + */ + private callOptionHandler(ev: any) { + const { options } = this; + const option = options.find(o => this.getValue(o.value) === ev.target.value); + + const values = this.getValues(ev); + + if (option && option.handler) { + safeCall(option.handler, values); + } + } + + /** + * This is required when selecting a radio that is already + * selected because it will not trigger the ionChange event + * but we still want to close the popover + */ + private rbClick(ev: any) { + this.callOptionHandler(ev); + } + + private setChecked(ev: any): void { + const { multiple, options } = this; + const option = options.find(o => this.getValue(o.value) === ev.target.value); + + // this is a popover with checkboxes (multiple value select) + // we need to set the checked value for this option + if (multiple && option) { + option.checked = ev.detail.checked; } } + private getValues(ev: any): any | any[] | null { + const { multiple, options } = this; + + if (multiple) { + // this is a popover with checkboxes (multiple value select) + // return an array of all the checked values + return options.filter(o => o.checked).map(o => o.value); + } + + // this is a popover with radio buttons (single value select) + // return the value that was clicked, otherwise undefined + const option = options.find(o => this.getValue(o.value) === ev.target.value); + return option ? option.value : undefined; + } + + private getValue(value: any): any { + return typeof value === 'number' ? value.toString() : value; + } + + renderOptions(options: SelectPopoverOption[]) { + const { multiple } = this; + + switch (multiple) { + case true: return this.renderCheckboxOptions(options); + default: return this.renderRadioOptions(options); + } + } + + renderCheckboxOptions(options: SelectPopoverOption[]) { + return ( + options.map(option => + + + + + {option.text} + + + ) + ) + } + + renderRadioOptions(options: SelectPopoverOption[]) { + const checked = options.filter(o => o.checked).map(o => o.value)[0]; + + return ( + + {options.map(option => + + + {option.text} + + this.rbClick(ev)} + > + + + )} + + ) + } + render() { - const checkedOption = this.options.find(o => o.checked); - const checkedValue = checkedOption ? checkedOption.value : undefined; + const { header, message, options, subHeader } = this; + const hasSubHeaderOrMessage = subHeader !== undefined || message !== undefined; + return ( - {this.header !== undefined && {this.header}} - { (this.subHeader !== undefined || this.message !== undefined) && + {header !== undefined && {header}} + { hasSubHeaderOrMessage && - {this.subHeader !== undefined &&

{this.subHeader}

} - {this.message !== undefined &&

{this.message}

} + {subHeader !== undefined &&

{subHeader}

} + {message !== undefined &&

{message}

}
} - - {this.options.map(option => - - - {option.text} - - - - - )} - + {this.renderOptions(options)}
); diff --git a/core/src/components/select/select.ios.scss b/core/src/components/select/select.ios.scss index 2e88ff47a83..286bce173ca 100644 --- a/core/src/components/select/select.ios.scss +++ b/core/src/components/select/select.ios.scss @@ -14,4 +14,6 @@ .select-icon { width: 12px; height: 18px; + + opacity: .33; } diff --git a/core/src/components/select/select.md.scss b/core/src/components/select/select.md.scss index cd128593027..42451dbfafb 100644 --- a/core/src/components/select/select.md.scss +++ b/core/src/components/select/select.md.scss @@ -14,8 +14,41 @@ .select-icon { width: 19px; height: 19px; + + transition: transform .15s cubic-bezier(.4, 0, .2, 1); + + opacity: .55; + } -:host-context(.item-label-floating) .select-icon { +/** + * Adjust the arrow so that it appears in the middle + * of the item. If the item has fill="outline" then + * we should adjust the entire ion-select rather than + * just the outline so the selected value appears centered too. + */ +:host-context(.item-label-stacked) .select-icon, +:host-context(.item-label-floating:not(.item-fill-outline)) .select-icon, +:host-context(.item-label-floating.item-fill-outline) { @include transform(translate3d(0, -9px, 0)); } + +:host-context(.item-has-focus) .select-icon { + @include transform(rotate(180deg)); +} + +/** + * Ensure that the translation we did + * above is preserved when we rotate the select icon. + */ +:host-context(.item-has-focus.item-label-stacked) .select-icon, +:host-context(.item-has-focus.item-label-floating:not(.item-fill-outline)) .select-icon { + @include transform(rotate(180deg), translate3d(0, -9px, 0)); +} + +:host-context(ion-item.ion-focused) .select-icon, +:host-context(.item-has-focus) .select-icon { + color: var(--highlight-color-focused); + + opacity: 1; +} diff --git a/core/src/components/select/select.scss b/core/src/components/select/select.scss index dd31ef52fa7..7aed200eb31 100644 --- a/core/src/components/select/select.scss +++ b/core/src/components/select/select.scss @@ -66,8 +66,6 @@ button { .select-icon { position: relative; - - opacity: .33; } .select-text { diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 7cc725a7a15..592664e9c05 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -125,7 +125,8 @@ export class Select implements ComponentInterface { @Watch('disabled') @Watch('placeholder') - disabledChanged() { + @Watch('isExpanded') + styleChanged() { this.emitStyle(); } @@ -177,28 +178,33 @@ export class Select implements ComponentInterface { this.isExpanded = false; this.setFocus(); }); - await overlay.present(); + + if (this.interface === 'popover') { + await (overlay as HTMLIonPopoverElement).presentFromTrigger(event, true); + } else { + await overlay.present(); + } return overlay; } private createOverlay(ev?: UIEvent): Promise { let selectInterface = this.interface; - if ((selectInterface === 'action-sheet' || selectInterface === 'popover') && this.multiple) { + if (selectInterface === 'action-sheet' && this.multiple) { console.warn(`Select interface cannot be "${selectInterface}" with a multi-value select. Using the "alert" interface instead.`); selectInterface = 'alert'; } if (selectInterface === 'popover' && !ev) { - console.warn('Select interface cannot be a "popover" without passing an event. Using the "alert" interface instead.'); + console.warn(`Select interface cannot be a "${selectInterface}" without passing an event. Using the "alert" interface instead.`); selectInterface = 'alert'; } - if (selectInterface === 'popover') { - return this.openPopover(ev!); - } if (selectInterface === 'action-sheet') { return this.openActionSheet(); } + if (selectInterface === 'popover') { + return this.openPopover(ev!); + } return this.openAlert(); } @@ -291,9 +297,11 @@ export class Select implements ComponentInterface { value, checked: isOptionSelected(value, selectValue, this.compareWith), disabled: option.disabled, - handler: () => { - this.value = value; - this.close(); + handler: (selected: any) => { + this.value = selected; + if (!this.multiple) { + this.close(); + } } }; }); @@ -304,18 +312,43 @@ export class Select implements ComponentInterface { private async openPopover(ev: UIEvent) { const interfaceOptions = this.interfaceOptions; const mode = getIonMode(this); + const showBackdrop = mode === 'md' ? false : true; + const multiple = this.multiple; const value = this.value; + + let event: Event | CustomEvent = ev; + let size = 'auto'; + + const item = this.el.closest('ion-item'); + + // If the select is inside of an item containing a floating + // or stacked label then the popover should take up the + // full width of the item when it presents + if (item && (item.classList.contains('item-label-floating') || item.classList.contains('item-label-stacked'))) { + event = { + ...ev, + detail: { + ionShadowTarget: item + } + } + size = 'cover'; + } + const popoverOpts: PopoverOptions = { mode, + event, + alignment: 'center', + size, + showBackdrop, ...interfaceOptions, component: 'ion-select-popover', cssClass: ['select-popover', interfaceOptions.cssClass], - event: ev, componentProps: { header: interfaceOptions.header, subHeader: interfaceOptions.subHeader, message: interfaceOptions.message, + multiple, value, options: this.createPopoverOptions(this.childOpts, value) } @@ -411,11 +444,12 @@ export class Select implements ComponentInterface { private emitStyle() { this.ionStyle.emit({ 'interactive': true, + 'interactive-disabled': this.disabled, 'select': true, + 'select-disabled': this.disabled, 'has-placeholder': this.placeholder !== undefined, 'has-value': this.hasValue(), - 'interactive-disabled': this.disabled, - 'select-disabled': this.disabled + 'has-focus': this.isExpanded, }); } @@ -423,6 +457,7 @@ export class Select implements ComponentInterface { this.setFocus(); this.open(ev); } + private onFocus = () => { this.ionFocus.emit(); } diff --git a/core/src/components/select/test/basic/index.html b/core/src/components/select/test/basic/index.html index 0f59c0a73e6..54d335a0503 100644 --- a/core/src/components/select/test/basic/index.html +++ b/core/src/components/select/test/basic/index.html @@ -134,14 +134,11 @@ - Gaming - - NES - Nintendo64 - PlayStation - Sega Genesis - Sega Saturn - SNES + Favorite food + + Steak + Pizza + Tacos @@ -178,7 +175,6 @@ - @@ -244,7 +240,7 @@ Numbers - + 0 1 2 @@ -254,6 +250,17 @@ + + Toppings + + Extra cheese + Mushroom + Onion + Pepperoni + Sausage + + + Disabled @@ -348,7 +355,7 @@ objectSelectElement.appendChild(selectOption) }); - + objectSelectElement.value = { id: 1, first: 'Alice', diff --git a/core/src/components/select/test/spec/e2e.ts b/core/src/components/select/test/spec/e2e.ts new file mode 100644 index 00000000000..c7f99b15dc9 --- /dev/null +++ b/core/src/components/select/test/spec/e2e.ts @@ -0,0 +1,10 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('select: spec', async () => { + const page = await newE2EPage({ + url: '/src/components/select/test/spec?ionic:_testing=true' + }); + + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); +}); diff --git a/core/src/components/select/test/spec/index.html b/core/src/components/select/test/spec/index.html new file mode 100644 index 00000000000..0f1ab76e2de --- /dev/null +++ b/core/src/components/select/test/spec/index.html @@ -0,0 +1,455 @@ + + + + + + Select - Spec + + + + + + + + + + + + + Select - Spec + + + + +

Floating Selects

+ +
+
+

Default

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Default: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+ +
+ +

Stacked Selects

+ +
+
+

Default

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Default: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+ +
+ +

Inline Selects

+ +
+
+

Default

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Default: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+ +
+ +

Fixed Selects

+ +
+
+

Default

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Default: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Filled: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+

Outlined: Focused

+ + + Fruit + + + Apple + Orange + Banana + + +
+
+ +
+ +

Full Width Selects

+ + + + Inline + + + Apple + Orange + Banana + + + + + + Fixed + + + Apple + Orange + Banana + + + + + Floating + + + Apple + Orange + Banana + + + + + Stacked + + + Apple + Orange + Banana + + + + +
+
+
+ + + + diff --git a/core/src/utils/focus-visible.ts b/core/src/utils/focus-visible.ts index 6077d61bc70..f058dd3a57f 100644 --- a/core/src/utils/focus-visible.ts +++ b/core/src/utils/focus-visible.ts @@ -50,11 +50,16 @@ export const startFocusVisible = (rootEl?: HTMLElement) => { ref.addEventListener('touchstart', pointerDown); ref.addEventListener('mousedown', pointerDown); - return () => { + const destroy = () => { ref.removeEventListener('keydown', onKeydown); ref.removeEventListener('focusin', onFocusin); ref.removeEventListener('focusout', onFocusout); ref.removeEventListener('touchstart', pointerDown); ref.removeEventListener('mousedown', pointerDown); } + + return { + destroy, + setFocus + } }; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 7683bdca67d..f832b566614 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -79,6 +79,21 @@ export const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElemen if (firstInput) { firstInput.focus(); + + /** + * When programmatically focusing an element, + * the focus-visible utility will not run because + * it is expecting a keyboard event to have triggered this; + * however, there are times when we need to manually control + * this behavior so we call the `setFocus` method on ion-app + * which will let us explicitly set the elements to focus. + */ + if (firstInput.classList.contains('ion-focusable')) { + const app = overlay.closest('ion-app'); + if (app) { + app.setFocus([firstInput]); + } + } } else { // Focus overlay instead of letting focus escape overlay.focus(); diff --git a/core/src/utils/tap-click.ts b/core/src/utils/tap-click.ts index 02cd574b49d..650788f3a87 100644 --- a/core/src/utils/tap-click.ts +++ b/core/src/utils/tap-click.ts @@ -43,6 +43,10 @@ export const startTapClick = (config: Config) => { } }; + const onContextMenu = (ev: MouseEvent) => { + pointerUp(ev); + }; + const cancelActive = () => { clearTimeout(activeDefer); activeDefer = undefined; @@ -155,6 +159,8 @@ export const startTapClick = (config: Config) => { doc.addEventListener('mousedown', onMouseDown, true); doc.addEventListener('mouseup', onMouseUp, true); + + doc.addEventListener('contextmenu', onContextMenu, true); }; const getActivatableTarget = (ev: any): any => {