feat(select): add modal as valid interface

This commit is contained in:
Tanner Reits
2024-10-21 11:47:34 -04:00
parent ab61c122e4
commit 8bc5b2ec53
13 changed files with 403 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./compone
import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
import { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
import { SelectModalOption } from "./components/select-modal/select-modal-interface";
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
@@ -70,6 +71,7 @@ export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./compone
export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
export { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
export { SelectModalOption } from "./components/select-modal/select-modal-interface";
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface";
@@ -3267,6 +3269,12 @@ export namespace Components {
*/
"value"?: any | null;
}
interface IonSelectModal {
"confirmHandler"?: (value?: any | null) => void;
"header"?: string;
"multiple"?: boolean;
"options": SelectModalOption[];
}
interface IonSelectOption {
/**
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
@@ -5015,6 +5023,12 @@ declare global {
prototype: HTMLIonSelectElement;
new (): HTMLIonSelectElement;
};
interface HTMLIonSelectModalElement extends Components.IonSelectModal, HTMLStencilElement {
}
var HTMLIonSelectModalElement: {
prototype: HTMLIonSelectModalElement;
new (): HTMLIonSelectModalElement;
};
interface HTMLIonSelectOptionElement extends Components.IonSelectOption, HTMLStencilElement {
}
var HTMLIonSelectOptionElement: {
@@ -5303,6 +5317,7 @@ declare global {
"ion-segment": HTMLIonSegmentElement;
"ion-segment-button": HTMLIonSegmentButtonElement;
"ion-select": HTMLIonSelectElement;
"ion-select-modal": HTMLIonSelectModalElement;
"ion-select-option": HTMLIonSelectOptionElement;
"ion-select-popover": HTMLIonSelectPopoverElement;
"ion-skeleton-text": HTMLIonSkeletonTextElement;
@@ -8626,6 +8641,12 @@ declare namespace LocalJSX {
*/
"value"?: any | null;
}
interface IonSelectModal {
"confirmHandler"?: (value?: any | null) => void;
"header"?: string;
"multiple"?: boolean;
"options"?: SelectModalOption[];
}
interface IonSelectOption {
/**
* If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons.
@@ -9328,6 +9349,7 @@ declare namespace LocalJSX {
"ion-segment": IonSegment;
"ion-segment-button": IonSegmentButton;
"ion-select": IonSelect;
"ion-select-modal": IonSelectModal;
"ion-select-option": IonSelectOption;
"ion-select-popover": IonSelectPopover;
"ion-skeleton-text": IonSkeletonText;
@@ -9427,6 +9449,7 @@ declare module "@stencil/core" {
"ion-segment": LocalJSX.IonSegment & JSXBase.HTMLAttributes<HTMLIonSegmentElement>;
"ion-segment-button": LocalJSX.IonSegmentButton & JSXBase.HTMLAttributes<HTMLIonSegmentButtonElement>;
"ion-select": LocalJSX.IonSelect & JSXBase.HTMLAttributes<HTMLIonSelectElement>;
"ion-select-modal": LocalJSX.IonSelectModal & JSXBase.HTMLAttributes<HTMLIonSelectModalElement>;
"ion-select-option": LocalJSX.IonSelectOption & JSXBase.HTMLAttributes<HTMLIonSelectOptionElement>;
"ion-select-popover": LocalJSX.IonSelectPopover & JSXBase.HTMLAttributes<HTMLIonSelectPopoverElement>;
"ion-skeleton-text": LocalJSX.IonSkeletonText & JSXBase.HTMLAttributes<HTMLIonSkeletonTextElement>;

View File

@@ -0,0 +1,8 @@
export interface SelectModalOption {
text: string;
value: string;
disabled: boolean;
checked: boolean;
cssClass?: string | string[];
handler?: (value: any) => boolean | void | { [key: string]: any };
}

View File

View File

@@ -0,0 +1,186 @@
import { getIonTheme } from '@global/ionic-global';
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
import { safeCall } from '@utils/overlays';
import { getClassMap } from '@utils/theme';
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';
import type { SelectModalOption } from './select-modal-interface';
@Component({
tag: 'ion-select-modal',
styleUrls: {
ios: 'select-modal.scss',
md: 'select-modal.scss',
ionic: 'select-modal.scss',
},
scoped: true,
})
export class SelectModal implements ComponentInterface {
@Element() el!: HTMLIonSelectModalElement;
@Prop() header?: string;
@Prop() multiple?: boolean;
// TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button)
@Prop() confirmHandler?: (value?: any | null) => void;
@Prop() options: SelectModalOption[] = [];
private closeModal() {
const modal = this.el.closest('ion-modal');
if (modal) {
modal.dismiss();
}
}
private findOptionFromEvent(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const { options } = this;
return options.find((o) => o.value === ev.target.value);
}
private getValues(ev?: CheckboxCustomEvent | RadioGroupCustomEvent): string | string[] | undefined {
const { multiple, options } = this;
if (multiple) {
// this is a popover with checkboxes (multiple value select)
// return an array of all the checked values
return options.filter((o) => o.checked).map((o) => o.value);
}
// this is a popover with radio buttons (single value select)
// return the value that was clicked, otherwise undefined
const option = ev ? this.findOptionFromEvent(ev) : null;
return option ? option.value : undefined;
}
private callOptionHandler(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const option = this.findOptionFromEvent(ev);
const values = this.getValues(ev);
if (option?.handler) {
safeCall(option.handler, values);
}
}
private setChecked(ev: CheckboxCustomEvent): void {
const { multiple } = this;
const option = this.findOptionFromEvent(ev);
// this is a popover with checkboxes (multiple value select)
// we need to set the checked value for this option
if (multiple && option) {
option.checked = ev.detail.checked;
}
}
private renderRadioOptions() {
const checked = this.options.filter((o) => o.checked).map((o) => o.value)[0];
return (
<ion-radio-group value={checked} onIonChange={(ev) => this.callOptionHandler(ev)}>
{this.options.map((option) => (
<ion-item
class={{
// TODO FW-4784
'item-radio-checked': option.value === checked,
...getClassMap(option.cssClass),
}}
>
<ion-radio
value={option.value}
disabled={option.disabled}
onClick={() => this.closeModal()}
onKeyUp={(ev) => {
if (ev.key === ' ') {
/**
* Selecting a radio option with keyboard navigation,
* either through the Enter or Space keys, should
* dismiss the modal.
*/
this.closeModal();
}
}}
>
{option.text}
</ion-radio>
</ion-item>
))}
</ion-radio-group>
);
}
private renderCheckboxOptions() {
return this.options.map((option) => (
<ion-item
class={{
// TODO FW-4784
'item-checkbox-checked': option.checked,
...getClassMap(option.cssClass),
}}
>
<ion-checkbox
value={option.value}
disabled={option.disabled}
checked={option.checked}
justify="space-between"
labelPlacement="start"
onIonChange={(ev) => {
this.setChecked(ev);
this.callOptionHandler(ev);
// TODO FW-4784
forceUpdate(this);
}}
>
{option.text}
</ion-checkbox>
</ion-item>
));
}
render() {
const theme = getIonTheme(this);
return (
<Host
class={{
[theme]: true,
}}
>
{this.header !== undefined && (
<ion-header>
<ion-toolbar>
<ion-title>{this.header}</ion-title>
</ion-toolbar>
</ion-header>
)}
<ion-content>
<ion-list>{this.multiple === true ? this.renderCheckboxOptions() : this.renderRadioOptions()}</ion-list>
</ion-content>
{/* TODO(ROU-11272): Design did not provide designs for how this component should work for multi-select.
* `ion-select-popover` automatically posts data back to the select component, but this feels like an incorrect pattern for modals.
* We'll need to chat with design to figure out the desired UI/UX here.
*/}
{this.multiple === true && (
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button
onClick={() => {
this.confirmHandler?.(this.getValues());
this.closeModal();
}}
>
Done
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
)}
</Host>
);
}
}

