Browse Source

feat(ui): implement browser back button support for question sheets

Enhance the user experience by allowing the browser's back button to
close active question sheets instead of navigating away from the page.

- Update `useSheetScrollLock` to manage history state using `pushState`
- Implement `popstate` event listener to trigger `onBack` callback
- Add support for an optional `onBack` option in the scroll lock hook
- Clean up redundant CSS rules related to `question-sheet-open`
- Add integration tests to verify sheet closure on `popstate` events
Dev
mortezaei 1 week ago
parent
commit
ebaa273c1b
  1. 35
      src/app/globals.css
  2. 4
      src/components/Componentes/question-birthplace.tsx
  3. 4
      src/components/Componentes/question-date-sheet.tsx
  4. 2
      src/components/Componentes/question-phone.tsx
  5. 31
      src/components/Componentes/question-sheet.test.tsx
  6. 2
      src/components/Componentes/question-sheet.tsx
  7. 42
      src/components/Componentes/use-sheet-scroll-lock.ts

35
src/app/globals.css

@ -253,47 +253,16 @@ body.dropdown-open .app-shell {
margin 300ms ease-in-out; margin 300ms ease-in-out;
} }
body.question-sheet-open .app-shell .question-detail-header {
max-height: 0;
margin-top: -80px;
transform: translateY(-150px);
opacity: 0;
pointer-events: none;
}
body.question-sheet-open .app-shell .question-progress {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
overflow: hidden;
}
body.question-sheet-open .app-shell .question-snap-list {
padding-top: 0;
padding-bottom: 0;
}
body.question-keyboard-open .app-shell .question-snap-list { body.question-keyboard-open .app-shell .question-snap-list {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
} }
body.question-sheet-open .app-shell .question-snap-item[aria-current="step"],
body.question-keyboard-open .app-shell .question-snap-item[aria-current="step"] { body.question-keyboard-open .app-shell .question-snap-item[aria-current="step"] {
top: 0; top: 0;
bottom: 0; bottom: 0;
} }
body.question-sheet-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-content {
margin-top: 8px;
margin-bottom: 0;
transform: none;
}
body.question-keyboard-open body.question-keyboard-open
.app-shell .app-shell
.question-snap-item[aria-current="step"] .question-snap-item[aria-current="step"]
@ -304,10 +273,6 @@ body.question-keyboard-open
transform: translateY(calc(var(--question-keyboard-shift, 0px) * -1)); transform: translateY(calc(var(--question-keyboard-shift, 0px) * -1));
} }
body.question-sheet-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-spacer,
body.question-keyboard-open body.question-keyboard-open
.app-shell .app-shell
.question-snap-item[aria-current="step"] .question-snap-item[aria-current="step"]

4
src/components/Componentes/question-birthplace.tsx

@ -104,8 +104,6 @@ export function QuestionBirthplace({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(""); const [detectedLocation, setDetectedLocation] = useState("");
useSheetScrollLock(isOpen);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => { return () => {
@ -131,6 +129,8 @@ export function QuestionBirthplace({
setIsClosing(false); setIsClosing(false);
}, [disabled]); }, [disabled]);
useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;

4
src/components/Componentes/question-date-sheet.tsx

@ -164,13 +164,13 @@ export function QuestionDateSheet({
const [selectedDay, setSelectedDay] = useState(parts[2] || "01"); const [selectedDay, setSelectedDay] = useState(parts[2] || "01");
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
useSheetScrollLock(true);
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
setIsClosing(true); setIsClosing(true);
window.setTimeout(onClose, EXIT_ANIMATION_MS); window.setTimeout(onClose, EXIT_ANIMATION_MS);
}, [onClose]); }, [onClose]);
useSheetScrollLock(true, { onBack: closeSheet });
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {

2
src/components/Componentes/question-phone.tsx

@ -363,7 +363,7 @@ export function QuestionPhone({
); );
}, [countryList, searchQuery]); }, [countryList, searchQuery]);
useSheetScrollLock(isOpen);
useSheetScrollLock(isOpen, { onBack: closeSheet });
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;

