header.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. /*!
  2. * (C) Ionic http://ionicframework.com - MIT License
  3. */
  4. import { readTask, writeTask, proxyCustomElement, HTMLElement, h, Host } from '@stencil/core/internal/client';
  5. import { g as getScrollElement, f as findIonContent, p as printIonContentErrorMsg } from './index8.js';
  6. import { k as clamp, i as inheritAriaAttributes } from './helpers.js';
  7. import { h as hostContext } from './theme.js';
  8. import { b as getIonMode } from './ionic-global.js';
  9. const TRANSITION = 'all 0.2s ease-in-out';
  10. const cloneElement = (tagName) => {
  11. const getCachedEl = document.querySelector(`${tagName}.ion-cloned-element`);
  12. if (getCachedEl !== null) {
  13. return getCachedEl;
  14. }
  15. const clonedEl = document.createElement(tagName);
  16. clonedEl.classList.add('ion-cloned-element');
  17. clonedEl.style.setProperty('display', 'none');
  18. document.body.appendChild(clonedEl);
  19. return clonedEl;
  20. };
  21. const createHeaderIndex = (headerEl) => {
  22. if (!headerEl) {
  23. return;
  24. }
  25. const toolbars = headerEl.querySelectorAll('ion-toolbar');
  26. return {
  27. el: headerEl,
  28. toolbars: Array.from(toolbars).map((toolbar) => {
  29. const ionTitleEl = toolbar.querySelector('ion-title');
  30. return {
  31. el: toolbar,
  32. background: toolbar.shadowRoot.querySelector('.toolbar-background'),
  33. ionTitleEl,
  34. innerTitleEl: ionTitleEl ? ionTitleEl.shadowRoot.querySelector('.toolbar-title') : null,
  35. ionButtonsEl: Array.from(toolbar.querySelectorAll('ion-buttons')),
  36. };
  37. }),
  38. };
  39. };
  40. const handleContentScroll = (scrollEl, scrollHeaderIndex, contentEl) => {
  41. readTask(() => {
  42. const scrollTop = scrollEl.scrollTop;
  43. const scale = clamp(1, 1 + -scrollTop / 500, 1.1);
  44. // Native refresher should not cause titles to scale
  45. const nativeRefresher = contentEl.querySelector('ion-refresher.refresher-native');
  46. if (nativeRefresher === null) {
  47. writeTask(() => {
  48. scaleLargeTitles(scrollHeaderIndex.toolbars, scale);
  49. });
  50. }
  51. });
  52. };
  53. const setToolbarBackgroundOpacity = (headerEl, opacity) => {
  54. /**
  55. * Fading in the backdrop opacity
  56. * should happen after the large title
  57. * has collapsed, so it is handled
  58. * by handleHeaderFade()
  59. */
  60. if (headerEl.collapse === 'fade') {
  61. return;
  62. }
  63. if (opacity === undefined) {
  64. headerEl.style.removeProperty('--opacity-scale');
  65. }
  66. else {
  67. headerEl.style.setProperty('--opacity-scale', opacity.toString());
  68. }
  69. };
  70. const handleToolbarBorderIntersection = (ev, mainHeaderIndex, scrollTop) => {
  71. if (!ev[0].isIntersecting) {
  72. return;
  73. }
  74. /**
  75. * There is a bug in Safari where overflow scrolling on a non-body element
  76. * does not always reset the scrollTop position to 0 when letting go. It will
  77. * set to 1 once the rubber band effect has ended. This causes the background to
  78. * appear slightly on certain app setups.
  79. *
  80. * Additionally, we check if user is rubber banding (scrolling is negative)
  81. * as this can mean they are using pull to refresh. Once the refresher starts,
  82. * the content is transformed which can cause the intersection observer to erroneously
  83. * fire here as well.
  84. */
  85. const scale = ev[0].intersectionRatio > 0.9 || scrollTop <= 0 ? 0 : ((1 - ev[0].intersectionRatio) * 100) / 75;
  86. setToolbarBackgroundOpacity(mainHeaderIndex.el, scale === 1 ? undefined : scale);
  87. };
  88. /**
  89. * If toolbars are intersecting, hide the scrollable toolbar content
  90. * and show the primary toolbar content. If the toolbars are not intersecting,
  91. * hide the primary toolbar content and show the scrollable toolbar content
  92. */
  93. const handleToolbarIntersection = (ev, // TODO(FW-2832): type (IntersectionObserverEntry[] triggers errors which should be sorted)
  94. mainHeaderIndex, scrollHeaderIndex, scrollEl) => {
  95. writeTask(() => {
  96. const scrollTop = scrollEl.scrollTop;
  97. handleToolbarBorderIntersection(ev, mainHeaderIndex, scrollTop);
  98. const event = ev[0];
  99. const intersection = event.intersectionRect;
  100. const intersectionArea = intersection.width * intersection.height;
  101. const rootArea = event.rootBounds.width * event.rootBounds.height;
  102. const isPageHidden = intersectionArea === 0 && rootArea === 0;
  103. const leftDiff = Math.abs(intersection.left - event.boundingClientRect.left);
  104. const rightDiff = Math.abs(intersection.right - event.boundingClientRect.right);
  105. const isPageTransitioning = intersectionArea > 0 && (leftDiff >= 5 || rightDiff >= 5);
  106. if (isPageHidden || isPageTransitioning) {
  107. return;
  108. }
  109. if (event.isIntersecting) {
  110. setHeaderActive(mainHeaderIndex, false);
  111. setHeaderActive(scrollHeaderIndex);
  112. }
  113. else {
  114. /**
  115. * There is a bug with IntersectionObserver on Safari
  116. * where `event.isIntersecting === false` when cancelling
  117. * a swipe to go back gesture. Checking the intersection
  118. * x, y, width, and height provides a workaround. This bug
  119. * does not happen when using Safari + Web Animations,
  120. * only Safari + CSS Animations.
  121. */
  122. const hasValidIntersection = (intersection.x === 0 && intersection.y === 0) || (intersection.width !== 0 && intersection.height !== 0);
  123. if (hasValidIntersection && scrollTop > 0) {
  124. setHeaderActive(mainHeaderIndex);
  125. setHeaderActive(scrollHeaderIndex, false);
  126. setToolbarBackgroundOpacity(mainHeaderIndex.el);
  127. }
  128. }
  129. });
  130. };
  131. const setHeaderActive = (headerIndex, active = true) => {
  132. const headerEl = headerIndex.el;
  133. const toolbars = headerIndex.toolbars;
  134. const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl);
  135. if (active) {
  136. headerEl.classList.remove('header-collapse-condense-inactive');
  137. ionTitles.forEach((ionTitle) => {
  138. if (ionTitle) {
  139. ionTitle.removeAttribute('aria-hidden');
  140. }
  141. });
  142. }
  143. else {
  144. headerEl.classList.add('header-collapse-condense-inactive');
  145. /**
  146. * The small title should only be accessed by screen readers
  147. * when the large title collapses into the small title due
  148. * to scrolling.
  149. *
  150. * Originally, the header was given `aria-hidden="true"`
  151. * but this caused issues with screen readers not being
  152. * able to access any focusable elements within the header.
  153. */
  154. ionTitles.forEach((ionTitle) => {
  155. if (ionTitle) {
  156. ionTitle.setAttribute('aria-hidden', 'true');
  157. }
  158. });
  159. }
  160. };
  161. const scaleLargeTitles = (toolbars = [], scale = 1, transition = false) => {
  162. toolbars.forEach((toolbar) => {
  163. const ionTitle = toolbar.ionTitleEl;
  164. const titleDiv = toolbar.innerTitleEl;
  165. if (!ionTitle || ionTitle.size !== 'large') {
  166. return;
  167. }
  168. titleDiv.style.transition = transition ? TRANSITION : '';
  169. titleDiv.style.transform = `scale3d(${scale}, ${scale}, 1)`;
  170. });
  171. };
  172. const handleHeaderFade = (scrollEl, baseEl, condenseHeader) => {
  173. readTask(() => {
  174. const scrollTop = scrollEl.scrollTop;
  175. const baseElHeight = baseEl.clientHeight;
  176. const fadeStart = condenseHeader ? condenseHeader.clientHeight : 0;
  177. /**
  178. * If we are using fade header with a condense
  179. * header, then the toolbar backgrounds should
  180. * not begin to fade in until the condense
  181. * header has fully collapsed.
  182. *
  183. * Additionally, the main content should not
  184. * overflow out of the container until the
  185. * condense header has fully collapsed. When
  186. * using just the condense header the content
  187. * should overflow out of the container.
  188. */
  189. if (condenseHeader !== null && scrollTop < fadeStart) {
  190. baseEl.style.setProperty('--opacity-scale', '0');
  191. scrollEl.style.setProperty('clip-path', `inset(${baseElHeight}px 0px 0px 0px)`);
  192. return;
  193. }
  194. const distanceToStart = scrollTop - fadeStart;
  195. const fadeDuration = 10;
  196. const scale = clamp(0, distanceToStart / fadeDuration, 1);
  197. writeTask(() => {
  198. scrollEl.style.removeProperty('clip-path');
  199. baseEl.style.setProperty('--opacity-scale', scale.toString());
  200. });
  201. });
  202. };
  203. const headerIosCss = "ion-header{display:block;position:relative;-ms-flex-order:-1;order:-1;width:100%;z-index:10}ion-header ion-toolbar:first-of-type{padding-top:var(--ion-safe-area-top, 0)}.header-ios ion-toolbar:last-of-type{--border-width:0 0 0.55px}@supports ((-webkit-backdrop-filter: blur(0)) or (backdrop-filter: blur(0))){.header-background{left:0;right:0;top:0;bottom:0;position:absolute;-webkit-backdrop-filter:saturate(180%) blur(20px);backdrop-filter:saturate(180%) blur(20px)}.header-translucent-ios ion-toolbar{--opacity:.8}.header-collapse-condense-inactive .header-background{-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}}.header-ios.ion-no-border ion-toolbar:last-of-type{--border-width:0}.header-collapse-fade ion-toolbar{--opacity-scale:inherit}.header-collapse-condense{z-index:9}.header-collapse-condense ion-toolbar{position:-webkit-sticky;position:sticky;top:0}.header-collapse-condense ion-toolbar:first-of-type{padding-top:0px;z-index:1}.header-collapse-condense ion-toolbar{--background:var(--ion-background-color, #fff);z-index:0}.header-collapse-condense ion-toolbar:last-of-type{--border-width:0px}.header-collapse-condense ion-toolbar ion-searchbar{padding-top:0px;padding-bottom:13px}.header-collapse-main{--opacity-scale:1}.header-collapse-main ion-toolbar{--opacity-scale:inherit}.header-collapse-main ion-toolbar.in-toolbar ion-title,.header-collapse-main ion-toolbar.in-toolbar ion-buttons{-webkit-transition:all 0.2s ease-in-out;transition:all 0.2s ease-in-out}.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-title,.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-buttons.buttons-collapse{opacity:0;pointer-events:none}.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-title,.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-buttons.buttons-collapse{visibility:hidden}ion-header.header-ios:not(.header-collapse-main):has(~ion-content ion-header.header-ios[collapse=condense],~ion-content ion-header.header-ios.header-collapse-condense){opacity:0}";
  204. const IonHeaderIosStyle0 = headerIosCss;
  205. const headerMdCss = "ion-header{display:block;position:relative;-ms-flex-order:-1;order:-1;width:100%;z-index:10}ion-header ion-toolbar:first-of-type{padding-top:var(--ion-safe-area-top, 0)}.header-md{-webkit-box-shadow:0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12);box-shadow:0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12)}.header-collapse-condense{display:none}.header-md.ion-no-border{-webkit-box-shadow:none;box-shadow:none}";
  206. const IonHeaderMdStyle0 = headerMdCss;
  207. const Header = /*@__PURE__*/ proxyCustomElement(class Header extends HTMLElement {
  208. constructor() {
  209. super();
  210. this.__registerHost();
  211. this.inheritedAttributes = {};
  212. this.setupFadeHeader = async (contentEl, condenseHeader) => {
  213. const scrollEl = (this.scrollEl = await getScrollElement(contentEl));
  214. /**
  215. * Handle fading of toolbars on scroll
  216. */
  217. this.contentScrollCallback = () => {
  218. handleHeaderFade(this.scrollEl, this.el, condenseHeader);
  219. };
  220. scrollEl.addEventListener('scroll', this.contentScrollCallback);
  221. handleHeaderFade(this.scrollEl, this.el, condenseHeader);
  222. };
  223. this.collapse = undefined;
  224. this.translucent = false;
  225. }
  226. componentWillLoad() {
  227. this.inheritedAttributes = inheritAriaAttributes(this.el);
  228. }
  229. componentDidLoad() {
  230. this.checkCollapsibleHeader();
  231. }
  232. componentDidUpdate() {
  233. this.checkCollapsibleHeader();
  234. }
  235. disconnectedCallback() {
  236. this.destroyCollapsibleHeader();
  237. }
  238. async checkCollapsibleHeader() {
  239. const mode = getIonMode(this);
  240. if (mode !== 'ios') {
  241. return;
  242. }
  243. const { collapse } = this;
  244. const hasCondense = collapse === 'condense';
  245. const hasFade = collapse === 'fade';
  246. this.destroyCollapsibleHeader();
  247. if (hasCondense) {
  248. const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner');
  249. const contentEl = pageEl ? findIonContent(pageEl) : null;
  250. // Cloned elements are always needed in iOS transition
  251. writeTask(() => {
  252. const title = cloneElement('ion-title');
  253. title.size = 'large';
  254. cloneElement('ion-back-button');
  255. });
  256. await this.setupCondenseHeader(contentEl, pageEl);
  257. }
  258. else if (hasFade) {
  259. const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner');
  260. const contentEl = pageEl ? findIonContent(pageEl) : null;
  261. if (!contentEl) {
  262. printIonContentErrorMsg(this.el);
  263. return;
  264. }
  265. const condenseHeader = contentEl.querySelector('ion-header[collapse="condense"]');
  266. await this.setupFadeHeader(contentEl, condenseHeader);
  267. }
  268. }
  269. destroyCollapsibleHeader() {
  270. if (this.intersectionObserver) {
  271. this.intersectionObserver.disconnect();
  272. this.intersectionObserver = undefined;
  273. }
  274. if (this.scrollEl && this.contentScrollCallback) {
  275. this.scrollEl.removeEventListener('scroll', this.contentScrollCallback);
  276. this.contentScrollCallback = undefined;
  277. }
  278. if (this.collapsibleMainHeader) {
  279. this.collapsibleMainHeader.classList.remove('header-collapse-main');
  280. this.collapsibleMainHeader = undefined;
  281. }
  282. }
  283. async setupCondenseHeader(contentEl, pageEl) {
  284. if (!contentEl || !pageEl) {
  285. printIonContentErrorMsg(this.el);
  286. return;
  287. }
  288. if (typeof IntersectionObserver === 'undefined') {
  289. return;
  290. }
  291. this.scrollEl = await getScrollElement(contentEl);
  292. const headers = pageEl.querySelectorAll('ion-header');
  293. this.collapsibleMainHeader = Array.from(headers).find((header) => header.collapse !== 'condense');
  294. if (!this.collapsibleMainHeader) {
  295. return;
  296. }
  297. const mainHeaderIndex = createHeaderIndex(this.collapsibleMainHeader);
  298. const scrollHeaderIndex = createHeaderIndex(this.el);
  299. if (!mainHeaderIndex || !scrollHeaderIndex) {
  300. return;
  301. }
  302. setHeaderActive(mainHeaderIndex, false);
  303. setToolbarBackgroundOpacity(mainHeaderIndex.el, 0);
  304. /**
  305. * Handle interaction between toolbar collapse and
  306. * showing/hiding content in the primary ion-header
  307. * as well as progressively showing/hiding the main header
  308. * border as the top-most toolbar collapses or expands.
  309. */
  310. const toolbarIntersection = (ev) => {
  311. handleToolbarIntersection(ev, mainHeaderIndex, scrollHeaderIndex, this.scrollEl);
  312. };
  313. this.intersectionObserver = new IntersectionObserver(toolbarIntersection, {
  314. root: contentEl,
  315. threshold: [0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
  316. });
  317. this.intersectionObserver.observe(scrollHeaderIndex.toolbars[scrollHeaderIndex.toolbars.length - 1].el);
  318. /**
  319. * Handle scaling of large iOS titles and
  320. * showing/hiding border on last toolbar
  321. * in primary header
  322. */
  323. this.contentScrollCallback = () => {
  324. handleContentScroll(this.scrollEl, scrollHeaderIndex, contentEl);
  325. };
  326. this.scrollEl.addEventListener('scroll', this.contentScrollCallback);
  327. writeTask(() => {
  328. if (this.collapsibleMainHeader !== undefined) {
  329. this.collapsibleMainHeader.classList.add('header-collapse-main');
  330. }
  331. });
  332. }
  333. render() {
  334. const { translucent, inheritedAttributes } = this;
  335. const mode = getIonMode(this);
  336. const collapse = this.collapse || 'none';
  337. // banner role must be at top level, so remove role if inside a menu
  338. const roleType = hostContext('ion-menu', this.el) ? 'none' : 'banner';
  339. return (h(Host, Object.assign({ key: 'b6cc27f0b08afc9fcc889683525da765d80ba672', role: roleType, class: {
  340. [mode]: true,
  341. // Used internally for styling
  342. [`header-${mode}`]: true,
  343. [`header-translucent`]: this.translucent,
  344. [`header-collapse-${collapse}`]: true,
  345. [`header-translucent-${mode}`]: this.translucent,
  346. } }, inheritedAttributes), mode === 'ios' && translucent && h("div", { key: '395766d4dcee3398bc91960db21f922095292f14', class: "header-background" }), h("slot", { key: '09a67ece27b258ff1248805d43d92a49b2c6859a' })));
  347. }
  348. get el() { return this; }
  349. static get style() { return {
  350. ios: IonHeaderIosStyle0,
  351. md: IonHeaderMdStyle0
  352. }; }
  353. }, [36, "ion-header", {
  354. "collapse": [1],
  355. "translucent": [4]
  356. }]);
  357. function defineCustomElement() {
  358. if (typeof customElements === "undefined") {
  359. return;
  360. }
  361. const components = ["ion-header"];
  362. components.forEach(tagName => { switch (tagName) {
  363. case "ion-header":
  364. if (!customElements.get(tagName)) {
  365. customElements.define(tagName, Header);
  366. }
  367. break;
  368. } });
  369. }
  370. export { Header as H, defineCustomElement as d };