mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
fix(mobile): one form sheet for every dialog, opaque and placed beside the rail
Every sheet now stands on the opaque sheet surface: the frosting the translucent one relied on is dropped while the scrim fades, so the page read through the fields as sharp ghost text. On a wide window a sheet is centred in the content beside the navigation rail instead of on the whole window, so it never lies on the rail, and it grows to the window's height instead of stopping at 880px. On a phone the card stands between the status bar and the dock rather than across the dock. A sheet marked as a form fills the height of a window as short as a phone on its side, with Cancel and Save beside the title (new `short:` variant, m-form-sheet grid in mobile.css). The form chrome gains a scrolling body that fades at the edges with more behind them (useEdgeFade on the vertical axis), a date field whose keyboard entry moves into the picker, a field wrapper for amounts with a currency, and two-line titles. The focused sheet no longer draws the app-wide focus frame or loses its corners, and the desktop's dark field paint stays out of the touch shell, which painted a dark box inside every rounded field.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { type RefObject, useLayoutEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Grows a textarea with what is written in it, so its last line is never
|
||||
* sliced through the glyphs at the bottom edge the way a fixed `rows` height
|
||||
* cuts a fourth line. `rows` stays the least it shows. It measures again when
|
||||
* the text changes and when the field's width does (a rotation, a fold), since
|
||||
* both decide how many lines the text wraps to.
|
||||
*/
|
||||
export function useAutoGrow(ref: RefObject<HTMLTextAreaElement | null>, value: string): void {
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let width = -1;
|
||||
const fit = () => {
|
||||
width = el.clientWidth;
|
||||
el.style.height = 'auto';
|
||||
// scrollHeight leaves the border out; the box is border-box.
|
||||
const border = el.offsetHeight - el.clientHeight;
|
||||
el.style.height = `${el.scrollHeight + border}px`;
|
||||
};
|
||||
fit();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (el.clientWidth !== width) fit();
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, value]);
|
||||
}
|
||||
@@ -1307,12 +1307,16 @@ img[alt="TREK"] {
|
||||
.dark .bg-slate-100 { background-color: #27272a !important; }
|
||||
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
|
||||
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
|
||||
.dark input, .dark textarea, .dark select {
|
||||
/* Not inside the touch shell (.m-root): its fields state their own fill, a
|
||||
transparent input inside a rounded wrapper above all, and this rule outweighs
|
||||
every utility class, so it painted a dark box inside each of them. The
|
||||
:where() keeps the desktop's weight exactly as it was. */
|
||||
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
||||
background-color: #1c1c21;
|
||||
color: #f4f4f5;
|
||||
border-color: #27272a;
|
||||
}
|
||||
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
|
||||
.dark input:not(:where(.m-root *))::placeholder, .dark textarea:not(:where(.m-root *))::placeholder { color: #71717a; }
|
||||
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
|
||||
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
|
||||
|
||||
|
||||
@@ -17,11 +17,17 @@ interface MSheetProps {
|
||||
/** card = centred floating card, bottom = docked panel above the nav, drawer = left drawer */
|
||||
variant?: MSheetVariant
|
||||
/**
|
||||
* glass = translucent sheet (--m-sheet), bar-glass = lighter bar glass
|
||||
* (--m-glass, default for the bottom variant), opaque = solid (--m-sheetop)
|
||||
* @deprecated Every sheet stands on the one opaque surface now (--m-sheetop),
|
||||
* whatever is asked for here; see SURFACE. Kept so the callers that still
|
||||
* name a material compile.
|
||||
*/
|
||||
material?: MSheetMaterial
|
||||
/** Transparent scrim (the "Mehr" sheet sits on the UI without dimming it) */
|
||||
/**
|
||||
* Transparent scrim (the "Mehr" sheet sits on the UI without dimming it). Not
|
||||
* on a window as short as a phone on its side: there the sheet takes most of
|
||||
* the height, and the strips of page left above and below it read as part of
|
||||
* it unless they are dimmed.
|
||||
*/
|
||||
dimTransparent?: boolean
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
@@ -36,6 +42,14 @@ interface MSheetProps {
|
||||
wideWidth?: MSheetWidth
|
||||
/** Where it stands on a wide window (default centre). */
|
||||
wideAnchor?: MSheetWideAnchor
|
||||
/**
|
||||
* A form: FormSheetHeader, FormSheetBody and FormSheetFooter (PlSheetChrome)
|
||||
* as its three children. On a window as short as a phone on its side it
|
||||
* fills the height instead of floating as a card a third of the screen
|
||||
* tall, and its actions move up beside the title (mobile.css, m-form-sheet),
|
||||
* so the fields get every line in between.
|
||||
*/
|
||||
form?: boolean
|
||||
}
|
||||
|
||||
const EXIT_MS = 300
|
||||
@@ -48,22 +62,10 @@ const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold
|
||||
* regular for a form, large for a sheet that holds a page of its own.
|
||||
*/
|
||||
type MSheetWidth = 'compact' | 'regular' | 'large'
|
||||
|
||||
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
|
||||
// what iPadOS shows there: a form sheet centred over the dimmed page, as wide
|
||||
// as its content needs and no wider, instead of a band across a 13" screen.
|
||||
// Centred with auto margins between zero insets, so it needs no physical side.
|
||||
const WIDE_POSITION = 'wide:inset-x-0 wide:mx-auto wide:bottom-auto wide:top-1/2 wide:-translate-y-1/2'
|
||||
const WIDE_WIDTH: Record<MSheetWidth, string> = {
|
||||
compact: 'wide:w-[min(320px,calc(100%-48px))]',
|
||||
regular: 'wide:w-[min(540px,calc(100%-48px))]',
|
||||
large: 'wide:w-[min(720px,calc(100%-48px))]',
|
||||
}
|
||||
|
||||
const POSITION: Record<MSheetVariant, string> = {
|
||||
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
|
||||
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
|
||||
drawer: 'absolute left-0 top-0 bottom-0 w-[78%] max-w-[320px]',
|
||||
compact: 'wide:w-[min(320px,100%)]',
|
||||
regular: 'wide:w-[min(540px,100%)]',
|
||||
large: 'wide:w-[min(720px,100%)]',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,15 +75,54 @@ const POSITION: Record<MSheetVariant, string> = {
|
||||
* opened it rather than in the middle of the screen.
|
||||
*/
|
||||
type MSheetWideAnchor = 'center' | 'rail-foot'
|
||||
const WIDE_ANCHOR: Record<MSheetWideAnchor, string> = {
|
||||
center: WIDE_POSITION,
|
||||
'rail-foot': 'wide:top-auto wide:translate-y-0 wide:end-auto wide:bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:start-[calc(var(--m-rail-w)+6px)]',
|
||||
|
||||
/*
|
||||
* The region a sheet may take: a box over the screen that ignores the pointer
|
||||
* (a tap on it reaches the scrim) and lays the sheet out inside itself, so a
|
||||
* tall sheet is held by the region's edges instead of running past them.
|
||||
*
|
||||
* On a phone the card stands between the status bar and the dock, and the
|
||||
* docked sheet just above the dock; neither lies across the dock.
|
||||
*/
|
||||
const REGION: Record<MSheetVariant, string> = {
|
||||
card: 'inset-x-[14px] top-[var(--m-safe-top,12px)] bottom-[calc(var(--bottom-nav-h,84px)+12px)] justify-center',
|
||||
// On a phone on its side the gap it keeps at the top would leave it a strip
|
||||
// of a few rows, so there it may rise nearly to the top.
|
||||
bottom:
|
||||
'inset-x-4 top-[calc(var(--m-safe-top,12px)+56px)] bottom-[calc(var(--bottom-nav-h,84px)+16px)] justify-end short:top-[calc(var(--m-safe-top,12px)+8px)]',
|
||||
drawer: 'start-0 top-0 bottom-0 w-[78%] max-w-[320px]',
|
||||
}
|
||||
|
||||
/** The box the panel sits in: its place on a phone, and on a wide window. */
|
||||
function frameClass(variant: MSheetVariant, width: MSheetWidth = 'regular', anchor: MSheetWideAnchor = 'center'): string {
|
||||
if (variant === 'drawer') return POSITION.drawer
|
||||
return `${POSITION[variant]} ${WIDE_WIDTH[width]} ${WIDE_ANCHOR[anchor]}`
|
||||
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
|
||||
// what iPadOS shows there: a form sheet over the dimmed page, centred in the
|
||||
// content beside the navigation rail (never over the rail), as wide as its
|
||||
// content needs and as tall as the window allows. The region starts after the
|
||||
// rail on the leading edge, so it needs no physical side.
|
||||
const WIDE_REGION: Record<MSheetWideAnchor, string> = {
|
||||
center:
|
||||
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+24px)] wide:pe-6 wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+24px)] wide:items-center wide:justify-center',
|
||||
'rail-foot':
|
||||
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+6px)] wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:items-start wide:justify-end',
|
||||
}
|
||||
|
||||
// A form on a window as short as a phone on its side: a sheet the window's
|
||||
// full height, over the dock, as wide as a form reads well and no wider.
|
||||
const SHORT_FORM_REGION =
|
||||
'short:inset-x-4 short:top-[max(env(safe-area-inset-top,0px),8px)] short:bottom-0 short:items-center short:justify-stretch'
|
||||
const SHORT_FORM_BOX = 'short:max-w-[640px] short:flex-1'
|
||||
|
||||
/** The region the sheet stands in, on a phone and on a wide window. */
|
||||
function regionClass(variant: MSheetVariant, anchor: MSheetWideAnchor = 'center', form = false): string {
|
||||
const base = 'pointer-events-none absolute flex flex-col'
|
||||
if (variant === 'drawer') return `${base} ${REGION.drawer}`
|
||||
return `${base} ${REGION[variant]} ${WIDE_REGION[anchor]}${form ? ` ${SHORT_FORM_REGION}` : ''}`
|
||||
}
|
||||
|
||||
/** The sheet's own box inside the region: its width, and the arrow when it has one. */
|
||||
function boxClass(variant: MSheetVariant, width: MSheetWidth = 'regular', form = false): string {
|
||||
const base = 'pointer-events-auto relative flex min-h-0 w-full flex-col'
|
||||
if (variant === 'drawer') return `${base} h-full`
|
||||
return `${base} max-h-full ${WIDE_WIDTH[width]}${form ? ` ${SHORT_FORM_BOX}` : ''}`
|
||||
}
|
||||
|
||||
/** The popover's arrow, pointing at the More button at the foot of the rail (wide windows only). */
|
||||
@@ -90,30 +131,29 @@ function PopoverArrow({ variant, anchor }: { variant: MSheetVariant; anchor?: MS
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -start-[10px] bottom-[33px] hidden h-[18px] w-[10px] bg-[color:var(--m-sheet)] backdrop-blur-[40px] [clip-path:polygon(100%_0,0_50%,100%_100%)] rtl:-scale-x-100 wide:block"
|
||||
className="absolute -start-[10px] bottom-[33px] hidden h-[18px] w-[10px] bg-[color:var(--m-sheetop)] [clip-path:polygon(100%_0,0_50%,100%_100%)] rtl:-scale-x-100 wide:block"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const WIDE_HEIGHT = 'wide:max-h-[min(calc(100dvh-var(--m-safe-top,12px)-64px),880px)]'
|
||||
|
||||
// The corner radius goes through --m-sheet-r, so mobile.css can keep it on the
|
||||
// focused panel, where the app-wide focus ring would square it off to 4px.
|
||||
const SHAPE: Record<MSheetVariant, string> = {
|
||||
card: `rounded-[28px] shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)] max-h-[calc(100dvh-var(--m-safe-top,12px)-96px)] ${WIDE_HEIGHT}`,
|
||||
bottom: `rounded-[26px] shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] max-h-[calc(100dvh-var(--bottom-nav-h,84px)-var(--m-safe-top,12px)-72px)] ${WIDE_HEIGHT} wide:shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]`,
|
||||
drawer: 'h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
|
||||
card: '[--m-sheet-r:28px] max-h-full shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
|
||||
bottom:
|
||||
'[--m-sheet-r:26px] max-h-full shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] wide:[--m-sheet-r:28px] wide:shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
|
||||
drawer: '[--m-sheet-r:0px] h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
|
||||
}
|
||||
|
||||
// Deliberate normalisation: the demo borders a few opaque sheets (journey
|
||||
// create/entry/settings, drawer) with --rowbr instead of --shbr; both hairlines
|
||||
// are near-identical on the solid sheet surface, so every material keeps one
|
||||
// border token here.
|
||||
const MATERIAL: Record<MSheetMaterial, string> = {
|
||||
glass:
|
||||
'bg-[color:var(--m-sheet)] backdrop-blur-[40px] backdrop-saturate-[1.8] border border-[color:var(--m-shbr)]',
|
||||
'bar-glass':
|
||||
'bg-[color:var(--m-glass)] backdrop-blur-[30px] backdrop-saturate-[1.8] border border-[color:var(--m-gbr)]',
|
||||
opaque: 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]',
|
||||
}
|
||||
// The full-height form on a short window: square at the foot, where it meets
|
||||
// the edge of the screen, and no border there.
|
||||
const SHORT_FORM_SHAPE = 'm-form-sheet short:flex-1 short:border-b-0 short:[--m-sheet-r:28px_28px_0_0]'
|
||||
|
||||
// Every sheet stands on the one opaque surface. A translucent one let the page
|
||||
// behind read through as sharp ghost text: the frosting it counted on is
|
||||
// dropped whenever an ancestor animates its opacity (the scrim fading in),
|
||||
// which WebKit and Chromium both do, so the fields floated over the list behind.
|
||||
const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]'
|
||||
|
||||
// The docked sheet rises from below the screen edge like a native sheet; the
|
||||
// card pops in place like an alert; the drawer slides from the side.
|
||||
@@ -134,7 +174,6 @@ export default function MSheet({
|
||||
open,
|
||||
onClose,
|
||||
variant = 'card',
|
||||
material,
|
||||
dimTransparent = false,
|
||||
ariaLabel,
|
||||
className = '',
|
||||
@@ -142,6 +181,7 @@ export default function MSheet({
|
||||
discardGuard,
|
||||
wideWidth,
|
||||
wideAnchor,
|
||||
form = false,
|
||||
}: MSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const { asking, markStart, requestClose, wouldAsk, keepEditing, discard, reset } = useDiscardGuard(discardGuard, onClose)
|
||||
@@ -225,67 +265,63 @@ export default function MSheet({
|
||||
const exitClass = EXIT[variant]
|
||||
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
|
||||
|
||||
// One material for every sheet, docked or floating, so they all read the same.
|
||||
const resolvedMaterial = material ?? 'glass'
|
||||
|
||||
return createPortal(
|
||||
// m-root on the overlay so the --m-* tokens resolve even when the portal
|
||||
// has to fall back to document.body (sheet mounted in the same commit as
|
||||
// the shell).
|
||||
// A transparent scrim skips the overlay's opacity animation: an animating
|
||||
// opacity<1 ancestor becomes a backdrop-root that cancels the sheet's own
|
||||
// backdrop-blur (the "liquid glass" frost). The sheet still slides via m-sheet-in.
|
||||
<div
|
||||
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent' : `bg-[color:var(--m-dim)] ${open ? 'm-fade-in' : 'm-fade-out'}`}`}
|
||||
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) requestClose()
|
||||
}}
|
||||
>
|
||||
<div className={frameClass(variant, wideWidth, wideAnchor)}>
|
||||
<PopoverArrow variant={variant} anchor={wideAnchor} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-m-sheet={open ? 'open' : 'closing'}
|
||||
tabIndex={-1}
|
||||
className={`relative flex flex-col overflow-hidden outline-none text-m-ink ${SHAPE[variant]} ${MATERIAL[resolvedMaterial]} ${animation} ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={markStart}
|
||||
onPointerDownCapture={markStart}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target === e.currentTarget && open) setEntered(true)
|
||||
}}
|
||||
>
|
||||
{variant === 'bottom' && (
|
||||
// Invisible grab strip along the top edge — the design shows no
|
||||
// handle, the panel starts directly with its content. The content
|
||||
// pulls the sheet too, once it is scrolled to its top.
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
|
||||
{...sheetDrag.strip}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{asking && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
|
||||
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
|
||||
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
|
||||
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
|
||||
{t('common.keepEditing')}
|
||||
</button>
|
||||
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
|
||||
{t('common.discard')}
|
||||
</button>
|
||||
<div className={regionClass(variant, wideAnchor, form)}>
|
||||
<div className={boxClass(variant, wideWidth, form)}>
|
||||
<PopoverArrow variant={variant} anchor={wideAnchor} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-m-sheet={open ? 'open' : 'closing'}
|
||||
tabIndex={-1}
|
||||
className={`relative flex min-h-0 flex-col overflow-hidden rounded-[var(--m-sheet-r)] text-m-ink outline-none ${SHAPE[variant]}${form ? ` ${SHORT_FORM_SHAPE}` : ''} ${SURFACE} ${animation} ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={markStart}
|
||||
onPointerDownCapture={markStart}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target === e.currentTarget && open) setEntered(true)
|
||||
}}
|
||||
>
|
||||
{variant === 'bottom' && (
|
||||
// Invisible grab strip along the top edge — the design shows no
|
||||
// handle, the panel starts directly with its content. The content
|
||||
// pulls the sheet too, once it is scrolled to its top.
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
|
||||
{...sheetDrag.strip}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{asking && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
|
||||
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
|
||||
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
|
||||
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
|
||||
{t('common.keepEditing')}
|
||||
</button>
|
||||
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
|
||||
{t('common.discard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface EdgeFade {
|
||||
/** Content is scrolled past the leading edge. */
|
||||
/** Content is scrolled past the leading (or top) edge. */
|
||||
start: boolean;
|
||||
/** More content waits beyond the trailing edge. */
|
||||
/** More content waits beyond the trailing (or bottom) edge. */
|
||||
end: boolean;
|
||||
}
|
||||
|
||||
/** x: a horizontal strip, y: a vertical scroll box such as a form sheet's body. */
|
||||
export type EdgeFadeAxis = 'x' | 'y';
|
||||
|
||||
const NONE: EdgeFade = { start: false, end: false };
|
||||
|
||||
/**
|
||||
* Which ends of a horizontal scroller have more behind them, so the strip can
|
||||
* fade out there (m-fade-s / m-fade-e in mobile.css) instead of cutting a chip
|
||||
* in half with nothing to say the row goes on. Follows scrolling and every
|
||||
* change of size: a rotation, a fold, the pane beside the map opening.
|
||||
* Which ends of a scroller have more behind them, so it can fade out there
|
||||
* (m-fade-s / m-fade-e, m-fade-t / m-fade-b in mobile.css) instead of cutting a
|
||||
* chip or a field in half with nothing to say the content goes on. Follows
|
||||
* scrolling and every change of size: a rotation, a fold, the pane beside the
|
||||
* map opening, and on the vertical axis content that grows or shrinks inside
|
||||
* a box that keeps its height.
|
||||
*/
|
||||
export function useEdgeFade(el: HTMLElement | null): EdgeFade {
|
||||
export function useEdgeFade(el: HTMLElement | null, axis: EdgeFadeAxis = 'x'): EdgeFade {
|
||||
const [fade, setFade] = useState<EdgeFade>(NONE);
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
// scrollLeft runs from 0 towards the negative in a right-to-left strip.
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
const room = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = axis === 'x' ? Math.abs(el.scrollLeft) : el.scrollTop;
|
||||
const room = axis === 'x' ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
|
||||
const next = { start: scrolled > 1, end: room - scrolled > 1 };
|
||||
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
||||
};
|
||||
@@ -30,18 +35,25 @@ export function useEdgeFade(el: HTMLElement | null): EdgeFade {
|
||||
el.addEventListener('scroll', measure, { passive: true });
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
||||
observer?.observe(el);
|
||||
// A form's fields come and go (a booking type, a split mode), which changes
|
||||
// what there is to scroll without changing the box itself.
|
||||
const content = axis === 'y' && typeof MutationObserver !== 'undefined' ? new MutationObserver(measure) : null;
|
||||
content?.observe(el, { childList: true, subtree: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', measure);
|
||||
observer?.disconnect();
|
||||
content?.disconnect();
|
||||
};
|
||||
}, [el]);
|
||||
}, [el, axis]);
|
||||
return fade;
|
||||
}
|
||||
|
||||
/** The mask classes for a fade state. */
|
||||
export function edgeFadeClass(fade: EdgeFade): string {
|
||||
if (fade.start && fade.end) return 'm-fade-se';
|
||||
if (fade.start) return 'm-fade-s';
|
||||
if (fade.end) return 'm-fade-e';
|
||||
export function edgeFadeClass(fade: EdgeFade, axis: EdgeFadeAxis = 'x'): string {
|
||||
const [both, start, end] =
|
||||
axis === 'x' ? ['m-fade-se', 'm-fade-s', 'm-fade-e'] : ['m-fade-tb', 'm-fade-t', 'm-fade-b'];
|
||||
if (fade.start && fade.end) return both;
|
||||
if (fade.start) return start;
|
||||
if (fade.end) return end;
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -675,3 +675,77 @@ html:has(.m-shell) ::-webkit-scrollbar {
|
||||
:root[dir='rtl'] .m-fade-se {
|
||||
--m-fade-dir: 270deg;
|
||||
}
|
||||
|
||||
/* The same for a vertical scroller, a form sheet's body above all: it fades
|
||||
out at the top once scrolled and at the foot while more waits below, so a
|
||||
field cut by the footer reads as "the form goes on". Never mirrored. */
|
||||
.m-fade-t,
|
||||
.m-fade-b,
|
||||
.m-fade-tb {
|
||||
--m-fade-h: 24px;
|
||||
-webkit-mask-image: var(--m-fade-vmask);
|
||||
mask-image: var(--m-fade-vmask);
|
||||
}
|
||||
.m-fade-t {
|
||||
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h));
|
||||
}
|
||||
.m-fade-b {
|
||||
--m-fade-vmask: linear-gradient(to bottom, #000 calc(100% - var(--m-fade-h)), transparent 100%);
|
||||
}
|
||||
.m-fade-tb {
|
||||
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h), #000 calc(100% - var(--m-fade-h)), transparent 100%);
|
||||
}
|
||||
|
||||
/* A hint longer than its field ends in an ellipsis instead of being cut off
|
||||
mid-word at the field's edge ("Код аэропорта или город (нап"). */
|
||||
.m-root input:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Sheets ─────────────────────────────────────────────────────────────────
|
||||
A sheet takes the focus itself when it opens (modalFocus.ts), so a screen
|
||||
reader starts inside it. The app-wide focus ring (index.css) is meant for
|
||||
controls: on the sheet it drew a 2px frame round the whole dialog and
|
||||
squared its corners off to 4px. */
|
||||
.m-root [data-m-sheet]:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--m-sheet-r);
|
||||
}
|
||||
|
||||
/* A form sheet (MSheet `form`) on a window as short as a phone on its side
|
||||
fills the height, and its actions move up beside the title the way an
|
||||
iPhone form carries Cancel and Save in its bar, so the fields get every
|
||||
line in between instead of a slot 140px tall. Cancel then does what the
|
||||
header's close button did, so that one goes. */
|
||||
@media (max-height: 500px) {
|
||||
/* Row 1: the title and the actions. Row 2: whatever a form keeps fixed
|
||||
under its title (the transport's manual/automated switch). Row 3: the
|
||||
fields, which take the rest. Doubled class: the panel's own `flex`
|
||||
utility must not win the display back. */
|
||||
.m-form-sheet.m-form-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.m-form-sheet > * {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.m-form-sheet > .m-fs-head {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.m-form-sheet > .m-fs-foot {
|
||||
grid-area: 1 / 2;
|
||||
border-top: 0;
|
||||
padding-block: 16px 8px;
|
||||
padding-inline: 0 18px;
|
||||
}
|
||||
.m-form-sheet > .m-fs-body {
|
||||
grid-row: 3;
|
||||
align-self: stretch;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
||||
}
|
||||
.m-form-sheet:has(> .m-fs-foot) > .m-fs-head .m-fs-close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ComponentProps, HTMLAttributes, ReactNode, useState } from 'react'
|
||||
import { Trash2, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { edgeFadeClass, useEdgeFade } from '../../../components/useEdgeFade'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
|
||||
/**
|
||||
* Shared chrome of the trip form sheets (place edit, day note, import):
|
||||
* glass-card header with optional 40px icon tile, eyebrow labels, the standard
|
||||
* field surfaces and the delete/cancel/save footer.
|
||||
* Shared chrome of the form sheets (expense, payment, booking, transport, note,
|
||||
* place edit, new trip, import): the header with its 40px icon tile, the
|
||||
* scrolling body, eyebrow labels, the standard field surfaces and the
|
||||
* delete/cancel/save footer. Header, body and footer are what MSheet's `form`
|
||||
* lays out (mobile.css, m-form-sheet), so a form uses all three.
|
||||
*/
|
||||
|
||||
export const FIELD_CLS =
|
||||
'w-full min-w-0 box-border rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-[inherit] text-[0.84375rem] font-medium text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
/**
|
||||
* A field that holds more than its input: a currency before or after the
|
||||
* amount, a search icon. The round field is the wrapper; the input inside it
|
||||
* stays clear (bg-transparent, no border), so only one box is painted.
|
||||
*/
|
||||
export const AMOUNT_FIELD_CLS =
|
||||
'flex h-[42px] w-full min-w-0 items-center gap-[6px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
||||
|
||||
/**
|
||||
* Two fields side by side where the form is wide enough for both, one above
|
||||
* the other where it is not (a phone upright, a narrow Split View): a day
|
||||
* named "Departure" with its date beside it read "Depa…" in half a phone.
|
||||
*/
|
||||
export const FORM_PAIR_CLS = 'grid grid-cols-1 gap-2 [@container(min-width:400px)]:grid-cols-2'
|
||||
|
||||
/** A button that opens a picker, drawn as the field it fills in. */
|
||||
export const SELECT_FIELD_CLS =
|
||||
'flex h-[42px] w-full min-w-0 items-center overflow-hidden rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
||||
|
||||
export const FIELD_AREA_CLS =
|
||||
'w-full box-border resize-none rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
/**
|
||||
* A date field of the form sheets: the shared picker inside the 42px round
|
||||
* field. Its "type the date" button moves into the picker's popup (the
|
||||
* picker's borderless mode), so the date has the field's whole width and
|
||||
* keeps its year where a second round button used to take the room.
|
||||
*/
|
||||
export function SheetDateField(props: Omit<ComponentProps<typeof CustomDatePicker>, 'borderless' | 'style'>) {
|
||||
return (
|
||||
<div className="flex h-[42px] w-full min-w-0 items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] [&>*]:min-w-0 [&>*]:flex-1">
|
||||
<CustomDatePicker {...props} borderless />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One Eyebrow for all trip sheets — defined with the inspection-sheet chrome.
|
||||
export { Eyebrow } from './MTripSheetUi'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from './MTripSheetUi'
|
||||
@@ -31,10 +68,10 @@ interface FormSheetHeaderProps {
|
||||
closeLabel: string
|
||||
}
|
||||
|
||||
/** Glass-sheet header: optional tile, 17px/700 title, 34px close. No hairline. */
|
||||
/** Form-sheet header: optional tile, 17px/700 title, 34px close. No hairline. */
|
||||
export function FormSheetHeader({ icon: Icon, title, subtitle, leading, onClose, closeLabel }: FormSheetHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-2 pt-4">
|
||||
<div className="m-fs-head flex flex-none items-center gap-3 px-[18px] pb-2 pt-4">
|
||||
{leading}
|
||||
{!leading && Icon && (
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)]">
|
||||
@@ -42,16 +79,39 @@ export function FormSheetHeader({ icon: Icon, title, subtitle, leading, onClose,
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[1.0625rem] font-bold text-m-ink">{title}</div>
|
||||
{/* Two lines before it ends in an ellipsis: "Редактировать бронирование"
|
||||
and "Eintrag bearbeiten" were cut after one at phone width. */}
|
||||
<div className="line-clamp-2 break-words text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
|
||||
{subtitle && <div className="truncate font-geist text-[0.71875rem] text-m-muted">{subtitle}</div>}
|
||||
</div>
|
||||
<MIconBtn ariaLabel={closeLabel} onClick={onClose} variant="neutral" size={34}>
|
||||
<MIconBtn ariaLabel={closeLabel} onClick={onClose} variant="neutral" size={34} className="m-fs-close">
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's fields, between the header and the footer: the one part that
|
||||
* scrolls. It fades out at the top once scrolled and at the foot while more
|
||||
* waits below, so a field the footer cuts reads as "the form goes on". It is
|
||||
* the container its rows measure themselves against (FORM_PAIR_CLS), since a
|
||||
* form is as narrow on a phone as it is wide in a form sheet.
|
||||
*/
|
||||
export function FormSheetBody({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null)
|
||||
const fade = useEdgeFade(el, 'y')
|
||||
return (
|
||||
<div
|
||||
ref={setEl}
|
||||
className={`m-fs-body min-h-0 flex-1 overflow-y-auto px-[18px] pb-4 pt-[2px] [container-type:inline-size] ${edgeFadeClass(fade, 'y')} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FormSheetFooterProps {
|
||||
/** Renders the 42px delete circle; two-tap confirm is the caller's business. */
|
||||
onDelete?: () => void
|
||||
@@ -71,7 +131,7 @@ export function FormSheetFooter({
|
||||
onCancel, cancelLabel, onSubmit, submitLabel, submitDisabled = false,
|
||||
}: FormSheetFooterProps) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -128,6 +128,9 @@ export default {
|
||||
({ addVariant }) => {
|
||||
addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')
|
||||
addVariant('split', '@media (min-width: 840px) and (min-height: 501px)')
|
||||
// The other side of that height bound: a window as short as a phone on its
|
||||
// side, where a form sheet takes the full height (MSheet's `form`).
|
||||
addVariant('short', '@media (max-height: 500px)')
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('MSheet', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const backdrop = dialog.parentElement!.parentElement!;
|
||||
const backdrop = dialog.closest('[role="presentation"]') as HTMLElement;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -446,7 +446,7 @@ describe('MSheet', () => {
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
const backdrop = () => screen.getByRole('dialog', { name: 'Form' }).parentElement!.parentElement!;
|
||||
const backdrop = () => screen.getByRole('dialog', { name: 'Form' }).closest('[role="presentation"]') as HTMLElement;
|
||||
|
||||
it('FE-MOB-SHEET-020: a tap on the scrim asks before throwing a change away, and keep editing stays', () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -482,5 +482,75 @@ describe('MSheet', () => {
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('presentation', () => {
|
||||
const panel = (name = 'Sheet') => screen.getByRole('dialog', { name });
|
||||
const region = (name = 'Sheet') => panel(name).parentElement!.parentElement as HTMLElement;
|
||||
|
||||
it('FE-MOB-SHEET-031: every sheet stands on the one opaque surface, whatever material it names', () => {
|
||||
for (const material of ['glass', 'bar-glass', 'opaque'] as const) {
|
||||
const { unmount } = render(
|
||||
<MSheet open onClose={() => {}} material={material} ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
expect(panel().className).toContain('bg-[color:var(--m-sheetop)]');
|
||||
expect(panel().className).not.toContain('backdrop-blur');
|
||||
expect(panel().className).not.toContain('var(--m-sheet)');
|
||||
expect(panel().className).not.toContain('var(--m-glass)');
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-032: on a phone the card stands between the status bar and the dock, never across the dock', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
expect(region().className).toContain('top-[var(--m-safe-top,12px)]');
|
||||
expect(region().className).toContain('bottom-[calc(var(--bottom-nav-h,84px)+12px)]');
|
||||
expect(panel().className).toContain('max-h-full');
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-033: a form fills the height of a short window and lays its actions beside the title', () => {
|
||||
const { unmount } = render(
|
||||
<MSheet open onClose={() => {}} form ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
expect(panel().className).toContain('m-form-sheet');
|
||||
expect(panel().className).toContain('short:flex-1');
|
||||
expect(region().className).toContain('short:bottom-0');
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
expect(panel().className).not.toContain('m-form-sheet');
|
||||
expect(region().className).not.toContain('short:bottom-0');
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-034: a transparent scrim still dims a short window, where the sheet takes most of the height', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} variant="bottom" dimTransparent ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
const scrim = panel().closest('[role="presentation"]') as HTMLElement;
|
||||
expect(scrim.className).toContain('bg-transparent');
|
||||
expect(scrim.className).toContain('short:bg-[color:var(--m-dim)]');
|
||||
});
|
||||
|
||||
it('FE-MOB-SHEET-035: the region lets taps through to the scrim, the sheet itself takes them', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} ariaLabel="Sheet">
|
||||
<span>body</span>
|
||||
</MSheet>,
|
||||
);
|
||||
expect(region().className).toContain('pointer-events-none');
|
||||
expect(panel().parentElement!.className).toContain('pointer-events-auto');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useAutoGrow } from '../../../src/hooks/useAutoGrow';
|
||||
import MSheet from '../../../src/mobile/components/MSheet';
|
||||
import { edgeFadeClass, useEdgeFade } from '../../../src/mobile/components/useEdgeFade';
|
||||
import {
|
||||
FormSheetBody,
|
||||
FormSheetFooter,
|
||||
FormSheetHeader,
|
||||
SheetDateField,
|
||||
} from '../../../src/mobile/screens/trip/sheets/PlSheetChrome';
|
||||
import { MemberAvatar } from '../../../src/mobile/screens/trip/tabs/MCostsPayment';
|
||||
import { fireEvent, render, screen } from '../../helpers/render';
|
||||
|
||||
// FE-MOB-FORMSHEET-001 onwards: the form sheet the touch shell presents every
|
||||
// form in (MSheet `form` + PlSheetChrome), and the stylesheet rules that lay it out.
|
||||
|
||||
function Form({ onClose = () => {} }: { onClose?: () => void }) {
|
||||
return (
|
||||
<MSheet open onClose={onClose} form ariaLabel="Add expense">
|
||||
<FormSheetHeader title="Add expense" onClose={onClose} closeLabel="Close" />
|
||||
<FormSheetBody>
|
||||
<input aria-label="Name" />
|
||||
</FormSheetBody>
|
||||
<FormSheetFooter onCancel={onClose} cancelLabel="Cancel" onSubmit={() => {}} submitLabel="Add" />
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
|
||||
describe('form sheet chrome', () => {
|
||||
it('FE-MOB-FORMSHEET-001: header, body and footer carry the parts the short-window layout places', () => {
|
||||
render(<Form />);
|
||||
const panel = screen.getByRole('dialog', { name: 'Add expense' });
|
||||
expect(panel).toHaveClass('m-form-sheet');
|
||||
const [head, body, foot] = Array.from(panel.children) as HTMLElement[];
|
||||
expect(head).toHaveClass('m-fs-head');
|
||||
expect(body).toHaveClass('m-fs-body', 'overflow-y-auto');
|
||||
expect(foot).toHaveClass('m-fs-foot');
|
||||
// The close button is the one the layout hides once Cancel stands beside the title.
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('m-fs-close');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-002: a long title wraps to a second line instead of being cut after one', () => {
|
||||
render(<Form />);
|
||||
const title = screen.getAllByText('Add expense').find((el) => el.tagName === 'DIV') as HTMLElement;
|
||||
expect(title).toHaveClass('line-clamp-2');
|
||||
expect(title).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-003: the body is the container its paired rows measure against', () => {
|
||||
render(<Form />);
|
||||
const body = screen.getByRole('textbox', { name: 'Name' }).parentElement as HTMLElement;
|
||||
expect(body.className).toContain('[container-type:inline-size]');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-004: the body fades at the foot while more waits below, and at the top once scrolled', () => {
|
||||
render(<Form />);
|
||||
const body = screen.getByRole('textbox', { name: 'Name' }).parentElement as HTMLElement;
|
||||
Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 900 });
|
||||
Object.defineProperty(body, 'clientHeight', { configurable: true, value: 400 });
|
||||
act(() => {
|
||||
body.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(body).toHaveClass('m-fade-b');
|
||||
act(() => {
|
||||
body.scrollTop = 200;
|
||||
body.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(body).toHaveClass('m-fade-tb');
|
||||
act(() => {
|
||||
body.scrollTop = 500;
|
||||
body.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(body).toHaveClass('m-fade-t');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-005: the date field keeps its keyboard entry in the picker, so the date has the field to itself', () => {
|
||||
render(<SheetDateField value="2026-11-07" onChange={() => {}} />);
|
||||
// No second round button beside the date taking its room.
|
||||
expect(screen.queryByRole('button', { name: /enter date manually|common\.datepicker\.enterManually/i })).toBeNull();
|
||||
const trigger = screen.getByRole('button', { expanded: false });
|
||||
fireEvent.click(trigger);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /enter date manually|common\.datepicker\.enterManually/i })
|
||||
).toBeInTheDocument();
|
||||
expect(trigger.closest('.rounded-full')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useEdgeFade on the vertical axis', () => {
|
||||
function box(scrollHeight: number, clientHeight: number, scrollTop = 0) {
|
||||
const el = document.createElement('div');
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: scrollHeight });
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, value: clientHeight });
|
||||
el.scrollTop = scrollTop;
|
||||
return el;
|
||||
}
|
||||
|
||||
it('FE-MOB-FORMSHEET-006: reads the top and the foot, and its classes never mirror', () => {
|
||||
expect(edgeFadeClass(renderHook(() => useEdgeFade(box(400, 400), 'y')).result.current, 'y')).toBe('');
|
||||
expect(edgeFadeClass(renderHook(() => useEdgeFade(box(900, 400), 'y')).result.current, 'y')).toBe('m-fade-b');
|
||||
expect(edgeFadeClass({ start: true, end: false }, 'y')).toBe('m-fade-t');
|
||||
expect(edgeFadeClass({ start: true, end: true }, 'y')).toBe('m-fade-tb');
|
||||
// The strip's own classes are unchanged.
|
||||
expect(edgeFadeClass({ start: true, end: true })).toBe('m-fade-se');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-007: measures again when fields come and go inside a box that keeps its height', async () => {
|
||||
const el = box(400, 400);
|
||||
const { result } = renderHook(() => useEdgeFade(el, 'y'));
|
||||
expect(result.current.end).toBe(false);
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 800 });
|
||||
await act(async () => {
|
||||
el.appendChild(document.createElement('div'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.end).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAutoGrow', () => {
|
||||
it('FE-MOB-FORMSHEET-008: a textarea grows to its lines instead of slicing the last one', () => {
|
||||
function Story() {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [text, setText] = useState('one');
|
||||
useAutoGrow(ref, text);
|
||||
return <textarea ref={ref} aria-label="Story" rows={3} value={text} onChange={(e) => setText(e.target.value)} />;
|
||||
}
|
||||
const scrollHeight = vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockReturnValue(66);
|
||||
render(<Story />);
|
||||
const area = screen.getByRole('textbox', { name: 'Story' });
|
||||
expect(area.style.height).toBe('66px');
|
||||
scrollHeight.mockReturnValue(110);
|
||||
fireEvent.change(area, { target: { value: 'one\ntwo\nthree\nfour' } });
|
||||
expect(area.style.height).toBe('110px');
|
||||
scrollHeight.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemberAvatar', () => {
|
||||
it('FE-MOB-FORMSHEET-009: shows the member initial, and turns over on a chip filled with the accent', () => {
|
||||
const { rerender } = render(<MemberAvatar name="alice" variant="accent" size={16} />);
|
||||
expect(screen.getByText('A')).toHaveClass('bg-m-act', 'text-m-actfg');
|
||||
rerender(<MemberAvatar name="alice" variant="onAccent" size={16} />);
|
||||
expect(screen.getByText('A')).toHaveClass('bg-m-actfg', 'text-m-act');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form sheet stylesheet', () => {
|
||||
// jsdom applies no stylesheet, so these read the real files. Vitest runs with
|
||||
// the client package as its root.
|
||||
const mobileCss = readFileSync(resolve(process.cwd(), 'src/mobile/mobile.css'), 'utf8');
|
||||
const indexCss = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const tailwind = readFileSync(resolve(process.cwd(), 'tailwind.config.js'), 'utf8');
|
||||
const shortBlock = (() => {
|
||||
const start = mobileCss.indexOf('@media (max-height: 500px)');
|
||||
return mobileCss.slice(start, mobileCss.indexOf('\n}\n', start));
|
||||
})();
|
||||
|
||||
it('FE-MOB-FORMSHEET-010: the short-window variant is the other side of the wide size class height bound', () => {
|
||||
expect(tailwind).toContain("addVariant('short', '@media (max-height: 500px)')");
|
||||
expect(tailwind).toContain("addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')");
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-011: on a short window the actions sit beside the title and the fields take the rest', () => {
|
||||
expect(shortBlock).toContain('.m-form-sheet.m-form-sheet {');
|
||||
expect(shortBlock).toMatch(/display: grid;/);
|
||||
expect(shortBlock).toMatch(/\.m-fs-foot \{[^}]*grid-area: 1 \/ 2;[^}]*border-top: 0;/);
|
||||
expect(shortBlock).toMatch(/\.m-fs-body \{[^}]*grid-row: 3;/);
|
||||
// Cancel then does the close button's job; a form without a footer keeps it.
|
||||
expect(shortBlock).toContain('.m-form-sheet:has(> .m-fs-foot) > .m-fs-head .m-fs-close');
|
||||
});
|
||||
|
||||
it('FE-MOB-FORMSHEET-012: the focused sheet keeps its corners and draws no focus frame', () => {
|
||||
expect(mobileCss).toMatch(
|
||||
/\.m-root \[data-m-sheet\]:focus-visible \{\s*outline: none;\s*border-radius: var\(--m-sheet-r\);/
|
||||
);
|
||||
});
|
||||
|
||||
it("FE-MOB-FORMSHEET-013: the desktop's dark field paint stays out of the touch shell", () => {
|
||||
expect(indexCss).toContain('.dark input:not(:where(.m-root *))');
|
||||
expect(indexCss).toContain('.dark textarea:not(:where(.m-root *))');
|
||||
expect(indexCss).not.toMatch(/^\.dark input, \.dark textarea, \.dark select \{/m);
|
||||
});
|
||||
});
|
||||
@@ -15,17 +15,24 @@ import { resetAllStores, seedStore } from '../../helpers/store';
|
||||
// FE-MOB-LARGE-001 onwards: the touch shell's pieces laid out for a wide window.
|
||||
|
||||
describe('MSheet on a wide window', () => {
|
||||
it('FE-MOB-LARGE-001: a sheet becomes a centred form sheet of the width it asks for', () => {
|
||||
// dialog > its box (width, arrow) > the region it stands in (place on the screen).
|
||||
const boxOf = (name: string) => screen.getByRole('dialog', { name }).parentElement as HTMLElement;
|
||||
const regionOf = (name: string) => boxOf(name).parentElement as HTMLElement;
|
||||
|
||||
it('FE-MOB-LARGE-001: a sheet becomes a form sheet centred in the content beside the rail, of the width it asks for', () => {
|
||||
render(
|
||||
<MSheet open onClose={() => {}} variant="bottom" wideWidth="large" ariaLabel="Form">
|
||||
<span>body</span>
|
||||
</MSheet>
|
||||
);
|
||||
const frame = screen.getByRole('dialog', { name: 'Form' }).parentElement as HTMLElement;
|
||||
expect(frame.className).toContain('wide:mx-auto');
|
||||
expect(frame.className).toContain('wide:top-1/2');
|
||||
expect(frame.className).toContain('wide:w-[min(720px,calc(100%-48px))]');
|
||||
expect(frame.querySelector('[aria-hidden="true"].wide\\:block')).toBeNull();
|
||||
const region = regionOf('Form');
|
||||
// The region starts after the rail, so the sheet is centred in what is left of the window.
|
||||
expect(region.className).toContain('wide:ps-[calc(var(--m-rail-w)+24px)]');
|
||||
expect(region.className).toContain('wide:pe-6');
|
||||
expect(region.className).toContain('wide:justify-center');
|
||||
expect(region.className).toContain('wide:items-center');
|
||||
expect(boxOf('Form').className).toContain('wide:w-[min(720px,100%)]');
|
||||
expect(boxOf('Form').querySelector(':scope > span[aria-hidden="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("FE-MOB-LARGE-002: a menu from the rail's foot is a popover beside it, with an arrow", () => {
|
||||
@@ -42,11 +49,12 @@ describe('MSheet on a wide window', () => {
|
||||
<span>body</span>
|
||||
</MSheet>
|
||||
);
|
||||
const frame = screen.getByRole('dialog', { name: 'More' }).parentElement as HTMLElement;
|
||||
expect(frame.className).toContain('wide:start-[calc(var(--m-rail-w)+6px)]');
|
||||
expect(frame.className).not.toContain('wide:mx-auto');
|
||||
expect(frame.className).toContain('wide:w-[min(320px,calc(100%-48px))]');
|
||||
expect(frame.querySelector(':scope > span[aria-hidden="true"]')).not.toBeNull();
|
||||
const region = regionOf('More');
|
||||
expect(region.className).toContain('wide:ps-[calc(var(--m-rail-w)+6px)]');
|
||||
expect(region.className).toContain('wide:justify-end');
|
||||
expect(region.className).not.toContain('wide:justify-center');
|
||||
expect(boxOf('More').className).toContain('wide:w-[min(320px,100%)]');
|
||||
expect(boxOf('More').querySelector(':scope > span[aria-hidden="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-LARGE-003: the drawer keeps its own place', () => {
|
||||
@@ -55,8 +63,8 @@ describe('MSheet on a wide window', () => {
|
||||
<span>body</span>
|
||||
</MSheet>
|
||||
);
|
||||
const frame = screen.getByRole('dialog', { name: 'Drawer' }).parentElement as HTMLElement;
|
||||
expect(frame.className).not.toContain('wide:');
|
||||
expect(regionOf('Drawer').className).not.toContain('wide:');
|
||||
expect(boxOf('Drawer').className).not.toContain('wide:');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user