View File

@@ -1,4 +1,4 @@
export type SelectInterface = 'action-sheet' | 'popover' | 'alert';
export type SelectInterface = 'action-sheet' | 'popover' | 'alert' | 'modal';
export type SelectCompareFn = (currentValue: any, compareValue: any) => boolean;

View File

@@ -5,7 +5,7 @@ import type { NotchController } from '@utils/forms';
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
import type { OverlaySelect } from '@utils/overlays-interface';
import { isRTL } from '@utils/rtl';
import { createColorClasses, hostContext } from '@utils/theme';
@@ -21,6 +21,8 @@ import type {
CssClassMap,
PopoverOptions,
StyleEventDetail,
ModalOptions,
SelectModalOption,
} from '../../interface';
import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
import type { AlertInput } from '../alert/alert-interface';
@@ -102,15 +104,15 @@ export class Select implements ComponentInterface {
@Prop() fill?: 'outline' | 'solid';
/**
* The interface the select should use: `action-sheet`, `popover` or `alert`.
* The interface the select should use: `action-sheet`, `popover`, `alert`, or `modal`.
*/
@Prop() interface: SelectInterface = 'alert';
/**
* Any additional options that the `alert`, `action-sheet` or `popover` interface
* can take. See the [ion-alert docs](./alert), the
* [ion-action-sheet docs](./action-sheet) and the
* [ion-popover docs](./popover) for the
* [ion-action-sheet docs](./action-sheet), the
* [ion-popover docs](./popover), and the [ion-modal docs](./modal) for the
* create options for each interface.
*
* Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
@@ -393,6 +395,9 @@ export class Select implements ComponentInterface {
if (selectInterface === 'popover') {
return this.openPopover(ev!);
}
if (selectInterface === 'modal') {
return this.openModal();
}
return this.openAlert();
}
@@ -413,6 +418,12 @@ export class Select implements ComponentInterface {
popover.options = this.createPopoverOptions(childOpts, value);
}
break;
case 'modal':
const modal = overlay.querySelector('ion-select-modal');
if (modal) {
modal.options = this.createPopoverOptions(childOpts, value);
}
break;
case 'alert':
const inputType = this.multiple ? 'checkbox' : 'radio';
overlay.inputs = this.createAlertInputs(childOpts, inputType, value);
@@ -507,6 +518,36 @@ export class Select implements ComponentInterface {
return popoverOptions;
}
// TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button).
// In that case, modal and popover could use the same utility to construct options.
private createModalOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectModalOption[] {
const modalOptions = data.map((option) => {
const value = getOptionValue(option);
// Remove hydrated before copying over classes
const copyClasses = Array.from(option.classList)
.filter((cls) => cls !== 'hydrated')
.join(' ');
const optClass = `${OPTION_CLASS} ${copyClasses}`;
return {
text: option.textContent || '',
cssClass: optClass,
value,
checked: isOptionSelected(selectValue, value, this.compareWith),
disabled: option.disabled,
handler: (selected: any) => {
if (!this.multiple) {
this.setValue(selected);
this.close();
}
},
};
});
return modalOptions;
}
private async openPopover(ev: UIEvent) {
const { fill, labelPlacement } = this;
const interfaceOptions = this.interfaceOptions;
@@ -651,6 +692,47 @@ export class Select implements ComponentInterface {
return alertController.create(alertOpts);
}
private openModal() {
const { multiple, value, interfaceOptions } = this;
const theme = getIonTheme(this);
const modalOpts: ModalOptions = {
// TODO(ROU-11272): If OS only wants to use the sheet modal style, we can set default breakpoints for the modal.
// We could also set them contingent on the theme if this is an interface we want to support outside of ODC
...interfaceOptions,
theme,
cssClass: ['select-modal', interfaceOptions.cssClass],
component: 'ion-select-modal',
componentProps: {
header: interfaceOptions.header,
multiple,
value,
// TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button).
// In that case, modal and popover could use the same utility to construct options.
options: this.createModalOptions(this.childOpts, value),
// TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button)
confirmHandler: this.setValue.bind(this),
},
};
/**
* Workaround for Stencil to autodefine
* ion-select-modal and ion-modal when
* using Custom Elements build.
*/
// eslint-disable-next-line
if (false) {
// eslint-disable-next-line
// @ts-ignore
document.createElement('ion-select-mdoal');
document.createElement('ion-modal');
}
return modalController.create(modalOpts);
}
/**
* Close the select interface.
*/

