Browse Source

Feature: Mobile - Implement sticky headers

Vladimir Sheremet 2 years ago
parent
commit
3ff64493f1

+ 5 - 0
.cypress/support/index.js

@@ -5,6 +5,11 @@ import 'virtual:svg-icons-register' // eslint-disable-line import/no-unresolved
 
 import './commands'
 
+// @testing-library/cypress uses env to display errors
+globalThis.process.env = {
+  DEBUG_PRINT_LIMIT: 5000,
+}
+
 // eslint-disable-next-line no-underscore-dangle
 window.__ = (str) => str
 

+ 1 - 1
app/frontend/apps/mobile/components/CommonButtonGroup/CommonButtonGroup.vue

@@ -68,7 +68,7 @@ const isTabs = computed(() => props.as === 'tabs')
       :data-value="option.value"
       :class="{
         'bg-gray-600/50 text-white/30': option.disabled,
-        'bg-gray-200':
+        '!bg-gray-200':
           option.selected ||
           (option.value != null && modelValue === option.value),
         'flex-1 py-2': mode === 'full',

+ 1 - 1
app/frontend/apps/mobile/components/CommonButtonGroup/__tests__/CommonButtonGroup.spec.ts

@@ -41,7 +41,7 @@ describe('buttons group', () => {
     const button = view.getByRole('button', { name: 'button' })
 
     expect(button).toBeEnabled()
-    expect(button, 'selected button has a class').toHaveClass('bg-gray-200')
+    expect(button, 'selected button has a class').toHaveClass('!bg-gray-200')
 
     await view.events.click(button)
 

+ 9 - 1
app/frontend/apps/mobile/components/layout/LayoutHeader.vue

@@ -1,6 +1,7 @@
 <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { ref } from 'vue'
 import type { RouteLocationRaw } from 'vue-router'
 import CommonBackButton from '../CommonBackButton/CommonBackButton.vue'
 
@@ -14,13 +15,20 @@ export interface Props {
   onAction?(): void
 }
 
+const headerElement = ref()
+
+defineExpose({
+  headerElement,
+})
+
 defineProps<Props>()
 </script>
 
 <template>
   <header
     v-if="title || backUrl || (onAction && actionTitle)"
-    class="grid h-[64px] grid-cols-3 border-b-[0.5px] border-white/10 px-4"
+    ref="headerElement"
+    class="grid h-[64px] grid-cols-3 border-b-[0.5px] border-white/10 bg-black px-4"
     data-test-id="appHeader"
   >
     <div class="flex items-center justify-self-start text-base">

+ 20 - 3
app/frontend/apps/mobile/components/layout/LayoutMain.vue

@@ -2,9 +2,10 @@
 
 <script setup lang="ts">
 import { headerOptions as header } from '@mobile/composables/useHeader'
-import { computed, unref } from 'vue'
+import { computed, ref, unref } from 'vue'
 import { useRoute } from 'vue-router'
 // import TransitionViewNavigation from '../transition/TransitionViewNavigation/TransitionViewNavigation.vue'
+import { useStickyHeader } from '@shared/composables/useStickyHeader'
 import LayoutBottomNavigation from './LayoutBottomNavigation.vue'
 import LayoutHeader from './LayoutHeader.vue'
 
@@ -21,12 +22,28 @@ const showBottomNavigation = computed(() => {
 const showHeader = computed(() => {
   return route.meta.hasHeader
 })
+
+const headerComponent = ref<{ headerElement: HTMLElement }>()
+const headerElement = computed(() => {
+  return headerComponent.value?.headerElement
+})
+
+const { stickyStyles } = useStickyHeader([title], headerElement)
 </script>
 
 <template>
   <div class="flex h-full flex-col overflow-hidden">
-    <LayoutHeader v-if="showHeader" v-bind="header" :title="title" />
-    <main :class="{ 'pb-14': showBottomNavigation }">
+    <LayoutHeader
+      v-if="showHeader"
+      ref="headerComponent"
+      v-bind="header"
+      :title="title"
+      :style="stickyStyles.header"
+    />
+    <main
+      :class="{ 'pb-14': showBottomNavigation }"
+      :style="showHeader ? stickyStyles.body : {}"
+    >
       <!-- let's see how it feels without transition -->
       <RouterView />
       <!-- TODO check when we will have more time -->

+ 84 - 73
app/frontend/apps/mobile/pages/search/views/SearchOverview.vue

@@ -15,6 +15,7 @@ import { debounce } from 'lodash-es'
 import type { CommonButtonOption } from '@mobile/components/CommonButtonGroup/types'
 import CommonButtonGroup from '@mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
 import { useSessionStore } from '@shared/stores/session'
+import { useStickyHeader } from '@shared/composables/useStickyHeader'
 import SearchResults from '../components/SearchResults.vue'
 import { useSearchPlugins } from '../plugins'
 import { useSearchLazyQuery } from '../graphql/queries/searchOverview.api'
@@ -169,6 +170,11 @@ const canShowLastSearches = computed(() => {
 
   return (props.type && !found[props.type]?.length) || !canSearch.value
 })
+
+const { headerElement, stickyStyles } = useStickyHeader([
+  loading,
+  () => !!props.type,
+])
 </script>
 
 <script lang="ts">
@@ -199,81 +205,86 @@ export default {
 
 <template>
   <div>
-    <div class="flex p-4">
-      <CommonInputSearch
-        ref="searchInput"
-        v-model="search"
-        wrapper-class="flex-1"
-        :aria-label="$t('Enter search and select a type to search for')"
+    <header ref="headerElement" class="bg-black" :style="stickyStyles.header">
+      <div class="flex p-4">
+        <CommonInputSearch
+          ref="searchInput"
+          v-model="search"
+          wrapper-class="flex-1"
+          class="!h-10"
+          :aria-label="$t('Enter search and select a type to search for')"
+        />
+        <CommonLink
+          link="/"
+          class="flex items-center justify-center text-base text-blue ltr:pl-3 rtl:pr-3"
+        >
+          {{ $t('Cancel') }}
+        </CommonLink>
+      </div>
+      <h1 class="sr-only">{{ $t('Search') }}</h1>
+      <CommonButtonGroup
+        v-if="type"
+        class="border-b border-white/10 px-4 pb-4"
+        as="tabs"
+        :options="searchPills"
+        :model-value="type"
+        @update:model-value="selectType($event as string)"
       />
-      <CommonLink
-        link="/"
-        class="flex items-center justify-center text-lg text-blue ltr:pl-3 rtl:pr-3"
+      <div
+        v-else-if="canSearch"
+        class="mt-8 px-4"
+        data-test-id="selectTypesSection"
       >
-        {{ $t('Cancel') }}
-      </CommonLink>
-    </div>
-    <h1 class="sr-only">{{ $t('Search') }}</h1>
-    <CommonButtonGroup
-      v-if="type"
-      class="border-b border-white/10 px-4 pb-4"
-      as="tabs"
-      :options="searchPills"
-      :model-value="type"
-      @update:model-value="selectType($event as string)"
-    />
-    <div
-      v-else-if="canSearch"
-      class="mt-8 px-4"
-      data-test-id="selectTypesSection"
-    >
-      <CommonSectionMenu
-        :header-label="__('Search for…')"
-        :items="menuSearchTypes"
-      />
-    </div>
-    <div v-if="loading" class="flex h-14 w-full items-center justify-center">
-      <CommonIcon name="mobile-loading" animation="spin" />
-    </div>
-    <div
-      v-else-if="canSearch && type && found[type]?.length"
-      id="search-results"
-      aria-live="polite"
-      role="tabpanel"
-      :aria-busy="loading"
-    >
-      <SearchResults :data="found[type]" :type="type" />
-    </div>
-    <div v-else-if="canSearch && type" class="mt-4 px-4">
-      {{ $t('No entries') }}
-    </div>
-    <div
-      v-if="canShowLastSearches"
-      class="mt-8 px-4"
-      data-test-id="lastSearches"
-    >
-      <div class="text-white/50">{{ $t('Last searches') }}</div>
-      <ul class="pt-3">
-        <li
-          v-for="searchItem in [...lastSearches].reverse()"
-          :key="searchItem"
-          class="pb-4"
-          @click="selectLastSearch(searchItem)"
-        >
-          <button class="flex items-center">
-            <div>
-              <CommonIcon
-                name="mobile-clock"
-                size="small"
-                class="mx-2 text-white/50"
-                decorative
-              />
-            </div>
-            <span class="text-left text-base">{{ searchItem }}</span>
-          </button>
-        </li>
-        <li v-if="!lastSearches.length">{{ $t('No previous searches') }}</li>
-      </ul>
+        <CommonSectionMenu
+          :header-label="__('Search for…')"
+          :items="menuSearchTypes"
+        />
+      </div>
+    </header>
+    <div :style="stickyStyles.body">
+      <div v-if="loading" class="flex h-14 w-full items-center justify-center">
+        <CommonIcon name="mobile-loading" animation="spin" />
+      </div>
+      <div
+        v-else-if="canSearch && type && found[type]?.length"
+        id="search-results"
+        aria-live="polite"
+        role="tabpanel"
+        :aria-busy="loading"
+      >
+        <SearchResults :data="found[type]" :type="type" />
+      </div>
+      <div v-else-if="canSearch && type" class="px-4 pt-4">
+        {{ $t('No entries') }}
+      </div>
+      <div
+        v-if="canShowLastSearches"
+        class="px-4 pt-8"
+        data-test-id="lastSearches"
+      >
+        <div class="text-white/50">{{ $t('Last searches') }}</div>
+        <ul class="pt-3">
+          <li
+            v-for="searchItem in [...lastSearches].reverse()"
+            :key="searchItem"
+            class="pb-4"
+            @click="selectLastSearch(searchItem)"
+          >
+            <button class="flex items-center">
+              <div>
+                <CommonIcon
+                  name="mobile-clock"
+                  size="small"
+                  class="mx-2 text-white/50"
+                  decorative
+                />
+              </div>
+              <span class="text-left text-base">{{ searchItem }}</span>
+            </button>
+          </li>
+          <li v-if="!lastSearches.length">{{ $t('No previous searches') }}</li>
+        </ul>
+      </div>
     </div>
   </div>
 </template>

+ 0 - 1
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue

@@ -90,7 +90,6 @@ const filterAttachments = (article: TicketArticle) => {
   <CommonSectionPopup
     v-model:state="articleContextShown"
     :items="contextOptions"
-    :z-index="9"
   />
   <ArticlesPullDown
     ref="loaderElement"

+ 1 - 1
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewUpdateButton.vue

@@ -9,7 +9,7 @@ const { canUpdateTicket, canSubmitForm, isFormValid } = useTicketInformation()
 <template>
   <button
     v-if="canUpdateTicket"
-    class="relative z-10 h-10 w-10 rounded-full bg-yellow p-1 text-black disabled:bg-yellow-inactive"
+    class="relative h-10 w-10 rounded-full bg-yellow p-1 text-black disabled:bg-yellow-inactive"
     form="form-ticket-edit"
     :disabled="!canSubmitForm"
     :aria-label="$t('Save ticket')"

+ 9 - 2
app/frontend/apps/mobile/pages/ticket/views/TicketCreate.vue

@@ -44,6 +44,7 @@ import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import { useTicketSignature } from '@shared/composables/useTicketSignature'
 import { TicketFormData } from '@shared/entities/ticket/types'
 import { convertFilesToAttachmentInput } from '@shared/utils/files'
+import { useStickyHeader } from '@shared/composables/useStickyHeader'
 import { useTicketCreateMutation } from '../graphql/mutations/create.api'
 
 const router = useRouter()
@@ -427,6 +428,8 @@ onBeforeRouteLeave(async () => {
 })
 
 const { signatureHandling } = useTicketSignature()
+
+const { stickyStyles, headerElement } = useStickyHeader()
 </script>
 
 <script lang="ts">
@@ -459,7 +462,11 @@ export default {
 </script>
 
 <template>
-  <header class="border-b-[0.5px] border-white/10 px-4">
+  <header
+    ref="headerElement"
+    :style="stickyStyles.header"
+    class="border-b-[0.5px] border-white/10 bg-black px-4"
+  >
     <div class="grid h-16 grid-cols-3">
       <div
         class="flex cursor-pointer items-center justify-self-start text-base"
@@ -483,7 +490,7 @@ export default {
       </div>
     </div>
   </header>
-  <div class="flex h-full flex-col px-4 pb-36">
+  <div :style="stickyStyles.body" class="flex h-full flex-col px-4 pb-36">
     <Form
       id="ticket-create"
       ref="form"

+ 23 - 2
app/frontend/apps/mobile/pages/ticket/views/TicketDetailArticlesView.vue

@@ -14,6 +14,7 @@ import type {
   TicketArticleUpdatesSubscriptionVariables,
 } from '@shared/graphql/types'
 import { noop } from 'lodash-es'
+import { useStickyHeader } from '@shared/composables/useStickyHeader'
 import TicketHeader from '../components/TicketDetailView/TicketDetailViewHeader.vue'
 import TicketTitle from '../components/TicketDetailView/TicketDetailViewTitle.vue'
 import TicketArticlesList from '../components/TicketDetailView/ArticlesList.vue'
@@ -133,10 +134,20 @@ const loadPreviousArticles = async () => {
     },
   })
 }
+
+const { stickyStyles, headerElement } = useStickyHeader([
+  isLoadingTicket,
+  ticket,
+])
 </script>
 
 <template>
-  <div class="flex min-h-[calc(100vh_-_5rem)] flex-col pb-20">
+  <div
+    id="ticket-header"
+    ref="headerElement"
+    class="relative backdrop-blur-lg"
+    :style="stickyStyles.header"
+  >
     <TicketHeader
       :ticket="ticket"
       :live-user-list="liveUserList"
@@ -149,6 +160,16 @@ const loadPreviousArticles = async () => {
     >
       <TicketTitle v-if="ticket" :ticket="ticket" />
     </CommonLoader>
+  </div>
+  <div
+    class="flex flex-col pb-16"
+    :style="[
+      stickyStyles.body || {},
+      {
+        minHeight: `calc(100vh - ${stickyStyles.body?.marginTop || '0px'})`,
+      },
+    ]"
+  >
     <CommonLoader
       data-test-id="loader-list"
       :loading="isLoadingTicket"
@@ -163,5 +184,5 @@ const loadPreviousArticles = async () => {
       />
     </CommonLoader>
   </div>
-  <TicketReplyButton v-if="isTicketEditable" />
+  <TicketReplyButton v-if="isTicketEditable" class="z-10" />
 </template>

Some files were not shown because too many files changed in this diff