Merge remote-tracking branch 'origin/main' into final-7.2-sync

This commit is contained in:
Liam DeBeasi
2023-07-19 09:52:12 -04:00
74 changed files with 636 additions and 218 deletions

View File

@@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, h } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
@@ -32,6 +32,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
private inItem = false;
private inListHeader = false;
private inToolbar = false;
private formButtonEl: HTMLButtonElement | null = null;
private formEl: HTMLFormElement | null = null;
private inheritedAttributes: Attributes = {};
@Element() el!: HTMLElement;
@@ -52,6 +54,13 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
* If `true`, the user cannot interact with the button.
*/
@Prop({ reflect: true }) disabled = false;
@Watch('disabled')
disabledChanged() {
const { disabled } = this;
if (this.formButtonEl) {
this.formButtonEl.disabled = disabled;
}
}
/**
* Set to `"block"` for a full-width button or to `"full"` for a full-width button
@@ -144,6 +153,22 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
*/
@Event() ionBlur!: EventEmitter<void>;
connectedCallback(): void {
// Allow form to be submitted through `ion-button`
if (this.type !== 'button' && hasShadowDom(this.el)) {
this.formEl = this.findForm();
if (this.formEl) {
// Create a hidden native button inside of the form
this.formButtonEl = document.createElement('button');
this.formButtonEl.type = this.type;
this.formButtonEl.style.display = 'none';
// Only submit if the button is not disabled.
this.formButtonEl.disabled = this.disabled;
this.formEl.appendChild(this.formButtonEl);
}
}
}
componentWillLoad() {
this.inToolbar = !!this.el.closest('ion-buttons');
this.inListHeader = !!this.el.closest('ion-list-header');
@@ -177,12 +202,63 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
return form;
}
if (typeof form === 'string') {
const el = document.getElementById(form);
if (el instanceof HTMLFormElement) {
return el;
// Check if the string provided is a form id.
const el: HTMLElement | null = document.getElementById(form);
if (el) {
if (el instanceof HTMLFormElement) {
return el;
} else {
/**
* The developer specified a string for the form attribute, but the
* element with that id is not a form element.
*/
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is attached to a <form> element.`,
this.el
);
return null;
}
} else {
/**
* The developer specified a string for the form attribute, but the
* element with that id could not be found in the DOM.
*/
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is correct and the form is rendered in the DOM.`,
this.el
);
return null;
}
}
return null;
if (form !== undefined) {
/**
* The developer specified a HTMLElement for the form attribute,
* but the element is not a HTMLFormElement.
* This will also catch if the developer tries to pass in null
* as the form attribute.
*/
printIonWarning(
`The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`,
this.el
);
return null;
}
/**
* If the form element is not set, the button may be inside
* of a form element. Query the closest form element to the button.
*/
return this.el.closest('form');
}
private submitForm(ev: Event) {
// this button wants to specifically submit a form
// climb up the dom to see if we're in a <form>
// and if so, then use JS to submit it
if (this.formEl && this.formButtonEl) {
ev.preventDefault();
this.formButtonEl.click();
}
}
private handleClick = (ev: Event) => {
@@ -190,49 +266,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
if (this.type === 'button') {
openURL(this.href, ev, this.routerDirection, this.routerAnimation);
} else if (hasShadowDom(el)) {
// this button wants to specifically submit a form
// climb up the dom to see if we're in a <form>
// and if so, then use JS to submit it
let formEl = this.findForm();
const { form } = this;
if (!formEl && form !== undefined) {
/**
* The developer specified a form selector for
* the button to submit, but it was not found.
*/
if (typeof form === 'string') {
printIonWarning(
`Form with selector: "#${form}" could not be found. Verify that the id is correct and the form is rendered in the DOM.`,
el
);
} else {
printIonWarning(
`The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`,
el
);
}
return;
}
if (!formEl) {
/**
* If the form element is not set, the button may be inside
* of a form element. Query the closest form element to the button.
*/
formEl = el.closest('form');
}
if (formEl) {
ev.preventDefault();
const fakeButton = document.createElement('button');
fakeButton.type = this.type;
fakeButton.style.display = 'none';
formEl.appendChild(fakeButton);
fakeButton.click();
fakeButton.remove();
}
this.submitForm(ev);
}
};

