astryx
This commit is contained in:
108
src/components/AppDialog.tsx
Normal file
108
src/components/AppDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* PHASE 6 — Astryx's Dialog, plus the one behaviour it does not provide.
|
||||
*
|
||||
* Dialog gives Escape, a focus trap and a scroll lock. It does NOT restore
|
||||
* focus when it closes: verified in the browser, focus lands on <body>, which
|
||||
* drops a keyboard user back to the top of the document every time they dismiss
|
||||
* a modal. On screens like the staff panel or the order board — where a dialog
|
||||
* is opened from a row action, dismissed, and the next row acted on — that
|
||||
* means re-tabbing through the whole page between every edit.
|
||||
*
|
||||
* This wrapper captures whatever was focused at the moment the dialog opened
|
||||
* and hands focus back on close. Everything else passes straight through, so a
|
||||
* call site swaps `Dialog` for `AppDialog` and changes nothing else.
|
||||
*
|
||||
* When Astryx adds focus restoration upstream, delete this file and change the
|
||||
* imports back — nothing else here depends on it.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Dialog, type DialogProps } from '@astryxdesign/core/Dialog';
|
||||
|
||||
/**
|
||||
* Hands focus back to `el`, retrying briefly until it takes.
|
||||
*
|
||||
* Two things make this less trivial than one `el.focus()`:
|
||||
*
|
||||
* 1. Timing. Closing a native <dialog> makes the browser run its own focus
|
||||
* fixup, and Astryx unwinds its focus trap on top of that. A restore that
|
||||
* lands inside either pass is overwritten and focus ends up on <body> —
|
||||
* the exact bug this file exists to fix.
|
||||
* 2. Scheduler. The obvious tool is requestAnimationFrame, and it is the wrong
|
||||
* one: Chrome does not fire rAF in a background tab, so the restore silently
|
||||
* never happened. Timers keep running (throttled), so they are used instead.
|
||||
*
|
||||
* Attempts stop as soon as focus lands, and give up quietly if the opener has
|
||||
* been removed from the document meanwhile.
|
||||
*/
|
||||
function restoreFocusSoon(el: HTMLElement) {
|
||||
const attempt = () => {
|
||||
if (document.activeElement === el) return true;
|
||||
if (!document.contains(el)) return true; // opener is gone; nothing to aim at
|
||||
el.focus?.();
|
||||
return document.activeElement === el;
|
||||
};
|
||||
if (attempt()) return;
|
||||
for (const delay of [0, 30, 90]) {
|
||||
setTimeout(() => { attempt(); }, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export function AppDialog({ isOpen, ...rest }: DialogProps) {
|
||||
/** What had focus when this dialog opened. */
|
||||
const openerRef = useRef<HTMLElement | null>(null);
|
||||
/** Distinguishes "closed because it was dismissed" from "closed since mount". */
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
// Captured during render, NOT in an effect — and that is the whole trick.
|
||||
//
|
||||
// React runs child effects before parent ones, and AppDialog is the parent of
|
||||
// Dialog. An effect here therefore fires *after* Astryx has already pulled
|
||||
// focus into the dialog, so it captures the dialog's own heading rather than
|
||||
// the control that opened it — and that heading is unmounted moments later,
|
||||
// so the restore silently no-ops and focus lands on <body>. Which is exactly
|
||||
// the symptom this file was written to cure, reproduced by the cure.
|
||||
//
|
||||
// Render runs before any of that. Reading document.activeElement here is a
|
||||
// side-effect-free DOM read into a ref, and the guard makes it idempotent
|
||||
// under StrictMode's double render.
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
openerRef.current = document.activeElement as HTMLElement | null;
|
||||
wasOpenRef.current = true;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
if (!wasOpenRef.current) return;
|
||||
wasOpenRef.current = false;
|
||||
|
||||
const opener = openerRef.current;
|
||||
openerRef.current = null;
|
||||
// The opener can be gone by now — a row action whose row the dialog just
|
||||
// deleted, for one. Restoring focus to a detached node silently sends it
|
||||
// to <body>, which is the bug this exists to fix.
|
||||
if (!opener || !document.contains(opener)) return;
|
||||
restoreFocusSoon(opener);
|
||||
}, [isOpen]);
|
||||
|
||||
// Unmounting while open is how MOST of these actually close. Several call
|
||||
// sites — PosStaffPanel's Modal, PosView's PosModal — pass a constant
|
||||
// `isOpen` and mount the dialog conditionally instead (`{editing && <Modal/>}`),
|
||||
// so the effect above never sees a true→false transition and this is the only
|
||||
// path that runs for them.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const opener = openerRef.current;
|
||||
if (wasOpenRef.current && opener && document.contains(opener)) restoreFocusSoon(opener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Dialog isOpen={isOpen} {...rest} />;
|
||||
}
|
||||
|
||||
export default AppDialog;
|
||||
@@ -20,6 +20,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { AppDialog } from './AppDialog';
|
||||
|
||||
/**
|
||||
* The image on a browse card, with the rest reachable.
|
||||
@@ -136,29 +137,39 @@ export function Lightbox({
|
||||
[index, images.length, onIndex],
|
||||
);
|
||||
|
||||
// PHASE 5 — only the arrow keys are hand-wired now.
|
||||
//
|
||||
// Escape and the body scroll lock used to live here too, and the lock had to
|
||||
// save/restore the previous value rather than clear it, because the import
|
||||
// drawer beneath this one locks the body as well. Dialog owns both now, and
|
||||
// owns the unwinding order with them, so that whole balancing act is gone.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowRight') move(1);
|
||||
if (e.key === 'ArrowRight') move(1);
|
||||
else if (e.key === 'ArrowLeft') move(-1);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
// Restored rather than cleared: the import modal beneath this one also
|
||||
// locks the body, and clearing outright would let the page behind it
|
||||
// scroll once this closes.
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [move, onClose]);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [move]);
|
||||
|
||||
if (!images.length) return null;
|
||||
|
||||
// Renders in the top layer, so it sits above the import drawer without the
|
||||
// z-[400] this used to need.
|
||||
return (
|
||||
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} />
|
||||
<AppDialog
|
||||
isOpen
|
||||
variant="fullscreen"
|
||||
purpose="info"
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
// Dialog paints a light surface. A photo viewer needs the opposite: every
|
||||
// control in here is white-on-dark, and on the default ground the close
|
||||
// button, the counter and the arrows all vanished into the background.
|
||||
style={{ background: 'rgb(2 6 23 / 0.95)', padding: 0 }}
|
||||
>
|
||||
{/* Fills the dialog so the floating close has something to anchor to —
|
||||
`absolute` against the <dialog> itself lands outside the painted box. */}
|
||||
<div className="relative w-full h-full flex items-center justify-center p-4">
|
||||
|
||||
<IconButton
|
||||
icon={<X size={18} />}
|
||||
@@ -216,7 +227,8 @@ export function Lightbox({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { X, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
|
||||
export default function ComparisonModal() {
|
||||
@@ -51,10 +52,23 @@ export default function ComparisonModal() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparison Drawer */}
|
||||
{/* PHASE 5 — was a hand-rolled `fixed inset-0` bottom sheet with its own
|
||||
backdrop, no Escape and no focus trap. Now a fullscreen Dialog, which
|
||||
is the closest shape to a sheet that fills the width.
|
||||
|
||||
NOTE: this could not be exercised in the browser, because nothing can
|
||||
open it — `toggleProduct` in CompareContext is never called anywhere
|
||||
in the app, so `selectedProducts` is always empty and the component
|
||||
returns null above. The conversion typechecks and builds; it has not
|
||||
been seen running. */}
|
||||
{isComparing && (
|
||||
<div className="fixed inset-0 bg-[#0f172a]/60 backdrop-blur-md z-[250] flex flex-col justify-end">
|
||||
<div className="bg-white border-t border-[#e2e8f0] w-full h-[80vh] sm:h-[70vh] shadow-[0_-20px_50px_rgba(0,0,0,0.15)] rounded-t-3xl overflow-hidden flex flex-col animate-in slide-in-from-bottom duration-300">
|
||||
<AppDialog
|
||||
isOpen
|
||||
variant="fullscreen"
|
||||
purpose="info"
|
||||
onOpenChange={(open) => { if (!open) setIsComparing(false); }}
|
||||
>
|
||||
<div className="bg-white w-full h-full overflow-hidden flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-slate-50 shrink-0">
|
||||
@@ -242,7 +256,7 @@ export default function ComparisonModal() {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
)}
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,7 @@ export function ConfirmModal({
|
||||
isLoading = false,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Ignore every close request while a mutation is running — the original
|
||||
@@ -79,7 +80,7 @@ export function ConfirmModal({
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { shortTime } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
@@ -441,7 +441,7 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
|
||||
// page sits inside several, so the panel used to collapse. Dialog renders in
|
||||
// the browser's top layer, which no ancestor can contain.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
@@ -495,6 +495,6 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
@@ -176,11 +177,19 @@ export default function OfflineSalesUpload({
|
||||
? template?.locations[0]?.locationname || 'your store'
|
||||
: `all ${storeCount} stores`;
|
||||
|
||||
// PHASE 5 — was a hand-rolled `fixed inset-0` with its own backdrop div and a
|
||||
// click-to-dismiss handler on it. Dialog supplies the backdrop, the top-layer
|
||||
// placement, Escape and the focus trap; the width and max-height are passed
|
||||
// through so the panel keeps the size it had.
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/50" onClick={onClose} />
|
||||
|
||||
<div className="relative z-10 flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl">
|
||||
<AppDialog
|
||||
isOpen
|
||||
width={1152}
|
||||
maxHeight="92vh"
|
||||
purpose="info"
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
>
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between bg-[#662582] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={20} className="text-white/90" />
|
||||
@@ -507,7 +516,7 @@ export default function OfflineSalesUpload({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
@@ -426,7 +426,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
// the panel used to collapse. Dialog renders in the browser's top layer, which
|
||||
// no ancestor can contain.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
{/* Modal header */}
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
@@ -532,6 +532,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our
|
||||
// dates are plain strings out of `ymd()`. Importing the real type keeps the cast
|
||||
@@ -724,7 +724,7 @@ function SelectedOrdersPage({
|
||||
// it applies to every Dialog in the app, and it is a missing gain rather
|
||||
// than a regression (the old overlay managed no focus at all), so it is not
|
||||
// patched here one screen at a time.
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
|
||||
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* Sticky page header with the assign controls */}
|
||||
<div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #fff)', borderColor: BORDER }}>
|
||||
@@ -820,7 +820,7 @@ function SelectedOrdersPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -852,7 +852,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
// collapse to a sliver. Dialog renders in the browser's top layer, which no
|
||||
// ancestor can contain — so the escape hatches are no longer needed.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
@@ -895,6 +895,6 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import React, { useMemo, useState } from 'react';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { TimeInput } from '@astryxdesign/core/TimeInput';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { ToggleButton } from '@astryxdesign/core/ToggleButton';
|
||||
@@ -967,10 +968,10 @@ function Modal({
|
||||
// from a `width` prop rather than a Tailwind utility — so the collision that
|
||||
// bug came from cannot reach it.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
|
||||
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
|
||||
{/* Names come from user input and can be long and unbroken. */}
|
||||
<DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} />
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Ba
|
||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { useNotify } from './Toasts';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
@@ -47,7 +47,7 @@ function PosModal({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
@@ -55,7 +55,7 @@ function PosModal({
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ import { stockRowToProduct } from '../services/fiestaMappers';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
@@ -1156,10 +1158,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EXPORTING DIALOG MODAL */}
|
||||
{/* PHASE 5 — the export progress dialog, was a hand-rolled `fixed inset-0`.
|
||||
`purpose="required"` rather than "info": this one has no dismiss
|
||||
affordance and never had, because the export is already running and
|
||||
there is nothing to cancel it with. Blocking Escape matches what the
|
||||
overlay did; it unmounts itself when the export finishes, so the
|
||||
latching that `required` does cannot strand anyone. */}
|
||||
{exportingFormat && (
|
||||
<div className="fixed inset-0 bg-[#0f172a]/35 backdrop-blur-sm z-[200] flex items-center justify-center p-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-2xl w-full max-w-[20rem] p-lg flex flex-col items-center justify-center shadow-2xl animate-in zoom-in-95 duration-200 text-center font-sans">
|
||||
<AppDialog
|
||||
isOpen
|
||||
width={320}
|
||||
purpose="required"
|
||||
// Required by the type, and deliberately a no-op: `required` blocks
|
||||
// every dismissal route anyway, and this closes when the export ends.
|
||||
onOpenChange={() => {}}
|
||||
>
|
||||
<div className="w-full p-lg flex flex-col items-center justify-center text-center font-sans">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-[#581c87] mb-md animate-bounce">
|
||||
<Download size={20} />
|
||||
</div>
|
||||
@@ -1170,16 +1184,20 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
Compiling database records and SVG vector curves...
|
||||
</p>
|
||||
|
||||
{/* Progress track */}
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden mb-sm relative">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-500 to-[#581c87] h-full rounded-full transition-all duration-150"
|
||||
style={{ width: `${exportProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-purple-750">{exportProgress}%</span>
|
||||
{/* Was two nested divs with a width percentage — visible progress
|
||||
that reported nothing. ProgressBar carries role="progressbar"
|
||||
and aria-valuenow, so the percentage is now announced as it
|
||||
moves rather than only drawn. */}
|
||||
<ProgressBar
|
||||
label={`Generating ${exportingFormat} report`}
|
||||
isLabelHidden
|
||||
value={exportProgress}
|
||||
max={100}
|
||||
hasValueLabel
|
||||
className="w-full mb-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
@@ -52,7 +52,7 @@ function SettingsModal({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
@@ -60,7 +60,7 @@ function SettingsModal({
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
@@ -65,7 +65,7 @@ function CatalogueModal({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
@@ -73,7 +73,7 @@ function CatalogueModal({
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { TabList, Tab } from '@astryxdesign/core/TabList';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
@@ -96,7 +96,7 @@ function StoreModal({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open && isDismissable) onClose(); }}
|
||||
width={width}
|
||||
@@ -109,7 +109,7 @@ function StoreModal({
|
||||
purpose={isDismissable ? 'info' : 'required'}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -30,6 +29,8 @@ import {
|
||||
} from '../services/fiestaQueries';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import Header from './Header';
|
||||
@@ -425,25 +426,28 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Store QR — centered modal opened from the navbar QR button. Portaled to
|
||||
body so `fixed inset-0` is viewport-relative regardless of ancestors. */}
|
||||
{showQrModal &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(15,23,42,0.45)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
|
||||
{/* PHASE 5 — Store QR, opened from the navbar QR button.
|
||||
Was a hand-rolled `fixed inset-0` portaled to body, which is what it
|
||||
took to make the overlay viewport-relative regardless of ancestors.
|
||||
Dialog renders in the top layer, so neither the portal nor the manual
|
||||
backdrop is needed — and it brings Escape and the focus trap the
|
||||
hand-rolled version never had. */}
|
||||
{showQrModal && (
|
||||
<AppDialog
|
||||
isOpen
|
||||
width={600}
|
||||
maxHeight="90vh"
|
||||
purpose="info"
|
||||
onOpenChange={(open) => { if (!open) setShowQrModal(false); }}
|
||||
>
|
||||
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
|
||||
<IconButton
|
||||
icon={<X size={16} />}
|
||||
label="Close store QR code"
|
||||
tooltip="Close"
|
||||
variant="ghost"
|
||||
onClick={() => setShowQrModal(false)}
|
||||
style={{ height: 'auto', width: 'auto', minWidth: 0 }}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors"
|
||||
/>
|
||||
{/* The close is DialogHeader's, not a hand-placed one. An
|
||||
absolutely-positioned button here lands outside the painted
|
||||
surface: the <dialog> element is wider and taller than the card
|
||||
Astryx paints inside it, so `absolute top-3 right-3` against
|
||||
this wrapper put the X off the card entirely — and the same
|
||||
mismatch clipped the Download button off the bottom. */}
|
||||
<DialogHeader title="Store QR code" onOpenChange={(open) => { if (!open) setShowQrModal(false); }} />
|
||||
<div className="w-full mx-auto">
|
||||
<StoreQRView
|
||||
tenantId={tenantId}
|
||||
locationid={resolvedLocationId || undefined}
|
||||
@@ -456,9 +460,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
storeAddress={matchedLoc ? fstr(matchedLoc.address) || undefined : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</AppDialog>
|
||||
)}
|
||||
|
||||
<ComparisonModal />
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { AppDialog } from './AppDialog';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
@@ -65,7 +65,7 @@ function UsersModal({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
<AppDialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
@@ -73,7 +73,7 @@ function UsersModal({
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
33
src/main.tsx
33
src/main.tsx
@@ -51,14 +51,33 @@ createRoot(document.getElementById('root')!).render(
|
||||
|
||||
The theme carries a full dark palette and `mode="system"` does work — but
|
||||
only Astryx-backed components respond to it. Everything still on raw
|
||||
Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip
|
||||
renders `bg-white text-slate-900`, SearchPill sets `color: TEXT`
|
||||
(#0f172a). Switching to "system" today means any user whose OS is dark
|
||||
gets white cards on a dark page and a search box with black text on a
|
||||
black field. Verified in the browser, not assumed.
|
||||
Tailwind or inline hex is pinned to the light scheme.
|
||||
|
||||
Flip this to "system" once Phase 6 has removed the hardcoded colours.
|
||||
That single word is the dark-mode launch switch.
|
||||
Measured, not assumed — 2026-08-14, by flipping this to "dark" and
|
||||
looking:
|
||||
|
||||
- The page ground stays white. Astryx's own surfaces (table rules, card
|
||||
borders, dialog chrome) go dark and sit on it, so the result is a
|
||||
light app wearing dark furniture rather than a dark app.
|
||||
- On /store/pos-till the scan field becomes near-black text on a
|
||||
near-black field, and Browse / Refund / Park / Retrieve / Report all
|
||||
wash out to unreadable grey. That screen is a till: it is the worst
|
||||
possible one to render illegible.
|
||||
|
||||
The scale of what has to change first, counted across src/**:
|
||||
|
||||
2468 colour references in 53 files
|
||||
174 bg-white
|
||||
1400 pinned #rrggbb (685 of them in DispatchView.css alone)
|
||||
894 text-/bg-/border-slate|zinc|gray-NNN
|
||||
|
||||
So this is not a tail-end cleanup of Phase 6 — it is its own body of work,
|
||||
and it needs a token layer plus a brand decision before it can start: our
|
||||
BRAND #662582 fails contrast on a dark ground, and no lightened accent has
|
||||
been signed off.
|
||||
|
||||
Flip this to "system" once that lands. That single word is the dark-mode
|
||||
launch switch.
|
||||
|
||||
Outermost so nothing above it renders unthemed, but inside StrictMode so
|
||||
it gets the same double-render checks as everything else.
|
||||
|
||||
Reference in New Issue
Block a user