diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index 45ccbabe63..c15791990f 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -75,7 +75,7 @@ export class IonButton { proxyInputs(IonButton, ['buttonType', 'color', 'disabled', 'download', 'expand', 'fill', 'href', 'mode', 'rel', 'routerDirection', 'shape', 'size', 'strong', 'target', 'type']); export declare interface IonButtons extends Components.IonButtons {} -@Component({ selector: 'ion-buttons', changeDetection: ChangeDetectionStrategy.OnPush, template: '' }) +@Component({ selector: 'ion-buttons', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['collapse'] }) export class IonButtons { protected el: HTMLElement; constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { @@ -83,6 +83,7 @@ export class IonButtons { this.el = r.nativeElement; } } +proxyInputs(IonButtons, ['collapse']); export declare interface IonCard extends Components.IonCard {} @Component({ selector: 'ion-card', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['button', 'color', 'disabled', 'download', 'href', 'mode', 'rel', 'routerDirection', 'target', 'type'] }) @@ -269,7 +270,7 @@ export class IonGrid { proxyInputs(IonGrid, ['fixed']); export declare interface IonHeader extends Components.IonHeader {} -@Component({ selector: 'ion-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['mode', 'translucent'] }) +@Component({ selector: 'ion-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['collapse', 'mode', 'translucent'] }) export class IonHeader { protected el: HTMLElement; constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { @@ -277,7 +278,7 @@ export class IonHeader { this.el = r.nativeElement; } } -proxyInputs(IonHeader, ['mode', 'translucent']); +proxyInputs(IonHeader, ['collapse', 'mode', 'translucent']); export declare interface IonIcon extends Components.IonIcon {} @Component({ selector: 'ion-icon', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['ariaLabel', 'color', 'flipRtl', 'icon', 'ios', 'lazy', 'md', 'mode', 'name', 'size', 'src'] }) @@ -895,7 +896,7 @@ export class IonThumbnail { } export declare interface IonTitle extends Components.IonTitle {} -@Component({ selector: 'ion-title', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['color'] }) +@Component({ selector: 'ion-title', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['color', 'size'] }) export class IonTitle { protected el: HTMLElement; constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { @@ -903,7 +904,7 @@ export class IonTitle { this.el = r.nativeElement; } } -proxyInputs(IonTitle, ['color']); +proxyInputs(IonTitle, ['color', 'size']); export declare interface IonToggle extends Components.IonToggle {} @Component({ selector: 'ion-toggle', changeDetection: ChangeDetectionStrategy.OnPush, template: '', inputs: ['checked', 'color', 'disabled', 'mode', 'name', 'value'] }) diff --git a/core/api.txt b/core/api.txt index f8e0f3756f..71c9c5ebc5 100644 --- a/core/api.txt +++ b/core/api.txt @@ -178,6 +178,7 @@ ion-button,css-prop,--ripple-color ion-button,css-prop,--transition ion-buttons,scoped +ion-buttons,prop,collapse,boolean,false,false,false ion-card,scoped ion-card,prop,button,boolean,false,false,false @@ -399,6 +400,7 @@ ion-grid,css-prop,--ion-grid-width-xl ion-grid,css-prop,--ion-grid-width-xs ion-header,none +ion-header,prop,collapse,boolean,false,false,false ion-header,prop,mode,"ios" | "md",undefined,false,false ion-header,prop,translucent,boolean,false,false,false @@ -1204,6 +1206,7 @@ ion-thumbnail,css-prop,--size ion-title,shadow ion-title,prop,color,string | undefined,undefined,false,false +ion-title,prop,size,"large" | undefined,undefined,false,false ion-title,css-prop,--color ion-toast,shadow diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 48fcd2dc1e..874bb03165 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -389,7 +389,12 @@ export namespace Components { */ 'type': 'submit' | 'reset' | 'button'; } - interface IonButtons {} + interface IonButtons { + /** + * If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in `ios` mode with `collapse` set to `true` on `ion-header` + */ + 'collapse': boolean; + } interface IonCard { /** * If `true`, a button tag will be rendered and the card will be tappable. @@ -865,6 +870,10 @@ export namespace Components { 'fixed': boolean; } interface IonHeader { + /** + * If `true`, the header will collapse on scroll of the content. Only applies in `ios` mode. + */ + 'collapse': boolean; /** * The mode determines which platform styles to use. */ @@ -2696,6 +2705,10 @@ export namespace Components { * 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; + /** + * The size of the toolbar title. Only applies in `ios` mode. + */ + 'size'?: 'large' | undefined; } interface IonToast { /** @@ -3883,7 +3896,12 @@ declare namespace LocalJSX { */ 'type'?: 'submit' | 'reset' | 'button'; } - interface IonButtons extends JSXBase.HTMLAttributes {} + interface IonButtons extends JSXBase.HTMLAttributes { + /** + * If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in `ios` mode with `collapse` set to `true` on `ion-header` + */ + 'collapse'?: boolean; + } interface IonCard extends JSXBase.HTMLAttributes { /** * If `true`, a button tag will be rendered and the card will be tappable. @@ -4371,6 +4389,10 @@ declare namespace LocalJSX { 'fixed'?: boolean; } interface IonHeader extends JSXBase.HTMLAttributes { + /** + * If `true`, the header will collapse on scroll of the content. Only applies in `ios` mode. + */ + 'collapse'?: boolean; /** * The mode determines which platform styles to use. */ @@ -6012,6 +6034,10 @@ declare namespace LocalJSX { * 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; + /** + * The size of the toolbar title. Only applies in `ios` mode. + */ + 'size'?: 'large' | undefined; } interface IonToast extends JSXBase.HTMLAttributes { /** diff --git a/core/src/components/buttons/buttons.tsx b/core/src/components/buttons/buttons.tsx index bfd8b681f3..adaf48aafe 100644 --- a/core/src/components/buttons/buttons.tsx +++ b/core/src/components/buttons/buttons.tsx @@ -1,4 +1,4 @@ -import { Component, ComponentInterface, Host, h } from '@stencil/core'; +import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; @@ -12,6 +12,18 @@ import { getIonMode } from '../../global/ionic-global'; }) export class Buttons implements ComponentInterface { + /** + * If true, buttons will disappear when its + * parent toolbar has fully collapsed if the toolbar + * is not the first toolbar. If the toolbar is the + * first toolbar, the buttons will be hidden and will + * only be shown once all toolbars have fully collapsed. + * + * Only applies in `ios` mode with `collapse` set to + * `true` on `ion-header` + */ + @Prop() collapse = false; + render() { return ( diff --git a/core/src/components/buttons/readme.md b/core/src/components/buttons/readme.md index 8977d0fd59..8dbc0a1724 100644 --- a/core/src/components/buttons/readme.md +++ b/core/src/components/buttons/readme.md @@ -204,6 +204,13 @@ export const ButtonsExample: React.FC = () => ( +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------- | +| `collapse` | `collapse` | If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in `ios` mode with `collapse` set to `true` on `ion-header` | `boolean` | `false` | + + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/header/header.ios.scss b/core/src/components/header/header.ios.scss index 2214f68c81..0b42259dec 100644 --- a/core/src/components/header/header.ios.scss +++ b/core/src/components/header/header.ios.scss @@ -16,9 +16,58 @@ .header-translucent-ios { backdrop-filter: $header-ios-translucent-filter; } - + .header-translucent-ios ion-toolbar { --opacity: .8; --backdrop-filter: #{$header-ios-translucent-filter}; } +} + +// iOS Header - Collapse +// -------------------------------------------------- +.header-collapse-ios { + z-index: 9; +} + +.header-collapse-ios ion-toolbar { + position: sticky; + top: 0; +} + +.header-collapse-ios ion-toolbar:first-child { + padding-top: 7px; + + z-index: 1; +} + +.header-collapse-ios ion-toolbar { + z-index: 0; +} + +.header-collapse-ios ion-toolbar ion-searchbar { + height: 48px; + + padding-top: 0px; + padding-bottom: 13px; +} + +ion-toolbar.in-toolbar ion-title, +ion-toolbar.in-toolbar ion-buttons { + transition: all 0.2s ease-in-out; +} + +/** + * There is a bug in Safari where animating the opacity + * on an element in a scrollable container while scrolling + * causes the scroll position to jump to the top + */ +.header-collapse-ios ion-toolbar ion-title, +.header-collapse-ios ion-toolbar ion-buttons { + transition: none; +} + +.header-collapse-ios-inactive ion-toolbar.in-toolbar ion-title, +.header-collapse-ios-inactive ion-toolbar.in-toolbar ion-buttons[collapse] { + opacity: 0; + pointer-events: none; } \ No newline at end of file diff --git a/core/src/components/header/header.md.scss b/core/src/components/header/header.md.scss index 300f8b6abf..71d03249b2 100644 --- a/core/src/components/header/header.md.scss +++ b/core/src/components/header/header.md.scss @@ -25,3 +25,7 @@ .header-md[no-border]::after { display: none; } + +.header-collapse-md { + display: none; +} \ No newline at end of file diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 46f05db10f..3002104c2d 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -1,7 +1,8 @@ -import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Host, Prop, h, readTask, writeTask } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; +import { cloneElement, createHeaderIndex, handleContentScroll, handleToolbarIntersection, setHeaderActive } from './header.utils'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. */ @@ -14,6 +15,17 @@ import { getIonMode } from '../../global/ionic-global'; }) export class Header implements ComponentInterface { + private scrollEl?: HTMLElement; + private contentScrollCallback?: any; + + @Element() el!: HTMLElement; + + /** + * If `true`, the header will collapse on scroll of the content. + * Only applies in `ios` mode. + */ + @Prop() collapse = false; + /** * If `true`, the header will be translucent. * Only applies when the mode is `"ios"` and the device supports @@ -24,6 +36,76 @@ export class Header implements ComponentInterface { */ @Prop() translucent = false; + async componentDidLoad() { + // Determine if the header can collapse + const canCollapse = (this.collapse && getIonMode(this) === 'ios') ? this.collapse : false; + + const tabs = this.el.closest('ion-tabs'); + const page = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); + const contentEl = tabs ? tabs.querySelector('ion-content') : page!.querySelector('ion-content'); + + if (canCollapse) { + await this.setupCollapsableHeader(contentEl, (tabs) ? tabs : page!); + } + } + + componentDidUnload() { + if (this.scrollEl && this.contentScrollCallback) { + this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); + } + } + + private async setupCollapsableHeader(contentEl: HTMLIonContentElement | null, pageEl: Element) { + if (!contentEl) { console.error('ion-header requires a content to collapse, make sure there is an ion-content.'); } + + this.scrollEl = await contentEl!.getScrollElement(); + + readTask(() => { + const headers = pageEl.querySelectorAll('ion-header'); + const mainHeader = Array.from(headers).find((header: any) => !header.collapse) as HTMLElement | undefined; + + if (!mainHeader || !this.scrollEl) { return; } + + const mainHeaderIndex = createHeaderIndex(mainHeader); + const scrollHeaderIndex = createHeaderIndex(this.el); + + if (!mainHeaderIndex || !scrollHeaderIndex) { return; } + + setHeaderActive(mainHeaderIndex, false); + + // TODO: Find a better way to do this + let remainingHeight = 0; + for (let i = 1; i <= scrollHeaderIndex.toolbars.length - 1; i++) { + remainingHeight += scrollHeaderIndex.toolbars[i].el.clientHeight; + } + + /** + * Handle interaction between toolbar collapse and + * showing/hiding content in the primary ion-header + */ + const toolbarIntersection = (ev: any) => { handleToolbarIntersection(ev, mainHeaderIndex, scrollHeaderIndex); }; + + readTask(() => { + const mainHeaderHeight = mainHeaderIndex.el.clientHeight; + const intersectionObserver = new IntersectionObserver(toolbarIntersection, { threshold: 0.25, rootMargin: `-${mainHeaderHeight}px 0px 0px 0px` }); + intersectionObserver.observe(scrollHeaderIndex.toolbars[0].el); + }); + + /** + * Handle scaling of large iOS titles and + * showing/hiding border on last toolbar + * in primary header + */ + this.contentScrollCallback = () => { handleContentScroll(this.scrollEl!, mainHeaderIndex, scrollHeaderIndex, remainingHeight); }; + this.scrollEl.addEventListener('scroll', this.contentScrollCallback); + }); + + writeTask(() => { + cloneElement('ion-title'); + cloneElement('ion-back-button'); + }); + } + render() { const mode = getIonMode(this); return ( @@ -36,6 +118,7 @@ export class Header implements ComponentInterface { [`header-${mode}`]: true, [`header-translucent`]: this.translucent, + [`header-collapse-${mode}`]: this.collapse, [`header-translucent-${mode}`]: this.translucent, }} > diff --git a/core/src/components/header/header.utils.ts b/core/src/components/header/header.utils.ts new file mode 100644 index 0000000000..b796220dca --- /dev/null +++ b/core/src/components/header/header.utils.ts @@ -0,0 +1,142 @@ +import { readTask, writeTask } from '@stencil/core'; + +import { clamp } from '../../utils/helpers'; + +const TRANSITION = 'all 0.2s ease-in-out'; + +interface HeaderIndex { + el: HTMLElement; + toolbars: ToolbarIndex[] | []; +} + +interface ToolbarIndex { + el: HTMLElement; + background: HTMLElement; + ionTitleEl: HTMLIonTitleElement | undefined; + innerTitleEl: HTMLElement; + ionButtonsEl: HTMLElement[] | []; +} + +export const cloneElement = (tagName: string) => { + const getCachedEl = document.querySelector(`${tagName}.ion-cloned-element`); + if (getCachedEl !== null) { return getCachedEl; } + + const clonedEl = document.createElement(tagName); + clonedEl.classList.add('ion-cloned-element'); + clonedEl.style.setProperty('display', 'none'); + document.body.appendChild(clonedEl); + + return clonedEl; +}; + +export const createHeaderIndex = (headerEl: HTMLElement | undefined): HeaderIndex | undefined => { + if (!headerEl) { return; } + + const toolbars = headerEl.querySelectorAll('ion-toolbar'); + + return { + el: headerEl, + toolbars: Array.from(toolbars).map((toolbar: any) => { + const ionTitleEl = toolbar.querySelector('ion-title'); + return { + el: toolbar, + background: toolbar.shadowRoot!.querySelector('.toolbar-background'), + ionTitleEl, + innerTitleEl: (ionTitleEl) ? ionTitleEl.shadowRoot!.querySelector('.toolbar-title') : null, + ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')) || [] + } as ToolbarIndex; + }) || [[]] + } as HeaderIndex; +}; + +export const handleContentScroll = (scrollEl: HTMLElement, mainHeaderIndex: HeaderIndex, scrollHeaderIndex: HeaderIndex, remainingHeight = 0) => { + readTask(() => { + const scrollTop = scrollEl.scrollTop; + const lastMainToolbar = mainHeaderIndex.toolbars[mainHeaderIndex.toolbars.length - 1]; + + const scale = clamp(1, 1 + (-scrollTop / 500), 1.1); + + const borderOpacity = clamp(0, (scrollTop - remainingHeight) / lastMainToolbar.el.clientHeight, 1); + const maxOpacity = 1; + const scaledOpacity = borderOpacity * maxOpacity; + + writeTask(() => { + scaleLargeTitles(scrollHeaderIndex.toolbars, scale); + setToolbarBackgroundOpacity(mainHeaderIndex.toolbars[0], (scaledOpacity === 1) ? undefined : scaledOpacity); + }); + }); +}; + +const setToolbarBackgroundOpacity = (toolbar: ToolbarIndex, opacity: number | undefined) => { + if (opacity === undefined) { + toolbar.background.style.removeProperty('--opacity'); + } else { + toolbar.background.style.setProperty('--opacity', opacity.toString()); + } +}; + +/** + * If toolbars are intersecting, hide the scrollable toolbar content + * and show the primary toolbar content. If the toolbars are not intersecting, + * hide the primary toolbar content and show the scrollable toolbar content + */ +export const handleToolbarIntersection = (ev: any, mainHeaderIndex: HeaderIndex, scrollHeaderIndex: HeaderIndex) => { + console.log(ev); + writeTask(() => { + const event = ev[0]; + const intersection = event.intersectionRect; + const intersectionArea = intersection.width * intersection.height; + const rootArea = event.rootBounds.width * event.rootBounds.height; + + const isPageHidden = intersectionArea === 0 && rootArea === 0; + const isPageTransitioning = intersectionArea > 0 && (intersection.left !== event.rootBounds.left || intersection.right !== event.rootBounds.right); + + if (isPageHidden || isPageTransitioning) { + return; + } + + if (event.isIntersecting) { + setHeaderActive(mainHeaderIndex, false); + setHeaderActive(scrollHeaderIndex); + } else { + /** + * There is a bug with IntersectionObserver on Safari + * where `event.isIntersecting === false` when cancelling + * a swipe to go back gesture. Checking the intersection + * x, y, width, and height provides a workaround. This bug + * does not happen when using Safari + Web Animations, + * only Safari + CSS Animations. + */ + + const hasValidIntersection = (intersection.x === 0 && intersection.y === 0) || (intersection.width !== 0 && intersection.height !== 0); + + if (hasValidIntersection) { + setHeaderActive(mainHeaderIndex); + setHeaderActive(scrollHeaderIndex, false); + } + } + }); +}; + +export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => { + writeTask(() => { + if (active) { + headerIndex.el.classList.remove('header-collapse-ios-inactive'); + } else { + headerIndex.el.classList.add('header-collapse-ios-inactive'); + } + setToolbarBackgroundOpacity(headerIndex.toolbars[0], (active) ? undefined : 0); + }); +}; + +export const scaleLargeTitles = (toolbars: ToolbarIndex[] = [], scale = 1, transition = false) => { + toolbars.forEach(toolbar => { + const ionTitle = toolbar.ionTitleEl; + const titleDiv = toolbar.innerTitleEl; + if (!ionTitle || ionTitle.size !== 'large') { return; } + + titleDiv.style.transformOrigin = 'left center'; + titleDiv.style.transition = (transition) ? TRANSITION : ''; + titleDiv.style.transform = `scale3d(${scale}, ${scale}, 1)`; + }); +}; diff --git a/core/src/components/header/readme.md b/core/src/components/header/readme.md index 1f8883ef3d..633dbbc780 100644 --- a/core/src/components/header/readme.md +++ b/core/src/components/header/readme.md @@ -62,6 +62,7 @@ export const HeaderExample: React.FC = () => ( | Property | Attribute | Description | Type | Default | | ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------- | +| `collapse` | `collapse` | If `true`, the header will collapse on scroll of the content. Only applies in `ios` mode. | `boolean` | `false` | | `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | | `translucent` | `translucent` | If `true`, the header will be translucent. Only applies when the mode is `"ios"` and the device supports [`backdrop-filter`](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter#Browser_compatibility). Note: In order to scroll content behind the header, the `fullscreen` attribute needs to be set on the content. | `boolean` | `false` | diff --git a/core/src/components/searchbar/searchbar.ios.scss b/core/src/components/searchbar/searchbar.ios.scss index f80e59aed4..61f804a754 100644 --- a/core/src/components/searchbar/searchbar.ios.scss +++ b/core/src/components/searchbar/searchbar.ios.scss @@ -50,7 +50,7 @@ height: 100%; - font-size: 14px; + font-size: 16px; font-weight: 400; contain: strict; diff --git a/core/src/components/title/readme.md b/core/src/components/title/readme.md index dd554b1e84..1a2e70e132 100644 --- a/core/src/components/title/readme.md +++ b/core/src/components/title/readme.md @@ -25,9 +25,10 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | -| `color` | `color` | 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). | `string \| undefined` | `undefined` | +| Property | Attribute | Description | Type | Default | +| -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------- | +| `color` | `color` | 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). | `string \| undefined` | `undefined` | +| `size` | `size` | The size of the toolbar title. Only applies in `ios` mode. | `"large" \| undefined` | `undefined` | ## CSS Custom Properties diff --git a/core/src/components/title/test/scenarios/index.html b/core/src/components/title/test/scenarios/index.html new file mode 100644 index 0000000000..a8b73e5c1f --- /dev/null +++ b/core/src/components/title/test/scenarios/index.html @@ -0,0 +1,89 @@ + + + + + + Toolbar - Title + + + + + + + + + + + + + + + + + Edit + + + Inbox + + + + + + + + + + Edit + + + Inbox + + + + + + + + + + Edit + + + Inbox + + + + + + + + + Title + + + + + Title + + + + + + + Toggle Size + + + + + + + diff --git a/core/src/components/title/title.scss b/core/src/components/title/title.scss index f54f65a788..e2d829f922 100644 --- a/core/src/components/title/title.scss +++ b/core/src/components/title/title.scss @@ -33,8 +33,6 @@ font-size: 17px; font-weight: 600; - letter-spacing: -.03em; - text-align: center; box-sizing: border-box; pointer-events: none; @@ -64,4 +62,25 @@ overflow: hidden; pointer-events: auto; -} \ No newline at end of file +} + + +// Title: Large +// -------------------------------------------------- + +:host(.title-ios-large) { + @include padding(0, 16px); + + bottom: 0; + + align-items: flex-end; + + min-width: 100%; + + padding-bottom: 6px; + + font-size: 34px; + font-weight: 700; + + text-align: left; +} diff --git a/core/src/components/title/title.tsx b/core/src/components/title/title.tsx index 60fdc5b6e2..057375eee2 100644 --- a/core/src/components/title/title.tsx +++ b/core/src/components/title/title.tsx @@ -1,7 +1,7 @@ -import { Component, ComponentInterface, Element, Host, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; -import { Color } from '../../interface'; +import { Color, StyleEventDetail } from '../../interface'; import { createColorClasses } from '../../utils/theme'; @Component({ @@ -20,6 +20,37 @@ export class ToolbarTitle implements ComponentInterface { */ @Prop() color?: Color; + /** + * The size of the toolbar title. + * Only applies in `ios` mode. + */ + @Prop() size?: 'large' | undefined; + + /** + * Emitted when the styles change. + * @internal + */ + @Event() ionStyle!: EventEmitter; + + @Watch('size') + protected sizeChanged() { + this.emitStyle(); + } + + connectedCallback() { + this.emitStyle(); + } + + private emitStyle() { + this.ionStyle.emit({ + [`title-${this.getSize()}`]: true + }); + } + + private getSize() { + return (this.size !== undefined) ? this.size : 'standard'; + } + private getMode() { const mode = getIonMode(this); const toolbar = this.el.closest('ion-toolbar'); @@ -33,6 +64,7 @@ export class ToolbarTitle implements ComponentInterface { class={{ [mode]: true, [`title-${mode}`]: true, + [`title-${mode}-${this.getSize()}`]: true, ...createColorClasses(this.color), }} diff --git a/core/src/components/toolbar/test/title/index.html b/core/src/components/toolbar/test/title/index.html new file mode 100644 index 0000000000..93effe25f9 --- /dev/null +++ b/core/src/components/toolbar/test/title/index.html @@ -0,0 +1,204 @@ + + + + + + Toolbar - Title + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/components/toolbar/toolbar.ios.scss b/core/src/components/toolbar/toolbar.ios.scss index e428ee99d1..b5d1f23097 100644 --- a/core/src/components/toolbar/toolbar.ios.scss +++ b/core/src/components/toolbar/toolbar.ios.scss @@ -8,11 +8,11 @@ --background: #{$toolbar-ios-background}; --color: #{$toolbar-ios-color}; --border-color: #{$toolbar-ios-border-color}; - --padding-top: 4px; - --padding-bottom: 4px; - --padding-start: 4px; - --padding-end: 4px; - --min-height: 44px; + --padding-top: #{$toolbar-ios-padding-top}; + --padding-bottom: #{$toolbar-ios-padding-bottom}; + --padding-start: #{$toolbar-ios-padding-start}; + --padding-end: #{$toolbar-ios-padding-end}; + --min-height: #{$toolbar-ios-min-height}; } // Toolbar: Content @@ -37,6 +37,10 @@ // iOS Toolbar Slot Placement // -------------------------------------------------- +::slotted(ion-buttons) { + min-height: 38px; +} + ::slotted([slot="start"]) { order: map-get($toolbar-order-ios, slot-start); } @@ -56,3 +60,19 @@ text-align: end; } + + +// Toolbar: Large Title +// -------------------------------------------------- + +:host(.toolbar-title-large) .toolbar-container { + flex-wrap: wrap; + align-items: flex-start; +} + +:host(.toolbar-title-large) .toolbar-content ion-title { + flex: 1; + order: map-get($toolbar-order-ios, title-large); + + min-width: 100%; +} \ No newline at end of file diff --git a/core/src/components/toolbar/toolbar.ios.vars.scss b/core/src/components/toolbar/toolbar.ios.vars.scss index f6ce545c3c..964f8e6e59 100644 --- a/core/src/components/toolbar/toolbar.ios.vars.scss +++ b/core/src/components/toolbar/toolbar.ios.vars.scss @@ -13,41 +13,26 @@ $toolbar-order-ios: ( slot-primary: 5, slot-end: 6, menu-toggle-end: 7, + title-large: 8, ); +/// @prop - Minimum height of the toolbar +$toolbar-ios-min-height: 44px !default; + +/// @prop - Padding top of the toolbar +$toolbar-ios-padding-top: 3px !default; + +/// @prop - Padding end of the toolbar +$toolbar-ios-padding-end: 4px !default; + +/// @prop - Padding bottom of the toolbar +$toolbar-ios-padding-bottom: $toolbar-ios-padding-top !default; + +/// @prop - Padding start of the toolbar +$toolbar-ios-padding-start: $toolbar-ios-padding-end !default; + /// @prop - Font size of the toolbar button $toolbar-ios-button-font-size: 17px !default; -/// @prop - Text color of the toolbar button -$toolbar-ios-button-color: ion-color(primary, base) !default; - -/// @prop - Background color of the toolbar button -$toolbar-ios-button-background-color: $toolbar-ios-background !default; - -/// @prop - Background color of the toolbar button when activated -$toolbar-ios-button-background-color-activated: $toolbar-ios-color-activated !default; - /// @prop - Border radius of the toolbar button -$toolbar-ios-button-border-radius: 4px !default; - -/// @prop - Font weight of the strong toolbar button -$toolbar-ios-button-strong-font-weight: 600 !default; - -/// @prop - Fill color of the toolbar button icon -$toolbar-ios-button-icon-fill-color: currentColor !default; - - -// iOS Title -// -------------------------------------------------- - -/// @prop - Font size of the toolbar title -$toolbar-ios-title-font-size: 17px !default; - -/// @prop - Font weight of the toolbar title -$toolbar-ios-title-font-weight: 600 !default; - -/// @prop - Text alignment of the toolbar title -$toolbar-ios-title-text-align: center !default; - -/// @prop - Text color of the toolbar title -$toolbar-ios-title-text-color: $toolbar-ios-color !default; +$toolbar-ios-button-border-radius: 4px !default; \ No newline at end of file diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index 2ad8500b83..bb5cebf3b6 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Host, Listen, Prop, h } from '@ import { getIonMode } from '../../global/ionic-global'; import { Color, CssClassMap, StyleEventDetail } from '../../interface'; -import { createColorClasses } from '../../utils/theme'; +import { createColorClasses, hostContext } from '../../utils/theme'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -89,6 +89,7 @@ export class Toolbar implements ComponentInterface { return ( import('./ios.transition'); const mdTransitionAnimation = () => import('./md.transition'); // TODO: Remove when removing AnimationBuilder -export type IonicAnimationInterface = (navEl: HTMLElement, opts: TransitionOptions) => IonicAnimation; +export type IonicAnimationInterface = ((navEl: HTMLElement, opts: TransitionOptions) => IonicAnimation) | ((navEl: HTMLElement, opts: TransitionOptions) => Promise); export const transition = (opts: TransitionOptions): Promise => { return new Promise((resolve, reject) => { @@ -88,7 +88,7 @@ const animation = async (animationBuilder: IonicAnimationInterface | AnimationBu const mod = await import('../animation/old-animation'); trans = await mod.create(animationBuilder as AnimationBuilder, opts.baseEl, opts); } catch (err) { - trans = (animationBuilder as IonicAnimationInterface)(opts.baseEl, opts); + trans = (animationBuilder as IonicAnimationInterface)(opts.baseEl, opts) as IonicAnimation; } fireWillEvents(opts.enteringEl, opts.leavingEl); diff --git a/core/src/utils/transition/ios.transition.ts b/core/src/utils/transition/ios.transition.ts index 81d94a6237..26886ca0fb 100644 --- a/core/src/utils/transition/ios.transition.ts +++ b/core/src/utils/transition/ios.transition.ts @@ -2,13 +2,173 @@ import { IonicAnimation } from '../../interface'; import { createAnimation } from '../animation/animation'; import { TransitionOptions } from '../transition'; +const DURATION = 540; +const addSafeArea = (val: number, side = 'top'): string => { + return `calc(${val}px + var(--ion-safe-area-${side}))`; +}; + +const getClonedElement = (tagName: string): any => { + return document.querySelector(`${tagName}.ion-cloned-element`) as any ; +}; + export const shadow = (el: T): ShadowRoot | T => { return el.shadowRoot || el; }; +const getLargeTitle = (refEl: any) => { + return refEl.querySelector('ion-header:not(.header-collapse-ios-inactive) ion-title[size=large]'); +}; + +const getBackButton = (refEl: any, backDirection: boolean) => { + const buttonsList = refEl.querySelectorAll('ion-buttons'); + + for (const buttons of buttonsList) { + const parentHeader = buttons.closest('ion-header'); + const activeHeader = parentHeader && !parentHeader.classList.contains('header-collapse-ios-inactive'); + const backButton = buttons.querySelector('ion-back-button'); + + if (backButton !== null && ((buttons.collapse && activeHeader && backDirection) || !buttons.collapse)) { + return backButton; + } + } + + return null; +}; + +const createLargeTitleTransition = (rootAnimation: IonicAnimation, rtl: boolean, backDirection: boolean, enteringEl: any, leavingEl: any) => { + const enteringBackButton = getBackButton(enteringEl, backDirection); + const leavingLargeTitle = getLargeTitle(leavingEl); + + const enteringLargeTitle = getLargeTitle(enteringEl); + const leavingBackButton = getBackButton(leavingEl, backDirection); + + const shouldAnimationForward = enteringBackButton !== null && leavingLargeTitle !== null && !backDirection; + const shouldAnimationBackward = enteringLargeTitle !== null && leavingBackButton !== null && backDirection; + + if (shouldAnimationForward) { + animateLargeTitle(rootAnimation, rtl, backDirection, leavingLargeTitle); + animateBackButton(rootAnimation, rtl, backDirection, enteringBackButton); + } else if (shouldAnimationBackward) { + animateLargeTitle(rootAnimation, rtl, backDirection, enteringLargeTitle); + animateBackButton(rootAnimation, rtl, backDirection, leavingBackButton); + } + + return { + forward: shouldAnimationForward, + backward: shouldAnimationBackward + }; +}; + +const animateBackButton = (rootAnimation: IonicAnimation, rtl: boolean, backDirection: boolean, backButtonEl: any) => { + console.log(rtl); + const FORWARD_TEXT_KEYFRAMES = [ + { offset: 0, opacity: 0, transform: `translate(-7px, ${addSafeArea(8)}) scale(2.1)` }, + { offset: 1, opacity: 1, transform: `translate(4px, ${addSafeArea(-40)}) scale(1)` } + ]; + const BACKWARD_TEXT_KEYFRAMES = [ + { offset: 0, opacity: 1, transform: `translate(4px, ${addSafeArea(-40)}) scale(1)` }, + { offset: 0.6, opacity: 0 }, + { offset: 1, opacity: 0, transform: `translate(-7px, ${addSafeArea(8)}) scale(2.1)` } + ]; + const TEXT_KEYFRAMES = (backDirection) ? BACKWARD_TEXT_KEYFRAMES : FORWARD_TEXT_KEYFRAMES; + + const FORWARD_ICON_KEYFRAMES = [ + { offset: 0, opacity: 0, transform: `translate3d(4px, ${addSafeArea(-35)}, 0) scale(0.6)` }, + { offset: 1, opacity: 1, transform: `translate3d(4px, ${addSafeArea(-40)}, 0) scale(1)` } + ]; + const BACKWARD_ICON_KEYFRAMES = [ + { offset: 0, opacity: 1, transform: `translate(4px, ${addSafeArea(-40)}) scale(1)` }, + { offset: 0.2, opacity: 0, transform: `translate(4px, ${addSafeArea(-35)}) scale(0.6)` }, + { offset: 1, opacity: 0, transform: `translate(4px, ${addSafeArea(-35)}) scale(0.6)` } + ]; + const ICON_KEYFRAMES = (backDirection) ? BACKWARD_ICON_KEYFRAMES : FORWARD_ICON_KEYFRAMES; + + const enteringBackButtonTextAnimation = createAnimation(); + const enteringBackButtonIconAnimation = createAnimation(); + + const clonedBackButtonEl = getClonedElement('ion-back-button'); + + const backButtonTextEl = clonedBackButtonEl.querySelector('.button-text'); + const backButtonIconEl = clonedBackButtonEl.querySelector('ion-icon'); + + clonedBackButtonEl.text = backButtonEl.text; + clonedBackButtonEl.mode = backButtonEl.mode; + clonedBackButtonEl.icon = backButtonEl.icon; + clonedBackButtonEl.color = backButtonEl.color; + clonedBackButtonEl.disabled = backButtonEl.disabled; + + clonedBackButtonEl.style.setProperty('display', 'block'); + clonedBackButtonEl.style.setProperty('position', 'fixed'); + + enteringBackButtonIconAnimation.addElement(backButtonIconEl); + enteringBackButtonTextAnimation.addElement(backButtonTextEl); + + enteringBackButtonTextAnimation + .beforeStyles({ + 'transform-origin': 'left center' + }) + .beforeAddWrite(() => { + backButtonEl.style.setProperty('display', 'none'); + }) + .afterAddWrite(() => { + backButtonEl.style.setProperty('display', ''); + clonedBackButtonEl.style.setProperty('display', 'none'); + }) + .keyframes(TEXT_KEYFRAMES); + + enteringBackButtonIconAnimation + .beforeStyles({ + 'transform-origin': 'right center' + }) + .keyframes(ICON_KEYFRAMES); + + rootAnimation.addAnimation([enteringBackButtonTextAnimation, enteringBackButtonIconAnimation]); +}; + +const animateLargeTitle = (rootAnimation: IonicAnimation, rtl: boolean, backDirection: boolean, largeTitleEl: any) => { + const TRANSLATE = (rtl) ? '-18px' : '18px'; + const BACKWARDS_KEYFRAMES = [ + { offset: 0, opacity: 0, transform: `translate(${TRANSLATE}, ${addSafeArea(0)}) scale(0.49)` }, + { offset: 0.1, opacity: 0 }, + { offset: 1, opacity: 1, transform: `translate(0, ${addSafeArea(49)}) scale(1)` } + ]; + const FORWARDS_KEYFRAMES = [ + { offset: 0, opacity: 0.99, transform: `translate(0, ${addSafeArea(49)}) scale(1)` }, + { offset: 0.6, opacity: 0 }, + { offset: 1, opacity: 0, transform: `translate(${TRANSLATE}, ${addSafeArea(0)}) scale(0.5)` } + ]; + const KEYFRAMES = (backDirection) ? BACKWARDS_KEYFRAMES : FORWARDS_KEYFRAMES; + + const clonedTitleEl = getClonedElement('ion-title'); + const clonedLargeTitleAnimation = createAnimation(); + + clonedTitleEl.innerText = largeTitleEl.innerText; + clonedTitleEl.size = largeTitleEl.size; + clonedTitleEl.color = largeTitleEl.color; + + clonedLargeTitleAnimation.addElement(clonedTitleEl); + + clonedLargeTitleAnimation + .beforeStyles({ + 'transform-origin': 'left center', + 'height': '46px', + 'display': '', + 'position': 'relative' + }) + .beforeAddWrite(() => { + largeTitleEl.style.setProperty('display', 'none'); + }) + .afterAddWrite(() => { + largeTitleEl.style.setProperty('display', ''); + clonedTitleEl.style.setProperty('display', 'none'); + }) + .keyframes(KEYFRAMES); + + rootAnimation.addAnimation(clonedLargeTitleAnimation); +}; + export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptions): IonicAnimation => { try { - const DURATION = 540; const EASING = 'cubic-bezier(0.32,0.72,0,1)'; const OPACITY = 'opacity'; const TRANSFORM = 'transform'; @@ -94,6 +254,10 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio } } + const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-ios'); + + const { forward, backward } = createLargeTitleTransition(rootAnimation, isRTL, backDirection, enteringEl, leavingEl); + enteringToolBarEls.forEach(enteringToolBarEl => { const enteringToolBar = createAnimation(); enteringToolBar.addElement(enteringToolBarEl); @@ -103,7 +267,22 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio enteringTitle.addElement(enteringToolBarEl.querySelector('ion-title')); const enteringToolBarButtons = createAnimation(); - enteringToolBarButtons.addElement(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); + const buttons = Array.from(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); + + const parentHeader = enteringToolBarEl.closest('ion-header'); + const inactiveHeader = parentHeader && parentHeader.classList.contains('header-collapse-ios-inactive'); + + let buttonsToAnimate; + if (backDirection) { + buttonsToAnimate = buttons.filter(button => { + const isCollapseButton = (button as any).collapse; + return (isCollapseButton && !inactiveHeader) || !isCollapseButton; + }); + } else { + buttonsToAnimate = buttons.filter(button => !(button as any).collapse); + } + + enteringToolBarButtons.addElement(buttonsToAnimate); const enteringToolBarItems = createAnimation(); enteringToolBarItems.addElement(enteringToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])')); @@ -119,12 +298,16 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio } enteringToolBar.addAnimation([enteringTitle, enteringToolBarButtons, enteringToolBarItems, enteringToolBarBg, enteringBackButton]); - enteringTitle.fromTo(OPACITY, 0.01, 1); enteringToolBarButtons.fromTo(OPACITY, 0.01, 1); enteringToolBarItems.fromTo(OPACITY, 0.01, 1); if (backDirection) { - enteringTitle.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`); + + if (!inactiveHeader) { + enteringTitle + .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`) + .fromTo(OPACITY, 0.01, 1); + } enteringToolBarItems.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`); @@ -132,23 +315,24 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio enteringBackButton.fromTo(OPACITY, 0.01, 1); } else { // entering toolbar, forward direction - enteringTitle.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); + if (!enteringContentHasLargeTitle) { + enteringTitle + .fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`) + .fromTo(OPACITY, 0.01, 1); + } enteringToolBarItems.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); enteringToolBarBg .beforeClearStyles([OPACITY]) - .keyframes([ - { offset: 0, opacity: 0.01 }, - { offset: 0.99, opacity: 1 }, - { offset: 1, opacity: 'var(--opacity)' } - // TODO: Find a way to support clearing properties from Web Animations - ]); + .fromTo(OPACITY, 0.01, 1); // forward direction, entering page has a back button - enteringBackButton.fromTo(OPACITY, 0.01, 1); + if (!forward) { + enteringBackButton.fromTo(OPACITY, 0.01, 1); + } - if (backButtonEl) { + if (backButtonEl && !forward) { const enteringBackBtnText = createAnimation(); enteringBackBtnText .addElement(shadow(backButtonEl).querySelector('.button-text')) @@ -222,7 +406,17 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio leavingTitle.addElement(leavingToolBarEl.querySelector('ion-title')); const leavingToolBarButtons = createAnimation(); - leavingToolBarButtons.addElement(leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); + const buttons = leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]'); + + const parentHeader = leavingToolBarEl.closest('ion-header'); + const inactiveHeader = parentHeader && parentHeader.classList.contains('header-collapse-ios-inactive'); + + const buttonsToAnimate = Array.from(buttons).filter(button => { + const isCollapseButton = (button as any).collapse; + return (isCollapseButton && !inactiveHeader) || !isCollapseButton; + }); + + leavingToolBarButtons.addElement(buttonsToAnimate); const leavingToolBarItems = createAnimation(); const leavingToolBarItemEls = leavingToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'); @@ -244,13 +438,19 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio // fade out leaving toolbar items leavingBackButton.fromTo(OPACITY, 0.99, 0); - leavingTitle.fromTo(OPACITY, 0.99, 0); + leavingToolBarButtons.fromTo(OPACITY, 0.99, 0); leavingToolBarItems.fromTo(OPACITY, 0.99, 0); if (backDirection) { - // leaving toolbar, back direction - leavingTitle.fromTo('transform', `translateX(${CENTER})`, (isRTL ? 'translateX(-100%)' : 'translateX(100%)')); + + if (!inactiveHeader) { + // leaving toolbar, back direction + leavingTitle + .fromTo('transform', `translateX(${CENTER})`, (isRTL ? 'translateX(-100%)' : 'translateX(100%)')) + .fromTo(OPACITY, 0.99, 0); + } + leavingToolBarItems.fromTo('transform', `translateX(${CENTER})`, (isRTL ? 'translateX(-100%)' : 'translateX(100%)')); // leaving toolbar, back direction, and there's no entering toolbar @@ -259,18 +459,23 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio .beforeClearStyles([OPACITY]) .fromTo(OPACITY, 1, 0.01); - if (backButtonEl) { + if (backButtonEl && !backward) { const leavingBackBtnText = createAnimation(); - leavingBackBtnText.addElement(shadow(backButtonEl).querySelector('.button-text')); - leavingBackBtnText.fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); + leavingBackBtnText + .addElement(shadow(backButtonEl).querySelector('.button-text')) + .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); leavingToolBar.addAnimation(leavingBackBtnText); } } else { // leaving toolbar, forward direction - leavingTitle - .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) - .afterClearStyles([TRANSFORM]); + if (!inactiveHeader) { + leavingTitle + .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) + .fromTo(OPACITY, 0.99, 0) + .afterClearStyles([TRANSFORM, OPACITY]); + } + leavingToolBarItems .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) .afterClearStyles([TRANSFORM, OPACITY]);