app-layoutpackage.src.vaadin-app-layout.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of vaadin-webcomponents Show documentation
Show all versions of vaadin-webcomponents Show documentation
Mvnpm composite: Vaadin webcomponents
The newest version!
/**
* @license
* Copyright (c) 2018 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import './detect-ios-navbar.js';
import './safe-area-inset.js';
import { afterNextRender, beforeNextRender } from '@polymer/polymer/lib/utils/render-status.js';
import { html, PolymerElement } from '@polymer/polymer/polymer-element.js';
import { AriaModalController } from '@vaadin/a11y-base/src/aria-modal-controller.js';
import { FocusTrapController } from '@vaadin/a11y-base/src/focus-trap-controller.js';
import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js';
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
/**
* @typedef {import('./vaadin-app-layout.js').AppLayoutI18n} AppLayoutI18n
*/
/**
* `` is a Web Component providing a quick and easy way to get a common application layout structure done.
*
* ```
*
*
* Company Name
*
* Menu item 1
*
*
*
* Page title
* Page content
*
*
* ```
*
* For best results, the component should be added to the root level of your application (i.e., as a direct child of ``).
*
* The page should include a viewport meta tag which contains `viewport-fit=cover`, like the following:
* ```
*
* ```
* This causes the viewport to be scaled to fill the device display.
* To ensure that important content is displayed, use the provided css variables.
* ```
* --safe-area-inset-top
* --safe-area-inset-right
* --safe-area-inset-bottom
* --safe-area-inset-left
* ```
*
* ### Styling
*
* The following Shadow DOM parts of the `` are available for styling:
*
* Part name | Description
* --------------|---------------------------------------------------------|
* `navbar` | Container for the navigation bar
* `drawer` | Container for the drawer area
*
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
*
* ### Component's slots
*
* The following slots are available to be set
*
* Slot name | Description
* -------------------|---------------------------------------------------|
* no name | Default container for the page content
* `navbar ` | Container for the top navbar area
* `drawer` | Container for an application menu
* `touch-optimized` | Container for the bottom navbar area (only visible for mobile devices)
*
* #### Touch optimized
*
* App Layout has a pseudo-slot `touch-optimized` in order to give more control of the presentation of
* elements with `slot[navbar]`. Internally, when the user is interacting with App Layout from a
* touchscreen device, the component will search for elements with `slot[navbar touch-optimized]` and move
* them to the bottom of the page.
*
* ### Navigation
*
* As the drawer opens as an overlay in small devices, it makes sense to close it once a navigation happens.
* If you are using Vaadin Router, this will happen automatically unless you change the `closeDrawerOn` event name.
*
* In order to do so, there are two options:
* - If the `vaadin-app-layout` instance is available, then `drawerOpened` can be set to `false`
* - If not, a custom event `close-overlay-drawer` can be dispatched either by calling
* `window.dispatchEvent(new CustomEvent('close-overlay-drawer'))` or by calling
* `AppLayout.dispatchCloseOverlayDrawerEvent()`
*
* ### Scrolling areas
*
* By default, the component will act with the "body scrolling", so on mobile (iOS Safari and Android Chrome),
* the toolbars will collapse when a scroll happens.
*
* To use the "content scrolling", in case of the content of the page relies on a pre-defined height (for instance,
* it has a `height:100%`), then the developer can set `height: 100%` to both `html` and `body`.
* That will make the `[content]` element of app layout scrollable.
* On this case, the toolbars on mobile device won't collapse.
*
* @fires {CustomEvent} drawer-opened-changed - Fired when the `drawerOpened` property changes.
* @fires {CustomEvent} overlay-changed - Fired when the `overlay` property changes.
* @fires {CustomEvent} primary-section-changed - Fired when the `primarySection` property changes.
*
* @customElement
* @extends HTMLElement
* @mixes ElementMixin
* @mixes ThemableMixin
* @mixes ControllerMixin
*/
class AppLayout extends ElementMixin(ThemableMixin(ControllerMixin(PolymerElement))) {
static get template() {
return html`
`;
}
static get is() {
return 'vaadin-app-layout';
}
static get properties() {
return {
/**
* The object used to localize this component.
* To change the default localization, replace the entire
* `i18n` object with a custom one.
*
* To update individual properties, extend the existing i18n object as follows:
* ```js
* appLayout.i18n = {
* ...appLayout.i18n,
* drawer: 'Drawer'
* }
* ```
*
* The object has the following structure and default values:
* ```
* {
* drawer: 'Drawer'
* }
* ```
*
* @type {AppLayoutI18n}
* @default {English/US}
*/
i18n: {
type: Object,
observer: '__i18nChanged',
value: () => {
return {
drawer: 'Drawer',
};
},
},
/**
* Defines whether navbar or drawer will come first visually.
* - By default (`primary-section="navbar"`), the navbar takes the full available width and moves the drawer down.
* - If `primary-section="drawer"` is set, then the drawer will move the navbar, taking the full available height.
* @attr {navbar|drawer} primary-section
* @type {!PrimarySection}
*/
primarySection: {
type: String,
value: 'navbar',
notify: true,
reflectToAttribute: true,
observer: '__primarySectionChanged',
},
/**
* Controls whether the drawer is opened (visible) or not.
* Its default value depends on the viewport:
* - `true`, for desktop size views
* - `false`, for mobile size views
* @attr {boolean} drawer-opened
* @type {boolean}
*/
drawerOpened: {
type: Boolean,
notify: true,
value: true,
reflectToAttribute: true,
observer: '__drawerOpenedChanged',
},
/**
* Drawer is an overlay on top of the content
* Controlled via CSS using `--vaadin-app-layout-drawer-overlay: true|false`;
* @type {boolean}
*/
overlay: {
type: Boolean,
notify: true,
readOnly: true,
value: false,
reflectToAttribute: true,
},
/**
* A global event that causes the drawer to close (be hidden) when it is in overlay mode.
* - The default is `vaadin-router-location-changed` dispatched by Vaadin Router
*
* @attr {string} close-drawer-on
* @type {string}
*/
closeDrawerOn: {
type: String,
value: 'vaadin-router-location-changed',
observer: '_closeDrawerOnChanged',
},
};
}
/**
* Helper static method that dispatches a `close-overlay-drawer` event
*/
static dispatchCloseOverlayDrawerEvent() {
window.dispatchEvent(new CustomEvent('close-overlay-drawer'));
}
constructor() {
super();
// TODO(jouni): might want to debounce
this.__boundResizeListener = this._resize.bind(this);
this.__drawerToggleClickListener = this._drawerToggleClick.bind(this);
this.__onDrawerKeyDown = this.__onDrawerKeyDown.bind(this);
this.__closeOverlayDrawerListener = this.__closeOverlayDrawer.bind(this);
this.__trapFocusInDrawer = this.__trapFocusInDrawer.bind(this);
this.__releaseFocusFromDrawer = this.__releaseFocusFromDrawer.bind(this);
// Hide all the elements except the drawer toggle and drawer content
this.__ariaModalController = new AriaModalController(this, () => [
...this.querySelectorAll('vaadin-drawer-toggle, [slot="drawer"]'),
]);
this.__focusTrapController = new FocusTrapController(this);
}
/** @protected */
connectedCallback() {
super.connectedCallback();
this._blockAnimationUntilAfterNextRender();
window.addEventListener('resize', this.__boundResizeListener);
this.addEventListener('drawer-toggle-click', this.__drawerToggleClickListener);
beforeNextRender(this, this._afterFirstRender);
this._updateTouchOptimizedMode();
this._updateDrawerSize();
this._updateOverlayMode();
this._navbarSizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
// Prevent updating offset size multiple times
// during the drawer open / close transition.
if (this.__isDrawerAnimating) {
this.__updateOffsetSizePending = true;
} else {
this._updateOffsetSize();
}
});
});
this._navbarSizeObserver.observe(this.$.navbarTop);
this._navbarSizeObserver.observe(this.$.navbarBottom);
window.addEventListener('close-overlay-drawer', this.__closeOverlayDrawerListener);
window.addEventListener('keydown', this.__onDrawerKeyDown);
}
/** @protected */
ready() {
super.ready();
this.addController(this.__focusTrapController);
this.__setAriaExpanded();
this.$.drawer.addEventListener('transitionstart', () => {
this.__isDrawerAnimating = true;
});
this.$.drawer.addEventListener('transitionend', () => {
// Update offset size after drawer animation.
if (this.__updateOffsetSizePending) {
this.__updateOffsetSizePending = false;
this._updateOffsetSize();
}
// Delay resetting the flag until animation frame
// to avoid updating offset size again on resize.
requestAnimationFrame(() => {
this.__isDrawerAnimating = false;
});
});
}
/** @protected */
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('resize', this.__boundResizeListener);
this.removeEventListener('drawer-toggle-click', this.__drawerToggleClickListener);
window.removeEventListener('close-overlay-drawer', this.__drawerToggleClickListener);
window.removeEventListener('keydown', this.__onDrawerKeyDown);
}
/**
* A callback for the `primarySection` property observer.
*
* Ensures the property is set to its default value `navbar`
* whenever the new value is not one of the valid values: `navbar`, `drawer`.
*
* @param {string} value
* @private
*/
__primarySectionChanged(value) {
const isValid = ['navbar', 'drawer'].includes(value);
if (!isValid) {
this.set('primarySection', 'navbar');
}
}
/**
* A callback for the `drawerOpened` property observer.
*
* When the drawer opens, the method ensures the drawer has a proper height and sets focus on it.
* As long as the drawer is open, the focus is trapped within the drawer.
*
* When the drawer closes, the method releases focus from the drawer, setting focus on the drawer toggle.
*
* @param {boolean} drawerOpened
* @param {boolean} oldDrawerOpened
* @private
*/
__drawerOpenedChanged(drawerOpened, oldDrawerOpened) {
if (this.overlay) {
if (drawerOpened) {
this.__trapFocusInDrawer();
} else if (oldDrawerOpened) {
this.__releaseFocusFromDrawer();
}
}
this.__setAriaExpanded();
}
/**
* A callback for the `i18n` property observer.
*
* The method ensures the drawer has ARIA attributes updated
* once the `i18n` property changes.
*
* @private
*/
__i18nChanged() {
this.__updateDrawerAriaAttributes();
}
/** @protected */
_afterFirstRender() {
this._blockAnimationUntilAfterNextRender();
this._updateOffsetSize();
}
/** @private */
_drawerToggleClick(e) {
e.stopPropagation();
this.drawerOpened = !this.drawerOpened;
}
/** @private */
__closeOverlayDrawer() {
if (this.overlay) {
this.drawerOpened = false;
}
}
/** @private */
__setAriaExpanded() {
const toggle = this.querySelector('vaadin-drawer-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', this.drawerOpened);
}
}
/** @protected */
_updateDrawerSize() {
const childCount = this.querySelectorAll('[slot=drawer]').length;
const drawer = this.$.drawer;
if (childCount === 0) {
drawer.setAttribute('hidden', '');
this.style.setProperty('--_vaadin-app-layout-drawer-width', 0);
} else {
drawer.removeAttribute('hidden');
this.style.removeProperty('--_vaadin-app-layout-drawer-width');
}
this._updateOffsetSize();
}
/** @private */
_resize() {
this._blockAnimationUntilAfterNextRender();
this._updateTouchOptimizedMode();
this._updateOverlayMode();
}
/** @protected */
_updateOffsetSize() {
const navbar = this.$.navbarTop;
const navbarRect = navbar.getBoundingClientRect();
const navbarBottom = this.$.navbarBottom;
const navbarBottomRect = navbarBottom.getBoundingClientRect();
const drawer = this.$.drawer;
const drawerRect = drawer.getBoundingClientRect();
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size', `${navbarRect.height}px`);
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size-bottom', `${navbarBottomRect.height}px`);
this.style.setProperty('--_vaadin-app-layout-drawer-offset-size', `${drawerRect.width}px`);
}
/** @protected */
_updateOverlayMode() {
const overlay = this._getCustomPropertyValue('--vaadin-app-layout-drawer-overlay') === 'true';
if (!this.overlay && overlay) {
// Changed from not overlay to overlay
this._drawerStateSaved = this.drawerOpened;
this.drawerOpened = false;
}
this._setOverlay(overlay);
if (!this.overlay && this._drawerStateSaved) {
this.drawerOpened = this._drawerStateSaved;
this._drawerStateSaved = null;
}
this.__updateDrawerAriaAttributes();
}
/**
* Updates ARIA attributes on the drawer depending on the drawer mode.
*
* - In the overlay mode, the method marks the drawer with ARIA attributes as a dialog
* labelled with the `i18n.drawer` property.
* - In the normal mode, the method removes the ARIA attributes that has been set for the overlay mode.
*
* @private
*/
__updateDrawerAriaAttributes() {
const drawer = this.$.drawer;
if (this.overlay) {
drawer.setAttribute('role', 'dialog');
drawer.setAttribute('aria-modal', 'true');
drawer.setAttribute('aria-label', this.i18n.drawer);
} else {
drawer.removeAttribute('role');
drawer.removeAttribute('aria-modal');
drawer.removeAttribute('aria-label');
}
}
/**
* Returns a promise that resolves when the drawer opening/closing CSS transition ends.
*
* The method relies on the `--vaadin-app-layout-transition` CSS variable to detect whether
* the drawer has a CSS transition that needs to be awaited. If the CSS variable equals `none`,
* the promise resolves immediately.
*
* @return {Promise}
* @private
*/
__drawerTransitionComplete() {
return new Promise((resolve) => {
if (this._getCustomPropertyValue('--vaadin-app-layout-transition') === 'none') {
resolve();
return;
}
this.$.drawer.addEventListener('transitionend', resolve, { once: true });
});
}
/** @private */
async __trapFocusInDrawer() {
// Wait for the drawer CSS transition before focusing the drawer
// in order for VoiceOver to have a proper outline.
await this.__drawerTransitionComplete();
if (!this.drawerOpened) {
// The drawer has been closed during the CSS transition.
return;
}
this.$.drawer.setAttribute('tabindex', '0');
this.__ariaModalController.showModal();
this.__focusTrapController.trapFocus(this.$.drawer);
}
/** @private */
async __releaseFocusFromDrawer() {
// Wait for the drawer CSS transition in order to restore focus to the toggle
// only after `visibility` becomes `hidden`, that is, the drawer becomes inaccessible by the tabbing navigation.
await this.__drawerTransitionComplete();
if (this.drawerOpened) {
// The drawer has been opened during the CSS transition.
return;
}
this.__ariaModalController.close();
this.__focusTrapController.releaseFocus();
this.$.drawer.removeAttribute('tabindex');
// Move focus to the drawer toggle when closing the drawer.
const toggle = this.querySelector('vaadin-drawer-toggle');
if (toggle) {
toggle.focus();
toggle.setAttribute('focus-ring', 'focus');
}
}
/**
* Closes the drawer on Escape press if it has been opened in the overlay mode.
*
* @param {KeyboardEvent} event
* @private
*/
__onDrawerKeyDown(event) {
if (event.key === 'Escape' && this.overlay) {
this.drawerOpened = false;
}
}
/** @private */
_closeDrawerOnChanged(closeDrawerOn, oldCloseDrawerOn) {
if (oldCloseDrawerOn) {
window.removeEventListener(oldCloseDrawerOn, this.__closeOverlayDrawerListener);
}
if (closeDrawerOn) {
window.addEventListener(closeDrawerOn, this.__closeOverlayDrawerListener);
}
}
/** @private */
_onBackdropClick() {
this._close();
}
/** @private */
_onBackdropTouchend(event) {
// Prevent the click event from being fired
// on clickable element behind the backdrop
event.preventDefault();
this._close();
}
/** @protected */
_close() {
this.drawerOpened = false;
}
/** @private */
_getCustomPropertyValue(customProperty) {
const customPropertyValue = getComputedStyle(this).getPropertyValue(customProperty);
return (customPropertyValue || '').trim().toLowerCase();
}
/** @protected */
_updateTouchOptimizedMode() {
const touchOptimized = this._getCustomPropertyValue('--vaadin-app-layout-touch-optimized') === 'true';
const navbarItems = this.querySelectorAll('[slot*="navbar"]');
if (navbarItems.length > 0) {
Array.from(navbarItems).forEach((navbar) => {
if (navbar.getAttribute('slot').indexOf('touch-optimized') > -1) {
navbar.__touchOptimized = true;
}
if (touchOptimized && navbar.__touchOptimized) {
navbar.setAttribute('slot', 'navbar-bottom');
} else {
navbar.setAttribute('slot', 'navbar');
}
});
}
if (this.$.navbarTop.querySelector('[name=navbar]').assignedNodes().length === 0) {
this.$.navbarTop.setAttribute('hidden', '');
} else {
this.$.navbarTop.removeAttribute('hidden');
}
if (this.$.navbarBottom.querySelector('[name=navbar-bottom]').assignedNodes().length === 0) {
this.$.navbarBottom.setAttribute('hidden', '');
} else {
this.$.navbarBottom.removeAttribute('hidden');
}
this._updateOffsetSize();
}
/** @protected */
_blockAnimationUntilAfterNextRender() {
this.setAttribute('no-anim', '');
afterNextRender(this, () => {
this.removeAttribute('no-anim');
});
}
/**
* App Layout listens to `close-overlay-drawer` on the window level.
* A custom event can be dispatched and the App Layout will close the drawer in overlay.
*
* That can be used, for instance, when a navigation occurs when user clicks in a menu item inside the drawer.
*
* See `dispatchCloseOverlayDrawerEvent()` helper method.
*
* @event close-overlay-drawer
*/
}
defineCustomElement(AppLayout);
export { AppLayout };