← back to Norma
feat(modals): shared ResizableModal with full-screen toggle + drag-resize
d43304891fcb39ca8a51e6ed34956470a39e7bb4 · 2026-05-20 12:38:54 -0700 · Steve Abrams
- components/shared/ResizableModal.tsx — drop-in wrapper providing:
- Maximize/Restore icon in header → expand to full viewport
- Bottom-right corner drag handle → freeform resize
- localStorage per modalId remembers size + maximized state
- Esc-to-close, click-backdrop-to-close, smooth border-radius transition
- Retrofit both modals in CredentialsSection (User Create/Edit, Delete
Confirm) as the proof point. Other Norma modals can adopt incrementally
by swapping their wrapper div for <ResizableModal modalId="…" />.
Files touched
M components/settings/CredentialsSection.tsxA components/shared/ResizableModal.tsx
Diff
commit d43304891fcb39ca8a51e6ed34956470a39e7bb4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 12:38:54 2026 -0700
feat(modals): shared ResizableModal with full-screen toggle + drag-resize
- components/shared/ResizableModal.tsx — drop-in wrapper providing:
- Maximize/Restore icon in header → expand to full viewport
- Bottom-right corner drag handle → freeform resize
- localStorage per modalId remembers size + maximized state
- Esc-to-close, click-backdrop-to-close, smooth border-radius transition
- Retrofit both modals in CredentialsSection (User Create/Edit, Delete
Confirm) as the proof point. Other Norma modals can adopt incrementally
by swapping their wrapper div for <ResizableModal modalId="…" />.
---
components/settings/CredentialsSection.tsx | 108 +++++--------
components/shared/ResizableModal.tsx | 242 +++++++++++++++++++++++++++++
2 files changed, 285 insertions(+), 65 deletions(-)
diff --git a/components/settings/CredentialsSection.tsx b/components/settings/CredentialsSection.tsx
index d544d51..c90f605 100644
--- a/components/settings/CredentialsSection.tsx
+++ b/components/settings/CredentialsSection.tsx
@@ -2,10 +2,11 @@
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
import {
- Users, Plus, Pencil, Trash2, X, Save, RefreshCw, Shield, Eye, EyeOff,
+ Users, Plus, Pencil, Trash2, Save, RefreshCw, Shield, Eye, EyeOff,
ChevronDown, ChevronRight, AlertTriangle, Check, Minus,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
type UserRole = 'admin' | 'staff' | 'intern' | 'pulse';
@@ -522,31 +523,31 @@ export default function CredentialsSection() {
{/* ─── Create/Edit Modal ───────────────────────────────────────────────── */}
{showModal && (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ backgroundColor: 'rgba(0, 0, 0, 0.4)' }}
- onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
- >
- <div
- className="w-full max-w-md rounded-xl shadow-xl"
- style={{ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
- >
- {/* Header */}
- <div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: '1px solid var(--color-border)' }}>
- <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
- {editingId ? 'Edit User' : 'Add User'}
- </h4>
+ <ResizableModal
+ modalId={editingId ? 'user-edit' : 'user-create'}
+ title={editingId ? 'Edit User' : 'Add User'}
+ onClose={closeModal}
+ defaultWidth={520}
+ footer={
+ <>
+ <button className="btn btn-ghost btn-sm" onClick={closeModal}>
+ Cancel
+ </button>
<button
- type="button"
- onClick={closeModal}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
+ className="btn btn-primary btn-sm"
+ onClick={handleSave}
+ disabled={formSaving}
>
- <X size={18} />
+ {formSaving ? (
+ <><RefreshCw size={13} className="animate-spin" /><span>Saving...</span></>
+ ) : (
+ <><Save size={13} /><span>{editingId ? 'Update' : 'Create'}</span></>
+ )}
</button>
- </div>
-
- {/* Body */}
- <div className="px-5 py-4 space-y-4" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
+ </>
+ }
+ >
+ <div className="space-y-4">
{formError && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm" style={{ backgroundColor: 'rgba(220, 38, 38, 0.08)', color: '#dc2626', border: '1px solid rgba(220, 38, 38, 0.2)' }}>
<AlertTriangle size={14} />
@@ -686,52 +687,19 @@ export default function CredentialsSection() {
placeholder="e.g. Natalia (SDCC ED)"
/>
</div>
- </div>
-
- {/* Footer */}
- <div className="flex items-center justify-end gap-2 px-5 py-4" style={{ borderTop: '1px solid var(--color-border)' }}>
- <button className="btn btn-ghost btn-sm" onClick={closeModal}>
- Cancel
- </button>
- <button
- className="btn btn-primary btn-sm"
- onClick={handleSave}
- disabled={formSaving}
- >
- {formSaving ? (
- <><RefreshCw size={13} className="animate-spin" /><span>Saving...</span></>
- ) : (
- <><Save size={13} /><span>{editingId ? 'Update' : 'Create'}</span></>
- )}
- </button>
- </div>
</div>
- </div>
+ </ResizableModal>
)}
{/* ─── Delete Confirm Dialog ───────────────────────────────────────────── */}
{deleteId && (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ backgroundColor: 'rgba(0, 0, 0, 0.4)' }}
- onClick={(e) => { if (e.target === e.currentTarget) setDeleteId(null); }}
- >
- <div
- className="w-full max-w-sm rounded-xl shadow-xl p-5"
- style={{ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
- >
- <div className="flex items-center gap-3 mb-4">
- <div className="p-2 rounded-full" style={{ backgroundColor: 'rgba(220, 38, 38, 0.10)' }}>
- <AlertTriangle size={18} style={{ color: '#dc2626' }} />
- </div>
- <div>
- <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Delete User</h4>
- <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
- This will permanently remove the login for <strong>{credentials.find(c => c.id === deleteId)?.username}</strong>.
- </p>
- </div>
- </div>
- <div className="flex items-center justify-end gap-2">
+ <ResizableModal
+ modalId="user-delete-confirm"
+ title="Delete User"
+ onClose={() => setDeleteId(null)}
+ defaultWidth={400}
+ footer={
+ <>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteId(null)}>
Cancel
</button>
@@ -746,9 +714,19 @@ export default function CredentialsSection() {
<><Trash2 size={13} /><span>Delete</span></>
)}
</button>
+ </>
+ }
+ >
+ <div className="flex items-center gap-3">
+ <div className="p-2 rounded-full" style={{ backgroundColor: 'rgba(220, 38, 38, 0.10)' }}>
+ <AlertTriangle size={18} style={{ color: '#dc2626' }} />
</div>
+ <p className="text-sm" style={{ color: 'var(--color-text)' }}>
+ This will permanently remove the login for{' '}
+ <strong>{credentials.find(c => c.id === deleteId)?.username}</strong>.
+ </p>
</div>
- </div>
+ </ResizableModal>
)}
</div>
);
diff --git a/components/shared/ResizableModal.tsx b/components/shared/ResizableModal.tsx
new file mode 100644
index 0000000..562bfb7
--- /dev/null
+++ b/components/shared/ResizableModal.tsx
@@ -0,0 +1,242 @@
+'use client';
+
+/**
+ * ResizableModal — shared wrapper for any modal that should be:
+ * • full-screen toggleable (button in header)
+ * • corner-drag resizable (handle bottom-right)
+ * • size + maximized state persisted in localStorage per `modalId`
+ * • Esc-to-close, click-outside-to-close, smooth transitions
+ *
+ * Usage:
+ * <ResizableModal
+ * modalId="user-edit"
+ * title={editingId ? 'Edit User' : 'Add User'}
+ * onClose={closeModal}
+ * footer={<><button onClick={…}>Save</button></>}
+ * >
+ * …body fields…
+ * </ResizableModal>
+ *
+ * Notes:
+ * - Pass a stable `modalId` per modal so each remembers its own size.
+ * - Body content scrolls when it overflows; the chrome (header + footer)
+ * stays pinned, so even a maximized modal feels like a clean panel.
+ */
+
+import {
+ ReactNode, useEffect, useRef, useState, useCallback,
+} from 'react';
+import { X, Maximize2, Minimize2 } from 'lucide-react';
+
+interface Props {
+ modalId: string;
+ title: ReactNode;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+ defaultWidth?: number; // px
+ defaultHeight?: number; // px; auto if omitted
+ minWidth?: number;
+ minHeight?: number;
+ closeOnBackdrop?: boolean;
+}
+
+interface Size {
+ w: number;
+ h: number | null; // null = auto
+ max: boolean;
+}
+
+function loadSize(modalId: string, fallback: Size): Size {
+ if (typeof window === 'undefined') return fallback;
+ try {
+ const raw = localStorage.getItem(`norma-modal-size:${modalId}`);
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw);
+ return {
+ w: typeof parsed.w === 'number' ? parsed.w : fallback.w,
+ h: typeof parsed.h === 'number' ? parsed.h : null,
+ max: !!parsed.max,
+ };
+ } catch {
+ return fallback;
+ }
+}
+
+function saveSize(modalId: string, size: Size) {
+ try {
+ localStorage.setItem(`norma-modal-size:${modalId}`, JSON.stringify(size));
+ } catch { /* noop */ }
+}
+
+export default function ResizableModal({
+ modalId,
+ title,
+ onClose,
+ children,
+ footer,
+ defaultWidth = 480,
+ defaultHeight = null as unknown as number,
+ minWidth = 320,
+ minHeight = 240,
+ closeOnBackdrop = true,
+}: Props) {
+ const [size, setSize] = useState<Size>(() =>
+ loadSize(modalId, { w: defaultWidth, h: defaultHeight || null, max: false }),
+ );
+ const dragRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
+ const panelRef = useRef<HTMLDivElement | null>(null);
+
+ // Persist whenever size changes (debounced to next tick)
+ useEffect(() => {
+ const t = window.setTimeout(() => saveSize(modalId, size), 250);
+ return () => window.clearTimeout(t);
+ }, [modalId, size]);
+
+ // Esc closes
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ /* ─── Drag resize from the bottom-right corner ─────────────────────────── */
+ const onResizeMouseDown = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (size.max) return; // don't resize while maximized
+ const rect = panelRef.current?.getBoundingClientRect();
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startW: rect?.width ?? size.w,
+ startH: rect?.height ?? (size.h || minHeight),
+ };
+ const onMove = (ev: MouseEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const dw = ev.clientX - d.startX;
+ const dh = ev.clientY - d.startY;
+ const newW = Math.max(minWidth, Math.min(window.innerWidth - 40, d.startW + dw));
+ const newH = Math.max(minHeight, Math.min(window.innerHeight - 40, d.startH + dh));
+ setSize((s) => ({ ...s, w: newW, h: newH }));
+ };
+ const onUp = () => {
+ dragRef.current = null;
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseup', onUp);
+ };
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseup', onUp);
+ }, [minWidth, minHeight, size.max, size.h, size.w]);
+
+ function toggleMax() {
+ setSize((s) => ({ ...s, max: !s.max }));
+ }
+
+ // Compute applied dimensions
+ const applied = size.max
+ ? { width: '100vw', height: '100vh', maxWidth: '100vw', maxHeight: '100vh', borderRadius: 0 }
+ : {
+ width: `${size.w}px`,
+ height: size.h ? `${size.h}px` : 'auto',
+ maxWidth: `min(96vw, ${size.w}px)`,
+ maxHeight: 'min(92vh, 100%)',
+ };
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center"
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.45)' }}
+ onMouseDown={(e) => {
+ if (closeOnBackdrop && e.target === e.currentTarget) onClose();
+ }}
+ >
+ <div
+ ref={panelRef}
+ className="flex flex-col shadow-2xl"
+ style={{
+ position: 'relative',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: size.max ? 0 : 'var(--radius-lg)',
+ transition: 'border-radius 120ms ease',
+ overflow: 'hidden',
+ ...applied,
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ {/* Header */}
+ <div
+ className="flex items-center justify-between px-5 py-4 flex-shrink-0"
+ style={{ borderBottom: '1px solid var(--color-border)' }}
+ >
+ <h4 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
+ {title}
+ </h4>
+ <div className="flex items-center gap-1 flex-shrink-0">
+ <button
+ type="button"
+ onClick={toggleMax}
+ className="p-1.5 rounded-md hover:bg-black/10 transition-colors"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
+ title={size.max ? 'Restore' : 'Expand to full screen'}
+ aria-label={size.max ? 'Restore' : 'Maximize'}
+ >
+ {size.max ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
+ </button>
+ <button
+ type="button"
+ onClick={onClose}
+ className="p-1.5 rounded-md hover:bg-black/10 transition-colors"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
+ aria-label="Close"
+ >
+ <X size={16} />
+ </button>
+ </div>
+ </div>
+
+ {/* Body */}
+ <div
+ className="px-5 py-4 flex-1"
+ style={{ overflowY: 'auto', minHeight: 0 }}
+ >
+ {children}
+ </div>
+
+ {/* Footer */}
+ {footer && (
+ <div
+ className="flex items-center justify-end gap-2 px-5 py-4 flex-shrink-0"
+ style={{ borderTop: '1px solid var(--color-border)' }}
+ >
+ {footer}
+ </div>
+ )}
+
+ {/* Drag handle (hidden when maximized) */}
+ {!size.max && (
+ <div
+ onMouseDown={onResizeMouseDown}
+ className="absolute"
+ style={{
+ right: 0,
+ bottom: 0,
+ width: 18,
+ height: 18,
+ cursor: 'nwse-resize',
+ background: 'linear-gradient(135deg, transparent 50%, var(--color-text-muted) 50%, var(--color-text-muted) 60%, transparent 60%, transparent 70%, var(--color-text-muted) 70%, var(--color-text-muted) 80%, transparent 80%)',
+ opacity: 0.55,
+ borderBottomRightRadius: 'var(--radius-lg)',
+ }}
+ title="Drag to resize"
+ aria-label="Resize handle"
+ />
+ )}
+ </div>
+ </div>
+ );
+}
← 38145df feat(gmail): per-message Assign-to + per-user read receipts
·
back to Norma
·
feat(mockups): 6 inbox UI variants — Classic Dense / Notion 68f7b63 →