Skip to content

Commit

Permalink
rework(AraTabs): tabs go to nested routes
Browse files Browse the repository at this point in the history
* Each tab has its slug as a route parameter
* Default redirect to first tab
* History management between tabs
* Better scroll behavior:
  * generally try to display tabs at the top of the screen when navigating between tabs
  * remember saved positions (mimics native browser behavior)
  * Dynamic scroll margin to always scroll to the proper position
* Add a "useResizeObserver" (taken from vueuse project)
  • Loading branch information
yaaax committed Feb 25, 2025
1 parent 4e531ae commit d45a692
Show file tree
Hide file tree
Showing 18 changed files with 957 additions and 313 deletions.
245 changes: 183 additions & 62 deletions confiture-web-app/src/components/audit/AraTabs.vue
Original file line number Diff line number Diff line change
@@ -1,65 +1,172 @@
<!--
This component is only used to replicate DSFR tabs with sticky functionality.
For tabs that have no sticky needs, please use `fr-tabs` instead of this component.
This component is used to replicate DSFR tabs with:
- sticky functionality
- routing behaviour, one nested route per tab
For "regular" tabs, please use `fr-tabs` instead of this component.
https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/onglet
-->

<script setup lang="ts" generic="T">
import { type Component, ref, watch } from "vue";
<script setup lang="ts">
import { computed, onMounted, ref, watchEffect } from "vue";
import { onBeforeRouteUpdate, useRouter } from "vue-router";

import { useResizeObserver } from "../../composables/useResizeObserver";
import { useUniqueId } from "../../composables/useUniqueId";
import { AraTabsTabData } from "./AraTabsTabData";

const props = defineProps<{
tabs: {
label: string;
icon?: Component;
data: T;
}[];
selectedTab?: number;
stickyTop?: string;
}>();
/** Types */

defineSlots<{
panel(props: { i: number; data: T }): void;
}>();
export interface TabsRouteParams {
name: string;
params: {
uniqueId: string;
tabSlug?: string;
};
}

/**
* Props
* - tabs: array of tab data objects
* - routeParams: route parameters common to all tabs
* - selectedTabIndex: the selected tab index. Default is 0 (first one).
* - stickyTop: CSS top value (e.g. "0", "4px" or "1rem"). Default is "0";
*/
const props = withDefaults(
defineProps<{
tabs: AraTabsTabData[];
route: TabsRouteParams;
selectedTabSlug: string;
stickyTop?: string;
panelScrollBehavior?: "tabsTop" | "sameCriteria";
}>(),
{
stickyTop: "0",
panelScrollBehavior: "tabsTop"
}
);

/** Refs */
const selectedTabSlug = ref(props.selectedTabSlug);
const stickyTop = ref(props.stickyTop);
const tabButtonsRef = ref<HTMLButtonElement[]>();
const panelBottomMarkerRef = ref<HTMLDivElement>();
const panelMinHeight = ref<string>("0");

/** Composables */
const uniqueId = useUniqueId();

/** Routing */
const router = useRouter();

/** Event: "selectedTabChange" */
const emit = defineEmits<{
(e: "change", currentTab: number): void;
(e: "selectedTabChange", selectedTabIndex: number): void;
}>();

const uniqueId = useUniqueId();
const tabId = (i: number) => "tab-" + uniqueId.value + "-" + i;
const panelId = (i: number) => "panel-" + uniqueId.value + "-" + i;

const currentTab = ref(props.selectedTab || 0);
const tabControlRefs = ref<HTMLButtonElement[]>();

const selectNextTab = () => {
currentTab.value = (currentTab.value + 1) % props.tabs.length;
tabControlRefs.value?.at(currentTab.value)?.focus();
};

