mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 03:00:58 +08:00
fix(footer): remove toolbar bottom padding if near bottom slot tabs or keyboard is open (#25746)
Co-authored-by: EinfachHans <EinfachHans@users.noreply.github.com>
This commit is contained in:
@ -14,6 +14,6 @@ ion-footer {
|
||||
z-index: $z-index-toolbar;
|
||||
}
|
||||
|
||||
ion-footer ion-toolbar:last-of-type {
|
||||
ion-footer.footer-toolbar-padding ion-toolbar:last-of-type {
|
||||
padding-bottom: var(--ion-safe-area-bottom, 0);
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import type { ComponentInterface } from '@stencil/core';
|
||||
import { Component, Element, Host, Prop, h } from '@stencil/core';
|
||||
import { Component, Element, Host, Prop, State, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { findIonContent, getScrollElement, printIonContentErrorMsg } from '../../utils/content';
|
||||
import type { KeyboardController } from '../../utils/keyboard/keyboard-controller';
|
||||
import { createKeyboardController } from '../../utils/keyboard/keyboard-controller';
|
||||
|
||||
import { handleFooterFade } from './footer.utils';
|
||||
|
||||
@ -19,6 +21,9 @@ import { handleFooterFade } from './footer.utils';
|
||||
export class Footer implements ComponentInterface {
|
||||
private scrollEl?: HTMLElement;
|
||||
private contentScrollCallback: any;
|
||||
private keyboardCtrl: KeyboardController | null = null;
|
||||
|
||||
@State() private keyboardVisible = false;
|
||||
|
||||
@Element() el!: HTMLIonFooterElement;
|
||||
|
||||
@ -46,6 +51,18 @@ export class Footer implements ComponentInterface {
|
||||
this.checkCollapsibleFooter();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.keyboardCtrl = createKeyboardController((keyboardOpen) => {
|
||||
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.keyboardCtrl) {
|
||||
this.keyboardCtrl.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private checkCollapsibleFooter = () => {
|
||||
const mode = getIonMode(this);
|
||||
if (mode !== 'ios') {
|
||||
@ -94,6 +111,9 @@ export class Footer implements ComponentInterface {
|
||||
render() {
|
||||
const { translucent, collapse } = this;
|
||||
const mode = getIonMode(this);
|
||||
const tabs = this.el.closest('ion-tabs');
|
||||
const tabBar = tabs?.querySelector('ion-tab-bar');
|
||||
|
||||
return (
|
||||
<Host
|
||||
role="contentinfo"
|
||||
@ -105,6 +125,7 @@ export class Footer implements ComponentInterface {
|
||||
|
||||
[`footer-translucent`]: translucent,
|
||||
[`footer-translucent-${mode}`]: translucent,
|
||||
['footer-toolbar-padding']: !this.keyboardVisible && (!tabBar || tabBar.slot !== 'bottom'),
|
||||
|
||||
[`footer-collapse-${collapse}`]: collapse !== undefined,
|
||||
}}
|
||||
|
13
core/src/components/footer/test/with-tabs/footer.e2e.ts
Normal file
13
core/src/components/footer/test/with-tabs/footer.e2e.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '@utils/test/playwright';
|
||||
|
||||
test.describe('footer: with tabs', () => {
|
||||
test('should not have extra padding when near a tab bar', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.metadata.rtl === true, 'This does not test LTR vs. RTL layout.');
|
||||
|
||||
await page.goto('/src/components/footer/test/with-tabs');
|
||||
|
||||
const footer = page.locator('[tab="tab-one"] ion-footer');
|
||||
expect(await footer.screenshot()).toMatchSnapshot(`footer-with-tabs-${page.getSnapshotSettings()}.png`);
|
||||
});
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
71
core/src/components/footer/test/with-tabs/index.html
Normal file
71
core/src/components/footer/test/with-tabs/index.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Footer - With Tabs</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ion-safe-area-bottom: 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-tabs>
|
||||
<ion-tab tab="tab-one">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Tab One</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<h1>Tab One</h1>
|
||||
</ion-content>
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</ion-tab>
|
||||
|
||||
<ion-tab tab="tab-two">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Tab Two</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<h1>Tab Two</h1>
|
||||
</ion-content>
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</ion-tab>
|
||||
|
||||
<ion-tab-bar slot="bottom">
|
||||
<ion-tab-button tab="tab-one">
|
||||
<ion-label>Tab One</ion-label>
|
||||
<ion-icon name="star"></ion-icon>
|
||||
</ion-tab-button>
|
||||
|
||||
<ion-tab-button tab="tab-two">
|
||||
<ion-label>Tab Two</ion-label>
|
||||
<ion-icon name="globe"></ion-icon>
|
||||
</ion-tab-button>
|
||||
</ion-tab-bar>
|
||||
</ion-tabs>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
@ -1,5 +1,7 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import type { KeyboardController } from '@utils/keyboard/keyboard-controller';
|
||||
import { createKeyboardController } from '@utils/keyboard/keyboard-controller';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import type { Color, TabBarChangedEventDetail } from '../../interface';
|
||||
@ -17,8 +19,7 @@ import { createColorClasses } from '../../utils/theme';
|
||||
shadow: true,
|
||||
})
|
||||
export class TabBar implements ComponentInterface {
|
||||
private keyboardWillShowHandler?: () => void;
|
||||
private keyboardWillHideHandler?: () => void;
|
||||
private keyboardCtrl: KeyboardController | null = null;
|
||||
|
||||
@Element() el!: HTMLElement;
|
||||
|
||||
@ -59,43 +60,30 @@ export class TabBar implements ComponentInterface {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (typeof (window as any) !== 'undefined') {
|
||||
this.keyboardWillShowHandler = () => {
|
||||
if (this.el.getAttribute('slot') !== 'top') {
|
||||
this.keyboardVisible = true;
|
||||
}
|
||||
};
|
||||
|
||||
this.keyboardWillHideHandler = () => {
|
||||
setTimeout(() => (this.keyboardVisible = false), 50);
|
||||
};
|
||||
|
||||
window.addEventListener('keyboardWillShow', this.keyboardWillShowHandler!);
|
||||
window.addEventListener('keyboardWillHide', this.keyboardWillHideHandler!);
|
||||
}
|
||||
this.keyboardCtrl = createKeyboardController((keyboardOpen) => {
|
||||
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (typeof (window as any) !== 'undefined') {
|
||||
window.removeEventListener('keyboardWillShow', this.keyboardWillShowHandler!);
|
||||
window.removeEventListener('keyboardWillHide', this.keyboardWillHideHandler!);
|
||||
|
||||
this.keyboardWillShowHandler = this.keyboardWillHideHandler = undefined;
|
||||
if (this.keyboardCtrl) {
|
||||
this.keyboardCtrl.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { color, translucent, keyboardVisible } = this;
|
||||
const mode = getIonMode(this);
|
||||
const shouldHide = keyboardVisible && this.el.getAttribute('slot') !== 'top';
|
||||
|
||||
return (
|
||||
<Host
|
||||
role="tablist"
|
||||
aria-hidden={keyboardVisible ? 'true' : null}
|
||||
aria-hidden={shouldHide ? 'true' : null}
|
||||
class={createColorClasses(color, {
|
||||
[mode]: true,
|
||||
'tab-bar-translucent': translucent,
|
||||
'tab-bar-hidden': keyboardVisible,
|
||||
'tab-bar-hidden': shouldHide,
|
||||
})}
|
||||
>
|
||||
<slot></slot>
|
||||
|
48
core/src/utils/keyboard/keyboard-controller.ts
Normal file
48
core/src/utils/keyboard/keyboard-controller.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { win } from '../window';
|
||||
|
||||
/**
|
||||
* Creates a controller that tracks and reacts to opening or closing the keyboard.
|
||||
*
|
||||
* @internal
|
||||
* @param keyboardChangeCallback A function to call when the keyboard opens or closes.
|
||||
*/
|
||||
export const createKeyboardController = (
|
||||
keyboardChangeCallback?: (keyboardOpen: boolean) => void
|
||||
): KeyboardController => {
|
||||
let keyboardWillShowHandler: (() => void) | undefined;
|
||||
let keyboardWillHideHandler: (() => void) | undefined;
|
||||
let keyboardVisible: boolean;
|
||||
|
||||
const init = () => {
|
||||
keyboardWillShowHandler = () => {
|
||||
keyboardVisible = true;
|
||||
if (keyboardChangeCallback) keyboardChangeCallback(true);
|
||||
};
|
||||
|
||||
keyboardWillHideHandler = () => {
|
||||
keyboardVisible = false;
|
||||
if (keyboardChangeCallback) keyboardChangeCallback(false);
|
||||
};
|
||||
|
||||
win?.addEventListener('keyboardWillShow', keyboardWillShowHandler);
|
||||
win?.addEventListener('keyboardWillHide', keyboardWillHideHandler);
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
win?.removeEventListener('keyboardWillShow', keyboardWillShowHandler!);
|
||||
win?.removeEventListener('keyboardWillHide', keyboardWillHideHandler!);
|
||||
|
||||
keyboardWillShowHandler = keyboardWillHideHandler = undefined;
|
||||
};
|
||||
|
||||
const isKeyboardVisible = () => keyboardVisible;
|
||||
|
||||
init();
|
||||
return { init, destroy, isKeyboardVisible };
|
||||
};
|
||||
|
||||
export type KeyboardController = {
|
||||
init: () => void;
|
||||
destroy: () => void;
|
||||
isKeyboardVisible: () => boolean;
|
||||
};
|
24
core/src/utils/keyboard/test/keyboard-controller.spec.ts
Normal file
24
core/src/utils/keyboard/test/keyboard-controller.spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createKeyboardController } from '../keyboard-controller';
|
||||
|
||||
describe('Keyboard Controller', () => {
|
||||
it('should update isKeyboardVisible', () => {
|
||||
const keyboardCtrl = createKeyboardController();
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillShow'));
|
||||
expect(keyboardCtrl.isKeyboardVisible()).toBe(true);
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillHide'));
|
||||
expect(keyboardCtrl.isKeyboardVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('should run the callback', () => {
|
||||
const callbackMock = jest.fn();
|
||||
createKeyboardController(callbackMock);
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillShow'));
|
||||
expect(callbackMock).toHaveBeenCalledWith(true);
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillHide'));
|
||||
expect(callbackMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user