View File

@@ -44,7 +44,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
await page.setContent(
`
<form>
<ion-button type="submit">Submit</ion-button>
<ion-button type="submit">Submit</ion-button>
</form>
`,
config
@@ -56,19 +56,93 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
expect(submitEvent).toHaveReceivedEvent();
});
test('should submit the closest form by pressing the `enter` button on a form element', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<ion-button type="submit">Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).toHaveReceivedEvent();
});
test('should submit the closest form with multiple elements by pressing the `enter` button', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<textarea></textarea>
<ion-button type="submit">Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).toHaveReceivedEvent();
});
test('should not submit the closest form when button is disabled', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/19368',
});
await page.setContent(
`
<form>
<input type="text" />
<ion-button type="submit" disabled>Submit</ion-button>
</form>
`,
config
);
const submitEvent = await page.spyOnEvent('submit');
await page.press('input', 'Enter');
expect(submitEvent).not.toHaveReceivedEvent();
});
});
test.describe(title('should throw a warning if the form cannot be found'), () => {
test('form is a string selector', async ({ page }) => {
await page.setContent(`<ion-button type="submit" form="missingForm">Submit</ion-button>`, config);
const logs: string[] = [];
page.on('console', (msg) => {
logs.push(msg.text());
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.click('ion-button');
await page.setContent(`<ion-button type="submit" form="missingForm">Submit</ion-button>`, config);
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
@@ -77,6 +151,14 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
});
test('form is an element reference', async ({ page }) => {
const logs: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') {
logs.push(msg.text());
}
});
await page.setContent(
`
<ion-button type="submit">Submit</ion-button>
@@ -90,14 +172,6 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) =>
config
);
const logs: string[] = [];
page.on('console', (msg) => {
logs.push(msg.text());
});
await page.click('ion-button');
expect(logs.length).toBe(1);
expect(logs[0]).toContain(
'[Ionic Warning]: The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.'

View File

@@ -1,6 +1,7 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { doc } from '@utils/browser';
import { GESTURE_CONTROLLER } from '@utils/gesture';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
@@ -189,7 +190,6 @@ export class Menu implements ComponentInterface, MenuI {
}
if (!Build.isBrowser) {
this.disabled = true;
return;
}
@@ -705,9 +705,18 @@ export class Menu implements ComponentInterface, MenuI {
this.forceClosing();
}
if (!this.disabled) {
menuController._setActiveMenu(this);
if (doc?.contains(this.el)) {
/**
* Only set the active menu if the menu element is
* present in the DOM. Otherwise if it was destructively
* re-hydrated (through Angular Universal), then ignore
* setting the removed node as the active menu.
*/
if (!this.disabled) {
menuController._setActiveMenu(this);
}
}
assert(!this.isAnimating, 'can not be animating');
}

View File

@@ -74,6 +74,7 @@ export const getBackdropValueForSheet = (x: number, backdropBreakpoint: number)
* support for Style.Default.
*/
export const setCardStatusBarDark = () => {
// TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8
if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) {
return;
}
@@ -82,6 +83,7 @@ export const setCardStatusBarDark = () => {
};
export const setCardStatusBarDefault = (defaultStyle = Style.Default) => {
// TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8
if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) {
return;
}

View File

@@ -8,7 +8,7 @@ import {
printIonContentErrorMsg,
} from '@utils/content';
import { clamp, componentOnReady, getElementRoot, raf, transitionEndAsync } from '@utils/helpers';
import { hapticImpact } from '@utils/native/haptic';
import { ImpactStyle, hapticImpact } from '@utils/native/haptic';
import { getIonMode } from '../../global/ionic-global';
import type { Animation, Gesture, GestureDetail } from '../../interface';
@@ -246,7 +246,7 @@ export class Refresher implements ComponentInterface {
if (!this.didRefresh) {
this.beginRefresh();
this.didRefresh = true;
hapticImpact({ style: 'light' });
hapticImpact({ style: ImpactStyle.Light });
/**
* Translate the content element otherwise when pointer is removed

View File

@@ -17,6 +17,7 @@
html {
width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@@ -80,6 +81,7 @@ body {
word-wrap: break-word;
overscroll-behavior-y: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}

View File

@@ -1,6 +1,7 @@
import type { KeyboardResizeOptions } from '@capacitor/keyboard';
import { getScrollElement, scrollByPoint } from '../../content';
import { raf } from '../../helpers';
import type { KeyboardResizeOptions } from '../../native/keyboard';
import { KeyboardResize } from '../../native/keyboard';
import { relocateInput, SCROLL_AMOUNT_PADDING } from './common';

View File

@@ -1,6 +1,6 @@
import { doc, win } from '@utils/browser';
import { KeyboardResize, Keyboard } from '../native/keyboard';
import { Keyboard, KeyboardResize } from '../native/keyboard';
/**
* The element that resizes when the keyboard opens

View File

@@ -0,0 +1,9 @@
import type { CapacitorGlobal } from '@capacitor/core';
import { win } from '@utils/browser';
export const getCapacitor = () => {
if (win !== undefined) {
return (win as any).Capacitor as CapacitorGlobal;
}
return undefined;
};

View File

@@ -1,24 +1,91 @@
// Main types for this API
import type {
HapticsPlugin,
NotificationType as CapacitorNotificationType,
ImpactStyle as CapacitorImpactStyle,
} from '@capacitor/haptics';
import { getCapacitor } from './capacitor';
export enum ImpactStyle {
/**
* A collision between large, heavy user interface elements
*
* @since 1.0.0
*/
Heavy = 'HEAVY',
/**
* A collision between moderately sized user interface elements
*
* @since 1.0.0
*/
Medium = 'MEDIUM',
/**
* A collision between small, light user interface elements
*
* @since 1.0.0
*/
Light = 'LIGHT',
}
interface HapticImpactOptions {
style: 'light' | 'medium' | 'heavy';
style: CapacitorImpactStyle;
}
export enum NotificationType {
/**
* A notification feedback type indicating that a task has completed successfully
*
* @since 1.0.0
*/
Success = 'SUCCESS',
/**
* A notification feedback type indicating that a task has produced a warning
*
* @since 1.0.0
*/
Warning = 'WARNING',
/**
* A notification feedback type indicating that a task has failed
*
* @since 1.0.0
*/
Error = 'ERROR',
}
interface HapticNotificationOptions {
style: 'success' | 'warning' | 'error';
type: CapacitorNotificationType;
}
interface TapticEngine {
gestureSelectionStart: () => void;
gestureSelectionChanged: () => void;
gestureSelectionEnd: () => void;
}
const HapticEngine = {
getEngine() {
const win = window as any;
return win.TapticEngine || (win.Capacitor?.isPluginAvailable('Haptics') && win.Capacitor.Plugins.Haptics);
getEngine(): HapticsPlugin | undefined {
const tapticEngine = (window as any).TapticEngine;
if (tapticEngine) {
// Cordova
// TODO FW-4707 - Remove this in Ionic 8
return tapticEngine;
}
const capacitor = getCapacitor();
if (capacitor?.isPluginAvailable('Haptics')) {
// Capacitor
return capacitor.Plugins.Haptics as HapticsPlugin;
}
return undefined;
},
available() {
const win = window as any;
const engine = this.getEngine();
if (!engine) {
return false;
}
const capacitor = getCapacitor();
/**
* Developers can manually import the
* Haptics plugin in their app which will cause
@@ -28,25 +95,30 @@ const HapticEngine = {
* the Vibrate API. This check avoids that error
* if the browser does not support the Vibrate API.
*/
if (win.Capacitor?.getPlatform() === 'web') {
if (capacitor?.getPlatform() === 'web') {
return typeof navigator !== 'undefined' && navigator.vibrate !== undefined;
}
return true;
},
isCordova() {
return !!(window as any).TapticEngine;
return (window as any).TapticEngine !== undefined;
},
isCapacitor() {
const win = window as any;
return !!win.Capacitor;
return getCapacitor() !== undefined;
},
impact(options: HapticImpactOptions) {
const engine = this.getEngine();
if (!engine) {
return;
}
const style = this.isCapacitor() ? options.style.toUpperCase() : options.style;
/**
* To provide backwards compatibility with Cordova apps,
* we convert the style to lowercase.
*
* TODO: FW-4707 - Remove this in Ionic 8
*/
const style = this.isCapacitor() ? options.style : (options.style.toLowerCase() as ImpactStyle);
engine.impact({ style });
},
notification(options: HapticNotificationOptions) {
@@ -54,11 +126,24 @@ const HapticEngine = {
if (!engine) {
return;
}
const style = this.isCapacitor() ? options.style.toUpperCase() : options.style;
engine.notification({ style });
/**
* To provide backwards compatibility with Cordova apps,
* we convert the style to lowercase.
*
* TODO: FW-4707 - Remove this in Ionic 8
*/
const type = this.isCapacitor() ? options.type : (options.type.toLowerCase() as NotificationType);
engine.notification({ type });
},
selection() {
this.impact({ style: 'light' });
/**
* To provide backwards compatibility with Cordova apps,
* we convert the style to lowercase.
*
* TODO: FW-4707 - Remove this in Ionic 8
*/
const style = this.isCapacitor() ? ImpactStyle.Light : ('light' as ImpactStyle);
this.impact({ style });
},
selectionStart() {
const engine = this.getEngine();
@@ -68,7 +153,7 @@ const HapticEngine = {
if (this.isCapacitor()) {
engine.selectionStart();
} else {
engine.gestureSelectionStart();
(engine as unknown as TapticEngine).gestureSelectionStart();
}
},
selectionChanged() {
@@ -79,7 +164,7 @@ const HapticEngine = {
if (this.isCapacitor()) {
engine.selectionChanged();
} else {
engine.gestureSelectionChanged();
(engine as unknown as TapticEngine).gestureSelectionChanged();
}
},
selectionEnd() {
@@ -90,7 +175,7 @@ const HapticEngine = {
if (this.isCapacitor()) {
engine.selectionEnd();
} else {
engine.gestureSelectionEnd();
(engine as unknown as TapticEngine).gestureSelectionEnd();
}
},
};
@@ -135,7 +220,7 @@ export const hapticSelectionEnd = () => {
/**
* Use this to indicate success/failure/warning to the user.
* options should be of the type `{ type: 'success' }` (or `warning`/`error`)
* options should be of the type `{ type: NotificationType.SUCCESS }` (or `WARNING`/`ERROR`)
*/
export const hapticNotification = (options: HapticNotificationOptions) => {
hapticAvailable() && HapticEngine.notification(options);
@@ -143,7 +228,7 @@ export const hapticNotification = (options: HapticNotificationOptions) => {
/**
* Use this to indicate success/failure/warning to the user.
* options should be of the type `{ style: 'light' }` (or `medium`/`heavy`)
* options should be of the type `{ style: ImpactStyle.LIGHT }` (or `MEDIUM`/`HEAVY`)
*/
export const hapticImpact = (options: HapticImpactOptions) => {
hapticAvailable() && HapticEngine.impact(options);

View File

@@ -1,30 +1,55 @@
import { win } from '../browser';
import type { CapacitorException } from '@capacitor/core';
import type { KeyboardPlugin, KeyboardResizeOptions } from '@capacitor/keyboard';
import type { NativePluginError } from './native-interface';
// Interfaces source: https://capacitorjs.com/docs/apis/keyboard#interfaces
export interface KeyboardResizeOptions {
mode: KeyboardResize;
}
import { getCapacitor } from './capacitor';
import { ExceptionCode } from './native-interface';
export enum KeyboardResize {
/**
* Only the `body` HTML element will be resized.
* Relative units are not affected, because the viewport does not change.
*
* @since 1.0.0
*/
Body = 'body',
/**
* Only the `ion-app` HTML element will be resized.
* Use it only for Ionic Framework apps.
*
* @since 1.0.0
*/
Ionic = 'ionic',
/**
* The whole native Web View will be resized when the keyboard shows/hides.
* This affects the `vh` relative unit.
*
* @since 1.0.0
*/
Native = 'native',
/**
* Neither the app nor the Web View are resized.
*
* @since 1.0.0
*/
None = 'none',
}
export const Keyboard = {
getEngine() {
return (win as any)?.Capacitor?.isPluginAvailable('Keyboard') && (win as any)?.Capacitor.Plugins.Keyboard;
getEngine(): KeyboardPlugin | undefined {
const capacitor = getCapacitor();
if (capacitor?.isPluginAvailable('Keyboard')) {
return capacitor.Plugins.Keyboard as KeyboardPlugin;
}
return undefined;
},
getResizeMode(): Promise<KeyboardResizeOptions | undefined> {
const engine = this.getEngine();
if (!engine?.getResizeMode) {
return Promise.resolve(undefined);
}
return engine.getResizeMode().catch((e: NativePluginError) => {
if (e.code === 'UNIMPLEMENTED') {
return engine.getResizeMode().catch((e: CapacitorException) => {
if (e.code === ExceptionCode.Unimplemented) {
// If the native implementation is not available
// we treat it the same as if the plugin is not available.
return undefined;

View File

@@ -1,13 +1,17 @@
/**
* Used to represent a generic error from a native plugin call.
*/
export interface NativePluginError {
export enum ExceptionCode {
/**
* The error code.
* API is not implemented.
*
* This usually means the API can't be used because it is not implemented for
* the current platform.
*/
code?: string;
Unimplemented = 'UNIMPLEMENTED',
/**
* The error message.
* API is not available.
*
* This means the API can't be used right now because:
* - it is currently missing a prerequisite, such as network connectivity
* - it requires a particular platform or browser version
*/
message?: string;
Unavailable = 'UNAVAILABLE',
}

View File

@@ -1,7 +1,9 @@
import { win } from '../browser';
import type { StatusBarPlugin, Style as StatusBarStyle } from '@capacitor/status-bar';
import { getCapacitor } from './capacitor';
interface StyleOptions {
style: Style;
style: StatusBarStyle;
}
export enum Style {
@@ -11,17 +13,24 @@ export enum Style {
}
export const StatusBar = {
getEngine() {
return (win as any)?.Capacitor?.isPluginAvailable('StatusBar') && (win as any)?.Capacitor.Plugins.StatusBar;
getEngine(): StatusBarPlugin | undefined {
const capacitor = getCapacitor();
if (capacitor?.isPluginAvailable('StatusBar')) {
return capacitor.Plugins.StatusBar as StatusBarPlugin;
}
return undefined;
},
// TODO FW-4696 Remove supportDefaultStatusBarStyle in Ionic v8
supportsDefaultStatusBarStyle() {
const capacitor = getCapacitor() as any;
/**
* The 'DEFAULT' status bar style was added
* to the @capacitor/status-bar plugin in Capacitor 3.
* PluginHeaders is only supported in Capacitor 3+,
* so we can use this to detect Capacitor 3.
*/
return !!(win as any)?.Capacitor?.PluginHeaders;
return !!capacitor?.PluginHeaders;
},
setStyle(options: StyleOptions) {
const engine = this.getEngine();
@@ -31,7 +40,7 @@ export const StatusBar = {
engine.setStyle(options);
},
getStyle: async function (): Promise<Style> {
getStyle: async function (): Promise<StatusBarStyle> {
const engine = this.getEngine();
if (!engine) {
return Style.Default;