View File

@@ -51,6 +51,14 @@
<ion-select-option value="pears">Pears</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select label="Modal" placeholder="Select one" interface="modal">
<ion-select-option value="apples">Apples</ion-select-option>
<ion-select-option value="oranges">Oranges</ion-select-option>
<ion-select-option value="pears">Pears</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<ion-list>
@@ -76,6 +84,15 @@
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select label="Modal" multiple="true" interface="modal">
<ion-select-option value="bird">Bird</ion-select-option>
<ion-select-option value="cat">Cat</ion-select-option>
<ion-select-option value="dog">Dog</ion-select-option>
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<ion-list>
@@ -124,6 +141,16 @@
<ion-select-option value="onions">Onions</ion-select-option>
</ion-select>
</ion-item>
<ion-item color="secondary">
<ion-select label="Modal Sheet" id="customModalSelect" interface="modal" placeholder="Select One">
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="bacon">Bacon</ion-select-option>
<ion-select-option value="xcheese">Extra Cheese</ion-select-option>
<ion-select-option value="mushrooms">Mushrooms</ion-select-option>
<ion-select-option value="onions">Onions</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
</ion-content>
@@ -152,6 +179,14 @@
message: '$1.50 charge for every topping',
};
customActionSheetSelect.interfaceOptions = customActionSheetOptions;
var customModalSelect = document.getElementById('customModalSelect');
var customModalSheetOptions = {
header: 'Pizza Toppings',
breakpoints: [0.5],
initialBreakpoint: 0.5,
};
customModalSelect.interfaceOptions = customModalSheetOptions;
</script>
</ion-app>
</body>

View File