const selectPreviousTab = () => {
if (currentTab.value === 0) {
currentTab.value = props.tabs.length - 1;
} else {
currentTab.value -= 1;
/** Computed propoerties */
const selectedTab = computed(() => {
return props.tabs[selectedTabIndex.value];
});

/** Writable computed properties */
const selectedTabIndex = computed({
get(prevTabIndex) {
let foundIndex = props.tabs.findIndex(
(tabData) => tabData.slug === selectedTabSlug.value
);
if (foundIndex === -1) {
return prevTabIndex;
} else {
return foundIndex;
}
},
set(newTabIndex) {
selectedTabSlug.value = props.tabs[newTabIndex].slug;
}
tabControlRefs.value?.at(currentTab.value)?.focus();
};
});

/** Functions */

function tabId(i: number) {
return "tab-" + uniqueId.value + "-" + i;
}

const selectFirstTab = () => {
currentTab.value = 0;
tabControlRefs.value?.at(currentTab.value)?.focus();
};
function panelId(i: number) {
return "panel-" + uniqueId.value + "-" + i;
}

const selectLastTab = () => {
currentTab.value = props.tabs.length - 1;
tabControlRefs.value?.at(currentTab.value)?.focus();
};
function selectTab(i: number) {
if (i === selectedTabIndex.value) {
return;
}

watch(currentTab, (currentTab) => {
emit("change", currentTab);
selectedTabIndex.value = i;
tabButtonsRef.value?.at(i)?.focus();

// Change route
router.push({
...props.route,
params: {
...props.route.params,
tabSlug: props.tabs[i].slug
}
});
}

function selectNextTab() {
selectTab((selectedTabIndex.value + 1) % props.tabs.length);
}

function selectPreviousTab() {
const len = props.tabs.length;
selectTab((selectedTabIndex.value - 1 + len) % len);
}

function selectFirstTab() {
selectTab(0);
}

function selectLastTab() {
selectTab(props.tabs.length - 1);
}

/** Lifecycle hooks */

onMounted(() => {
// Dynamic panel minimum height.
// Allows tabs to stick to the top of the screen
// even if content is not high enough
const tabsEl = document.getElementsByClassName(
"tabs-wrapper"
)[0] as HTMLElement;
const bodyEl = document.getElementsByTagName("body")[0] as HTMLElement;
useResizeObserver(bodyEl, () => {
panelMinHeight.value = `calc( 100vh - (${stickyTop.value}) - ${
tabsEl.clientHeight +
(bodyEl.getBoundingClientRect().bottom -
panelBottomMarkerRef.value!.getBoundingClientRect().top)
}px )`;
});
});

/** Watchers */
watchEffect(() => {
// stickyTop can change on window resize
stickyTop.value = props.stickyTop;

selectedTabSlug.value = props.tabs[selectedTabIndex.value].slug;

// other components may be interested by the current selected tab index
emit("selectedTabChange", selectedTabIndex.value);
});

/** Navigation guards */

onBeforeRouteUpdate(async (to, from) => {
// When going back
if (to.params.tabSlug !== from.params.tabSlug) {
selectedTabSlug.value = to.params.tabSlug as string;
}
});
</script>

Expand All @@ -69,17 +176,21 @@ watch(currentTab, (currentTab) => {
-->

<template>
<div class="tabs-wrapper" :style="{ '--tabs-top-offset': stickyTop }">
<div
class="tabs-wrapper"
:data-panel-scroll-behavior="panelScrollBehavior"
:style="{ '--tabs-top-offset': stickyTop }"
>
<ul role="tablist" class="tabs">
<li v-for="(tab, i) in tabs" :key="i" role="presentation">
<button
:id="tabId(i)"
ref="tabControlRefs"
ref="tabButtonsRef"
role="tab"
:aria-controls="panelId(i)"
:aria-selected="i === currentTab ? 'true' : 'false'"
:tabindex="i === currentTab ? undefined : '-1'"
@click="currentTab = i"
:aria-selected="i === selectedTabIndex ? 'true' : 'false'"
:tabindex="i === selectedTabIndex ? undefined : '-1'"
@click="selectTab(i)"
@keydown.right.down.prevent="selectNextTab"
@keydown.left.up.prevent="selectPreviousTab"
@keydown.home.prevent="selectFirstTab"
Expand All @@ -92,19 +203,24 @@ watch(currentTab, (currentTab) => {
</li>
</ul>
</div>
<div class="panel-container">
<template v-for="(tab, i) in tabs" :key="i">
<div
:id="panelId(i)"
:aria-labelledby="tabId(i)"
:class="{ visible: i === currentTab }"
role="tabpanel"
tabindex="0"
<div class="panel-container" :style="{ '--min-height': panelMinHeight }">
<RouterView v-slot="{ Component }">
<!-- Component should be AraTabsPanel (see router) -->
<component
:is="Component"
:panel-id="panelId(selectedTabIndex)"
:labelled-by="tabId(selectedTabIndex)"
:component-params="selectedTab.componentParams"
>
<slot v-if="i === currentTab" name="panel" :data="tab.data" :i="i" />
</div>
</template>
<component
:is="selectedTab.component"
v-bind="selectedTab.componentParams"
>
</component>
</component>
</RouterView>
</div>
<div ref="panelBottomMarkerRef"></div>
</template>

<style scoped>
Expand Down Expand Up @@ -217,5 +333,10 @@ li {
border: 1px solid var(--border-default-grey);
border-top: none;
padding: 2rem;
/**
* Allow tabs to stick to the top of the screen
* even if content is not high enough:
*/
min-height: var(--min-height);
}
</style>
27 changes: 27 additions & 0 deletions confiture-web-app/src/components/audit/AraTabsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup lang="ts">
/** Types */
interface AraTabsPanelSlots {
default: () => void;
}
/** Slots */
defineSlots<AraTabsPanelSlots>();
/** Props */
const { panelId, labelledBy } = defineProps<{
panelId: string;
labelledBy: string;
}>();
</script>

<template>
<div
:id="panelId"
:aria-labelledby="labelledBy"
role="tabpanel"
tabindex="0"
class="visible"
>
<slot name="default"></slot>
</div>
</template>
32 changes: 32 additions & 0 deletions confiture-web-app/src/components/audit/AraTabsTabData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type Component } from "vue";

import LayoutIcon from "../../components/icons/LayoutIcon.vue";
import { slugify } from "../../utils";

export class AraTabsTabData {
label: string;
#slug: string; // do not allow slug to be defined from outside
icon: typeof LayoutIcon | undefined;
component: Component;
componentParams: object | undefined;

constructor(data: {
label: string;
icon?: typeof LayoutIcon;
component: Component;
componentParams?: object;
}) {
const label = data.label;
this.label = label;
this.#slug = slugify(label);
this.icon = data.icon;
this.component = data.component;
this.componentParams = data.componentParams;
}

// TODO: check how to expose "slug" to DevTools
// Currently it is not exposed because #slug is private
public get slug(): string {
return this.#slug;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ onMounted(() => {

<div class="fr-col-12 fr-col-sm-7 fr-col-md-9 sub-header">
<SaveIndicator
v-if="route.name === 'audit-generation'"
v-if="route.name === 'audit-generation-full'"
class="audit-main-indicator"
/>
<ul class="top-actions fr-my-0 fr-p-0" role="list">
Expand Down
12 changes: 5 additions & 7 deletions confiture-web-app/src/components/audit/AuditSettingsForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed, nextTick, ref, toRaw, watch } from "vue";
import { useRoute } from "vue-router";
import { usePreviousRoute } from "../../composables/usePreviousRoute";
import router from "../../router";
import { useAccountStore } from "../../store/account";
import { AuditPage, AuditType, CreateAuditRequestData } from "../../types";
import { formatEmail } from "../../utils";
Expand Down Expand Up @@ -112,7 +113,7 @@ const backLinkLabel = computed(() => {
:label="backLinkLabel"
:to="{
name: previousRoute.route?.name || 'audit-overview',
params: { uniqueId: route.params.uniqueId }
params: previousRoute.route?.params
}"
/>

Expand Down Expand Up @@ -189,15 +190,12 @@ const backLinkLabel = computed(() => {
Enregistrer les modifications
</button>

<RouterLink
<button
class="fr-btn fr-btn--tertiary-no-outline fr-ml-2w"
:to="{
name: 'audit-generation',
params: { uniqueId: route.params.uniqueId }
}"
@click="router.back()"
>
Annuler
</RouterLink>
</button>
</div>

<div class="top-link">
Expand Down
Loading

0 comments on commit d45a692

Please sign in to comment.