From 0dd8bf21246fd4bb15690d8e78d09b798dbfcd76 Mon Sep 17 00:00:00 2001 From: Fernando Terra <79578735+fterra-encora@users.noreply.github.com> Date: Wed, 27 Dec 2023 19:36:30 -0300 Subject: [PATCH 1/9] Fix/mobile screen issues (#687) * fix: prevent horizontal scroll on small screens * fix: prevent automatic zoom on mobile devices When clicking an input field. * feat: scroll to step's title on Next/Back * feat: add hook * feat: add method to "safely" focus a component When moving from one step to another. * feat: add isTouchScreen * feat: skip scroll if not suitable * feat: remove tooltip on touch screens * fix: stretch Submit button in BCSC form * fix: stretch components on small screen * fix: scroll to the top-notification on error * fix: scroll to the top-notification in BCSC form * feat: improve hook code * test: useElementVisibility * style: fix linting issues --------- Co-authored-by: Paulo Gomes da Cruz Junior Co-authored-by: Maria Martinez <77364706+mamartinezmejia@users.noreply.github.com> --- frontend/index.html | 2 +- frontend/src/assets/styles/global.scss | 45 ++++++-- .../grouping/AddressGroupComponent.vue | 6 +- .../grouping/ContactGroupComponent.vue | 6 +- .../src/composables/useElementVisibility.ts | 44 ++++++++ frontend/src/composables/useFocus.ts | 48 ++++++++- frontend/src/composables/useScreenSize.ts | 7 +- frontend/src/pages/FormBCSCPage.vue | 24 +++-- frontend/src/pages/FormBCeIDPage.vue | 54 +++++++--- .../src/pages/bceidform/AddressWizardStep.vue | 6 +- .../src/pages/bceidform/ContactWizardStep.vue | 6 +- .../composables/useElementVisibility.spec.ts | 102 ++++++++++++++++++ 12 files changed, 296 insertions(+), 54 deletions(-) create mode 100644 frontend/src/composables/useElementVisibility.ts create mode 100644 frontend/tests/unittests/composables/useElementVisibility.spec.ts diff --git a/frontend/index.html b/frontend/index.html index a9f630a1c5..31b4a2fa90 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + Forest Client diff --git a/frontend/src/assets/styles/global.scss b/frontend/src/assets/styles/global.scss index 26b7b99184..162fc34b36 100644 --- a/frontend/src/assets/styles/global.scss +++ b/frontend/src/assets/styles/global.scss @@ -85,6 +85,9 @@ --cds-support-error: #e72000; --light-theme-background-background-selected: #93939533; + + //Layout + --header-height: 3.5rem; } *, @@ -321,7 +324,7 @@ div#app { align-items: center; background: var(--light-theme-layer-layer-02, #fff); flex-grow: 1; - margin-top: 3.5rem; + margin-top: var(--header-height); } .full { @@ -338,7 +341,7 @@ div#app { align-items: stretch; background: var(--light-theme-background-background, #fff); flex-grow: 1; - margin-top: 3.5rem; + margin-top: var(--header-height); } .headers { @@ -356,7 +359,7 @@ div#app { } .headers cds-header { - height: 3.5rem; + height: var(--header-height); } .headers a img { @@ -532,7 +535,7 @@ cds-actionable-notification * { .content { flex-grow: 1; - align-self: flex-start; + align-self: stretch; display: flex; flex-direction: column; gap: 2rem; @@ -649,21 +652,23 @@ cds-actionable-notification * { .form-footer { align-self: stretch; display: flex; - align-items: flex-start; + align-items: stretch; + flex-direction: column; gap: 1rem; } .form-footer-group { display: flex; - width: 34.75rem; - align-items: flex-start; + align-items: stretch; + flex-direction: column; gap: 1rem; } .form-footer-group-next { display: flex; flex-direction: column; - align-items: flex-start; + align-items: stretch; + align-self: stretch; gap: 1rem; } @@ -1270,6 +1275,21 @@ cds-header-panel[expanded] { margin-bottom: 1rem; } +/* +Useful for scrolling to the *start* of an HTML element without having it covered under the page's header. +*/ +.header-offset { + position: relative; + top: calc(-1 * var(--header-height)); +} + +.hide-when-less-than-two-children { + display: none; +} +.hide-when-less-than-two-children:has(:nth-child(2)) { + display: unset; +} + /* Small (up to 671px) */ @media screen and (max-width: 671px) { .wizard-head-toast { @@ -1344,12 +1364,10 @@ cds-header-panel[expanded] { .form-steps { align-self: stretch; - width: 18rem; } .form-steps-01 { align-self: stretch; - width: 18rem; } .form-steps-01-title { @@ -1360,6 +1378,7 @@ cds-header-panel[expanded] { .form-footer-group-buttons { flex-direction: column-reverse; justify-content: flex-end; + align-items: stretch; } .full-centered { @@ -1451,6 +1470,12 @@ cds-header-panel[expanded] { .card { padding: 1.5rem; } + + cds-tooltip:has(cds-button) { + display: flex; + flex-direction: column; + align-self: stretch; + } } /* Medium (from 672px to 1055px) */ diff --git a/frontend/src/components/grouping/AddressGroupComponent.vue b/frontend/src/components/grouping/AddressGroupComponent.vue index b6d96a7f6b..3a11d24a2b 100644 --- a/frontend/src/components/grouping/AddressGroupComponent.vue +++ b/frontend/src/components/grouping/AddressGroupComponent.vue @@ -32,7 +32,7 @@ const emit = defineEmits<{ }>(); const generalErrorBus = useEventBus("general-error-notification"); -const { setFocusedComponent } = useFocus(); +const { safeSetFocusedComponent } = useFocus(); const noValidation = (value: string) => ""; @@ -232,8 +232,8 @@ watch([detailsData], () => { }); onMounted(() => { - if (props.id == 0) setFocusedComponent(`addr_${props.id}`, 800); - else setFocusedComponent(`name_${props.id}`, 200); + if (props.id == 0) safeSetFocusedComponent(`addr_${props.id}`, 800); + else safeSetFocusedComponent(`name_${props.id}`, 200); }); diff --git a/frontend/src/components/grouping/ContactGroupComponent.vue b/frontend/src/components/grouping/ContactGroupComponent.vue index 08d95ba03f..2c34651248 100644 --- a/frontend/src/components/grouping/ContactGroupComponent.vue +++ b/frontend/src/components/grouping/ContactGroupComponent.vue @@ -33,7 +33,7 @@ const emit = defineEmits<{ (e: "remove", value: number): void; }>(); -const { setFocusedComponent } = useFocus(); +const { safeSetFocusedComponent } = useFocus(); const noValidation = (value: string) => ""; //We set it as a separated ref due to props not being updatable @@ -93,9 +93,9 @@ const nameTypesToCodeDescr = ( onMounted(() => { if (props.id === 0) { - setFocusedComponent(`phoneNumber_${props.id}`, 800); + safeSetFocusedComponent(`phoneNumber_${props.id}`, 800); } else { - setFocusedComponent(`firstName_${props.id}`, 800); + safeSetFocusedComponent(`firstName_${props.id}`, 800); } }); diff --git a/frontend/src/composables/useElementVisibility.ts b/frontend/src/composables/useElementVisibility.ts new file mode 100644 index 0000000000..4b77805f5d --- /dev/null +++ b/frontend/src/composables/useElementVisibility.ts @@ -0,0 +1,44 @@ +import { useIntersectionObserver } from "@vueuse/core"; +import { ref } from "vue"; +import type { Ref } from "vue"; + +const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'; +const defaultWindow = isClient ? window : void 0; + +// Based on @vueuse/core +export default function useElementVisibility(element, options = {}) { + const { window = defaultWindow, scrollTarget, threshold } = options; + const elementIsVisible = ref(undefined); + + let done: () => void; + const elementIsVisibleRefPromise = new Promise>((resolve) => { + done = () => { + resolve(elementIsVisible); + }; + }); + const { stop } = useIntersectionObserver( + element, + (intersectionObserverEntries) => { + let isIntersecting = elementIsVisible.value + + // Get the latest value of isIntersecting based on the entry time + let latestTime = 0; + for (const entry of intersectionObserverEntries) { + if (entry.time >= latestTime) { + latestTime = entry.time; + isIntersecting = entry.isIntersecting; + } + } + elementIsVisible.value = isIntersecting; + + // signalize the value is ready + done(); + }, + { + root: scrollTarget, + window, + threshold, + } + ); + return { elementIsVisibleRefPromise, stop }; +} diff --git a/frontend/src/composables/useFocus.ts b/frontend/src/composables/useFocus.ts index 93b4e7efd3..e6dddba75c 100644 --- a/frontend/src/composables/useFocus.ts +++ b/frontend/src/composables/useFocus.ts @@ -1,5 +1,7 @@ import { ref } from 'vue' import type { Ref } from 'vue' +import { isTouchScreen } from "./useScreenSize"; +import useElementVisibility from "./useElementVisibility"; type OptionalElement = Element | HTMLElement | undefined @@ -12,12 +14,16 @@ export const useFocus = (): { componentName: string, time?: number, callback?: (refComponent: Ref) => void, - ) => Ref + ) => Ref; + safeSetFocusedComponent: ( + componentName: string, + time?: number, + ) => Ref; setScrollPoint: ( componentName: string, time?: number, callback?: (refComponent: Ref) => void, - ) => Ref + ) => Ref; } => { // setActionOn is a function that execute the action on an a component const setActionOn = ( @@ -86,6 +92,42 @@ export const useFocus = (): { time, callback, ) + + /** + * Set the focus on a component with the data-focus attribute as long as: + * - the component is already visible (100%); + * - and the device is not a touch device (because we want to prevent the touch device's + * virtual keyboard from either covering the component or triggering automatic scroll). + * + * Otherwise, does nothing. + */ + const safeSetFocusedComponent = ( + componentName: string, + time: number = 100, + ): Ref => { + return execute( + componentName, + "data-focus", + async (element) => { + if (!(element instanceof HTMLElement) || isTouchScreen.value) { + return; + } + + const { elementIsVisibleRefPromise, stop } = useElementVisibility(element, { + threshold: 1, + }); + + const elementIsVisibleRef = await elementIsVisibleRefPromise; + stop(); + if (!elementIsVisibleRef.value) { + return; + } + element.focus(); + }, + time, + ); + }; + // Scroll into view a component with the data-scroll attribute const setScrollPoint = ( componentName: string, @@ -100,5 +142,5 @@ export const useFocus = (): { callback, ) - return { setFocusedComponent, setScrollPoint } + return { safeSetFocusedComponent, setFocusedComponent, setScrollPoint }; } diff --git a/frontend/src/composables/useScreenSize.ts b/frontend/src/composables/useScreenSize.ts index 5157574a08..d196b2349d 100644 --- a/frontend/src/composables/useScreenSize.ts +++ b/frontend/src/composables/useScreenSize.ts @@ -1,4 +1,5 @@ -import { useMediaQuery } from '@vueuse/core'; +import { useMediaQuery } from "@vueuse/core"; -export const isSmallScreen = useMediaQuery('(max-width: 671px)') -export const isMediumScreen = useMediaQuery('(min-width: 672px) and (max-width: 1055px)') \ No newline at end of file +export const isSmallScreen = useMediaQuery("(max-width: 671px)"); +export const isMediumScreen = useMediaQuery("(min-width: 672px) and (max-width: 1055px)"); +export const isTouchScreen = useMediaQuery("(hover: none)"); diff --git a/frontend/src/pages/FormBCSCPage.vue b/frontend/src/pages/FormBCSCPage.vue index 74e06a2fc3..3ec66ec919 100644 --- a/frontend/src/pages/FormBCSCPage.vue +++ b/frontend/src/pages/FormBCSCPage.vue @@ -13,6 +13,7 @@ import { } from "@/dto/CommonTypesDto"; import { useFetchTo, usePost } from "@/composables/useFetch"; import { useFocus } from "@/composables/useFocus"; +import { isTouchScreen } from "@/composables/useScreenSize"; import { useEventBus } from "@vueuse/core"; import { codeConversionFn, @@ -38,7 +39,7 @@ import Check16 from "@carbon/icons-vue/es/checkmark/16"; import Logout16 from "@carbon/icons-vue/es/logout/16"; import ForestClientUserSession from "@/helpers/ForestClientUserSession"; -const { setFocusedComponent } = useFocus(); +const { safeSetFocusedComponent } = useFocus(); const errorMessage = ref(""); const submitterInformation = ForestClientUserSession.user; @@ -118,7 +119,7 @@ const { setScrollPoint } = useFocus(); const scrollToNewContact = () => { setScrollPoint("", undefined, () => { - setFocusedComponent(""); + safeSetFocusedComponent(""); }); }; @@ -138,7 +139,7 @@ const addContact = (autoFocus = true) => { ); if (autoFocus) { const focusIndex = newLength - 1; - setFocusedComponent(`firstName_${focusIndex}`); + safeSetFocusedComponent(`firstName_${focusIndex}`); } return newLength; }; @@ -327,7 +328,7 @@ watch([error], () => { errorMsg: errorItem.errorMsg, }) ); - setScrollPoint("top"); + setScrollPoint("top-notification"); }); @@ -338,10 +339,15 @@ watch([error], () => { New client application - +
+
+ +
@@ -489,7 +495,7 @@ watch([error], () => { Submit application - + All fields must be filled in correctly. diff --git a/frontend/src/pages/FormBCeIDPage.vue b/frontend/src/pages/FormBCeIDPage.vue index 072fa71004..cf6be3e87b 100644 --- a/frontend/src/pages/FormBCeIDPage.vue +++ b/frontend/src/pages/FormBCeIDPage.vue @@ -10,7 +10,7 @@ import { useEventBus } from "@vueuse/core"; import { useRouter } from "vue-router"; import { useFocus } from "@/composables/useFocus"; import { usePost } from "@/composables/useFetch"; -import { isSmallScreen } from "@/composables/useScreenSize"; +import { isSmallScreen, isTouchScreen } from "@/composables/useScreenSize"; // Imported Pages import BusinessInformationWizardStep from "@/pages/bceidform/BusinessInformationWizardStep.vue"; import AddressWizardStep from "@/pages/bceidform/AddressWizardStep.vue"; @@ -56,7 +56,7 @@ const progressIndicatorBus = useEventBus( ); const router = useRouter(); -const { setScrollPoint, setFocusedComponent } = useFocus(); +const { setScrollPoint, safeSetFocusedComponent } = useFocus(); const submitterInformation = ForestClientUserSession.user; const instance = getCurrentInstance(); const session = instance?.appContext.config.globalProperties.$session; @@ -130,7 +130,7 @@ watch([error], () => { errorMsg: errorItem.errorMsg, }) ); - setScrollPoint("top"); + setScrollPoint("top-notification"); }); addValidation( @@ -271,8 +271,10 @@ const onNext = () => { currentTab.value++; progressData[currentTab.value - 1].kind = "complete"; progressData[currentTab.value].kind = "current"; + setScrollPoint("step-title"); + } else { + setScrollPoint("top-notification"); } - setScrollPoint("top"); } }; const onBack = () => { @@ -280,7 +282,7 @@ const onBack = () => { currentTab.value--; progressData[currentTab.value + 1].kind = "incomplete"; progressData[currentTab.value].kind = "current"; - setScrollPoint("top"); + setScrollPoint("step-title"); setTimeout(revalidateBus.emit, 1000); } }; @@ -355,7 +357,7 @@ const scrollToNewContact = () => { // Skip auto-focus so to do it only when scroll is done. const index = contactWizardRef.value.addContact(false) - 1; setScrollPoint(`additional-contact-${index}`, undefined, () => { - setFocusedComponent(`firstName_${index}`); + safeSetFocusedComponent(`firstName_${index}`); }); } }; @@ -364,7 +366,10 @@ const scrollToNewContact = () => {