[object Object]

← 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

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 →