commit 24cc7acd687d4c2bd875b9d70ed1cab1a29c2fb8
parent a5c4d9fcfee5b5d00a12c26242e375cd007033ef
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 23:55:05 +0100
merchant web UI: stabilize floating form actions and page geometry
Align sticky actions with page and modal gutters, applying glass only
while the bar floats. Keep wizard actions within the fields column and
use inline actions for Personalization. Reserve space for changing form
content.
Diffstat:
8 files changed, 229 insertions(+), 22 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx b/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx
@@ -132,7 +132,7 @@ export function PersonalizationScreen(): VNode {
</div>
{/* Explicit Save Action */}
- <FormActionFooter>
+ <FormActionFooter variant="inline">
{saveMessage && (
<span role="status" class="text-xs font-semibold text-emerald-700">
{saveMessage}
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/ReservedSpace.tsx b/packages/taler-merchant-webui/src/screens/token-setup/ReservedSpace.tsx
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { ComponentChildren } from "preact";
+
+/** Reserve the larger intrinsic size before switching, including wrapped text.
+ * Alternatives must be presentation-only: no controls, IDs, or side effects.
+ */
+export function ReservedSpace({
+ children,
+ alternative,
+ text = false,
+}: {
+ children: ComponentChildren;
+ alternative: ComponentChildren;
+ text?: boolean;
+}) {
+ const Tag = text ? "span" : "div";
+ return (
+ <Tag class="grid min-w-0">
+ <Tag class="min-w-0" style={{ gridArea: "1 / 1" }}>
+ {children}
+ </Tag>
+ <Tag
+ aria-hidden="true"
+ class="invisible pointer-events-none min-w-0"
+ style={{ gridArea: "1 / 1" }}
+ >
+ {alternative}
+ </Tag>
+ </Tag>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/stories/StoryViewerScreen.tsx b/packages/taler-merchant-webui/src/stories/StoryViewerScreen.tsx
@@ -146,7 +146,7 @@ export function StoryViewerScreen(): VNode {
: [];
return (
- <div class="min-h-screen bg-gray-100 p-4 sm:p-6 text-gray-900">
+ <div class="storybook-page min-h-screen bg-gray-100 text-gray-900">
<div class="max-w-screen-2xl mx-auto space-y-6">
<header
data-storybook-chrome
@@ -281,7 +281,7 @@ export function StoryViewerScreen(): VNode {
) : (
<div
key={`${selectedStory.id}:${activeDataSetId || "default"}`}
- class="border border-gray-300 rounded-xl bg-white shadow-sm p-6"
+ class="storybook-framed-preview border border-gray-300 rounded-xl bg-white shadow-sm p-6"
>
{selectedStory.render(activeDataSetId)}
</div>
diff --git a/packages/taler-merchant-webui/src/style.css b/packages/taler-merchant-webui/src/style.css
@@ -2,6 +2,86 @@
@tailwind components;
@tailwind utilities;
+/* Desktop actions have room to breathe and expand left when feedback appears.
+ * The controls stay right-aligned; feedback is visible throughout the resize.
+ * Browsers without intrinsic-size interpolation switch widths immediately.
+ */
+.floating-action-footer {
+ width: 100%;
+}
+
+@screen md {
+ .floating-action-footer {
+ width: max-content;
+ min-width: min(28rem, var(--form-action-max-width, 100%));
+ max-width: var(--form-action-max-width, 100%);
+ margin-left: auto;
+ margin-right: var(--form-action-end-space, 0px);
+ interpolate-size: allow-keywords;
+ transition: width 160ms ease-out;
+ }
+
+ .floating-action-footer:has(> [data-action-feedback]:not(:empty)) {
+ width: var(--form-action-max-width, 100%);
+ }
+}
+
+/* Wizard navigation spans the fields column in every state. Feedback adds
+ * height above the controls without resizing the action row horizontally.
+ */
+.token-setup-wizard > .floating-action-footer {
+ width: var(--form-action-max-width, 100%);
+ transition: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .floating-action-footer {
+ transition: none;
+ }
+}
+
+/* Match the sticky inset to the space after the form. At the end of scrolling,
+ * its normal-flow position then coincides with its floating position. Keep
+ * page gutters and the inset together so no scroll measurements are needed.
+ */
+.merchant-page-content,
+.storybook-page {
+ --page-gutter: 1rem;
+ --form-action-bottom: max(var(--page-gutter), env(safe-area-inset-bottom));
+ padding: var(--page-gutter);
+ padding-bottom: var(--form-action-bottom);
+}
+
+@screen sm {
+ .merchant-page-content {
+ --page-gutter: 1.25rem;
+ }
+ .storybook-page {
+ --page-gutter: 1.5rem;
+ }
+}
+
+@screen md {
+ .merchant-page-content {
+ --page-gutter: 1.5rem;
+ }
+}
+
+/* Framed catalogue previews add their own padding and one-pixel border after
+ * the form. Unframed previews inherit only the page gutter.
+ */
+.storybook-framed-preview {
+ --form-action-bottom: calc(
+ max(var(--page-gutter), env(safe-area-inset-bottom)) + 1.5rem + 1px
+ );
+}
+
+/* A modal is a separate scroll area; do not inherit the page's inset. */
+.modal-scroll-content {
+ --form-action-bottom: max(1.5rem, env(safe-area-inset-bottom));
+ padding-bottom: var(--form-action-bottom);
+}
+
/* Custom scrollbars for sidebars and menus */
.custom-scrollbar::-webkit-scrollbar,
aside nav::-webkit-scrollbar {
diff --git a/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx b/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx
@@ -9,6 +9,57 @@ import { useCallback, useLayoutEffect, useRef, useState } from "preact/hooks";
import { ApiErrorBanner } from "./ApiErrorBanner.js";
import { useTranslation } from "../context/translation.js";
+/** Observe the footer's normal-flow end, not the sticky element itself.
+ * The absolute marker takes no space and stays behind when the footer sticks.
+ * Only resize events need to re-read the CSS inset; scrolling stays native.
+ */
+function useFloatingFooter(sticky: boolean) {
+ const footer = useRef<HTMLDivElement>(null);
+ const marker = useRef<HTMLSpanElement>(null);
+ const [floating, setFloating] = useState(false);
+ useLayoutEffect(() => {
+ const element = footer.current;
+ const target = marker.current;
+ if (!sticky || !element || !target) return;
+ if (typeof IntersectionObserver === "undefined") {
+ setFloating(true);
+ return;
+ }
+ let root = element.parentElement;
+ while (
+ root &&
+ !/(auto|scroll|hidden|overlay)/.test(getComputedStyle(root).overflowY)
+ ) {
+ root = root.parentElement;
+ }
+ let observer: IntersectionObserver | undefined;
+ const observe = () => {
+ observer?.disconnect();
+ const inset = parseFloat(getComputedStyle(element).bottom) || 0;
+ observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry?.rootBounds) {
+ setFloating(
+ !entry.isIntersecting &&
+ entry.boundingClientRect.bottom > entry.rootBounds.bottom,
+ );
+ }
+ },
+ // A one-pixel marker tolerates fractional scroll rounding at page end.
+ { root, rootMargin: `0px 0px ${-inset}px 0px`, threshold: 0 },
+ );
+ observer.observe(target);
+ };
+ observe();
+ window.addEventListener("resize", observe);
+ return () => {
+ observer?.disconnect();
+ window.removeEventListener("resize", observe);
+ };
+ }, [sticky]);
+ return { footer, marker, floating };
+}
+
/** Keep local validation separate from the original exception and its details.
* Every update identifies a new feedback event, including identical failures.
*/
@@ -53,6 +104,8 @@ export function useFormFeedback() {
export interface FormActionFooterProps {
children: ComponentChildren;
+ /** Secondary actions at the start of the row, separate from progression. */
+ leadingActions?: ComponentChildren;
/** Inline editors use a plain divider; scrolling forms use a raised footer. */
variant?: "inline" | "sticky";
error?: unknown;
@@ -64,6 +117,7 @@ export interface FormActionFooterProps {
export function FormActionFooter({
children,
+ leadingActions,
variant,
error,
validationError,
@@ -111,7 +165,7 @@ export function FormActionFooter({
<div
role="alert"
data-error-banner
- class="flex items-start justify-between gap-3 rounded-md bg-red-50 p-3 text-sm text-red-800"
+ class="flex items-start justify-between gap-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
<div class="max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]">
{validationError}
@@ -143,9 +197,18 @@ export function FormActionFooter({
<div
ref={actions}
tabIndex={-1}
- class="flex flex-wrap items-center justify-end gap-3"
+ class={`flex flex-wrap items-center justify-end gap-3 ${leadingActions ? "w-full" : ""}`}
>
- {children}
+ {leadingActions ? (
+ <>
+ <div class="mr-auto">{leadingActions}</div>
+ <div class="contents flex-wrap items-center justify-end gap-3 sm:ml-auto sm:flex">
+ {children}
+ </div>
+ </>
+ ) : (
+ children
+ )}
</div>
</ActionFooter>
);
@@ -161,19 +224,48 @@ export function ActionFooter({
feedback?: ComponentChildren;
variant?: "inline" | "sticky";
}) {
+ const { footer, marker, floating } = useFloatingFooter(variant === "sticky");
return (
- <div
- data-form-action-footer
- class={`space-y-3 border-t border-gray-200 pt-3 ${
- variant === "sticky"
- ? "sticky bottom-0 z-20 bg-white px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_6px_rgb(0_0_0/0.06)] rounded-b-lg"
- : ""
- }`}
- >
- {feedback}
- <div class="flex flex-wrap items-center justify-end gap-3">
- {children}
+ <>
+ <div
+ ref={footer}
+ data-form-action-footer
+ data-floating={variant === "sticky" ? floating : undefined}
+ class={`flex flex-col gap-3 ${
+ variant === "sticky"
+ ? "floating-action-footer sticky bottom-[var(--form-action-bottom,max(0.75rem,env(safe-area-inset-bottom)))] z-20 rounded-xl border border-transparent p-3 md:p-4"
+ : "border-t border-gray-200 pt-3"
+ }`}
+ >
+ {variant === "sticky" && (
+ <span
+ aria-hidden="true"
+ data-action-surface
+ class={`pointer-events-none absolute -inset-px -z-10 rounded-[inherit] border border-gray-300/70 bg-slate-100/95 supports-[backdrop-filter]:bg-slate-100/45 bg-gradient-to-b from-sky-50/25 to-slate-100/10 backdrop-blur-md backdrop-saturate-[1.2] shadow-[0_2px_12px_rgb(0_0_0/0.08),inset_0_1px_0_rgb(255_255_255/0.8)] transition-opacity duration-150 motion-reduce:transition-none ${floating ? "opacity-100" : "opacity-0"}`}
+ />
+ )}
+ <div data-action-feedback class="empty:hidden">
+ {feedback}
+ </div>
+ <div class="flex flex-wrap items-center justify-end gap-3">
+ {children}
+ </div>
</div>
- </div>
+ {variant === "sticky" && (
+ <span
+ ref={marker}
+ aria-hidden="true"
+ data-action-end
+ style={{
+ position: "absolute",
+ width: 1,
+ height: 1,
+ margin: 0,
+ transform: "translateY(-1px)",
+ pointerEvents: "none",
+ }}
+ />
+ )}
+ </>
);
}
diff --git a/packages/taler-merchant-webui/src/ui/Header.tsx b/packages/taler-merchant-webui/src/ui/Header.tsx
@@ -25,7 +25,7 @@ import { useTranslation } from "../context/translation.js";
* survived only because a miss returns its argument.
*/
export interface HeaderProps {
- title: string;
+ title: ComponentChildren;
/**
* Where this screen was opened from.
*
@@ -37,7 +37,7 @@ export interface HeaderProps {
onBack?: () => void;
/** What the back link says, already translated. Defaults to a plain "Back". */
backLabel?: string;
- subtitle?: string;
+ subtitle?: ComponentChildren;
primaryActionText?: string;
onPrimaryAction?: () => void;
children?: ComponentChildren;
diff --git a/packages/taler-merchant-webui/src/ui/Layout.tsx b/packages/taler-merchant-webui/src/ui/Layout.tsx
@@ -80,7 +80,7 @@ export function Layout({
showAdministration={showAdministration}
/>
<div class="flex-1 min-w-0 min-h-screen">
- <main class="max-w-6xl w-full min-w-0 p-4 sm:p-5 md:p-6">
+ <main class="merchant-page-content max-w-6xl w-full min-w-0">
{/* Experimental Deployment Banner (configured via webui-config.json or #/dev overrides) */}
{isExperimentalDeployment.value && (
<div class="mb-4 p-3 bg-purple-900 text-purple-100 rounded-lg text-xs flex items-center justify-between shadow-xs">
diff --git a/packages/taler-merchant-webui/src/ui/Modal.tsx b/packages/taler-merchant-webui/src/ui/Modal.tsx
@@ -138,7 +138,7 @@ export function Modal({
>
<div class="fixed inset-0" onClick={onClose} aria-hidden="true" />
<div
- class={`relative z-10 w-full ${maxWidthMap[maxWidth]} bg-white rounded-xl shadow-xl border border-gray-100 p-6 space-y-4 max-h-[90vh] overflow-y-auto`}
+ class={`modal-scroll-content relative z-10 w-full ${maxWidthMap[maxWidth]} bg-white rounded-xl shadow-xl border border-gray-100 p-6 space-y-4 max-h-[90vh] overflow-y-auto`}
>
{title && (
<div class="flex items-center justify-between border-b border-gray-100 pb-3">