31
src/components/Componentes/question-sheet.test.tsx

@ -181,6 +181,37 @@ describe("QuestionSheet component", () => {
}); });
}); });
it("closes the sheet instead of leaving the page on browser Back", async () => {
const question = {
id: "q_back",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: [{ id: "iran", value: "Iran", label: "ایران", order: 1 }],
ui_config: {},
} as QuestionField;
const pushState = vi.spyOn(window.history, "pushState");
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i }));
expect(pushState).toHaveBeenCalledTimes(1);
window.dispatchEvent(new PopStateEvent("popstate"));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
pushState.mockRestore();
});
it("opens a searchable sheet without focusing search", () => { it("opens a searchable sheet without focusing search", () => {
const question = { const question = {
id: "q_country", id: "q_country",

2
src/components/Componentes/question-sheet.tsx

@ -53,7 +53,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
setIsClosing(false); setIsClosing(false);
}, [disabled]); }, [disabled]);
useSheetScrollLock(isOpen);
useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {

42
src/components/Componentes/use-sheet-scroll-lock.ts

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings"; import { viewPaddingsBridge } from "@/lib/view-paddings";
let activeSheetCount = 0; let activeSheetCount = 0;
@ -12,11 +12,35 @@ let initialHtmlOverflow = "";
let initialAppShellOverflow = ""; let initialAppShellOverflow = "";
let initialAppShellTouchAction = ""; let initialAppShellTouchAction = "";
let lockedAppShell: HTMLElement | null = null; let lockedAppShell: HTMLElement | null = null;
const SHEET_HISTORY_KEY = "__habibQuestionSheet";
type SheetScrollLockOptions = {
onBack?: () => void;
};
export function useSheetScrollLock(
isOpen: boolean,
options: SheetScrollLockOptions = {},
) {
const { onBack } = options;
const onBackRef = useRef(onBack);
onBackRef.current = onBack;
export function useSheetScrollLock(isOpen: boolean) {
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const historyState = window.history.state;
const ownsHistoryEntry =
historyState?.[SHEET_HISTORY_KEY] !== true && activeSheetCount === 0;
if (ownsHistoryEntry) {
window.history.pushState(
{ ...(historyState ?? {}), [SHEET_HISTORY_KEY]: true },
"",
window.location.href,
);
}
if (activeSheetCount === 0) { if (activeSheetCount === 0) {
bodyHadDropdownClass = document.body.classList.contains("dropdown-open"); bodyHadDropdownClass = document.body.classList.contains("dropdown-open");
initialBodyOverflow = document.body.style.overflow; initialBodyOverflow = document.body.style.overflow;
@ -36,9 +60,23 @@ export function useSheetScrollLock(isOpen: boolean) {
lockedAppShell.style.touchAction = "none"; lockedAppShell.style.touchAction = "none";
} }
const handlePopState = () => {
if (ownsHistoryEntry) {
onBackRef.current?.();
}
};
window.addEventListener("popstate", handlePopState);
return () => { return () => {
window.removeEventListener("popstate", handlePopState);
activeSheetCount = Math.max(0, activeSheetCount - 1); activeSheetCount = Math.max(0, activeSheetCount - 1);
if (activeSheetCount === 0) { if (activeSheetCount === 0) {
if (
ownsHistoryEntry &&
window.history.state?.[SHEET_HISTORY_KEY] === true
) {
window.history.back();
}
document.body.style.overflow = initialBodyOverflow; document.body.style.overflow = initialBodyOverflow;
document.documentElement.style.overflow = initialHtmlOverflow; document.documentElement.style.overflow = initialHtmlOverflow;
if (lockedAppShell) { if (lockedAppShell) {

Loading…
Cancel
Save