Compare commits

...

13 Commits

Author SHA1 Message Date
Liam DeBeasi
629d862c54 tests 2023-12-01 15:38:09 -05:00
Liam DeBeasi
2c773ed0e6 fix: do not emit ionChange if value did not change 2023-12-01 15:29:40 -05:00
Liam DeBeasi
b1fc67227c fix: column scrolls into view when option is ready 2023-12-01 15:16:59 -05:00
Liam DeBeasi
75ee951ce8 chore: lint 2023-12-01 14:58:06 -05:00
Liam DeBeasi
2f3f9dc9ca refactor: clicking option sets value 2023-12-01 14:58:01 -05:00
Liam DeBeasi
b68c93d55d refactor: scrolling column sets value 2023-12-01 14:49:23 -05:00
Liam DeBeasi
eace6425a2 refactor: add slot to integrate basic options 2023-12-01 14:28:07 -05:00
Liam DeBeasi
1aeb19403b chore: run build 2023-12-01 12:59:03 -05:00
ionitron
9d0834b201 chore(): add updated snapshots 2023-12-01 17:55:49 +00:00
Liam DeBeasi
7b21bd40a6 feat(picker-column): add styles, disabled and active states 2023-12-01 12:44:38 -05:00
Maria Hutt
0b469646b2 feat(picker-column-option): add the new component (#28591) 2023-11-30 09:54:01 -08:00
Shawn Taylor
5e47412e1f refactor(picker): rename internal picker components to ion-picker and ion-picker-column (#28589) 2023-11-28 16:17:28 -05:00
Liam DeBeasi
cc45e2220b refactor(picker): deprecate ion-picker and ion-picker-column (#28584)
BREAKING CHANGE: `ion-picker` and `ion-picker-column` have been renamed to `ion-picker-legacy` and `ion-picker-legacy-column`, respectively. This change was made to accommodate the new inline picker component while allowing developers to continue to use the legacy picker during this migration period.
2023-11-28 12:47:37 -05:00
185 changed files with 4040 additions and 3438 deletions

4
.github/CODEOWNERS vendored
View File

@@ -51,8 +51,8 @@
/core/src/components/nav/ @sean-perkins
/core/src/components/nav-link/ @sean-perkins
/core/src/components/picker-internal/ @liamdebeasi
/core/src/components/picker-column-internal/ @liamdebeasi
/core/src/components/picker/ @liamdebeasi
/core/src/components/picker-column/ @liamdebeasi
/core/src/components/radio/ @amandaejohnston
/core/src/components/radio-group/ @amandaejohnston

View File

@@ -17,6 +17,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Components](#version-8x-components)
- [Content](#version-8x-content)
- [Datetime](#version-8x-datetime)
- [Picker](#version-8x-picker)
<h2 id="version-8x-browser-platform-support">Browser and Platform Support</h2>
@@ -59,4 +60,9 @@ This section details the desktop browser, JavaScript framework, and mobile platf
+ background: red;
}
```
```
<h2 id="version-8x-picker">Picker</h2>
- `ion-picker` and `ion-picker-column` have been renamed to `ion-picker-legacy` and `ion-picker-legacy-column`, respectively. This change was made to accommodate the new inline picker component while allowing developers to continue to use the legacy picker during this migration period.
- Only the component names have been changed. Usages such as `ion-picker` or `IonPicker` should be changed to `ion-picker-legacy` and `IonPickerLegacy`, respectively.
- Non-component usages such as `pickerController` or `useIonPicker` remain unchanged. The new picker displays inline with your page content and does not have equivalents for these non-component usages.

View File

@@ -906,47 +906,64 @@ ion-note,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "second
ion-note,prop,mode,"ios" | "md",undefined,false,false
ion-note,css-prop,--color
ion-picker,scoped
ion-picker,prop,animated,boolean,true,false,false
ion-picker,prop,backdropDismiss,boolean,true,false,false
ion-picker,prop,buttons,PickerButton[],[],false,false
ion-picker,prop,columns,PickerColumn[],[],false,false
ion-picker,prop,cssClass,string | string[] | undefined,undefined,false,false
ion-picker,prop,duration,number,0,false,false
ion-picker,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-picker,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
ion-picker,prop,isOpen,boolean,false,false,false
ion-picker,prop,keyboardClose,boolean,true,false,false
ion-picker,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-picker,shadow
ion-picker,prop,mode,"ios" | "md",undefined,false,false
ion-picker,prop,showBackdrop,boolean,true,false,false
ion-picker,prop,trigger,string | undefined,undefined,false,false
ion-picker,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>
ion-picker,method,getColumn,getColumn(name: string) => Promise<PickerColumn | undefined>
ion-picker,method,onDidDismiss,onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-picker,method,onWillDismiss,onWillDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-picker,method,present,present() => Promise<void>
ion-picker,event,didDismiss,OverlayEventDetail<any>,true
ion-picker,event,didPresent,void,true
ion-picker,event,ionPickerDidDismiss,OverlayEventDetail<any>,true
ion-picker,event,ionPickerDidPresent,void,true
ion-picker,event,ionPickerWillDismiss,OverlayEventDetail<any>,true
ion-picker,event,ionPickerWillPresent,void,true
ion-picker,event,willDismiss,OverlayEventDetail<any>,true
ion-picker,event,willPresent,void,true
ion-picker,css-prop,--backdrop-opacity
ion-picker,css-prop,--background
ion-picker,css-prop,--background-rgb
ion-picker,css-prop,--border-color
ion-picker,css-prop,--border-radius
ion-picker,css-prop,--border-style
ion-picker,css-prop,--border-width
ion-picker,css-prop,--height
ion-picker,css-prop,--max-height
ion-picker,css-prop,--max-width
ion-picker,css-prop,--min-height
ion-picker,css-prop,--min-width
ion-picker,css-prop,--width
ion-picker,event,ionInputModeChange,PickerChangeEventDetail,true
ion-picker-column,shadow
ion-picker-column,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,'primary',false,true
ion-picker-column,prop,disabled,boolean,false,false,false
ion-picker-column,prop,items,PickerColumnItem[],[],false,false
ion-picker-column,prop,mode,"ios" | "md",undefined,false,false
ion-picker-column,prop,value,number | string | undefined,undefined,false,false
ion-picker-column,event,ionChange,PickerColumnItem,true
ion-picker-column-option,shadow
ion-picker-column-option,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | undefined,'primary',false,true
ion-picker-column-option,prop,disabled,boolean,false,false,false
ion-picker-column-option,prop,value,any,undefined,false,false
ion-picker-legacy,scoped
ion-picker-legacy,prop,animated,boolean,true,false,false
ion-picker-legacy,prop,backdropDismiss,boolean,true,false,false
ion-picker-legacy,prop,buttons,PickerButton[],[],false,false
ion-picker-legacy,prop,columns,PickerColumn[],[],false,false
ion-picker-legacy,prop,cssClass,string | string[] | undefined,undefined,false,false
ion-picker-legacy,prop,duration,number,0,false,false
ion-picker-legacy,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-picker-legacy,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false
ion-picker-legacy,prop,isOpen,boolean,false,false,false
ion-picker-legacy,prop,keyboardClose,boolean,true,false,false
ion-picker-legacy,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-picker-legacy,prop,mode,"ios" | "md",undefined,false,false
ion-picker-legacy,prop,showBackdrop,boolean,true,false,false
ion-picker-legacy,prop,trigger,string | undefined,undefined,false,false
ion-picker-legacy,method,dismiss,dismiss(data?: any, role?: string) => Promise<boolean>
ion-picker-legacy,method,getColumn,getColumn(name: string) => Promise<PickerColumn | undefined>
ion-picker-legacy,method,onDidDismiss,onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-picker-legacy,method,onWillDismiss,onWillDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-picker-legacy,method,present,present() => Promise<void>
ion-picker-legacy,event,didDismiss,OverlayEventDetail<any>,true
ion-picker-legacy,event,didPresent,void,true
ion-picker-legacy,event,ionPickerDidDismiss,OverlayEventDetail<any>,true
ion-picker-legacy,event,ionPickerDidPresent,void,true
ion-picker-legacy,event,ionPickerWillDismiss,OverlayEventDetail<any>,true
ion-picker-legacy,event,ionPickerWillPresent,void,true
ion-picker-legacy,event,willDismiss,OverlayEventDetail<any>,true
ion-picker-legacy,event,willPresent,void,true
ion-picker-legacy,css-prop,--backdrop-opacity
ion-picker-legacy,css-prop,--background
ion-picker-legacy,css-prop,--background-rgb
ion-picker-legacy,css-prop,--border-color
ion-picker-legacy,css-prop,--border-radius
ion-picker-legacy,css-prop,--border-style
ion-picker-legacy,css-prop,--border-width
ion-picker-legacy,css-prop,--height
ion-picker-legacy,css-prop,--max-height
ion-picker-legacy,css-prop,--max-width
ion-picker-legacy,css-prop,--min-height
ion-picker-legacy,css-prop,--min-width
ion-picker-legacy,css-prop,--width
ion-popover,shadow
ion-popover,prop,alignment,"center" | "end" | "start" | undefined,undefined,false,false

View File

@@ -23,9 +23,9 @@ import { MenuChangeEventDetail, Side } from "./components/menu/menu-interface";
import { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
import { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
import { ViewController } from "./components/nav/view-controller";
import { PickerButton, PickerColumn } from "./components/picker/picker-interface";
import { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces";
import { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces";
import { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
import { PickerColumnItem } from "./components/picker-column/picker-column-interfaces";
import { PickerButton, PickerColumn } from "./components/picker-legacy/picker-interface";
import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface";
import { RadioGroupChangeEventDetail } from "./components/radio-group/radio-group-interface";
import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
@@ -59,9 +59,9 @@ export { MenuChangeEventDetail, Side } from "./components/menu/menu-interface";
export { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from "./components/modal/modal-interface";
export { NavComponent, NavComponentWithProps, NavOptions, RouterOutletOptions, SwipeGestureHandler, TransitionDoneFn, TransitionInstruction } from "./components/nav/nav-interface";
export { ViewController } from "./components/nav/view-controller";
export { PickerButton, PickerColumn } from "./components/picker/picker-interface";
export { PickerColumnItem } from "./components/picker-column-internal/picker-column-internal-interfaces";
export { PickerInternalChangeEventDetail } from "./components/picker-internal/picker-internal-interfaces";
export { PickerChangeEventDetail } from "./components/picker/picker-interfaces";
export { PickerColumnItem } from "./components/picker-column/picker-column-interfaces";
export { PickerButton, PickerColumn } from "./components/picker-legacy/picker-interface";
export { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface";
export { RadioGroupChangeEventDetail } from "./components/radio-group/radio-group-interface";
export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
@@ -1949,6 +1949,58 @@ export namespace Components {
"mode"?: "ios" | "md";
}
interface IonPicker {
"exitInputMode": () => Promise<void>;
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
}
interface IonPickerColumn {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled": boolean;
/**
* A list of options to be displayed in the picker
*/
"items": PickerColumnItem[];
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* If `true`, tapping the picker will reveal a number input keyboard that lets the user type in values for each picker column. This is useful when working with time pickers.
*/
"numericInput": boolean;
"scrollActiveItemIntoView": () => Promise<void>;
/**
* Sets the value prop and fires the ionChange event. This is used when we need to fire ionChange from user-generated events that cannot be caught with normal input/change event listeners.
*/
"setValue": (value?: string | number) => Promise<void>;
/**
* The selected option in the picker.
*/
"value"?: string | number;
}
interface IonPickerColumnOption {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker column option.
*/
"disabled": boolean;
/**
* The text value of the option.
*/
"value"?: any | null;
}
interface IonPickerLegacy {
/**
* If `true`, the picker will animate.
*/
@@ -2032,50 +2084,12 @@ export namespace Components {
*/
"trigger": string | undefined;
}
interface IonPickerColumn {
interface IonPickerLegacyColumn {
/**
* Picker column data
*/
"col": PickerColumn;
}
interface IonPickerColumnInternal {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled": boolean;
/**
* A list of options to be displayed in the picker
*/
"items": PickerColumnItem[];
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* If `true`, tapping the picker will reveal a number input keyboard that lets the user type in values for each picker column. This is useful when working with time pickers.
*/
"numericInput": boolean;
"scrollActiveItemIntoView": () => Promise<void>;
/**
* Sets the value prop and fires the ionChange event. This is used when we need to fire ionChange from user-generated events that cannot be caught with normal input/change event listeners.
*/
"setValue": (value?: string | number) => Promise<void>;
/**
* The selected option in the picker.
*/
"value"?: string | number;
}
interface IonPickerInternal {
"exitInputMode": () => Promise<void>;
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
}
interface IonPopover {
/**
* Describes how to align the popover content with the `reference` point. Defaults to `"center"` for `ios` mode, and `"start"` for `md` mode.
@@ -3330,13 +3344,13 @@ export interface IonPickerColumnCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonPickerColumnElement;
}
export interface IonPickerColumnInternalCustomEvent<T> extends CustomEvent<T> {
export interface IonPickerLegacyCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonPickerColumnInternalElement;
target: HTMLIonPickerLegacyElement;
}
export interface IonPickerInternalCustomEvent<T> extends CustomEvent<T> {
export interface IonPickerLegacyColumnCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonPickerInternalElement;
target: HTMLIonPickerLegacyColumnElement;
}
export interface IonPopoverCustomEvent<T> extends CustomEvent<T> {
detail: T;
@@ -4020,14 +4034,7 @@ declare global {
new (): HTMLIonNoteElement;
};
interface HTMLIonPickerElementEventMap {
"ionPickerDidPresent": void;
"ionPickerWillPresent": void;
"ionPickerWillDismiss": OverlayEventDetail;
"ionPickerDidDismiss": OverlayEventDetail;
"didPresent": void;
"willPresent": void;
"willDismiss": OverlayEventDetail;
"didDismiss": OverlayEventDetail;
"ionInputModeChange": PickerChangeEventDetail;
}
interface HTMLIonPickerElement extends Components.IonPicker, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerElementEventMap>(type: K, listener: (this: HTMLIonPickerElement, ev: IonPickerCustomEvent<HTMLIonPickerElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -4044,7 +4051,7 @@ declare global {
new (): HTMLIonPickerElement;
};
interface HTMLIonPickerColumnElementEventMap {
"ionPickerColChange": PickerColumn;
"ionChange": string | number | undefined;
}
interface HTMLIonPickerColumnElement extends Components.IonPickerColumn, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerColumnElementEventMap>(type: K, listener: (this: HTMLIonPickerColumnElement, ev: IonPickerColumnCustomEvent<HTMLIonPickerColumnElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -4060,39 +4067,52 @@ declare global {
prototype: HTMLIonPickerColumnElement;
new (): HTMLIonPickerColumnElement;
};
interface HTMLIonPickerColumnInternalElementEventMap {
"ionChange": PickerColumnItem;
interface HTMLIonPickerColumnOptionElement extends Components.IonPickerColumnOption, HTMLStencilElement {
}
interface HTMLIonPickerColumnInternalElement extends Components.IonPickerColumnInternal, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerColumnInternalElementEventMap>(type: K, listener: (this: HTMLIonPickerColumnInternalElement, ev: IonPickerColumnInternalCustomEvent<HTMLIonPickerColumnInternalElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonPickerColumnInternalElementEventMap>(type: K, listener: (this: HTMLIonPickerColumnInternalElement, ev: IonPickerColumnInternalCustomEvent<HTMLIonPickerColumnInternalElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLIonPickerColumnInternalElement: {
prototype: HTMLIonPickerColumnInternalElement;
new (): HTMLIonPickerColumnInternalElement;
var HTMLIonPickerColumnOptionElement: {
prototype: HTMLIonPickerColumnOptionElement;
new (): HTMLIonPickerColumnOptionElement;
};
interface HTMLIonPickerInternalElementEventMap {
"ionInputModeChange": PickerInternalChangeEventDetail;
interface HTMLIonPickerLegacyElementEventMap {
"ionPickerDidPresent": void;
"ionPickerWillPresent": void;
"ionPickerWillDismiss": OverlayEventDetail;
"ionPickerDidDismiss": OverlayEventDetail;
"didPresent": void;
"willPresent": void;
"willDismiss": OverlayEventDetail;
"didDismiss": OverlayEventDetail;
}
interface HTMLIonPickerInternalElement extends Components.IonPickerInternal, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerInternalElementEventMap>(type: K, listener: (this: HTMLIonPickerInternalElement, ev: IonPickerInternalCustomEvent<HTMLIonPickerInternalElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
interface HTMLIonPickerLegacyElement extends Components.IonPickerLegacy, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerLegacyElementEventMap>(type: K, listener: (this: HTMLIonPickerLegacyElement, ev: IonPickerLegacyCustomEvent<HTMLIonPickerLegacyElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonPickerInternalElementEventMap>(type: K, listener: (this: HTMLIonPickerInternalElement, ev: IonPickerInternalCustomEvent<HTMLIonPickerInternalElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonPickerLegacyElementEventMap>(type: K, listener: (this: HTMLIonPickerLegacyElement, ev: IonPickerLegacyCustomEvent<HTMLIonPickerLegacyElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLIonPickerInternalElement: {
prototype: HTMLIonPickerInternalElement;
new (): HTMLIonPickerInternalElement;
var HTMLIonPickerLegacyElement: {
prototype: HTMLIonPickerLegacyElement;
new (): HTMLIonPickerLegacyElement;
};
interface HTMLIonPickerLegacyColumnElementEventMap {
"ionPickerColChange": PickerColumn;
}
interface HTMLIonPickerLegacyColumnElement extends Components.IonPickerLegacyColumn, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonPickerLegacyColumnElementEventMap>(type: K, listener: (this: HTMLIonPickerLegacyColumnElement, ev: IonPickerLegacyColumnCustomEvent<HTMLIonPickerLegacyColumnElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonPickerLegacyColumnElementEventMap>(type: K, listener: (this: HTMLIonPickerLegacyColumnElement, ev: IonPickerLegacyColumnCustomEvent<HTMLIonPickerLegacyColumnElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLIonPickerLegacyColumnElement: {
prototype: HTMLIonPickerLegacyColumnElement;
new (): HTMLIonPickerLegacyColumnElement;
};
interface HTMLIonPopoverElementEventMap {
"ionPopoverDidPresent": void;
@@ -4647,8 +4667,9 @@ declare global {
"ion-note": HTMLIonNoteElement;
"ion-picker": HTMLIonPickerElement;
"ion-picker-column": HTMLIonPickerColumnElement;
"ion-picker-column-internal": HTMLIonPickerColumnInternalElement;
"ion-picker-internal": HTMLIonPickerInternalElement;
"ion-picker-column-option": HTMLIonPickerColumnOptionElement;
"ion-picker-legacy": HTMLIonPickerLegacyElement;
"ion-picker-legacy-column": HTMLIonPickerLegacyColumnElement;
"ion-popover": HTMLIonPopoverElement;
"ion-progress-bar": HTMLIonProgressBarElement;
"ion-radio": HTMLIonRadioElement;
@@ -6580,6 +6601,57 @@ declare namespace LocalJSX {
"mode"?: "ios" | "md";
}
interface IonPicker {
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
"onIonInputModeChange"?: (event: IonPickerCustomEvent<PickerChangeEventDetail>) => void;
}
interface IonPickerColumn {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled"?: boolean;
/**
* A list of options to be displayed in the picker
*/
"items"?: PickerColumnItem[];
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* If `true`, tapping the picker will reveal a number input keyboard that lets the user type in values for each picker column. This is useful when working with time pickers.
*/
"numericInput"?: boolean;
/**
* Emitted when the value has changed.
*/
"onIonChange"?: (event: IonPickerColumnCustomEvent<string | number | undefined>) => void;
/**
* The selected option in the picker.
*/
"value"?: string | number;
}
interface IonPickerColumnOption {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker column option.
*/
"disabled"?: boolean;
/**
* The text value of the option.
*/
"value"?: any | null;
}
interface IonPickerLegacy {
/**
* If `true`, the picker will animate.
*/
@@ -6633,35 +6705,35 @@ declare namespace LocalJSX {
/**
* Emitted after the picker has dismissed. Shorthand for ionPickerDidDismiss.
*/
"onDidDismiss"?: (event: IonPickerCustomEvent<OverlayEventDetail>) => void;
"onDidDismiss"?: (event: IonPickerLegacyCustomEvent<OverlayEventDetail>) => void;
/**
* Emitted after the picker has presented. Shorthand for ionPickerWillDismiss.
*/
"onDidPresent"?: (event: IonPickerCustomEvent<void>) => void;
"onDidPresent"?: (event: IonPickerLegacyCustomEvent<void>) => void;
/**
* Emitted after the picker has dismissed.
*/
"onIonPickerDidDismiss"?: (event: IonPickerCustomEvent<OverlayEventDetail>) => void;
"onIonPickerDidDismiss"?: (event: IonPickerLegacyCustomEvent<OverlayEventDetail>) => void;
/**
* Emitted after the picker has presented.
*/
"onIonPickerDidPresent"?: (event: IonPickerCustomEvent<void>) => void;
"onIonPickerDidPresent"?: (event: IonPickerLegacyCustomEvent<void>) => void;
/**
* Emitted before the picker has dismissed.
*/
"onIonPickerWillDismiss"?: (event: IonPickerCustomEvent<OverlayEventDetail>) => void;
"onIonPickerWillDismiss"?: (event: IonPickerLegacyCustomEvent<OverlayEventDetail>) => void;
/**
* Emitted before the picker has presented.
*/
"onIonPickerWillPresent"?: (event: IonPickerCustomEvent<void>) => void;
"onIonPickerWillPresent"?: (event: IonPickerLegacyCustomEvent<void>) => void;
/**
* Emitted before the picker has dismissed. Shorthand for ionPickerWillDismiss.
*/
"onWillDismiss"?: (event: IonPickerCustomEvent<OverlayEventDetail>) => void;
"onWillDismiss"?: (event: IonPickerLegacyCustomEvent<OverlayEventDetail>) => void;
/**
* Emitted before the picker has presented. Shorthand for ionPickerWillPresent.
*/
"onWillPresent"?: (event: IonPickerCustomEvent<void>) => void;
"onWillPresent"?: (event: IonPickerLegacyCustomEvent<void>) => void;
"overlayIndex": number;
/**
* If `true`, a backdrop will be displayed behind the picker.
@@ -6672,7 +6744,7 @@ declare namespace LocalJSX {
*/
"trigger"?: string | undefined;
}
interface IonPickerColumn {
interface IonPickerLegacyColumn {
/**
* Picker column data
*/
@@ -6680,44 +6752,7 @@ declare namespace LocalJSX {
/**
* Emitted when the selected value has changed
*/
"onIonPickerColChange"?: (event: IonPickerColumnCustomEvent<PickerColumn>) => void;
}
interface IonPickerColumnInternal {
/**
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/
"color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled"?: boolean;
/**
* A list of options to be displayed in the picker
*/
"items"?: PickerColumnItem[];
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
/**
* If `true`, tapping the picker will reveal a number input keyboard that lets the user type in values for each picker column. This is useful when working with time pickers.
*/
"numericInput"?: boolean;
/**
* Emitted when the value has changed.
*/
"onIonChange"?: (event: IonPickerColumnInternalCustomEvent<PickerColumnItem>) => void;
/**
* The selected option in the picker.
*/
"value"?: string | number;
}
interface IonPickerInternal {
/**
* The mode determines which platform styles to use.
*/
"mode"?: "ios" | "md";
"onIonInputModeChange"?: (event: IonPickerInternalCustomEvent<PickerInternalChangeEventDetail>) => void;
"onIonPickerColChange"?: (event: IonPickerLegacyColumnCustomEvent<PickerColumn>) => void;
}
interface IonPopover {
/**
@@ -8086,8 +8121,9 @@ declare namespace LocalJSX {
"ion-note": IonNote;
"ion-picker": IonPicker;
"ion-picker-column": IonPickerColumn;
"ion-picker-column-internal": IonPickerColumnInternal;
"ion-picker-internal": IonPickerInternal;
"ion-picker-column-option": IonPickerColumnOption;
"ion-picker-legacy": IonPickerLegacy;
"ion-picker-legacy-column": IonPickerLegacyColumn;
"ion-popover": IonPopover;
"ion-progress-bar": IonProgressBar;
"ion-radio": IonRadio;
@@ -8183,8 +8219,9 @@ declare module "@stencil/core" {
"ion-note": LocalJSX.IonNote & JSXBase.HTMLAttributes<HTMLIonNoteElement>;
"ion-picker": LocalJSX.IonPicker & JSXBase.HTMLAttributes<HTMLIonPickerElement>;
"ion-picker-column": LocalJSX.IonPickerColumn & JSXBase.HTMLAttributes<HTMLIonPickerColumnElement>;
"ion-picker-column-internal": LocalJSX.IonPickerColumnInternal & JSXBase.HTMLAttributes<HTMLIonPickerColumnInternalElement>;
"ion-picker-internal": LocalJSX.IonPickerInternal & JSXBase.HTMLAttributes<HTMLIonPickerInternalElement>;
"ion-picker-column-option": LocalJSX.IonPickerColumnOption & JSXBase.HTMLAttributes<HTMLIonPickerColumnOptionElement>;
"ion-picker-legacy": LocalJSX.IonPickerLegacy & JSXBase.HTMLAttributes<HTMLIonPickerLegacyElement>;
"ion-picker-legacy-column": LocalJSX.IonPickerLegacyColumn & JSXBase.HTMLAttributes<HTMLIonPickerLegacyColumnElement>;
"ion-popover": LocalJSX.IonPopover & JSXBase.HTMLAttributes<HTMLIonPopoverElement>;
"ion-progress-bar": LocalJSX.IonProgressBar & JSXBase.HTMLAttributes<HTMLIonProgressBarElement>;
"ion-radio": LocalJSX.IonRadio & JSXBase.HTMLAttributes<HTMLIonRadioElement>;

View File

@@ -36,7 +36,7 @@
}
async function showPicker() {
const picker = Object.assign(document.createElement('ion-picker'), {
const picker = Object.assign(document.createElement('ion-picker-legacy'), {
columns: [
{
name: 'Picker',

View File

@@ -34,7 +34,7 @@
* widest item in the column. Setting a minimum
* width avoids this layout shifting.
*/
ion-picker-column-internal {
ion-picker-column {
min-width: 26px;
}

View File

@@ -9,7 +9,7 @@ import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward
import { getIonMode } from '../../global/ionic-global';
import type { Color, Mode, StyleEventDetail } from '../../interface';
import type { PickerColumnItem } from '../picker-column-internal/picker-column-internal-interfaces';
import type { PickerColumnItem } from '../picker-column/picker-column-interfaces';
import type {
DatetimePresentation,
@@ -1527,7 +1527,7 @@ export class Datetime implements ComponentInterface {
forcePresentation === 'time-date'
? [this.renderTimePickerColumns(forcePresentation), this.renderDatePickerColumns(forcePresentation)]
: [this.renderDatePickerColumns(forcePresentation), this.renderTimePickerColumns(forcePresentation)];
return <ion-picker-internal>{renderArray}</ion-picker-internal>;
return <ion-picker>{renderArray}</ion-picker>;
}
private renderDatePickerColumns(forcePresentation: string) {
@@ -1613,7 +1613,7 @@ export class Datetime implements ComponentInterface {
: `${defaultParts.year}-${defaultParts.month}-${defaultParts.day}`;
return (
<ion-picker-column-internal
<ion-picker-column
class="date-column"
color={this.color}
disabled={disabled}
@@ -1647,7 +1647,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
@@ -1733,7 +1733,7 @@ export class Datetime implements ComponentInterface {
const activePart = this.getActivePartsWithFallback();
return (
<ion-picker-column-internal
<ion-picker-column
class="day-column"
color={this.color}
disabled={disabled}
@@ -1764,7 +1764,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
@@ -1778,7 +1778,7 @@ export class Datetime implements ComponentInterface {
const activePart = this.getActivePartsWithFallback();
return (
<ion-picker-column-internal
<ion-picker-column
class="month-column"
color={this.color}
disabled={disabled}
@@ -1809,7 +1809,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
private renderYearPickerColumn(years: PickerColumnItem[]) {
@@ -1822,7 +1822,7 @@ export class Datetime implements ComponentInterface {
const activePart = this.getActivePartsWithFallback();
return (
<ion-picker-column-internal
<ion-picker-column
class="year-column"
color={this.color}
disabled={disabled}
@@ -1853,7 +1853,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
private renderTimePickerColumns(forcePresentation: string) {
@@ -1897,7 +1897,7 @@ export class Datetime implements ComponentInterface {
const activePart = this.getActivePartsWithFallback();
return (
<ion-picker-column-internal
<ion-picker-column
color={this.color}
disabled={disabled}
value={activePart.hour}
@@ -1916,7 +1916,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
private renderMinutePickerColumn(minutesData: PickerColumnItem[]) {
@@ -1926,7 +1926,7 @@ export class Datetime implements ComponentInterface {
const activePart = this.getActivePartsWithFallback();
return (
<ion-picker-column-internal
<ion-picker-column
color={this.color}
disabled={disabled}
value={activePart.minute}
@@ -1945,7 +1945,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
private renderDayPeriodPickerColumn(dayPeriodData: PickerColumnItem[]) {
@@ -1958,7 +1958,7 @@ export class Datetime implements ComponentInterface {
const isDayPeriodRTL = isLocaleDayPeriodRTL(this.locale);
return (
<ion-picker-column-internal
<ion-picker-column
style={isDayPeriodRTL ? { order: '-1' } : {}}
color={this.color}
disabled={disabled}
@@ -1981,7 +1981,7 @@ export class Datetime implements ComponentInterface {
ev.stopPropagation();
}}
></ion-picker-column-internal>
></ion-picker-column>
);
}
@@ -2345,7 +2345,7 @@ export class Datetime implements ComponentInterface {
* This will correctly scroll the element position to the correct time value,
* before the popover is fully presented.
*/
const cols = (ev.target! as HTMLElement).querySelectorAll('ion-picker-column-internal');
const cols = (ev.target! as HTMLElement).querySelectorAll('ion-picker-column');
// TODO (FW-615): Potentially remove this when intersection observers are fixed in picker column
cols.forEach((col) => col.scrollActiveItemIntoView());
}}

View File

@@ -52,28 +52,28 @@
}
/*
The second selectors that target ion-picker(-column)-internal
The second selectors that target ion-picker(-column)
directly are for styling the time picker. This is currently
undocumented usage.
*/
.custom-grid-wheel,
ion-picker-internal {
ion-picker {
--wheel-highlight-background: rgb(218, 216, 255);
--wheel-fade-background-rgb: 245, 235, 247;
}
ion-picker-internal {
ion-picker {
background-color: rgb(245, 235, 247);
}
.custom-grid-wheel::part(wheel-item),
ion-picker-column-internal::part(wheel-item) {
ion-picker-column::part(wheel-item) {
color: rgb(255, 134, 154);
}
.custom-grid-wheel::part(wheel-item active),
ion-picker-column-internal::part(wheel-item active) {
ion-picker-column::part(wheel-item active) {
color: rgb(128, 30, 171);
}

View File

@@ -2,8 +2,8 @@ import { h } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';
import { Datetime } from '../../../datetime/datetime';
import { PickerColumnInternal } from '../../../picker-column-internal/picker-column-internal';
import { PickerInternal } from '../../../picker-internal/picker-internal';
import { PickerColumn } from '../../../picker-column/picker-column';
import { Picker } from '../../../picker/picker';
describe('ion-datetime disabled', () => {
beforeEach(() => {
@@ -19,7 +19,7 @@ describe('ion-datetime disabled', () => {
it('picker should be disabled in prefer wheel mode', async () => {
const page = await newSpecPage({
components: [Datetime, PickerColumnInternal, PickerInternal],
components: [Datetime, PickerColumn, Picker],
template: () => (
<ion-datetime id="inline-datetime-wheel" disabled prefer-wheel value="2022-04-21T00:00:00"></ion-datetime>
),
@@ -28,7 +28,7 @@ describe('ion-datetime disabled', () => {
await page.waitForChanges();
const datetime = page.body.querySelector('ion-datetime')!;
const columns = datetime.shadowRoot!.querySelectorAll('ion-picker-column-internal');
const columns = datetime.shadowRoot!.querySelectorAll('ion-picker-column');
await expect(columns.length).toEqual(4);

View File

@@ -109,12 +109,8 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
await page.click('.time-body');
await ionPopoverDidPresent.next();
const hours = page.locator(
'ion-popover ion-picker-column-internal:nth-child(1) .picker-item:not(.picker-item-empty)'
);
const minutes = page.locator(
'ion-popover ion-picker-column-internal:nth-child(2) .picker-item:not(.picker-item-empty)'
);
const hours = page.locator('ion-popover ion-picker-column:nth-child(1) .picker-item:not(.picker-item-empty)');
const minutes = page.locator('ion-popover ion-picker-column:nth-child(2) .picker-item:not(.picker-item-empty)');
expect(await hours.count()).toBe(12);
expect(await minutes.count()).toBe(60);
@@ -219,7 +215,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
);
const hourPickerItems = page.locator(
'ion-datetime ion-picker-column-internal:first-of-type .picker-item:not(.picker-item-empty)'
'ion-datetime ion-picker-column:first-of-type .picker-item:not(.picker-item-empty)'
);
await expect(hourPickerItems).toHaveText(['8', '9', '10', '11']);
});
@@ -243,7 +239,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
);
const hourPickerItems = page.locator(
'ion-datetime ion-picker-column-internal:first-of-type .picker-item:not(.picker-item-empty)'
'ion-datetime ion-picker-column:first-of-type .picker-item:not(.picker-item-empty)'
);
await expect(hourPickerItems).toHaveText(['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']);
});
@@ -360,9 +356,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
await ionPopoverDidPresent.next();
const hours = page.locator(
'ion-popover ion-picker-column-internal:nth-child(1) .picker-item:not(.picker-item-empty)'
);
const hours = page.locator('ion-popover ion-picker-column:nth-child(1) .picker-item:not(.picker-item-empty)');
await expect(await hours.count()).toBe(4);
});

View File

@@ -308,7 +308,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await page.waitForSelector('.datetime-ready');
const columns = page.locator('ion-picker-column-internal');
const columns = page.locator('ion-picker-column');
await expect(columns.nth(0)).toHaveClass(/month-column/);
await expect(columns.nth(1)).toHaveClass(/day-column/);
@@ -329,7 +329,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
await page.waitForSelector('.datetime-ready');
const columns = page.locator('ion-picker-column-internal');
const columns = page.locator('ion-picker-column');
await expect(columns.nth(0)).toHaveClass(/day-column/);
await expect(columns.nth(1)).toHaveClass(/month-column/);

View File

@@ -227,7 +227,7 @@ class TimePickerFixture {
}
async expectTime(hour: number, minute: number, ampm: string) {
const pickerColumns = this.timePicker.locator('ion-picker-column-internal');
const pickerColumns = this.timePicker.locator('ion-picker-column');
await expect(pickerColumns.nth(0)).toHaveJSProperty('value', hour);
await expect(pickerColumns.nth(1)).toHaveJSProperty('value', minute);

View File

@@ -51,7 +51,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const items = page.locator('ion-picker-column-internal:first-of-type .picker-item:not(.picker-item-empty)');
const items = page.locator('ion-picker-column:first-of-type .picker-item:not(.picker-item-empty)');
await expect(items).toHaveText(['1', '2', '3']);
});
test('should render correct minutes', async ({ page }) => {
@@ -62,7 +62,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const items = page.locator('ion-picker-column-internal:nth-of-type(2) .picker-item:not(.picker-item-empty)');
const items = page.locator('ion-picker-column:nth-of-type(2) .picker-item:not(.picker-item-empty)');
await expect(items).toHaveText(['01', '02', '03']);
});
test('should adjust default parts for allowed hour and minute values', async ({ page }) => {
@@ -93,13 +93,11 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await page.waitForSelector('.datetime-ready');
const minuteItems = page.locator(
'ion-picker-column-internal:nth-of-type(2) .picker-item:not(.picker-item-empty)'
);
const minuteItems = page.locator('ion-picker-column:nth-of-type(2) .picker-item:not(.picker-item-empty)');
await expect(minuteItems).toHaveText(['00', '15', '30', '45']);
await expect(minuteItems.nth(1)).toHaveClass(/picker-item-active/);
const hourItems = page.locator('ion-picker-column-internal:nth-of-type(1) .picker-item:not(.picker-item-empty)');
const hourItems = page.locator('ion-picker-column:nth-of-type(1) .picker-item:not(.picker-item-empty)');
await expect(hourItems).toHaveText(['2']);
await expect(hourItems.nth(0)).toHaveClass(/picker-item-active/);
@@ -107,7 +105,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
* Since the allowed hour is 2AM, the time period
* should switch from PM to AM.
*/
const ampmItems = page.locator('ion-picker-column-internal:nth-of-type(3) .picker-item:not(.picker-item-empty)');
const ampmItems = page.locator('ion-picker-column:nth-of-type(3) .picker-item:not(.picker-item-empty)');
await expect(ampmItems).toHaveText(['AM', 'PM']);
await expect(ampmItems.nth(0)).toHaveClass(/picker-item-active/);
});

View File

@@ -1,5 +1,5 @@
import type { Mode } from '../../../interface';
import type { PickerColumnItem } from '../../picker-column-internal/picker-column-internal-interfaces';
import type { PickerColumnItem } from '../../picker-column/picker-column-interfaces';
import type { DatetimeParts, DatetimeHourCycle } from '../datetime-interface';
import { isAfter, isBefore, isSameDay } from './comparison';
@@ -380,7 +380,7 @@ export const getMonthColumnData = (
* @param minParts The minimum bound on the date that can be returned
* @param maxParts The maximum bound on the date that can be returned
* @param dayValues The allowed date values
* @returns Date data to be used in ion-picker-column-internal
* @returns Date data to be used in ion-picker-column
*/
export const getDayColumnData = (
locale: string,

View File

@@ -1 +0,0 @@
@import "./picker-column-internal.scss";

View File

@@ -1,6 +0,0 @@
@import "./picker-column-internal.scss";
@import "../../themes/ionic.globals.md";
:host .picker-item-active {
color: current-color(base);
}

View File

@@ -1,101 +0,0 @@
@import "../../themes/ionic.globals";
// Picker Internal
// --------------------------------------------------
:host {
@include padding(0px, 16px, 0px, 16px);
height: 200px;
outline: none;
font-size: 22px;
scroll-snap-type: y mandatory;
/**
* Need to explicitly set overflow-x: hidden
* for older implementations of scroll snapping.
*/
overflow-x: hidden;
overflow-y: scroll;
// Hide scrollbars on Firefox
scrollbar-width: none;
text-align: center;
}
/**
* Hide scrollbars on Chrome and Safari
*/
:host::-webkit-scrollbar {
display: none;
}
:host .picker-item {
@include padding(0);
@include margin(0);
display: block;
width: 100%;
height: 34px;
border: 0px;
outline: none;
background: transparent;
color: inherit;
font-family: $font-family-base;
font-size: inherit;
line-height: 34px;
text-align: inherit;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
scroll-snap-align: center;
}
:host .picker-item-empty,
:host .picker-item[disabled] {
cursor: default;
}
:host .picker-item-empty,
:host(:not([disabled])) .picker-item[disabled] {
scroll-snap-align: none;
}
:host([disabled]) {
overflow-y: hidden;
}
:host .picker-item[disabled] {
opacity: 0.4;
}
:host(.picker-column-active) .picker-item.picker-item-active {
color: current-color(base);
}
@media (any-hover: hover) {
:host(:focus) {
outline: none;
background: current-color(base, 0.2);
}
}

View File

@@ -1,501 +0,0 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { getElementRoot, raf } from '@utils/helpers';
import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from '@utils/native/haptic';
import { isPlatform } from '@utils/platform';
import { createColorClasses } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
import type { PickerInternalCustomEvent } from '../picker-internal/picker-internal-interfaces';
import type { PickerColumnItem } from './picker-column-internal-interfaces';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
* @internal
*/
@Component({
tag: 'ion-picker-column-internal',
styleUrls: {
ios: 'picker-column-internal.ios.scss',
md: 'picker-column-internal.md.scss',
},
shadow: true,
})
export class PickerColumnInternal implements ComponentInterface {
private destroyScrollListener?: () => void;
private isScrolling = false;
private scrollEndCallback?: () => void;
private isColumnVisible = false;
private parentEl?: HTMLIonPickerInternalElement | null;
private canExitInputMode = true;
@State() isActive = false;
@Element() el!: HTMLIonPickerColumnInternalElement;
/**
* If `true`, the user cannot interact with the picker.
*/
@Prop() disabled = false;
/**
* A list of options to be displayed in the picker
*/
@Prop() items: PickerColumnItem[] = [];
/**
* The selected option in the picker.
*/
@Prop({ mutable: true }) value?: string | number;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
* For more information on colors, see [theming](/docs/theming/basics).
*/
@Prop({ reflect: true }) color?: Color = 'primary';
/**
* If `true`, tapping the picker will
* reveal a number input keyboard that lets
* the user type in values for each picker
* column. This is useful when working
* with time pickers.
*
* @internal
*/
@Prop() numericInput = false;
/**
* Emitted when the value has changed.
*/
@Event() ionChange!: EventEmitter<PickerColumnItem>;
@Watch('value')
valueChange() {
if (this.isColumnVisible) {
/**
* Only scroll the active item into view when the picker column
* is actively visible to the user.
*/
this.scrollActiveItemIntoView();
}
}
/**
* Only setup scroll listeners
* when the picker is visible, otherwise
* the container will have a scroll
* height of 0px.
*/
componentWillLoad() {
const visibleCallback = (entries: IntersectionObserverEntry[]) => {
const ev = entries[0];
if (ev.isIntersecting) {
const { activeItem, el } = this;
this.isColumnVisible = true;
/**
* Because this initial call to scrollActiveItemIntoView has to fire before
* the scroll listener is set up, we need to manage the active class manually.
*/
const oldActive = getElementRoot(el).querySelector(`.${PICKER_ITEM_ACTIVE_CLASS}`);
if (oldActive) {
this.setPickerItemActiveState(oldActive, false);
}
this.scrollActiveItemIntoView();
if (activeItem) {
this.setPickerItemActiveState(activeItem, true);
}
this.initializeScrollListener();
} else {
this.isColumnVisible = false;
if (this.destroyScrollListener) {
this.destroyScrollListener();
this.destroyScrollListener = undefined;
}
}
};
new IntersectionObserver(visibleCallback, { threshold: 0.001 }).observe(this.el);
const parentEl = (this.parentEl = this.el.closest('ion-picker-internal') as HTMLIonPickerInternalElement | null);
if (parentEl !== null) {
// TODO(FW-2832): type
parentEl.addEventListener('ionInputModeChange', (ev: any) => this.inputModeChange(ev));
}
}
componentDidRender() {
const { activeItem, items, isColumnVisible, value } = this;
if (isColumnVisible) {
if (activeItem) {
this.scrollActiveItemIntoView();
} else if (items[0]?.value !== value) {
/**
* If the picker column does not have an active item and the current value
* does not match the first item in the picker column, that means
* the value is out of bounds. In this case, we assign the value to the
* first item to match the scroll position of the column.
*
*/
this.setValue(items[0].value);
}
}
}
/** @internal */
@Method()
async scrollActiveItemIntoView() {
const activeEl = this.activeItem;
if (activeEl) {
this.centerPickerItemInView(activeEl, false, false);
}
}
/**
* Sets the value prop and fires the ionChange event.
* This is used when we need to fire ionChange from
* user-generated events that cannot be caught with normal
* input/change event listeners.
* @internal
*/
@Method()
async setValue(value?: string | number) {
const { items } = this;
this.value = value;
const findItem = items.find((item) => item.value === value && item.disabled !== true);
if (findItem) {
this.ionChange.emit(findItem);
}
}
private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => {
const { el, isColumnVisible } = this;
if (isColumnVisible) {
// (Vertical offset from parent) - (three empty picker rows) + (half the height of the target to ensure the scroll triggers)
const top = target.offsetTop - 3 * target.clientHeight + target.clientHeight / 2;
if (el.scrollTop !== top) {
/**
* Setting this flag prevents input
* mode from exiting in the picker column's
* scroll callback. This is useful when the user manually
* taps an item or types on the keyboard as both
* of these can cause a scroll to occur.
*/
this.canExitInputMode = canExitInputMode;
el.scroll({
top,
left: 0,
behavior: smooth ? 'smooth' : undefined,
});
}
}
};
private setPickerItemActiveState = (item: Element, isActive: boolean) => {
if (isActive) {
item.classList.add(PICKER_ITEM_ACTIVE_CLASS);
item.part.add(PICKER_ITEM_ACTIVE_PART);
} else {
item.classList.remove(PICKER_ITEM_ACTIVE_CLASS);
item.part.remove(PICKER_ITEM_ACTIVE_PART);
}
};
/**
* When ionInputModeChange is emitted, each column
* needs to check if it is the one being made available
* for text entry.
*/
private inputModeChange = (ev: PickerInternalCustomEvent) => {
if (!this.numericInput) {
return;
}
const { useInputMode, inputModeColumn } = ev.detail;
/**
* If inputModeColumn is undefined then this means
* all numericInput columns are being selected.
*/
const isColumnActive = inputModeColumn === undefined || inputModeColumn === this.el;
if (!useInputMode || !isColumnActive) {
this.setInputModeActive(false);
return;
}
this.setInputModeActive(true);
};
/**
* Setting isActive will cause a re-render.
* As a result, we do not want to cause the
* re-render mid scroll as this will cause
* the picker column to jump back to
* whatever value was selected at the
* start of the scroll interaction.
*/
private setInputModeActive = (state: boolean) => {
if (this.isScrolling) {
this.scrollEndCallback = () => {
this.isActive = state;
};
return;
}
this.isActive = state;
};
/**
* When the column scrolls, the component
* needs to determine which item is centered
* in the view and will emit an ionChange with
* the item object.
*/
private initializeScrollListener = () => {
/**
* The haptics for the wheel picker are
* an iOS-only feature. As a result, they should
* be disabled on Android.
*/
const enableHaptics = isPlatform('ios');
const { el } = this;
let timeout: ReturnType<typeof setTimeout> | undefined;
let activeEl: HTMLElement | null = this.activeItem;
const scrollCallback = () => {
raf(() => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if (!this.isScrolling) {
enableHaptics && hapticSelectionStart();
this.isScrolling = true;
}
/**
* Select item in the center of the column
* which is the month/year that we want to select
*/
const bbox = el.getBoundingClientRect();
const centerX = bbox.x + bbox.width / 2;
const centerY = bbox.y + bbox.height / 2;
const activeElement = el.shadowRoot!.elementFromPoint(centerX, centerY) as HTMLButtonElement | null;
if (activeEl !== null) {
this.setPickerItemActiveState(activeEl, false);
}
if (activeElement === null || activeElement.disabled) {
return;
}
/**
* If we are selecting a new value,
* we need to run haptics again.
*/
if (activeElement !== activeEl) {
enableHaptics && hapticSelectionChanged();
if (this.canExitInputMode) {
/**
* The native iOS wheel picker
* only dismisses the keyboard
* once the selected item has changed
* as a result of a swipe
* from the user. If `canExitInputMode` is
* `false` then this means that the
* scroll is happening as a result of
* the `value` property programmatically changing
* either by an application or by the user via the keyboard.
*/
this.exitInputMode();
}
}
activeEl = activeElement;
this.setPickerItemActiveState(activeElement, true);
timeout = setTimeout(() => {
this.isScrolling = false;
enableHaptics && hapticSelectionEnd();
/**
* Certain tasks (such as those that
* cause re-renders) should only be done
* once scrolling has finished, otherwise
* flickering may occur.
*/
const { scrollEndCallback } = this;
if (scrollEndCallback) {
scrollEndCallback();
this.scrollEndCallback = undefined;
}
/**
* Reset this flag as the
* next scroll interaction could
* be a scroll from the user. In this
* case, we should exit input mode.
*/
this.canExitInputMode = true;
const dataIndex = activeElement.getAttribute('data-index');
/**
* If no value it is
* possible we hit one of the
* empty padding columns.
*/
if (dataIndex === null) {
return;
}
const index = parseInt(dataIndex, 10);
const selectedItem = this.items[index];
if (selectedItem.value !== this.value) {
this.setValue(selectedItem.value);
}
}, 250);
});
};
/**
* Wrap this in an raf so that the scroll callback
* does not fire when component is initially shown.
*/
raf(() => {
el.addEventListener('scroll', scrollCallback);
this.destroyScrollListener = () => {
el.removeEventListener('scroll', scrollCallback);
};
});
};
/**
* Tells the parent picker to
* exit text entry mode. This is only called
* when the selected item changes during scroll, so
* we know that the user likely wants to scroll
* instead of type.
*/
private exitInputMode = () => {
const { parentEl } = this;
if (parentEl == null) return;
parentEl.exitInputMode();
/**
* setInputModeActive only takes
* effect once scrolling stops to avoid
* a component re-render while scrolling.
* However, we want the visual active
* indicator to go away immediately, so
* we call classList.remove here.
*/
this.el.classList.remove('picker-column-active');
};
get activeItem() {
// If the whole picker column is disabled, the current value should appear active
// If the current value item is specifically disabled, it should not appear active
const selector = `.picker-item[data-value="${this.value}"]${this.disabled ? '' : ':not([disabled])'}`;
return getElementRoot(this.el).querySelector(selector) as HTMLElement | null;
}
render() {
const { items, color, disabled: pickerDisabled, isActive, numericInput } = this;
const mode = getIonMode(this);
/**
* exportparts is needed so ion-datetime can expose the parts
* from two layers of shadow nesting. If this causes problems,
* the attribute can be moved to datetime.tsx and set on every
* instance of ion-picker-column-internal there instead.
*/
return (
<Host
exportparts={`${PICKER_ITEM_PART}, ${PICKER_ITEM_ACTIVE_PART}`}
disabled={pickerDisabled}
tabindex={pickerDisabled ? null : 0}
class={createColorClasses(color, {
[mode]: true,
['picker-column-active']: isActive,
['picker-column-numeric-input']: numericInput,
})}
>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
{items.map((item, index) => {
const isItemDisabled = pickerDisabled || item.disabled || false;
{
/*
Users should be able to tab
between multiple columns. As a result,
we set tabindex here so that tabbing switches
between columns instead of buttons. Users
can still use arrow keys on the keyboard to
navigate the column up and down.
*/
}
return (
<button
tabindex="-1"
class={{
'picker-item': true,
}}
data-value={item.value}
data-index={index}
onClick={(ev: Event) => {
this.centerPickerItemInView(ev.target as HTMLElement, true);
}}
disabled={isItemDisabled}
part={PICKER_ITEM_PART}
>
{item.text}
</button>
);
})}
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
</Host>
);
}
}
const PICKER_ITEM_ACTIVE_CLASS = 'picker-item-active';
const PICKER_ITEM_PART = 'wheel-item';
const PICKER_ITEM_ACTIVE_PART = 'active';

View File

@@ -0,0 +1 @@
@import "./picker-column-option.scss";

View File

@@ -0,0 +1,5 @@
@import "./picker-column-option.scss";
:host(.option-active) button {
color: current-color(base);
}

View File

@@ -0,0 +1,45 @@
@import "../../themes/ionic.globals";
// Picker Column
// --------------------------------------------------
button {
@include padding(0);
@include margin(0);
width: 100%;
height: 34px;
border: 0px;
outline: none;
background: transparent;
color: inherit;
font-family: $font-family-base;
font-size: inherit;
line-height: 34px;
text-align: inherit;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
overflow: hidden;
}
:host(.option-disabled) {
opacity: 0.4;
}
:host(.option-disabled) button {
cursor: default;
}

View File

@@ -0,0 +1,111 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, State, Watch, h } from '@stencil/core';
import { inheritAttributes } from '@utils/helpers';
import { createColorClasses } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Color } from '../../interface';
@Component({
tag: 'ion-picker-column-option',
styleUrls: {
ios: 'picker-column-option.ios.scss',
md: 'picker-column-option.md.scss',
},
shadow: true,
})
export class PickerColumnOption implements ComponentInterface {
@Element() el!: HTMLElement;
/**
* The aria-label of the option.
*
* If the value changes, then it will trigger a
* re-render of the picker since it's a @State variable.
* Otherwise, the `aria-label` attribute cannot be updated
* after the component is loaded.
*/
@State() ariaLabel?: string | null = null;
/**
* If `true`, the user cannot interact with the picker column option.
*/
@Prop() disabled = false;
/**
* The text value of the option.
*/
@Prop() value?: any | null;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
* For more information on colors, see [theming](/docs/theming/basics).
*/
@Prop({ reflect: true }) color?: Color = 'primary';
/**
* The aria-label of the option has changed after the
* first render and needs to be updated within the component.
*
* @param ariaLbl The new aria-label value.
*/
@Watch('aria-label')
onAriaLabelChange(ariaLbl: string) {
this.ariaLabel = ariaLbl;
}
componentWillLoad() {
const inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
/**
* The initial value of `aria-label` needs to be set for
* the first render.
*/
this.ariaLabel = inheritedAttributes['aria-label'] || null;
}
/**
* The column options can load at any time
* so the selected option needs to tell the
* parent picker column when it is loaded
* so the picker column can ensure it is
* centered in the view.
*/
componentDidLoad() {
const parentPickerColumn = this.el.closest('ion-picker-column');
if (parentPickerColumn !== null && this.value === parentPickerColumn.value) {
parentPickerColumn.scrollActiveItemIntoView();
}
}
/**
* When an option is clicked update the
* parent picker column value. This
* component will handle centering the option
* in the column view.
*/
onClick() {
const parentPickerColumn = this.el.closest('ion-picker-column');
if (parentPickerColumn !== null) {
parentPickerColumn.setValue(this.value);
}
}
render() {
const { color, value, disabled, ariaLabel } = this;
const mode = getIonMode(this);
return (
<Host
class={createColorClasses(color, {
[mode]: true,
['option-disabled']: disabled,
})}
>
<button tabindex="-1" aria-label={ariaLabel} disabled={disabled} onClick={() => this.onClick()}>
<slot>{value}</slot>
</button>
</Host>
);
}
}

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Picker Column Option - a11y</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<main>
<ion-picker-column-option> my option </ion-picker-column-option>
<ion-picker-column-option aria-label="the best one"> other option </ion-picker-column-option>
<ion-picker-column-option color="tertiary" class="option-active">option</ion-picker-column-option>
<ion-picker-column-option disabled="true">option</ion-picker-column-option>
<ion-picker-column-option color="tertiary" class="option-active" disabled="true">option</ion-picker-column-option>
</main>
</body>
</html>

View File

@@ -0,0 +1,18 @@
import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions
*/
configs({ directions: ['ltr'] }).forEach(({ config, title }) => {
test.describe(title('picker column option: a11y'), () => {
test('should not have accessibility violations', async ({ page }) => {
await page.goto(`/src/components/picker-column-option/test/a11y`, config);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});
});

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Picker Column Option - Basic</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.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;
margin-left: 5px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
</style>
</head>
<body>
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Picker Column Option - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Default</h2>
<ion-picker-column-option>My Option</ion-picker-column-option>
</div>
<div class="grid-item">
<h2>Disabled</h2>
<ion-picker-column-option disabled="true">My Option</ion-picker-column-option>
</div>
<div class="grid-item">
<h2>Active</h2>
<ion-picker-column-option class="option-active">My Option</ion-picker-column-option>
</div>
<div class="grid-item">
<h2>Active / Disabled</h2>
<ion-picker-column-option class="option-active" disabled="true">My Option</ion-picker-column-option>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-column-option: rendering'), () => {
test('picker option should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-column-option value="option">My Option</ion-picker-column-option>
`,
config
);
const option = page.locator('ion-picker-column-option');
await expect(option).toHaveScreenshot(screenshot('picker-column-option'));
});
test('disabled picker option should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-column-option disabled="true" value="option">My Option</ion-picker-column-option>
`,
config
);
const option = page.locator('ion-picker-column-option');
await expect(option).toHaveScreenshot(screenshot('disabled-picker-column-option'));
});
test('active picker option should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-column-option class="option-active" value="option">My Option</ion-picker-column-option>
`,
config
);
const option = page.locator('ion-picker-column-option');
await expect(option).toHaveScreenshot(screenshot('active-picker-column-option'));
});
test('disabled active picker option should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-column-option class="option-active" disabled="true" value="option">My Option</ion-picker-column-option>
`,
config
);
const option = page.locator('ion-picker-column-option');
await expect(option).toHaveScreenshot(screenshot('disabled-active-picker-column-option'));
});
});
});

View File

@@ -0,0 +1,31 @@
import { newSpecPage } from '@stencil/core/testing';
import { PickerColumnOption } from '../picker-column-option';
describe('PickerColumnOption', () => {
it('option should be enabled by default', async () => {
const page = await newSpecPage({
components: [PickerColumnOption],
html: `
<ion-picker-column-option value="a">A</ion-picker-column-option>
`,
});
const pickerColumnOption = page.body.querySelector('ion-picker-column-option')!;
const button = pickerColumnOption.shadowRoot!.querySelector('button')!;
expect(button.hasAttribute('disabled')).toEqual(false);
});
it('disabled option should have disabled button', async () => {
const page = await newSpecPage({
components: [PickerColumnOption],
html: `
<ion-picker-column-option value="a" disabled="true">A</ion-picker-column-option>
`,
});
const pickerColumnOption = page.body.querySelector('ion-picker-column-option')!;
const button = pickerColumnOption.shadowRoot!.querySelector('button')!;
expect(button.hasAttribute('disabled')).toEqual(true);
});
});

View File

@@ -1,47 +1 @@
@import "./picker-column";
@import "../picker/picker.ios.vars";
// iOS Picker Column
// --------------------------------------------------
.picker-col {
@include padding($picker-ios-column-padding-top, $picker-ios-column-padding-end, $picker-ios-column-padding-bottom, $picker-ios-column-padding-start);
transform-style: preserve-3d;
}
.picker-prefix,
.picker-suffix,
.picker-opts {
top: $picker-ios-option-offset-y;
transform-style: preserve-3d;
color: inherit;
font-size: $picker-ios-option-font-size;
line-height: $picker-ios-option-height;
pointer-events: none;
}
.picker-opt {
@include padding($picker-ios-option-padding-top, $picker-ios-option-padding-end, $picker-ios-option-padding-bottom, $picker-ios-option-padding-start);
@include margin(0);
@include transform-origin(center, center);
height: 46px;
transform-style: preserve-3d;
transition-timing-function: ease-out;
background: transparent;
color: inherit;
font-size: $picker-ios-option-font-size;
line-height: $picker-ios-option-height;
backface-visibility: hidden;
pointer-events: auto;
}
@import "./picker-column.scss";

View File

@@ -1,51 +1,6 @@
@import "./picker-column";
@import "../picker/picker.md.vars";
@import "./picker-column.scss";
@import "../../themes/ionic.globals.md";
// Material Design Picker Column
// --------------------------------------------------
.picker-col {
@include padding($picker-md-column-padding-top, $picker-md-column-padding-end, $picker-md-column-padding-bottom, $picker-md-column-padding-start);
transform-style: preserve-3d;
:host .picker-item-active {
color: current-color(base);
}
.picker-prefix,
.picker-suffix,
.picker-opts {
top: $picker-md-option-offset-y;
transform-style: preserve-3d;
color: inherit;
font-size: $picker-md-option-font-size;
line-height: $picker-md-option-height;
pointer-events: none;
}
.picker-opt {
@include margin(0);
@include padding($picker-md-option-padding-top, $picker-md-option-padding-end, $picker-md-option-padding-bottom, $picker-md-option-padding-start);
height: 43px;
transition-timing-function: ease-out;
background: transparent;
color: inherit;
font-size: $picker-md-option-font-size;
line-height: $picker-md-option-height;
backface-visibility: hidden;
pointer-events: auto;
}
.picker-prefix,
.picker-suffix,
.picker-opt.picker-opt-selected {
color: $picker-md-option-selected-color;
}

View File

@@ -3,87 +3,110 @@
// Picker Column
// --------------------------------------------------
.picker-col {
display: flex;
position: relative;
:host {
@include padding(0px, 16px, 0px, 16px);
flex: 1;
justify-content: center;
height: 200px;
height: 100%;
box-sizing: content-box;
outline: none;
contain: content;
font-size: 22px;
scroll-snap-type: y mandatory;
/**
* Need to explicitly set overflow-x: hidden
* for older implementations of scroll snapping.
*/
overflow-x: hidden;
overflow-y: scroll;
// Hide scrollbars on Firefox
scrollbar-width: none;
text-align: center;
}
.picker-opts {
position: relative;
flex: 1;
max-width: 100%;
/**
* Hide scrollbars on Chrome and Safari
*/
:host::-webkit-scrollbar {
display: none;
}
// contain property is supported by Chrome
.picker-opt {
@include position(0, null, null, 0);
::slotted(ion-picker-column-option) {
display: block;
scroll-snap-align: center;
}
.picker-item-empty,
:host(:not([disabled])) ::slotted(ion-picker-column-option.option-disabled) {
scroll-snap-align: none;
}
:host .picker-item {
@include padding(0);
@include margin(0);
display: block;
position: absolute;
width: 100%;
border: 0;
height: 34px;
border: 0px;
outline: none;
background: transparent;
color: inherit;
font-family: $font-family-base;
font-size: inherit;
line-height: 34px;
text-align: inherit;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
contain: strict;
cursor: pointer;
overflow: hidden;
will-change: transform;
scroll-snap-align: center;
}
.picker-opt.picker-opt-disabled {
pointer-events: none;
:host .picker-item-empty,
:host .picker-item[disabled] {
cursor: default;
}
.picker-opt-disabled {
opacity: 0;
:host .picker-item-empty,
:host(:not([disabled])) .picker-item[disabled] {
scroll-snap-align: none;
}
.picker-opts-left {
justify-content: flex-start;
:host([disabled]) {
overflow-y: hidden;
}
.picker-opts-right {
justify-content: flex-end;
:host .picker-item[disabled] {
opacity: 0.4;
}
.picker-opt {
&:active,
&:focus {
:host(.picker-column-active) .picker-item.picker-item-active {
color: current-color(base);
}
@media (any-hover: hover) {
:host(:focus) {
outline: none;
background: current-color(base, 0.2);
}
}
.picker-prefix {
position: relative;
flex: 1;
text-align: end;
white-space: nowrap;
}
.picker-suffix {
position: relative;
flex: 1;
text-align: start;
white-space: nowrap;
}

View File

@@ -1,15 +1,18 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core';
import { clamp } from '@utils/helpers';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { getElementRoot, raf } from '@utils/helpers';
import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from '@utils/native/haptic';
import { getClassMap } from '@utils/theme';
import { isPlatform } from '@utils/platform';
import { createColorClasses } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Gesture, GestureDetail } from '../../interface';
import type { PickerColumn } from '../picker/picker-interface';
import type { Color } from '../../interface';
import type { PickerCustomEvent } from '../picker/picker-interfaces';
import type { PickerColumnItem } from './picker-column-interfaces';
/**
* @internal
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*/
@Component({
tag: 'ion-picker-column',
@@ -17,424 +20,456 @@ import type { PickerColumn } from '../picker/picker-interface';
ios: 'picker-column.ios.scss',
md: 'picker-column.md.scss',
},
shadow: true,
})
export class PickerColumnCmp implements ComponentInterface {
private bounceFrom!: number;
private lastIndex?: number;
private minY!: number;
private maxY!: number;
private optHeight = 0;
private rotateFactor = 0;
private scaleFactor = 1;
private velocity = 0;
private y = 0;
private optsEl?: HTMLElement;
private gesture?: Gesture;
private rafId?: ReturnType<typeof requestAnimationFrame>;
private tmrId?: ReturnType<typeof setTimeout>;
private noAnimate = true;
// `colDidChange` is a flag that gets set when the column is changed
// dynamically. When this flag is set, the column will refresh
// after the component re-renders to incorporate the new column data.
// This is necessary because `this.refresh` queries for the option elements,
// so it needs to wait for the latest elements to be available in the DOM.
// Ex: column is created with 3 options. User updates the column data
// to have 5 options. The column will still think it only has 3 options.
private colDidChange = false;
export class PickerColumn implements ComponentInterface {
private destroyScrollListener?: () => void;
private isScrolling = false;
private scrollEndCallback?: () => void;
private isColumnVisible = false;
private parentEl?: HTMLIonPickerElement | null;
private canExitInputMode = true;
@Element() el!: HTMLElement;
@State() isActive = false;
@Element() el!: HTMLIonPickerColumnElement;
/**
* Emitted when the selected value has changed
* If `true`, the user cannot interact with the picker.
*/
@Prop() disabled = false;
/**
* A list of options to be displayed in the picker
*/
@Prop() items: PickerColumnItem[] = [];
/**
* The selected option in the picker.
*/
@Prop({ mutable: true }) value?: string | number;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
* For more information on colors, see [theming](/docs/theming/basics).
*/
@Prop({ reflect: true }) color?: Color = 'primary';
/**
* If `true`, tapping the picker will
* reveal a number input keyboard that lets
* the user type in values for each picker
* column. This is useful when working
* with time pickers.
*
* @internal
*/
@Event() ionPickerColChange!: EventEmitter<PickerColumn>;
@Prop() numericInput = false;
/** Picker column data */
@Prop() col!: PickerColumn;
@Watch('col')
protected colChanged() {
this.colDidChange = true;
}
/**
* Emitted when the value has changed.
*/
@Event() ionChange!: EventEmitter<string | number | undefined>;
async connectedCallback() {
let pickerRotateFactor = 0;
let pickerScaleFactor = 0.81;
const mode = getIonMode(this);
if (mode === 'ios') {
pickerRotateFactor = -0.46;
pickerScaleFactor = 1;
}
this.rotateFactor = pickerRotateFactor;
this.scaleFactor = pickerScaleFactor;
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.el,
gestureName: 'picker-swipe',
gesturePriority: 100,
threshold: 0,
passive: false,
onStart: (ev) => this.onStart(ev),
onMove: (ev) => this.onMove(ev),
onEnd: (ev) => this.onEnd(ev),
});
this.gesture.enable();
// Options have not been initialized yet
// Animation must be disabled through the `noAnimate` flag
// Otherwise, the options will render
// at the top of the column and transition down
this.tmrId = setTimeout(() => {
this.noAnimate = false;
// After initialization, `refresh()` will be called
// At this point, animation will be enabled. The options will
// animate as they are being selected.
this.refresh(true);
}, 250);
}
componentDidLoad() {
this.onDomChange();
}
componentDidUpdate() {
// Options may have changed since last update.
if (this.colDidChange) {
// Animation must be disabled through the `onDomChange` parameter.
// Otherwise, the recently added options will render
// at the top of the column and transition down
this.onDomChange(true, false);
this.colDidChange = false;
@Watch('value')
valueChange() {
if (this.isColumnVisible) {
/**
* Only scroll the active item into view when the picker column
* is actively visible to the user.
*/
this.scrollActiveItemIntoView();
}
}
disconnectedCallback() {
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
if (this.tmrId) clearTimeout(this.tmrId);
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
}
/**
* Only setup scroll listeners
* when the picker is visible, otherwise
* the container will have a scroll
* height of 0px.
*/
componentWillLoad() {
const visibleCallback = (entries: IntersectionObserverEntry[]) => {
const ev = entries[0];
private emitColChange() {
this.ionPickerColChange.emit(this.col);
}
if (ev.isIntersecting) {
const { activeItem, el } = this;
private setSelected(selectedIndex: number, duration: number) {
// if there is a selected index, then figure out it's y position
// if there isn't a selected index, then just use the top y position
const y = selectedIndex > -1 ? -(selectedIndex * this.optHeight) : 0;
this.velocity = 0;
// set what y position we're at
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
this.update(y, duration, true);
this.emitColChange();
}
private update(y: number, duration: number, saveY: boolean) {
if (!this.optsEl) {
return;
}
// ensure we've got a good round number :)
let translateY = 0;
let translateZ = 0;
const { col, rotateFactor } = this;
const prevSelected = col.selectedIndex;
const selectedIndex = (col.selectedIndex = this.indexForY(-y));
const durationStr = duration === 0 ? '' : duration + 'ms';
const scaleStr = `scale(${this.scaleFactor})`;
const children = this.optsEl.children;
for (let i = 0; i < children.length; i++) {
const button = children[i] as HTMLElement;
const opt = col.options[i];
const optOffset = i * this.optHeight + y;
let transform = '';
if (rotateFactor !== 0) {
const rotateX = optOffset * rotateFactor;
if (Math.abs(rotateX) <= 90) {
translateY = 0;
translateZ = 90;
transform = `rotateX(${rotateX}deg) `;
} else {
translateY = -9999;
this.isColumnVisible = true;
/**
* Because this initial call to scrollActiveItemIntoView has to fire before
* the scroll listener is set up, we need to manage the active class manually.
*/
const oldActive = getElementRoot(el).querySelector(
`.${PICKER_ITEM_ACTIVE_CLASS}`
) as HTMLIonPickerColumnOptionElement | null;
if (oldActive) {
this.setPickerItemActiveState(oldActive, false);
}
this.scrollActiveItemIntoView();
if (activeItem) {
this.setPickerItemActiveState(activeItem, true);
}
this.initializeScrollListener();
} else {
translateZ = 0;
translateY = optOffset;
}
this.isColumnVisible = false;
const selected = selectedIndex === i;
transform += `translate3d(0px,${translateY}px,${translateZ}px) `;
if (this.scaleFactor !== 1 && !selected) {
transform += scaleStr;
if (this.destroyScrollListener) {
this.destroyScrollListener();
this.destroyScrollListener = undefined;
}
}
};
new IntersectionObserver(visibleCallback, { threshold: 0.001 }).observe(this.el);
// Update transition duration
if (this.noAnimate) {
opt.duration = 0;
button.style.transitionDuration = '';
} else if (duration !== opt.duration) {
opt.duration = duration;
button.style.transitionDuration = durationStr;
}
const parentEl = (this.parentEl = this.el.closest('ion-picker') as HTMLIonPickerElement | null);
if (parentEl !== null) {
// TODO(FW-2832): type
parentEl.addEventListener('ionInputModeChange', (ev: any) => this.inputModeChange(ev));
}
}
// Update transform
if (transform !== opt.transform) {
opt.transform = transform;
}
button.style.transform = transform;
componentDidRender() {
const { el, activeItem, isColumnVisible, value } = this;
if (isColumnVisible && !activeItem) {
const firstOption = el.querySelector('ion-picker-column-option');
/**
* Ensure that the select column
* item has the selected class
* If the picker column does not have an active item and the current value
* does not match the first item in the picker column, that means
* the value is out of bounds. In this case, we assign the value to the
* first item to match the scroll position of the column.
*
*/
opt.selected = selected;
if (selected) {
button.classList.add(PICKER_OPT_SELECTED);
} else {
button.classList.remove(PICKER_OPT_SELECTED);
if (firstOption !== null && firstOption.value !== value) {
this.setValue(firstOption.value);
}
}
this.col.prevSelected = prevSelected;
if (saveY) {
this.y = y;
}
if (this.lastIndex !== selectedIndex) {
// have not set a last index yet
hapticSelectionChanged();
this.lastIndex = selectedIndex;
}
}
private decelerate() {
if (this.velocity !== 0) {
// still decelerating
this.velocity *= DECELERATION_FRICTION;
/** @internal */
@Method()
async scrollActiveItemIntoView() {
const activeEl = this.activeItem;
// do not let it go slower than a velocity of 1
this.velocity = this.velocity > 0 ? Math.max(this.velocity, 1) : Math.min(this.velocity, -1);
let y = this.y + this.velocity;
if (y > this.minY) {
// whoops, it's trying to scroll up farther than the options we have!
y = this.minY;
this.velocity = 0;
} else if (y < this.maxY) {
// gahh, it's trying to scroll down farther than we can!
y = this.maxY;
this.velocity = 0;
}
this.update(y, 0, true);
const notLockedIn = Math.round(y) % this.optHeight !== 0 || Math.abs(this.velocity) > 1;
if (notLockedIn) {
// isn't locked in yet, keep decelerating until it is
this.rafId = requestAnimationFrame(() => this.decelerate());
} else {
this.velocity = 0;
this.emitColChange();
hapticSelectionEnd();
}
} else if (this.y % this.optHeight !== 0) {
// needs to still get locked into a position so options line up
const currentPos = Math.abs(this.y % this.optHeight);
// create a velocity in the direction it needs to scroll
this.velocity = currentPos > this.optHeight / 2 ? 1 : -1;
this.decelerate();
if (activeEl) {
this.centerPickerItemInView(activeEl, false, false);
}
}
private indexForY(y: number) {
return Math.min(Math.max(Math.abs(Math.round(y / this.optHeight)), 0), this.col.options.length - 1);
/**
* Sets the value prop and fires the ionChange event.
* This is used when we need to fire ionChange from
* user-generated events that cannot be caught with normal
* input/change event listeners.
* @internal
*/
@Method()
async setValue(value?: string | number) {
if (this.value === value) { return; }
this.value = value;
this.ionChange.emit(value);
}
private onStart(detail: GestureDetail) {
// We have to prevent default in order to block scrolling under the picker
// but we DO NOT have to stop propagation, since we still want
// some "click" events to capture
if (detail.event.cancelable) {
detail.event.preventDefault();
}
detail.event.stopPropagation();
private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => {
const { el, isColumnVisible } = this;
if (isColumnVisible) {
// (Vertical offset from parent) - (three empty picker rows) + (half the height of the target to ensure the scroll triggers)
const top = target.offsetTop - 3 * target.clientHeight + target.clientHeight / 2;
hapticSelectionStart();
// reset everything
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
const options = this.col.options;
let minY = options.length - 1;
let maxY = 0;
for (let i = 0; i < options.length; i++) {
if (!options[i].disabled) {
minY = Math.min(minY, i);
maxY = Math.max(maxY, i);
if (el.scrollTop !== top) {
/**
* Setting this flag prevents input
* mode from exiting in the picker column's
* scroll callback. This is useful when the user manually
* taps an item or types on the keyboard as both
* of these can cause a scroll to occur.
*/
this.canExitInputMode = canExitInputMode;
el.scroll({
top,
left: 0,
behavior: smooth ? 'smooth' : undefined,
});
}
}
};
this.minY = -(minY * this.optHeight);
this.maxY = -(maxY * this.optHeight);
}
private onMove(detail: GestureDetail) {
if (detail.event.cancelable) {
detail.event.preventDefault();
}
detail.event.stopPropagation();
// update the scroll position relative to pointer start position
let y = this.y + detail.deltaY;
if (y > this.minY) {
// scrolling up higher than scroll area
y = Math.pow(y, 0.8);
this.bounceFrom = y;
} else if (y < this.maxY) {
// scrolling down below scroll area
y += Math.pow(this.maxY - y, 0.9);
this.bounceFrom = y;
private setPickerItemActiveState = (item: HTMLIonPickerColumnOptionElement, isActive: boolean) => {
if (isActive) {
item.classList.add(PICKER_ITEM_ACTIVE_CLASS);
item.part.add(PICKER_ITEM_ACTIVE_PART);
} else {
this.bounceFrom = 0;
item.classList.remove(PICKER_ITEM_ACTIVE_CLASS);
item.part.remove(PICKER_ITEM_ACTIVE_PART);
}
};
this.update(y, 0, false);
}
private onEnd(detail: GestureDetail) {
if (this.bounceFrom > 0) {
// bounce back up
this.update(this.minY, 100, true);
this.emitColChange();
return;
} else if (this.bounceFrom < 0) {
// bounce back down
this.update(this.maxY, 100, true);
this.emitColChange();
/**
* When ionInputModeChange is emitted, each column
* needs to check if it is the one being made available
* for text entry.
*/
private inputModeChange = (ev: PickerCustomEvent) => {
if (!this.numericInput) {
return;
}
this.velocity = clamp(-MAX_PICKER_SPEED, detail.velocityY * 23, MAX_PICKER_SPEED);
if (this.velocity === 0 && detail.deltaY === 0) {
const opt = (detail.event.target as Element).closest('.picker-opt');
if (opt?.hasAttribute('opt-index')) {
this.setSelected(parseInt(opt.getAttribute('opt-index')!, 10), TRANSITION_DURATION);
}
} else {
this.y += detail.deltaY;
if (Math.abs(detail.velocityY) < 0.05) {
const isScrollingUp = detail.deltaY > 0;
const optHeightFraction = (Math.abs(this.y) % this.optHeight) / this.optHeight;
if (isScrollingUp && optHeightFraction > 0.5) {
this.velocity = Math.abs(this.velocity) * -1;
} else if (!isScrollingUp && optHeightFraction <= 0.5) {
this.velocity = Math.abs(this.velocity);
}
}
this.decelerate();
}
}
private refresh(forceRefresh?: boolean, animated?: boolean) {
let min = this.col.options.length - 1;
let max = 0;
const options = this.col.options;
for (let i = 0; i < options.length; i++) {
if (!options[i].disabled) {
min = Math.min(min, i);
max = Math.max(max, i);
}
}
const { useInputMode, inputModeColumn } = ev.detail;
/**
* Only update selected value if column has a
* velocity of 0. If it does not, then the
* column is animating might land on
* a value different than the value at
* selectedIndex
* If inputModeColumn is undefined then this means
* all numericInput columns are being selected.
*/
if (this.velocity !== 0) {
const isColumnActive = inputModeColumn === undefined || inputModeColumn === this.el;
if (!useInputMode || !isColumnActive) {
this.setInputModeActive(false);
return;
}
const selectedIndex = clamp(min, this.col.selectedIndex ?? 0, max);
if (this.col.prevSelected !== selectedIndex || forceRefresh) {
const y = selectedIndex * this.optHeight * -1;
const duration = animated ? TRANSITION_DURATION : 0;
this.velocity = 0;
this.update(y, duration, true);
}
}
this.setInputModeActive(true);
};
private onDomChange(forceRefresh?: boolean, animated?: boolean) {
const colEl = this.optsEl;
if (colEl) {
// DOM READ
// We perfom a DOM read over a rendered item, this needs to happen after the first render or after the the column has changed
this.optHeight = colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0;
/**
* Setting isActive will cause a re-render.
* As a result, we do not want to cause the
* re-render mid scroll as this will cause
* the picker column to jump back to
* whatever value was selected at the
* start of the scroll interaction.
*/
private setInputModeActive = (state: boolean) => {
if (this.isScrolling) {
this.scrollEndCallback = () => {
this.isActive = state;
};
return;
}
this.refresh(forceRefresh, animated);
this.isActive = state;
};
/**
* When the column scrolls, the component
* needs to determine which item is centered
* in the view and will emit an ionChange with
* the item object.
*/
private initializeScrollListener = () => {
/**
* The haptics for the wheel picker are
* an iOS-only feature. As a result, they should
* be disabled on Android.
*/
const enableHaptics = isPlatform('ios');
const { el } = this;
let timeout: ReturnType<typeof setTimeout> | undefined;
let activeEl: HTMLIonPickerColumnOptionElement | undefined = this.activeItem;
const scrollCallback = () => {
raf(() => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if (!this.isScrolling) {
enableHaptics && hapticSelectionStart();
this.isScrolling = true;
}
/**
* Select item in the center of the column
* which is the month/year that we want to select
*/
const bbox = el.getBoundingClientRect();
const centerX = bbox.x + bbox.width / 2;
const centerY = bbox.y + bbox.height / 2;
const newActiveElement = el.shadowRoot!.elementFromPoint(
centerX,
centerY
) as HTMLIonPickerColumnOptionElement | null;
if (activeEl !== undefined) {
this.setPickerItemActiveState(activeEl, false);
}
/**
* This null check is important because activeEl
* can be undefined but newActiveElement can be
* null since we are using a querySelector.
* newActiveElement !== activeEl would return true
* below if newActiveElement was null but activeEl
* was undefined.
*/
if (newActiveElement === null || newActiveElement.disabled) {
return;
}
/**
* If we are selecting a new value,
* we need to run haptics again.
*/
if (newActiveElement !== activeEl) {
enableHaptics && hapticSelectionChanged();
if (this.canExitInputMode) {
/**
* The native iOS wheel picker
* only dismisses the keyboard
* once the selected item has changed
* as a result of a swipe
* from the user. If `canExitInputMode` is
* `false` then this means that the
* scroll is happening as a result of
* the `value` property programmatically changing
* either by an application or by the user via the keyboard.
*/
this.exitInputMode();
}
}
activeEl = newActiveElement;
this.setPickerItemActiveState(newActiveElement, true);
timeout = setTimeout(() => {
this.isScrolling = false;
enableHaptics && hapticSelectionEnd();
/**
* Certain tasks (such as those that
* cause re-renders) should only be done
* once scrolling has finished, otherwise
* flickering may occur.
*/
const { scrollEndCallback } = this;
if (scrollEndCallback) {
scrollEndCallback();
this.scrollEndCallback = undefined;
}
/**
* Reset this flag as the
* next scroll interaction could
* be a scroll from the user. In this
* case, we should exit input mode.
*/
this.canExitInputMode = true;
this.setValue(newActiveElement.value);
}, 250);
});
};
/**
* Wrap this in an raf so that the scroll callback
* does not fire when component is initially shown.
*/
raf(() => {
el.addEventListener('scroll', scrollCallback);
this.destroyScrollListener = () => {
el.removeEventListener('scroll', scrollCallback);
};
});
};
/**
* Tells the parent picker to
* exit text entry mode. This is only called
* when the selected item changes during scroll, so
* we know that the user likely wants to scroll
* instead of type.
*/
private exitInputMode = () => {
const { parentEl } = this;
if (parentEl == null) return;
parentEl.exitInputMode();
/**
* setInputModeActive only takes
* effect once scrolling stops to avoid
* a component re-render while scrolling.
* However, we want the visual active
* indicator to go away immediately, so
* we call classList.remove here.
*/
this.el.classList.remove('picker-column-active');
};
get activeItem() {
const { value } = this;
const options = Array.from(
this.el.querySelectorAll('ion-picker-column-option') as NodeListOf<HTMLIonPickerColumnOptionElement>
);
return options.find((option: HTMLIonPickerColumnOptionElement) => {
/**
* If the whole picker column is disabled, the current value should appear active
* If the current value item is specifically disabled, it should not appear active
*/
if (!this.disabled && option.disabled) {
return false;
}
return option.value === value;
}) as HTMLIonPickerColumnOptionElement | undefined;
}
render() {
const col = this.col;
const { color, disabled: pickerDisabled, isActive, numericInput } = this;
const mode = getIonMode(this);
/**
* exportparts is needed so ion-datetime can expose the parts
* from two layers of shadow nesting. If this causes problems,
* the attribute can be moved to datetime.tsx and set on every
* instance of ion-picker-column there instead.
*/
return (
<Host
class={{
exportparts={`${PICKER_ITEM_PART}, ${PICKER_ITEM_ACTIVE_PART}`}
disabled={pickerDisabled}
tabindex={pickerDisabled ? null : 0}
class={createColorClasses(color, {
[mode]: true,
'picker-col': true,
'picker-opts-left': this.col.align === 'left',
'picker-opts-right': this.col.align === 'right',
...getClassMap(col.cssClass),
}}
style={{
'max-width': this.col.columnWidth,
}}
['picker-column-active']: isActive,
['picker-column-numeric-input']: numericInput,
})}
>
{col.prefix && (
<div class="picker-prefix" style={{ width: col.prefixWidth! }}>
{col.prefix}
</div>
)}
<div class="picker-opts" style={{ maxWidth: col.optionsWidth! }} ref={(el) => (this.optsEl = el)}>
{col.options.map((o, index) => (
<button
aria-label={o.ariaLabel}
class={{ 'picker-opt': true, 'picker-opt-disabled': !!o.disabled }}
opt-index={index}
>
{o.text}
</button>
))}
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<slot></slot>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
<div class="picker-item picker-item-empty" aria-hidden="true">
&nbsp;
</div>
{col.suffix && (
<div class="picker-suffix" style={{ width: col.suffixWidth! }}>
{col.suffix}
</div>
)}
</Host>
);
}
}
const PICKER_OPT_SELECTED = 'picker-opt-selected';
const DECELERATION_FRICTION = 0.97;
const MAX_PICKER_SPEED = 90;
const TRANSITION_DURATION = 150;
const PICKER_ITEM_ACTIVE_CLASS = 'option-active';
const PICKER_ITEM_PART = 'wheel-item';
const PICKER_ITEM_ACTIVE_PART = 'active';

View File

@@ -2,7 +2,7 @@
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Picker Column Internal - Basic</title>
<title>Picker Column - Basic</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
@@ -39,16 +39,16 @@
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Picker Column Internal - Basic</ion-title>
<ion-title>Picker Column - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Default</h2>
<ion-picker-internal>
<ion-picker-column-internal id="default"></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column id="default"></ion-picker-column>
</ion-picker>
</div>
</div>
</ion-content>
@@ -57,12 +57,18 @@
const items = Array(24)
.fill()
.map((_, i) => ({
text: `${i}`,
value: i,
}));
.forEach((_, i) => {
const option = document.createElement('ion-picker-column-option');
option.value = i;
option.textContent = i;
defaultPickerColumn.items = items;
defaultPickerColumn.appendChild(option);
});
//defaultPickerColumn.value = 11;
defaultPickerColumn.addEventListener('ionChange', (ev) => {
console.log(ev);
})
</script>
</ion-app>
</body>

View File

@@ -5,33 +5,28 @@ import { configs, test } from '@utils/test/playwright';
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal'), () => {
test.describe(title('picker-column'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/basic', config);
});
test('should render a picker item for each item', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item:not(.picker-item-empty)');
await expect(columns).toHaveCount(24);
await page.goto('/src/components/picker-column/test/basic', config);
});
test('should render 6 empty picker items', async ({ page }) => {
const columns = page.locator('ion-picker-column-internal .picker-item-empty');
const columns = page.locator('ion-picker-column .picker-item-empty');
await expect(columns).toHaveCount(6);
});
test('should not have an active item when value is not set', async ({ page }) => {
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
const activeColumn = page.locator('ion-picker-column .option-active');
await expect(activeColumn).toHaveCount(0);
});
test('should have an active item when value is set', async ({ page }) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnElement) => {
el.value = '12';
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
const activeColumn = page.locator('ion-picker-column .option-active');
expect(activeColumn).not.toBeNull();
});
@@ -40,14 +35,14 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
test.skip('scrolling should change the active item', async ({ page, skip }) => {
skip.browser('firefox', 'https://bugzilla.mozilla.org/show_bug.cgi?id=1766890');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnElement) => {
el.scrollTop = 801;
});
await page.waitForChanges();
const activeColumn = page.locator('ion-picker-column-internal .picker-item-active');
const activeColumn = page.locator('ion-picker-column .option-active');
expect(await activeColumn?.innerText()).toEqual('23');
await expect(activeColumn).toHaveJSProperty('value', 23);
});
test('should not emit ionChange when the value is modified externally', async ({ page, skip }) => {
@@ -55,7 +50,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnElement) => {
el.value = '12';
});
@@ -68,7 +63,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const ionChangeSpy = await page.spyOnEvent('ionChange');
await page.locator('#default').evaluate((el: HTMLIonPickerColumnInternalElement) => {
await page.locator('#default').evaluate((el: HTMLIonPickerColumnElement) => {
el.scrollTo(0, el.scrollHeight);
});
await page.waitForChanges();

View File

@@ -2,7 +2,7 @@
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Picker Column Internal - Basic</title>
<title>Picker Column - Basic</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
@@ -39,45 +39,50 @@
<ion-app>
<ion-header translucent="true">
<ion-toolbar>
<ion-title>Picker Column Internal - Disabled</ion-title>
<ion-title>Picker Column - Disabled</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Even items disabled</h2>
<ion-picker-internal>
<ion-picker-column-internal id="half-disabled"></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column id="half-disabled"></ion-picker-column>
</ion-picker>
</div>
<div class="grid-item">
<h2>Column disabled</h2>
<ion-picker-internal>
<ion-picker-column-internal id="column-disabled" value="11" disabled></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column id="column-disabled" disabled="true"></ion-picker-column>
</ion-picker>
</div>
</div>
</ion-content>
<script>
const halfDisabledPicker = document.getElementById('half-disabled');
const halfDisabledItems = Array(24)
Array(24)
.fill()
.map((_, i) => ({
text: `${i}`,
value: i,
disabled: i % 2 === 0,
}));
halfDisabledPicker.items = halfDisabledItems;
halfDisabledPicker.value = 12;
.forEach((_, i) => {
const option = document.createElement('ion-picker-column-option');
option.value = i;
option.textContent = i;
option.disabled = i % 2 === 0;
halfDisabledPicker.appendChild(option);
});
halfDisabledPicker.value = 4;
const fullDisabledPicker = document.getElementById('column-disabled');
const items = Array(24)
Array(24)
.fill()
.map((_, i) => ({
text: `${i}`,
value: i,
}));
fullDisabledPicker.items = items;
.forEach((_, i) => {
const option = document.createElement('ion-picker-column-option');
option.value = i;
option.textContent = i;
fullDisabledPicker.appendChild(option);
});
fullDisabledPicker.value = 11;
</script>
</ion-app>
</body>

View File

@@ -1,50 +1,20 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-column-internal: disabled rendering'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const column = document.querySelector('ion-picker-column-internal');
column.items = [
{ text: 'A', value: 'a', disabled: true },
{ text: 'B', value: 'b' },
{ text: 'C', value: 'c', disabled: true }
]
</script>
`,
config
);
const picker = page.locator('ion-picker-internal');
await expect(picker).toHaveScreenshot(screenshot(`picker-internal-disabled`));
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal: disabled items'), () => {
test.describe(title('picker-column: disabled items'), () => {
test('all picker items should be enabled by default', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column></ion-picker-column>
</ion-picker>
<script>
const column = document.querySelector('ion-picker-column-internal');
const column = document.querySelector('ion-picker-column');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b' },
@@ -55,19 +25,19 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const pickerItems = page.locator('ion-picker-column-internal .picker-item:not(.picker-item-empty, [disabled])');
const pickerItems = page.locator('ion-picker-column .picker-item:not(.picker-item-empty, [disabled])');
expect(await pickerItems.count()).toBe(3);
});
test('disabled picker item should not be interactive', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column></ion-picker-column>
</ion-picker>
<script>
const column = document.querySelector('ion-picker-column-internal');
const column = document.querySelector('ion-picker-column');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
@@ -78,18 +48,18 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[disabled]');
const disabledItem = page.locator('ion-picker-column .picker-item[disabled]');
await expect(disabledItem).not.toBeEnabled();
});
test('disabled picker item should not be considered active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="b"></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column value="b"></ion-picker-column>
</ion-picker>
<script>
const column = document.querySelector('ion-picker-column-internal');
const column = document.querySelector('ion-picker-column');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
@@ -100,18 +70,18 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
const disabledItem = page.locator('ion-picker-column .picker-item[data-value="b"]');
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('setting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column></ion-picker-column>
</ion-picker>
<script>
const column = document.querySelector('ion-picker-column-internal');
const column = document.querySelector('ion-picker-column');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
@@ -122,24 +92,24 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const pickerColumn = page.locator('ion-picker-column-internal');
await pickerColumn.evaluate((el: HTMLIonPickerColumnInternalElement) => (el.value = 'b'));
const pickerColumn = page.locator('ion-picker-column');
await pickerColumn.evaluate((el: HTMLIonPickerColumnElement) => (el.value = 'b'));
await page.waitForChanges();
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
const disabledItem = page.locator('ion-picker-column .picker-item[data-value="b"]');
await expect(disabledItem).toBeDisabled();
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
test('defaulting the value to a disabled item should not cause that item to be active', async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal></ion-picker-column-internal>
</ion-picker-internal>
<ion-picker>
<ion-picker-column></ion-picker-column>
</ion-picker>
<script>
const column = document.querySelector('ion-picker-column-internal');
const column = document.querySelector('ion-picker-column');
column.items = [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b', disabled: true },
@@ -151,7 +121,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config
);
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
const disabledItem = page.locator('ion-picker-column .picker-item[data-value="b"]');
await expect(disabledItem).toBeDisabled();
await expect(disabledItem).not.toHaveClass(/picker-item-active/);
});
@@ -162,16 +132,16 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-column-internal: disabled column rendering'), () => {
test.describe(title('picker-column: disabled column rendering'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/disabled', config);
await page.goto('/src/components/picker-column/test/disabled', config);
});
test('disabled column should not have visual regressions', async ({ page }) => {
const disabledColumn = page.locator('#column-disabled');
await page.waitForChanges();
await expect(disabledColumn).toHaveScreenshot(screenshot('picker-internal-disabled-column'));
await expect(disabledColumn).toHaveScreenshot(screenshot('picker-disabled-column'));
});
});
});
@@ -180,9 +150,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal: disabled column'), () => {
test.describe(title('picker-column: disabled column'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/disabled', config);
await page.goto('/src/components/picker-column/test/disabled', config);
});
test('item in disabled column should not be interactive', async ({ page }) => {

View File

@@ -1,9 +0,0 @@
export interface PickerInternalChangeEventDetail {
useInputMode: boolean;
inputModeColumn?: HTMLIonPickerColumnInternalElement;
}
export interface PickerInternalCustomEvent extends CustomEvent {
target: HTMLIonPickerInternalElement;
detail: PickerInternalChangeEventDetail;
}

View File

@@ -1,15 +0,0 @@
@import "./picker-internal.scss";
@import "./picker-internal.vars.scss";
@import "../../themes/ionic.globals.ios";
:host .picker-before {
background: linear-gradient(to bottom, rgba(#{$picker-fade-background}, 1) 20%, rgba(#{$picker-fade-background}, 0.8) 100%);
}
:host .picker-after {
background: linear-gradient(to top, rgba(#{$picker-fade-background}, 1) 20%, rgba(#{$picker-fade-background}, 0.8) 100%);
}
:host .picker-highlight {
background: var(--wheel-highlight-background, var(--ion-color-step-150, #eeeeef));
}

View File

@@ -1,11 +0,0 @@
@import "./picker-internal.scss";
@import "./picker-internal.vars.scss";
@import "../../themes/ionic.globals.md";
:host .picker-before {
background: linear-gradient(to bottom, rgba(#{$picker-fade-background}, 1) 20%, rgba(#{$picker-fade-background}, 0) 90%);
}
:host .picker-after {
background: linear-gradient(to top, rgba(#{$picker-fade-background}, 1) 30%, rgba(#{$picker-fade-background}, 0) 90%);
}

View File

@@ -1,94 +0,0 @@
@import "../../themes/ionic.globals";
// Picker Internal
// --------------------------------------------------
:host {
display: flex;
position: relative;
align-items: center;
justify-content: center;
width: 100%;
height: 200px;
/**
* Picker columns should display
* in the order in which developers
* added them and should ignore
* LTR vs RTL directions.
*/
direction: ltr;
/**
* This is required otherwise the
* highlight will appear behind
* the picker when used inline.
*/
z-index: 0;
}
:host .picker-before,
:host .picker-after {
position: absolute;
width: 100%;
/**
* The transform and z-index
* are needed for WebKit otherwise
* the fade will appear underneath the picker.
*/
transform: translateZ(0);
z-index: 1;
pointer-events: none;
}
:host .picker-before {
@include position(0, null, null, 0);
height: 83px;
}
:host .picker-after {
@include position(116px, null, null, 0);
height: 84px;
}
:host .picker-highlight {
@include border-radius(8px, 8px, 8px, 8px);
@include position(50%, 0, 0, 0);
@include margin(0, auto, 0, auto);
position: absolute;
width: calc(100% - 16px);
height: 34px;
transform: translateY(-50%);
background: var(--wheel-highlight-background);
z-index: -1;
}
:host input {
@include visually-hidden();
}
:host ::slotted(ion-picker-column-internal:first-of-type) {
text-align: start;
}
:host ::slotted(ion-picker-column-internal:last-of-type) {
text-align: end;
}
:host ::slotted(ion-picker-column-internal:only-child) {
text-align: center;
}

View File

@@ -1,564 +0,0 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Listen, Method, Host, h } from '@stencil/core';
import { getElementRoot } from '@utils/helpers';
import type { PickerInternalChangeEventDetail } from './picker-internal-interfaces';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
* @internal
*/
@Component({
tag: 'ion-picker-internal',
styleUrls: {
ios: 'picker-internal.ios.scss',
md: 'picker-internal.md.scss',
},
shadow: true,
})
export class PickerInternal implements ComponentInterface {
private inputEl?: HTMLInputElement;
private useInputMode = false;
private inputModeColumn?: HTMLIonPickerColumnInternalElement;
private highlightEl?: HTMLElement;
private actionOnClick?: () => void;
private destroyKeypressListener?: () => void;
private singleColumnSearchTimeout?: ReturnType<typeof setTimeout>;
@Element() el!: HTMLIonPickerInternalElement;
@Event() ionInputModeChange!: EventEmitter<PickerInternalChangeEventDetail>;
/**
* When the picker is interacted with
* we need to prevent touchstart so other
* gestures do not fire. For example,
* scrolling on the wheel picker
* in ion-datetime should not cause
* a card modal to swipe to close.
*/
@Listen('touchstart')
preventTouchStartPropagation(ev: TouchEvent) {
ev.stopPropagation();
}
componentWillLoad() {
getElementRoot(this.el).addEventListener('focusin', this.onFocusIn);
getElementRoot(this.el).addEventListener('focusout', this.onFocusOut);
}
private isInHighlightBounds = (ev: PointerEvent) => {
const { highlightEl } = this;
if (!highlightEl) {
return false;
}
const bbox = highlightEl.getBoundingClientRect();
/**
* Check to see if the user clicked
* outside the bounds of the highlight.
*/
const outsideX = ev.clientX < bbox.left || ev.clientX > bbox.right;
const outsideY = ev.clientY < bbox.top || ev.clientY > bbox.bottom;
if (outsideX || outsideY) {
return false;
}
return true;
};
/**
* If we are no longer focused
* on a picker column, then we should
* exit input mode. An exception is made
* for the input in the picker since having
* that focused means we are still in input mode.
*/
private onFocusOut = (ev: any) => {
// TODO(FW-2832): type
const { relatedTarget } = ev;
if (!relatedTarget || (relatedTarget.tagName !== 'ION-PICKER-COLUMN-INTERNAL' && relatedTarget !== this.inputEl)) {
this.exitInputMode();
}
};
/**
* When picker columns receive focus
* the parent picker needs to determine
* whether to enter/exit input mode.
*/
private onFocusIn = (ev: any) => {
// TODO(FW-2832): type
const { target } = ev;
/**
* Due to browser differences in how/when focus
* is dispatched on certain elements, we need to
* make sure that this function only ever runs when
* focusing a picker column.
*/
if (target.tagName !== 'ION-PICKER-COLUMN-INTERNAL') {
return;
}
/**
* If we have actionOnClick
* then this means the user focused
* a picker column via mouse or
* touch (i.e. a PointerEvent). As a result,
* we should not enter/exit input mode
* until the click event has fired, which happens
* after the `focusin` event.
*
* Otherwise, the user likely focused
* the column using their keyboard and
* we should enter/exit input mode automatically.
*/
if (!this.actionOnClick) {
const columnEl = target as HTMLIonPickerColumnInternalElement;
const allowInput = columnEl.numericInput;
if (allowInput) {
this.enterInputMode(columnEl, false);
} else {
this.exitInputMode();
}
}
};
/**
* On click we need to run an actionOnClick
* function that has been set in onPointerDown
* so that we enter/exit input mode correctly.
*/
private onClick = () => {
const { actionOnClick } = this;
if (actionOnClick) {
actionOnClick();
this.actionOnClick = undefined;
}
};
/**
* Clicking a column also focuses the column on
* certain browsers, so we use onPointerDown
* to tell the onFocusIn function that users
* are trying to click the column rather than
* focus the column using the keyboard. When the
* user completes the click, the onClick function
* runs and runs the actionOnClick callback.
*/
private onPointerDown = (ev: PointerEvent) => {
const { useInputMode, inputModeColumn, el } = this;
if (this.isInHighlightBounds(ev)) {
/**
* If we were already in
* input mode, then we should determine
* if we tapped a particular column and
* should switch to input mode for
* that specific column.
*/
if (useInputMode) {
/**
* If we tapped a picker column
* then we should either switch to input
* mode for that column or all columns.
* Otherwise we should exit input mode
* since we just tapped the highlight and
* not a column.
*/
if ((ev.target as HTMLElement).tagName === 'ION-PICKER-COLUMN-INTERNAL') {
/**
* If user taps 2 different columns
* then we should just switch to input mode
* for the new column rather than switching to
* input mode for all columns.
*/
if (inputModeColumn && inputModeColumn === ev.target) {
this.actionOnClick = () => {
this.enterInputMode();
};
} else {
this.actionOnClick = () => {
this.enterInputMode(ev.target as HTMLIonPickerColumnInternalElement);
};
}
} else {
this.actionOnClick = () => {
this.exitInputMode();
};
}
/**
* If we were not already in
* input mode, then we should
* enter input mode for all columns.
*/
} else {
/**
* If there is only 1 numeric input column
* then we should skip multi column input.
*/
const columns = el.querySelectorAll('ion-picker-column-internal.picker-column-numeric-input');
const columnEl = columns.length === 1 ? (ev.target as HTMLIonPickerColumnInternalElement) : undefined;
this.actionOnClick = () => {
this.enterInputMode(columnEl);
};
}
return;
}
this.actionOnClick = () => {
this.exitInputMode();
};
};
/**
* Enters input mode to allow
* for text entry of numeric values.
* If on mobile, we focus a hidden input
* field so that the on screen keyboard
* is brought up. When tabbing using a
* keyboard, picker columns receive an outline
* to indicate they are focused. As a result,
* we should not focus the hidden input as it
* would cause the outline to go away, preventing
* users from having any visual indication of which
* column is focused.
*/
private enterInputMode = (columnEl?: HTMLIonPickerColumnInternalElement, focusInput = true) => {
const { inputEl, el } = this;
if (!inputEl) {
return;
}
/**
* Only active input mode if there is at
* least one column that accepts numeric input.
*/
const hasInputColumn = el.querySelector('ion-picker-column-internal.picker-column-numeric-input');
if (!hasInputColumn) {
return;
}
/**
* If columnEl is undefined then
* it is assumed that all numeric pickers
* are eligible for text entry.
* (i.e. hour and minute columns)
*/
this.useInputMode = true;
this.inputModeColumn = columnEl;
/**
* Users with a keyboard and mouse can
* activate input mode where the input is
* focused as well as when it is not focused,
* so we need to make sure we clean up any
* old listeners.
*/
if (focusInput) {
if (this.destroyKeypressListener) {
this.destroyKeypressListener();
this.destroyKeypressListener = undefined;
}
inputEl.focus();
} else {
el.addEventListener('keypress', this.onKeyPress);
this.destroyKeypressListener = () => {
el.removeEventListener('keypress', this.onKeyPress);
};
}
this.emitInputModeChange();
};
/**
* @internal
* Exits text entry mode for the picker
* This method blurs the hidden input
* and cause the keyboard to dismiss.
*/
@Method()
async exitInputMode() {
const { inputEl, useInputMode } = this;
if (!useInputMode || !inputEl) {
return;
}
this.useInputMode = false;
this.inputModeColumn = undefined;
inputEl.blur();
inputEl.value = '';
if (this.destroyKeypressListener) {
this.destroyKeypressListener();
this.destroyKeypressListener = undefined;
}
this.emitInputModeChange();
}
private onKeyPress = (ev: KeyboardEvent) => {
const { inputEl } = this;
if (!inputEl) {
return;
}
const parsedValue = parseInt(ev.key, 10);
/**
* Only numbers should be allowed
*/
if (!Number.isNaN(parsedValue)) {
inputEl.value += ev.key;
this.onInputChange();
}
};
private selectSingleColumn = () => {
const { inputEl, inputModeColumn, singleColumnSearchTimeout } = this;
if (!inputEl || !inputModeColumn) {
return;
}
const values = inputModeColumn.items.filter((item) => item.disabled !== true);
/**
* If users pause for a bit, the search
* value should be reset similar to how a
* <select> behaves. So typing "34", waiting,
* then typing "5" should select "05".
*/
if (singleColumnSearchTimeout) {
clearTimeout(singleColumnSearchTimeout);
}
this.singleColumnSearchTimeout = setTimeout(() => {
inputEl.value = '';
this.singleColumnSearchTimeout = undefined;
}, 1000);
/**
* For values that are longer than 2 digits long
* we should shift the value over 1 character
* to the left. So typing "456" would result in "56".
* TODO: If we want to support more than just
* time entry, we should update this value to be
* the max length of all of the picker items.
*/
if (inputEl.value.length >= 3) {
const startIndex = inputEl.value.length - 2;
const newString = inputEl.value.substring(startIndex);
inputEl.value = newString;
this.selectSingleColumn();
return;
}
/**
* Checking the value of the input gets priority
* first. For example, if the value of the input
* is "1" and we entered "2", then the complete value
* is "12" and we should select hour 12.
*
* Regex removes any leading zeros from values like "02",
* but it keeps a single zero if there are only zeros in the string.
* 0+(?=[1-9]) --> Match 1 or more zeros that are followed by 1-9
* 0+(?=0$) --> Match 1 or more zeros that must be followed by one 0 and end.
*/
const findItemFromCompleteValue = values.find(({ text }) => {
const parsedText = text.replace(/^0+(?=[1-9])|0+(?=0$)/, '');
return parsedText === inputEl.value;
});
if (findItemFromCompleteValue) {
inputModeColumn.setValue(findItemFromCompleteValue.value);
return;
}
/**
* If we typed "56" to get minute 56, then typed "7",
* we should select "07" as "567" is not a valid minute.
*/
if (inputEl.value.length === 2) {
const changedCharacter = inputEl.value.substring(inputEl.value.length - 1);
inputEl.value = changedCharacter;
this.selectSingleColumn();
}
};
/**
* Searches a list of column items for a particular
* value. This is currently used for numeric values.
* The zeroBehavior can be set to account for leading
* or trailing zeros when looking at the item text.
*/
private searchColumn = (
colEl: HTMLIonPickerColumnInternalElement,
value: string,
zeroBehavior: 'start' | 'end' = 'start'
) => {
const behavior = zeroBehavior === 'start' ? /^0+/ : /0$/;
const item = colEl.items.find(({ text, disabled }) => disabled !== true && text.replace(behavior, '') === value);
if (item) {
colEl.setValue(item.value);
}
};
private selectMultiColumn = () => {
const { inputEl, el } = this;
if (!inputEl) {
return;
}
const numericPickers = Array.from(el.querySelectorAll('ion-picker-column-internal')).filter(
(col) => col.numericInput
);
const firstColumn = numericPickers[0];
const lastColumn = numericPickers[1];
let value = inputEl.value;
let minuteValue;
switch (value.length) {
case 1:
this.searchColumn(firstColumn, value);
break;
case 2:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacter = inputEl.value.substring(0, 1);
value = firstCharacter === '0' || firstCharacter === '1' ? inputEl.value : firstCharacter;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
if (value.length === 1) {
minuteValue = inputEl.value.substring(inputEl.value.length - 1);
this.searchColumn(lastColumn, minuteValue, 'end');
}
break;
case 3:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacterAgain = inputEl.value.substring(0, 1);
value =
firstCharacterAgain === '0' || firstCharacterAgain === '1'
? inputEl.value.substring(0, 2)
: firstCharacterAgain;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
minuteValue = value.length === 1 ? inputEl.value.substring(1) : inputEl.value.substring(2);
this.searchColumn(lastColumn, minuteValue, 'end');
break;
case 4:
/**
* If the first character is `0` or `1` it is
* possible that users are trying to type `09`
* or `11` into the hour field, so we should look
* at that first.
*/
const firstCharacterAgainAgain = inputEl.value.substring(0, 1);
value =
firstCharacterAgainAgain === '0' || firstCharacterAgainAgain === '1'
? inputEl.value.substring(0, 2)
: firstCharacterAgainAgain;
this.searchColumn(firstColumn, value);
/**
* If only checked the first value,
* we can check the second value
* for a match in the minutes column
*/
const minuteValueAgain =
value.length === 1
? inputEl.value.substring(1, inputEl.value.length)
: inputEl.value.substring(2, inputEl.value.length);
this.searchColumn(lastColumn, minuteValueAgain, 'end');
break;
default:
const startIndex = inputEl.value.length - 4;
const newString = inputEl.value.substring(startIndex);
inputEl.value = newString;
this.selectMultiColumn();
break;
}
};
/**
* Searches the value of the active column
* to determine which value users are trying
* to select
*/
private onInputChange = () => {
const { useInputMode, inputEl, inputModeColumn } = this;
if (!useInputMode || !inputEl) {
return;
}
if (inputModeColumn) {
this.selectSingleColumn();
} else {
this.selectMultiColumn();
}
};
/**
* Emit ionInputModeChange. Picker columns
* listen for this event to determine whether
* or not their column is "active" for text input.
*/
private emitInputModeChange = () => {
const { useInputMode, inputModeColumn } = this;
this.ionInputModeChange.emit({
useInputMode,
inputModeColumn,
});
};
render() {
return (
<Host onPointerDown={(ev: PointerEvent) => this.onPointerDown(ev)} onClick={() => this.onClick()}>
<input
aria-hidden="true"
tabindex={-1}
inputmode="numeric"
type="number"
ref={(el) => (this.inputEl = el)}
onInput={() => this.onInputChange()}
onBlur={() => this.exitInputMode()}
/>
<div class="picker-before"></div>
<div class="picker-after"></div>
<div class="picker-highlight" ref={(el) => (this.highlightEl = el)}></div>
<slot></slot>
</Host>
);
}
}

View File

@@ -1,2 +0,0 @@
$picker-fade-background-fallback: var(--background-rgb, var(--ion-background-color-rgb, 255, 255, 255));
$picker-fade-background: var(--wheel-fade-background-rgb, $picker-fade-background-fallback);

View File

@@ -1,239 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Picker - Basic</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.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;
margin-left: 5px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
ion-popover {
--width: 300px;
}
ion-modal {
--height: 250px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Picker - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>Inline</h2>
<ion-picker-internal id="inline">
<ion-picker-column-internal id="first"></ion-picker-column-internal>
<ion-picker-column-internal id="second"></ion-picker-column-internal>
</ion-picker-internal>
</div>
<div class="grid-item">
<h2>One Numeric Input</h2>
<ion-picker-internal>
<ion-picker-column-internal numeric-input="true" id="numeric-first"></ion-picker-column-internal>
<ion-picker-column-internal id="numeric-second"></ion-picker-column-internal>
</ion-picker-internal>
</div>
<div class="grid-item">
<h2>Two Numeric Input</h2>
<ion-picker-internal>
<ion-picker-column-internal numeric-input="true" id="dual-numeric-first"></ion-picker-column-internal>
<ion-picker-column-internal numeric-input="true" id="dual-numeric-second"></ion-picker-column-internal>
</ion-picker-internal>
</div>
<div class="grid-item">
<h2>Popover</h2>
<ion-button id="popover">Open Picker</ion-button>
<ion-popover trigger="popover">
<ion-picker-internal>
<ion-picker-column-internal id="popover-first"></ion-picker-column-internal>
<ion-picker-column-internal id="popover-second"></ion-picker-column-internal>
</ion-picker-internal>
</ion-popover>
</div>
<div class="grid-item">
<h2>Modal</h2>
<ion-button id="modal">Open Modal</ion-button>
<ion-modal trigger="modal">
<ion-header>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button>Done</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content force-overscroll="false">
<ion-picker-internal>
<ion-picker-column-internal id="modal-first"></ion-picker-column-internal>
<ion-picker-column-internal id="modal-second"></ion-picker-column-internal>
</ion-picker-internal>
</ion-content>
</ion-modal>
</div>
</div>
</ion-content>
</ion-app>
<script>
const column = document.querySelector('ion-picker-column-internal#numeric-first');
column.addEventListener('ionChange', (ev) => {
console.log('Column change', ev.detail);
});
const setPickerColumn = (selector, items, value) => {
const picker = document.querySelector(selector);
picker.items = items;
picker.value = value;
};
const modal = document.querySelector('ion-modal');
modal.breakpoints = [0, 1];
modal.initialBreakpoint = 1;
setPickerColumn(
'#first',
[
{ text: 'Minified', value: 'minified' },
{ text: 'Responsive', value: 'responsive' },
{ text: 'Full Stack', value: 'full-stack' },
{ text: 'Mobile First', value: 'mobile-first' },
{ text: 'Serverless', value: 'serverless' },
],
'full-stack'
);
setPickerColumn(
'#second',
[
{ text: 'Tomato', value: 'tomato' },
{ text: 'Avocado', value: 'avocado' },
{ text: 'Onion', value: 'onion' },
{ text: 'Potato', value: 'potato' },
{ text: 'Artichoke', value: 'artichoke' },
],
'onion'
);
let minutes = [];
for (let i = 1; i <= 60; i++) {
const text = i < 10 ? `0${i}` : `${i}`;
minutes.push({
text,
value: i,
});
}
setPickerColumn('#numeric-first', minutes, 3);
setPickerColumn(
'#numeric-second',
[
{ text: 'Tomatoes', value: 'tomato' },
{ text: 'Avocados', value: 'avocado' },
{ text: 'Onions', value: 'onion' },
{ text: 'Potatoes', value: 'potato' },
{ text: 'Artichokes', value: 'artichoke' },
],
'onion'
);
setPickerColumn(
'#dual-numeric-first',
[
{ text: '12', value: 12 },
{ text: '01', value: 1 },
{ text: '02', value: 2 },
{ text: '03', value: 3 },
{ text: '04', value: 4 },
{ text: '05', value: 5 },
{ text: '06', value: 6 },
{ text: '07', value: 7 },
{ text: '08', value: 8 },
{ text: '09', value: 9 },
{ text: '10', value: 10 },
{ text: '11', value: 11 },
],
3
);
setPickerColumn('#dual-numeric-second', minutes, 3);
setPickerColumn(
'#popover-first',
[
{ text: 'Minified', value: 'minified' },
{ text: 'Responsive', value: 'responsive' },
{ text: 'Full Stack', value: 'full-stack' },
{ text: 'Mobile First', value: 'mobile-first' },
{ text: 'Serverless', value: 'serverless' },
],
'full-stack'
);
setPickerColumn(
'#popover-second',
[
{ text: 'Tomato', value: 'tomato' },
{ text: 'Avocado', value: 'avocado' },
{ text: 'Onion', value: 'onion' },
{ text: 'Potato', value: 'potato' },
{ text: 'Artichoke', value: 'artichoke' },
],
'onion'
);
setPickerColumn(
'#modal-first',
[
{ text: 'Minified', value: 'minified' },
{ text: 'Responsive', value: 'responsive' },
{ text: 'Full Stack', value: 'full-stack' },
{ text: 'Mobile First', value: 'mobile-first' },
{ text: 'Serverless', value: 'serverless' },
],
'full-stack'
);
setPickerColumn(
'#modal-second',
[
{ text: 'Tomato', value: 'tomato' },
{ text: 'Avocado', value: 'avocado' },
{ text: 'Onion', value: 'onion' },
{ text: 'Potato', value: 'potato' },
{ text: 'Artichoke', value: 'artichoke' },
],
'onion'
);
</script>
</body>
</html>

View File

@@ -1,144 +0,0 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-internal: rendering'), () => {
test('inline pickers should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`, config);
const fullStack = page.locator('#inline button[data-value="full-stack"]');
const onion = page.locator('#inline button[data-value="onion"]');
await expect(fullStack).toHaveClass(/picker-item-active/);
await expect(onion).toHaveClass(/picker-item-active/);
await page.waitForChanges();
await expect(page.locator('#inline')).toHaveScreenshot(screenshot(`picker-internal-inline-diff`));
});
});
test.describe(title('picker-internal: overlay rendering'), () => {
test('popover: should not have visual regression', async ({ page }) => {
await page.goto(`/src/components/picker-internal/test/basic`, config);
const button = page.locator('#popover');
const didPresent = await page.spyOnEvent('ionPopoverDidPresent');
const pickerInternal = page.locator('ion-popover ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const popoverContent = page.locator('ion-popover .ion-delegate-host');
await expect(popoverContent).toHaveScreenshot(screenshot(`picker-internal-popover-diff`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the popover to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
test('modal: should not have visual regression', async ({ page }) => {
await page.goto('/src/components/picker-internal/test/basic', config);
const button = page.locator('#modal');
const didPresent = await page.spyOnEvent('ionModalDidPresent');
const pickerInternal = page.locator('ion-modal ion-picker-internal');
await button.click();
await didPresent.next();
await expect(pickerInternal).toBeVisible();
const modalContent = page.locator('ion-modal .ion-delegate-host');
await expect(modalContent).toHaveScreenshot(screenshot(`picker-internal-modal-diff`), {
/**
* Animations must be enabled to capture the screenshot.
* By default, animations are disabled with toHaveScreenshot,
* and when capturing the screenshot will call animation.finish().
* This will cause the modal to close and the screenshot capture
* to be invalid.
*/
animations: 'allow',
});
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-internal: focus'), () => {
test.beforeEach(async ({ page }) => {
await page.setContent(
`
<ion-picker-internal>
<ion-picker-column-internal value="full-stack" id="first"></ion-picker-column-internal>
<ion-picker-column-internal value="onion" id="second"></ion-picker-column-internal>
</ion-picker-internal>
<script>
const columns = document.querySelectorAll('ion-picker-column-internal');
columns[0].items = [
{ text: 'Minified', value: 'minified' },
{ text: 'Responsive', value: 'responsive' },
{ text: 'Full Stack', value: 'full-stack' },
{ text: 'Mobile First', value: 'mobile-first' },
{ text: 'Serverless', value: 'serverless' },
]
columns[1].items = [
{ text: 'Tomato', value: 'tomato' },
{ text: 'Avocado', value: 'avocado' },
{ text: 'Onion', value: 'onion' },
{ text: 'Potato', value: 'potato' },
{ text: 'Artichoke', value: 'artichoke' },
];
</script>
`,
config
);
});
test('tabbing should correctly move focus between columns', async ({ page }) => {
const firstColumn = page.locator('ion-picker-column-internal#first');
const secondColumn = page.locator('ion-picker-column-internal#second');
// Focus first column
await page.keyboard.press('Tab');
await expect(firstColumn).toBeFocused();
await page.waitForChanges();
// Focus second column
await page.keyboard.press('Tab');
await expect(secondColumn).toBeFocused();
});
test('tabbing should correctly move focus back', async ({ page }) => {
const firstColumn = page.locator('ion-picker-column-internal#first');
const secondColumn = page.locator('ion-picker-column-internal#second');
await secondColumn.focus();
await expect(secondColumn).toBeFocused();
await page.waitForChanges();
// Focus first column
await page.keyboard.press('Shift+Tab');
await expect(firstColumn).toBeFocused();
});
});
});

View File

@@ -0,0 +1,47 @@
@import "./picker-column";
@import "../picker-legacy/picker.ios.vars";
// iOS Picker Column
// --------------------------------------------------
.picker-col {
@include padding($picker-ios-column-padding-top, $picker-ios-column-padding-end, $picker-ios-column-padding-bottom, $picker-ios-column-padding-start);
transform-style: preserve-3d;
}
.picker-prefix,
.picker-suffix,
.picker-opts {
top: $picker-ios-option-offset-y;
transform-style: preserve-3d;
color: inherit;
font-size: $picker-ios-option-font-size;
line-height: $picker-ios-option-height;
pointer-events: none;
}
.picker-opt {
@include padding($picker-ios-option-padding-top, $picker-ios-option-padding-end, $picker-ios-option-padding-bottom, $picker-ios-option-padding-start);
@include margin(0);
@include transform-origin(center, center);
height: 46px;
transform-style: preserve-3d;
transition-timing-function: ease-out;
background: transparent;
color: inherit;
font-size: $picker-ios-option-font-size;
line-height: $picker-ios-option-height;
backface-visibility: hidden;
pointer-events: auto;
}

View File

@@ -0,0 +1,51 @@
@import "./picker-column";
@import "../picker-legacy/picker.md.vars";
// Material Design Picker Column
// --------------------------------------------------
.picker-col {
@include padding($picker-md-column-padding-top, $picker-md-column-padding-end, $picker-md-column-padding-bottom, $picker-md-column-padding-start);
transform-style: preserve-3d;
}
.picker-prefix,
.picker-suffix,
.picker-opts {
top: $picker-md-option-offset-y;
transform-style: preserve-3d;
color: inherit;
font-size: $picker-md-option-font-size;
line-height: $picker-md-option-height;
pointer-events: none;
}
.picker-opt {
@include margin(0);
@include padding($picker-md-option-padding-top, $picker-md-option-padding-end, $picker-md-option-padding-bottom, $picker-md-option-padding-start);
height: 43px;
transition-timing-function: ease-out;
background: transparent;
color: inherit;
font-size: $picker-md-option-font-size;
line-height: $picker-md-option-height;
backface-visibility: hidden;
pointer-events: auto;
}
.picker-prefix,
.picker-suffix,
.picker-opt.picker-opt-selected {
color: $picker-md-option-selected-color;
}

View File

@@ -0,0 +1,89 @@
@import "../../themes/ionic.globals";
// Picker Column
// --------------------------------------------------
.picker-col {
display: flex;
position: relative;
flex: 1;
justify-content: center;
height: 100%;
box-sizing: content-box;
contain: content;
}
.picker-opts {
position: relative;
flex: 1;
max-width: 100%;
}
// contain property is supported by Chrome
.picker-opt {
@include position(0, null, null, 0);
display: block;
position: absolute;
width: 100%;
border: 0;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
contain: strict;
overflow: hidden;
will-change: transform;
}
.picker-opt.picker-opt-disabled {
pointer-events: none;
}
.picker-opt-disabled {
opacity: 0;
}
.picker-opts-left {
justify-content: flex-start;
}
.picker-opts-right {
justify-content: flex-end;
}
.picker-opt {
&:active,
&:focus {
outline: none;
}
}
.picker-prefix {
position: relative;
flex: 1;
text-align: end;
white-space: nowrap;
}
.picker-suffix {
position: relative;
flex: 1;
text-align: start;
white-space: nowrap;
}

View File

@@ -0,0 +1,440 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core';
import { clamp } from '@utils/helpers';
import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from '@utils/native/haptic';
import { getClassMap } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { Gesture, GestureDetail } from '../../interface';
import type { PickerColumn } from '../picker-legacy/picker-interface';
/**
* @internal
*/
@Component({
tag: 'ion-picker-legacy-column',
styleUrls: {
ios: 'picker-column.ios.scss',
md: 'picker-column.md.scss',
},
})
export class PickerColumnCmp implements ComponentInterface {
private bounceFrom!: number;
private lastIndex?: number;
private minY!: number;
private maxY!: number;
private optHeight = 0;
private rotateFactor = 0;
private scaleFactor = 1;
private velocity = 0;
private y = 0;
private optsEl?: HTMLElement;
private gesture?: Gesture;
private rafId?: ReturnType<typeof requestAnimationFrame>;
private tmrId?: ReturnType<typeof setTimeout>;
private noAnimate = true;
// `colDidChange` is a flag that gets set when the column is changed
// dynamically. When this flag is set, the column will refresh
// after the component re-renders to incorporate the new column data.
// This is necessary because `this.refresh` queries for the option elements,
// so it needs to wait for the latest elements to be available in the DOM.
// Ex: column is created with 3 options. User updates the column data
// to have 5 options. The column will still think it only has 3 options.
private colDidChange = false;
@Element() el!: HTMLElement;
/**
* Emitted when the selected value has changed
* @internal
*/
@Event() ionPickerColChange!: EventEmitter<PickerColumn>;
/** Picker column data */
@Prop() col!: PickerColumn;
@Watch('col')
protected colChanged() {
this.colDidChange = true;
}
async connectedCallback() {
let pickerRotateFactor = 0;
let pickerScaleFactor = 0.81;
const mode = getIonMode(this);
if (mode === 'ios') {
pickerRotateFactor = -0.46;
pickerScaleFactor = 1;
}
this.rotateFactor = pickerRotateFactor;
this.scaleFactor = pickerScaleFactor;
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.el,
gestureName: 'picker-swipe',
gesturePriority: 100,
threshold: 0,
passive: false,
onStart: (ev) => this.onStart(ev),
onMove: (ev) => this.onMove(ev),
onEnd: (ev) => this.onEnd(ev),
});
this.gesture.enable();
// Options have not been initialized yet
// Animation must be disabled through the `noAnimate` flag
// Otherwise, the options will render
// at the top of the column and transition down
this.tmrId = setTimeout(() => {
this.noAnimate = false;
// After initialization, `refresh()` will be called
// At this point, animation will be enabled. The options will
// animate as they are being selected.
this.refresh(true);
}, 250);
}
componentDidLoad() {
this.onDomChange();
}
componentDidUpdate() {
// Options may have changed since last update.
if (this.colDidChange) {
// Animation must be disabled through the `onDomChange` parameter.
// Otherwise, the recently added options will render
// at the top of the column and transition down
this.onDomChange(true, false);
this.colDidChange = false;
}
}
disconnectedCallback() {
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
if (this.tmrId) clearTimeout(this.tmrId);
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
}
private emitColChange() {
this.ionPickerColChange.emit(this.col);
}
private setSelected(selectedIndex: number, duration: number) {
// if there is a selected index, then figure out it's y position
// if there isn't a selected index, then just use the top y position
const y = selectedIndex > -1 ? -(selectedIndex * this.optHeight) : 0;
this.velocity = 0;
// set what y position we're at
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
this.update(y, duration, true);
this.emitColChange();
}
private update(y: number, duration: number, saveY: boolean) {
if (!this.optsEl) {
return;
}
// ensure we've got a good round number :)
let translateY = 0;
let translateZ = 0;
const { col, rotateFactor } = this;
const prevSelected = col.selectedIndex;
const selectedIndex = (col.selectedIndex = this.indexForY(-y));
const durationStr = duration === 0 ? '' : duration + 'ms';
const scaleStr = `scale(${this.scaleFactor})`;
const children = this.optsEl.children;
for (let i = 0; i < children.length; i++) {
const button = children[i] as HTMLElement;
const opt = col.options[i];
const optOffset = i * this.optHeight + y;
let transform = '';
if (rotateFactor !== 0) {
const rotateX = optOffset * rotateFactor;
if (Math.abs(rotateX) <= 90) {
translateY = 0;
translateZ = 90;
transform = `rotateX(${rotateX}deg) `;
} else {
translateY = -9999;
}
} else {
translateZ = 0;
translateY = optOffset;
}
const selected = selectedIndex === i;
transform += `translate3d(0px,${translateY}px,${translateZ}px) `;
if (this.scaleFactor !== 1 && !selected) {
transform += scaleStr;
}
// Update transition duration
if (this.noAnimate) {
opt.duration = 0;
button.style.transitionDuration = '';
} else if (duration !== opt.duration) {
opt.duration = duration;
button.style.transitionDuration = durationStr;
}
// Update transform
if (transform !== opt.transform) {
opt.transform = transform;
}
button.style.transform = transform;
/**
* Ensure that the select column
* item has the selected class
*/
opt.selected = selected;
if (selected) {
button.classList.add(PICKER_OPT_SELECTED);
} else {
button.classList.remove(PICKER_OPT_SELECTED);
}
}
this.col.prevSelected = prevSelected;
if (saveY) {
this.y = y;
}
if (this.lastIndex !== selectedIndex) {
// have not set a last index yet
hapticSelectionChanged();
this.lastIndex = selectedIndex;
}
}
private decelerate() {
if (this.velocity !== 0) {
// still decelerating
this.velocity *= DECELERATION_FRICTION;
// do not let it go slower than a velocity of 1
this.velocity = this.velocity > 0 ? Math.max(this.velocity, 1) : Math.min(this.velocity, -1);
let y = this.y + this.velocity;
if (y > this.minY) {
// whoops, it's trying to scroll up farther than the options we have!
y = this.minY;
this.velocity = 0;
} else if (y < this.maxY) {
// gahh, it's trying to scroll down farther than we can!
y = this.maxY;
this.velocity = 0;
}
this.update(y, 0, true);
const notLockedIn = Math.round(y) % this.optHeight !== 0 || Math.abs(this.velocity) > 1;
if (notLockedIn) {
// isn't locked in yet, keep decelerating until it is
this.rafId = requestAnimationFrame(() => this.decelerate());
} else {
this.velocity = 0;
this.emitColChange();
hapticSelectionEnd();
}
} else if (this.y % this.optHeight !== 0) {
// needs to still get locked into a position so options line up
const currentPos = Math.abs(this.y % this.optHeight);
// create a velocity in the direction it needs to scroll
this.velocity = currentPos > this.optHeight / 2 ? 1 : -1;
this.decelerate();
}
}
private indexForY(y: number) {
return Math.min(Math.max(Math.abs(Math.round(y / this.optHeight)), 0), this.col.options.length - 1);
}
private onStart(detail: GestureDetail) {
// We have to prevent default in order to block scrolling under the picker
// but we DO NOT have to stop propagation, since we still want
// some "click" events to capture
if (detail.event.cancelable) {
detail.event.preventDefault();
}
detail.event.stopPropagation();
hapticSelectionStart();
// reset everything
if (this.rafId !== undefined) cancelAnimationFrame(this.rafId);
const options = this.col.options;
let minY = options.length - 1;
let maxY = 0;
for (let i = 0; i < options.length; i++) {
if (!options[i].disabled) {
minY = Math.min(minY, i);
maxY = Math.max(maxY, i);
}
}
this.minY = -(minY * this.optHeight);
this.maxY = -(maxY * this.optHeight);
}
private onMove(detail: GestureDetail) {
if (detail.event.cancelable) {
detail.event.preventDefault();
}
detail.event.stopPropagation();
// update the scroll position relative to pointer start position
let y = this.y + detail.deltaY;
if (y > this.minY) {
// scrolling up higher than scroll area
y = Math.pow(y, 0.8);
this.bounceFrom = y;
} else if (y < this.maxY) {
// scrolling down below scroll area
y += Math.pow(this.maxY - y, 0.9);
this.bounceFrom = y;
} else {
this.bounceFrom = 0;
}
this.update(y, 0, false);
}
private onEnd(detail: GestureDetail) {
if (this.bounceFrom > 0) {
// bounce back up
this.update(this.minY, 100, true);
this.emitColChange();
return;
} else if (this.bounceFrom < 0) {
// bounce back down
this.update(this.maxY, 100, true);
this.emitColChange();
return;
}
this.velocity = clamp(-MAX_PICKER_SPEED, detail.velocityY * 23, MAX_PICKER_SPEED);
if (this.velocity === 0 && detail.deltaY === 0) {
const opt = (detail.event.target as Element).closest('.picker-opt');
if (opt?.hasAttribute('opt-index')) {
this.setSelected(parseInt(opt.getAttribute('opt-index')!, 10), TRANSITION_DURATION);
}
} else {
this.y += detail.deltaY;
if (Math.abs(detail.velocityY) < 0.05) {
const isScrollingUp = detail.deltaY > 0;
const optHeightFraction = (Math.abs(this.y) % this.optHeight) / this.optHeight;
if (isScrollingUp && optHeightFraction > 0.5) {
this.velocity = Math.abs(this.velocity) * -1;
} else if (!isScrollingUp && optHeightFraction <= 0.5) {
this.velocity = Math.abs(this.velocity);
}
}
this.decelerate();
}
}
private refresh(forceRefresh?: boolean, animated?: boolean) {
let min = this.col.options.length - 1;
let max = 0;
const options = this.col.options;
for (let i = 0; i < options.length; i++) {
if (!options[i].disabled) {
min = Math.min(min, i);
max = Math.max(max, i);
}
}
/**
* Only update selected value if column has a
* velocity of 0. If it does not, then the
* column is animating might land on
* a value different than the value at
* selectedIndex
*/
if (this.velocity !== 0) {
return;
}
const selectedIndex = clamp(min, this.col.selectedIndex ?? 0, max);
if (this.col.prevSelected !== selectedIndex || forceRefresh) {
const y = selectedIndex * this.optHeight * -1;
const duration = animated ? TRANSITION_DURATION : 0;
this.velocity = 0;
this.update(y, duration, true);
}
}
private onDomChange(forceRefresh?: boolean, animated?: boolean) {
const colEl = this.optsEl;
if (colEl) {
// DOM READ
// We perfom a DOM read over a rendered item, this needs to happen after the first render or after the the column has changed
this.optHeight = colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0;
}
this.refresh(forceRefresh, animated);
}
render() {
const col = this.col;
const mode = getIonMode(this);
return (
<Host
class={{
[mode]: true,
'picker-col': true,
'picker-opts-left': this.col.align === 'left',
'picker-opts-right': this.col.align === 'right',
...getClassMap(col.cssClass),
}}
style={{
'max-width': this.col.columnWidth,
}}
>
{col.prefix && (
<div class="picker-prefix" style={{ width: col.prefixWidth! }}>
{col.prefix}
</div>
)}
<div class="picker-opts" style={{ maxWidth: col.optionsWidth! }} ref={(el) => (this.optsEl = el)}>
{col.options.map((o, index) => (
<button
aria-label={o.ariaLabel}
class={{ 'picker-opt': true, 'picker-opt-disabled': !!o.disabled }}
opt-index={index}
>
{o.text}
</button>
))}
</div>
{col.suffix && (
<div class="picker-suffix" style={{ width: col.suffixWidth! }}>
{col.suffix}
</div>
)}
</Host>
);
}
}
const PICKER_OPT_SELECTED = 'picker-opt-selected';
const DECELERATION_FRICTION = 0.97;
const MAX_PICKER_SPEED = 90;
const TRANSITION_DURATION = 150;

View File

@@ -24,11 +24,11 @@ describe('picker-column', () => {
const page = await newSpecPage({
components: [PickerColumnCmp],
template: () => <ion-picker-column col={col}></ion-picker-column>,
template: () => <ion-picker-legacy-column col={col}></ion-picker-legacy-column>,
});
const firstOption = page.body.querySelector('ion-picker-column .picker-opt:nth-child(1)')!;
const secondOption = page.body.querySelector('ion-picker-column .picker-opt:nth-child(2)')!;
const firstOption = page.body.querySelector('ion-picker-legacy-column .picker-opt:nth-child(1)')!;
const secondOption = page.body.querySelector('ion-picker-legacy-column .picker-opt:nth-child(2)')!;
expect(firstOption.getAttribute('aria-label')).toBe('C Sharp');
expect(secondOption.getAttribute('aria-label')).toBe(null);

View File

@@ -15,10 +15,12 @@ describe('picker-column: dynamic options', () => {
const page = await newSpecPage({
components: [PickerColumnCmp],
template: () => <ion-picker-column col={{ options: defaultOptions, name: 'animals' }}></ion-picker-column>,
template: () => (
<ion-picker-legacy-column col={{ options: defaultOptions, name: 'animals' }}></ion-picker-legacy-column>
),
});
const pickerCol = page.body.querySelector('ion-picker-column')!;
const pickerCol = page.body.querySelector('ion-picker-legacy-column')!;
pickerCol.col = {
options: [...defaultOptions, { text: 'Carrot', value: 'carrot' }],

View File

@@ -9,10 +9,10 @@ describe('picker-column', () => {
const page = await newSpecPage({
components: [PickerColumnCmp],
template: () => <ion-picker-column col={col}></ion-picker-column>,
template: () => <ion-picker-legacy-column col={col}></ion-picker-legacy-column>,
});
const pickerCol = page.body.querySelector('ion-picker-column')!;
const pickerCol = page.body.querySelector('ion-picker-legacy-column')!;
expect(pickerCol.classList.contains('test-class')).toBe(true);
});
});

View File

@@ -4,13 +4,13 @@ import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/picker-column/test/standalone`, config);
await page.goto(`/src/components/picker-legacy-column/test/standalone`, config);
});
test('should present picker without ion-app', async ({ page }) => {
const ionPickerDidPresent = await page.spyOnEvent('ionPickerDidPresent');
const picker = page.locator('ion-picker');
const picker = page.locator('ion-picker-legacy');
await page.click('#single-column-button');
@@ -28,7 +28,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const ionPickerDidPresent = await page.spyOnEvent('ionPickerDidPresent');
const ionPickerColChangeEvent = await page.spyOnEvent('ionPickerColChange');
const column = page.locator('ion-picker-column');
const column = page.locator('ion-picker-legacy-column');
const secondOption = column.locator('.picker-opt').nth(1);
await page.click('#single-column-button');

View File

@@ -0,0 +1,97 @@
@import "./picker";
@import "./picker.ios.vars";
// iOS Picker
// --------------------------------------------------
:host {
--background: #{$picker-ios-background-color};
--border-width: #{1px 0 0};
--border-color: #{$picker-ios-border-color};
--height: #{$picker-ios-height};
--backdrop-opacity: var(--ion-backdrop-opacity, 0.26);
color: $picker-ios-option-text-color;
}
.picker-toolbar {
display: flex;
height: $picker-ios-toolbar-height;
border-bottom: $hairlines-width solid var(--border-color);
}
.picker-toolbar-button {
flex: 1;
text-align: end;
}
.picker-toolbar-button:last-child .picker-button {
font-weight: $picker-ios-button-strong-font-weight;
}
.picker-toolbar-button:first-child {
font-weight: normal;
text-align: start;
}
.picker-button,
.picker-button.ion-activated {
@include margin(0);
@include padding($picker-ios-button-padding-top, $picker-ios-button-padding-end, $picker-ios-button-padding-bottom, $picker-ios-button-padding-start);
height: $picker-ios-button-height;
background: transparent;
color: $picker-ios-button-text-color;
font-size: $picker-ios-button-font-size;
}
.picker-columns {
height: $picker-ios-height - $picker-ios-toolbar-height - 1;
perspective: $picker-ios-column-perspective;
}
.picker-above-highlight {
@include position(0, null, null, 0);
@include transform(translate3d(0, 0, 90px));
display: block;
position: absolute;
width: 100%;
height: $picker-ios-option-offset-y + 4px;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(
to bottom,
$picker-ios-top-background-color 20%,
$picker-ios-bottom-background-color 100%);
z-index: 10;
}
.picker-below-highlight {
@include position($picker-ios-option-offset-y + $picker-ios-option-height - 4, null, null, 0);
@include transform(translate3d(0, 0, 90px));
display: block;
position: absolute;
width: 100%;
height: $picker-ios-option-offset-y + $picker-ios-option-height;
border-top: 1px solid var(--border-color);
background: linear-gradient(
to top,
$picker-ios-top-background-color 30%,
$picker-ios-bottom-background-color 100%);
z-index: 11;
}

View File

@@ -0,0 +1,84 @@
@import "./picker";
@import "./picker.md.vars";
// Material Design Picker
// --------------------------------------------------
:host {
--background: #{$picker-md-background-color};
--border-width: #{$hairlines-width 0 0};
--border-color: #{$picker-md-border-color};
--height: #{$picker-md-height};
--backdrop-opacity: var(--ion-backdrop-opacity, 0.26);
color: $picker-md-option-text-color;
}
.picker-toolbar {
display: flex;
justify-content: flex-end;
height: $picker-md-toolbar-height;
}
.picker-button,
.picker-button.ion-activated {
@include margin(0);
@include padding(0, 1.1em);
height: $picker-md-button-height;
background: $picker-md-button-background-color;
color: $picker-md-button-text-color;
font-size: $picker-md-button-font-size;
font-weight: 500;
text-transform: uppercase;
box-shadow: none;
}
.picker-columns {
height: $picker-md-height - $picker-md-toolbar-height;
perspective: 1800px;
}
.picker-above-highlight {
@include position(0, null, null, 0);
@include transform(translate3d(0, 0, 90px));
position: absolute;
width: 100%;
height: $picker-md-option-offset-y + 4px;
border-bottom: 1px solid $picker-md-border-color;
background: linear-gradient(
to bottom,
$picker-md-top-background-color 20%,
$picker-md-bottom-background-color 100%);
z-index: 10;
}
.picker-below-highlight {
@include position($picker-md-option-offset-y + $picker-md-option-height - 4, null, null, 0);
@include transform(translate3d(0, 0, 90px));
position: absolute;
width: 100%;
height: $picker-md-option-offset-y + $picker-md-option-height;
border-top: 1px solid $picker-md-border-color;
background: linear-gradient(
to top,
$picker-md-top-background-color 30%,
$picker-md-bottom-background-color 100%);
z-index: 11;
}

View File

@@ -0,0 +1,130 @@
@import "./picker.vars";
// Picker
// --------------------------------------------------
:host {
/**
* @prop --background: Background of the picker
* @prop --background-rgb: Background of the picker in rgb format
*
* @prop --border-radius: Border radius of the picker
* @prop --border-color: Border color of the picker
* @prop --border-width: Border width of the picker
* @prop --border-style: Border style of the picker
*
* @prop --min-width: Minimum width of the picker
* @prop --width: Width of the picker
* @prop --max-width: Maximum width of the picker
*
* @prop --min-height: Minimum height of the picker
* @prop --height: Height of the picker
* @prop --max-height: Maximum height of the picker
*
* @prop --backdrop-opacity: Opacity of the backdrop
*/
--border-radius: 0;
--border-style: solid;
--min-width: auto;
--width: #{$picker-width};
--max-width: #{$picker-max-width};
--min-height: auto;
--max-height: auto;
@include font-smoothing();
@include position(0, null, null, 0);
display: block;
position: absolute;
width: 100%;
height: 100%;
outline: none;
font-family: $font-family-base;
contain: strict;
user-select: none;
z-index: $z-index-overlay;
}
:host(.overlay-hidden) {
display: none;
}
.picker-wrapper {
@include border-radius(var(--border-radius));
@include position(null, 0, 0, 0);
@include margin(auto);
@include transform(translate3d(0, 100%, 0));
display: flex;
position: absolute;
flex-direction: column;
width: var(--width);
min-width: var(--min-width);
max-width: var(--max-width);
height: var(--height);
min-height: var(--min-height);
max-height: var(--max-height);
border-width: var(--border-width);
border-style: var(--border-style);
border-color: var(--border-color);
background: var(--background);
contain: strict;
overflow: hidden;
z-index: $z-index-overlay-wrapper;
}
// Picker Toolbar and Buttons
// --------------------------------------------------
.picker-toolbar {
width: 100%;
background: transparent;
contain: strict;
z-index: 1;
}
.picker-button {
border: 0;
font-family: inherit;
}
.picker-button {
&:active,
&:focus {
outline: none;
}
}
// Picker Columns and Highlight
// --------------------------------------------------
.picker-columns {
display: flex;
position: relative;
justify-content: center;
margin-bottom: var(--ion-safe-area-bottom, 0);
contain: strict;
overflow: hidden;
}
.picker-above-highlight,
.picker-below-highlight {
display: none;
pointer-events: none;
}

View File

@@ -0,0 +1,404 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { raf } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging';
import {
createDelegateController,
createTriggerController,
BACKDROP,
dismiss,
eventMethod,
isCancel,
prepareOverlay,
present,
safeCall,
setOverlayId,
} from '@utils/overlays';
import { getClassMap } from '@utils/theme';
import { getIonMode } from '../../global/ionic-global';
import type { AnimationBuilder, CssClassMap, OverlayInterface, FrameworkDelegate } from '../../interface';
import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave';
import type { PickerButton, PickerColumn } from './picker-interface';
// TODO(FW-2832): types
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*/
@Component({
tag: 'ion-picker-legacy',
styleUrls: {
ios: 'picker.ios.scss',
md: 'picker.md.scss',
},
scoped: true,
})
export class Picker implements ComponentInterface, OverlayInterface {
private readonly delegateController = createDelegateController(this);
private readonly lockController = createLockController();
private readonly triggerController = createTriggerController();
private durationTimeout?: ReturnType<typeof setTimeout>;
lastFocus?: HTMLElement;
@Element() el!: HTMLIonPickerLegacyElement;
@State() presented = false;
/** @internal */
@Prop() overlayIndex!: number;
/** @internal */
@Prop() delegate?: FrameworkDelegate;
/** @internal */
@Prop() hasController = false;
/**
* If `true`, the keyboard will be automatically dismissed when the overlay is presented.
*/
@Prop() keyboardClose = true;
/**
* Animation to use when the picker is presented.
*/
@Prop() enterAnimation?: AnimationBuilder;
/**
* Animation to use when the picker is dismissed.
*/
@Prop() leaveAnimation?: AnimationBuilder;
/**
* Array of buttons to be displayed at the top of the picker.
*/
@Prop() buttons: PickerButton[] = [];
/**
* Array of columns to be displayed in the picker.
*/
@Prop() columns: PickerColumn[] = [];
/**
* Additional classes to apply for custom CSS. If multiple classes are
* provided they should be separated by spaces.
*/
@Prop() cssClass?: string | string[];
/**
* Number of milliseconds to wait before dismissing the picker.
*/
@Prop() duration = 0;
/**
* If `true`, a backdrop will be displayed behind the picker.
*/
@Prop() showBackdrop = true;
/**
* If `true`, the picker will be dismissed when the backdrop is clicked.
*/
@Prop() backdropDismiss = true;
/**
* If `true`, the picker will animate.
*/
@Prop() animated = true;
/**
* Additional attributes to pass to the picker.
*/
@Prop() htmlAttributes?: { [key: string]: any };
/**
* If `true`, the picker will open. If `false`, the picker will close.
* Use this if you need finer grained control over presentation, otherwise
* just use the pickerController or the `trigger` property.
* Note: `isOpen` will not automatically be set back to `false` when
* the picker dismisses. You will need to do that in your code.
*/
@Prop() isOpen = false;
@Watch('isOpen')
onIsOpenChange(newValue: boolean, oldValue: boolean) {
if (newValue === true && oldValue === false) {
this.present();
} else if (newValue === false && oldValue === true) {
this.dismiss();
}
}
/**
* An ID corresponding to the trigger element that
* causes the picker to open when clicked.
*/
@Prop() trigger: string | undefined;
@Watch('trigger')
triggerChanged() {
const { trigger, el, triggerController } = this;
if (trigger) {
triggerController.addClickListener(el, trigger);
}
}
/**
* Emitted after the picker has presented.
*/
@Event({ eventName: 'ionPickerDidPresent' }) didPresent!: EventEmitter<void>;
/**
* Emitted before the picker has presented.
*/
@Event({ eventName: 'ionPickerWillPresent' }) willPresent!: EventEmitter<void>;
/**
* Emitted before the picker has dismissed.
*/
@Event({ eventName: 'ionPickerWillDismiss' }) willDismiss!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the picker has dismissed.
*/
@Event({ eventName: 'ionPickerDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the picker has presented.
* Shorthand for ionPickerWillDismiss.
*/
@Event({ eventName: 'didPresent' }) didPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the picker has presented.
* Shorthand for ionPickerWillPresent.
*/
@Event({ eventName: 'willPresent' }) willPresentShorthand!: EventEmitter<void>;
/**
* Emitted before the picker has dismissed.
* Shorthand for ionPickerWillDismiss.
*/
@Event({ eventName: 'willDismiss' }) willDismissShorthand!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the picker has dismissed.
* Shorthand for ionPickerDidDismiss.
*/
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
connectedCallback() {
prepareOverlay(this.el);
this.triggerChanged();
}
disconnectedCallback() {
this.triggerController.removeClickListener();
}
componentWillLoad() {
setOverlayId(this.el);
}
componentDidLoad() {
printIonWarning(
'ion-picker-legacy and ion-picker-legacy-column have been deprecated in favor of new versions of the ion-picker and ion-picker-column components. These new components display inline with your page content allowing for more presentation flexibility than before.',
this.el
);
/**
* If picker was rendered with isOpen="true"
* then we should open picker immediately.
*/
if (this.isOpen === true) {
raf(() => this.present());
}
}
/**
* Present the picker overlay after it has been created.
*/
@Method()
async present(): Promise<void> {
const unlock = await this.lockController.lock();
await this.delegateController.attachViewToDom();
await present(this, 'pickerEnter', iosEnterAnimation, iosEnterAnimation, undefined);
if (this.duration > 0) {
this.durationTimeout = setTimeout(() => this.dismiss(), this.duration);
}
unlock();
}
/**
* Dismiss the picker overlay after it has been presented.
*
* @param data Any data to emit in the dismiss events.
* @param role The role of the element that is dismissing the picker.
* This can be useful in a button handler for determining which button was
* clicked to dismiss the picker.
* Some examples include: ``"cancel"`, `"destructive"`, "selected"`, and `"backdrop"`.
*/
@Method()
async dismiss(data?: any, role?: string): Promise<boolean> {
const unlock = await this.lockController.lock();
if (this.durationTimeout) {
clearTimeout(this.durationTimeout);
}
const dismissed = await dismiss(this, data, role, 'pickerLeave', iosLeaveAnimation, iosLeaveAnimation);
if (dismissed) {
this.delegateController.removeViewFromDom();
}
unlock();
return dismissed;
}
/**
* Returns a promise that resolves when the picker did dismiss.
*/
@Method()
onDidDismiss<T = any>(): Promise<OverlayEventDetail<T>> {
return eventMethod(this.el, 'ionPickerDidDismiss');
}
/**
* Returns a promise that resolves when the picker will dismiss.
*/
@Method()
onWillDismiss<T = any>(): Promise<OverlayEventDetail<T>> {
return eventMethod(this.el, 'ionPickerWillDismiss');
}
/**
* Get the column that matches the specified name.
*
* @param name The name of the column.
*/
@Method()
getColumn(name: string): Promise<PickerColumn | undefined> {
return Promise.resolve(this.columns.find((column) => column.name === name));
}
private async buttonClick(button: PickerButton) {
const role = button.role;
if (isCancel(role)) {
return this.dismiss(undefined, role);
}
const shouldDismiss = await this.callButtonHandler(button);
if (shouldDismiss) {
return this.dismiss(this.getSelected(), button.role);
}
return Promise.resolve();
}
private async callButtonHandler(button: PickerButton | undefined) {
if (button) {
// a handler has been provided, execute it
// pass the handler the values from the inputs
const rtn = await safeCall(button.handler, this.getSelected());
if (rtn === false) {
// if the return value of the handler is false then do not dismiss
return false;
}
}
return true;
}
private getSelected() {
const selected: { [k: string]: any } = {};
this.columns.forEach((col, index) => {
const selectedColumn = col.selectedIndex !== undefined ? col.options[col.selectedIndex] : undefined;
selected[col.name] = {
text: selectedColumn ? selectedColumn.text : undefined,
value: selectedColumn ? selectedColumn.value : undefined,
columnIndex: index,
};
});
return selected;
}
private onBackdropTap = () => {
this.dismiss(undefined, BACKDROP);
};
private dispatchCancelHandler = (ev: CustomEvent) => {
const role = ev.detail.role;
if (isCancel(role)) {
const cancelButton = this.buttons.find((b) => b.role === 'cancel');
this.callButtonHandler(cancelButton);
}
};
render() {
const { htmlAttributes } = this;
const mode = getIonMode(this);
return (
<Host
aria-modal="true"
tabindex="-1"
{...(htmlAttributes as any)}
style={{
zIndex: `${20000 + this.overlayIndex}`,
}}
class={{
[mode]: true,
// Used internally for styling
[`picker-${mode}`]: true,
'overlay-hidden': true,
...getClassMap(this.cssClass),
}}
onIonBackdropTap={this.onBackdropTap}
onIonPickerWillDismiss={this.dispatchCancelHandler}
>
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}></ion-backdrop>
<div tabindex="0"></div>
<div class="picker-wrapper ion-overlay-wrapper" role="dialog">
<div class="picker-toolbar">
{this.buttons.map((b) => (
<div class={buttonWrapperClass(b)}>
<button type="button" onClick={() => this.buttonClick(b)} class={buttonClass(b)}>
{b.text}
</button>
</div>
))}
</div>
<div class="picker-columns">
<div class="picker-above-highlight"></div>
{this.presented && this.columns.map((c) => <ion-picker-legacy-column col={c}></ion-picker-legacy-column>)}
<div class="picker-below-highlight"></div>
</div>
</div>
<div tabindex="0"></div>
</Host>
);
}
}
const buttonWrapperClass = (button: PickerButton): CssClassMap => {
return {
[`picker-toolbar-${button.role}`]: button.role !== undefined,
'picker-toolbar-button': true,
};
};
const buttonClass = (button: PickerButton): CssClassMap => {
return {
'picker-button': true,
'ion-activatable': true,
...getClassMap(button.cssClass),
};
};

View File

@@ -0,0 +1,10 @@
@import "../../themes/ionic.globals";
// Picker
// --------------------------------------------------
/// @prop - Width of the picker
$picker-width: 100% !default;
/// @prop - Max width of the picker
$picker-max-width: 500px !default;

Some files were not shown because too many files have changed in this diff Show More