@@ -77,4 +77,8 @@ export interface HTMLIonOverlayElement extends HTMLStencilElement {
present: () => Promise<void>;
}
export type OverlaySelect = HTMLIonActionSheetElement | HTMLIonAlertElement | HTMLIonPopoverElement;
export type OverlaySelect =
| HTMLIonActionSheetElement
| HTMLIonAlertElement
| HTMLIonPopoverElement
| HTMLIonModalElement;

View File

@@ -70,6 +70,7 @@ export const DIRECTIVES = [
d.IonSegment,
d.IonSegmentButton,
d.IonSelect,
d.IonSelectModal,
d.IonSelectOption,
d.IonSkeletonText,
d.IonSpinner,

View File

@@ -2060,6 +2060,28 @@ This event will not emit when programmatically setting the `value` property.
}
@ProxyCmp({
inputs: ['confirmHandler', 'header', 'multiple', 'options']
})
@Component({
selector: 'ion-select-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['confirmHandler', 'header', 'multiple', 'options'],
})
export class IonSelectModal {
protected el: HTMLElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
c.detach();
this.el = r.nativeElement;
}
}
export declare interface IonSelectModal extends Components.IonSelectModal {}
@ProxyCmp({
inputs: ['disabled', 'mode', 'theme', 'value']
})

View File

@@ -65,6 +65,7 @@ import { defineCustomElement as defineIonReorderGroup } from '@ionic/core/compon
import { defineCustomElement as defineIonRippleEffect } from '@ionic/core/components/ion-ripple-effect.js';
import { defineCustomElement as defineIonRow } from '@ionic/core/components/ion-row.js';
import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js';
import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js';
import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js';
@@ -1843,6 +1844,30 @@ export class IonSegmentButton {
export declare interface IonSegmentButton extends Components.IonSegmentButton {}
@ProxyCmp({
defineCustomElementFn: defineIonSelectModal,
inputs: ['confirmHandler', 'header', 'multiple', 'options']
})
@Component({
selector: 'ion-select-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['confirmHandler', 'header', 'multiple', 'options'],
standalone: true
})
export class IonSelectModal {
protected el: HTMLElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
c.detach();
this.el = r.nativeElement;
}
}
export declare interface IonSelectModal extends Components.IonSelectModal {}
@ProxyCmp({
defineCustomElementFn: defineIonSelectOption,
inputs: ['disabled', 'mode', 'theme', 'value']

View File

@@ -62,6 +62,7 @@ import { defineCustomElement as defineIonSearchbar } from '@ionic/core/component
import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js';
import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js';
import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js';
import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js';
import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js';
@@ -131,6 +132,7 @@ export const IonSearchbar = /*@__PURE__*/createReactComponent<JSX.IonSearchbar,
export const IonSegment = /*@__PURE__*/createReactComponent<JSX.IonSegment, HTMLIonSegmentElement>('ion-segment', undefined, undefined, defineIonSegment);
export const IonSegmentButton = /*@__PURE__*/createReactComponent<JSX.IonSegmentButton, HTMLIonSegmentButtonElement>('ion-segment-button', undefined, undefined, defineIonSegmentButton);
export const IonSelect = /*@__PURE__*/createReactComponent<JSX.IonSelect, HTMLIonSelectElement>('ion-select', undefined, undefined, defineIonSelect);
export const IonSelectModal = /*@__PURE__*/createReactComponent<JSX.IonSelectModal, HTMLIonSelectModalElement>('ion-select-modal', undefined, undefined, defineIonSelectModal);
export const IonSelectOption = /*@__PURE__*/createReactComponent<JSX.IonSelectOption, HTMLIonSelectOptionElement>('ion-select-option', undefined, undefined, defineIonSelectOption);
export const IonSkeletonText = /*@__PURE__*/createReactComponent<JSX.IonSkeletonText, HTMLIonSkeletonTextElement>('ion-skeleton-text', undefined, undefined, defineIonSkeletonText);
export const IonSpinner = /*@__PURE__*/createReactComponent<JSX.IonSpinner, HTMLIonSpinnerElement>('ion-spinner', undefined, undefined, defineIonSpinner);

View File

@@ -68,6 +68,7 @@ import { defineCustomElement as defineIonSearchbar } from '@ionic/core/component
import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js';
import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js';
import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js';
import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js';
import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js';
import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js';
import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js';
@@ -796,6 +797,14 @@ export const IonSelect = /*@__PURE__*/ defineContainer<JSX.IonSelect, JSX.IonSel
'value', 'ion-change');
export const IonSelectModal = /*@__PURE__*/ defineContainer<JSX.IonSelectModal>('ion-select-modal', defineIonSelectModal, [
'header',
'multiple',
'confirmHandler',
'options'
]);
export const IonSelectOption = /*@__PURE__*/ defineContainer<JSX.IonSelectOption>('ion-select-option', defineIonSelectOption, [
'disabled',
'value'