refactor: extract BaseModal component to reduce duplication

- Create BaseModal with shared modal logic:
  - Backdrop click handling
  - Escape key handling
  - Body scroll locking
  - Transition animations
  - Size variants

- Refactor Modal and DeleteConfirmationModal to use BaseModal
- Reduce code duplication by ~100 lines
- Maintain all existing functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-25 22:10:33 -04:00
parent ba50981e09
commit fa52bb716d
4 changed files with 232 additions and 196 deletions

View file

@ -94,8 +94,8 @@ Create a consistent design system by extracting hardcoded values.
### Phase 3: Component Refactoring (Weeks 3-4)
Refactor components to reduce duplication and complexity.
- [ ] **Create base components**
- [ ] Extract `BaseModal` component for shared modal logic
- [-] **Create base components**
- [x] Extract `BaseModal` component for shared modal logic
- [ ] Create `BaseDropdown` for dropdown patterns
- [ ] Merge `FormField` and `FormFieldWrapper`
- [ ] Create `BaseSegmentedController` for shared logic

View file

@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
// Convert CSS transition durations to milliseconds for Svelte transitions
const TRANSITION_FAST_MS = 150 // $transition-fast: 0.15s
interface Props {
isOpen: boolean
size?: 'small' | 'medium' | 'large' | 'jumbo' | 'full' | 'auto'
closeOnBackdrop?: boolean
closeOnEscape?: boolean
onClose?: () => void
class?: string
}
let {
isOpen = $bindable(),
size = 'medium',
closeOnBackdrop = true,
closeOnEscape = true,
onClose,
class: className = ''
}: Props = $props()
function handleClose() {
isOpen = false
onClose?.()
}
function handleBackdropClick() {
if (closeOnBackdrop) {
handleClose()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && closeOnEscape && isOpen) {
handleClose()
}
}
// Effect to handle body scroll locking
$effect(() => {
if (isOpen) {
// Save current scroll position
const scrollY = window.scrollY
// Lock body scroll
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
return () => {
// Restore body scroll
const scrollY = document.body.style.top
document.body.style.position = ''
document.body.style.top = ''
document.body.style.width = ''
document.body.style.overflow = ''
// Restore scroll position
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || '0') * -1)
}
}
}
})
onMount(() => {
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('keydown', handleKeydown)
}
})
let modalClass = $derived(`modal modal-${size} ${className}`)
</script>
{#if isOpen}
<div
class="modal-backdrop"
on:click={handleBackdropClick}
transition:fade={{ duration: TRANSITION_FAST_MS }}
>
<div
class={modalClass}
on:click|stopPropagation
transition:fade={{ duration: TRANSITION_FAST_MS }}
role="dialog"
aria-modal="true"
>
<slot />
</div>
</div>
{/if}
<style lang="scss">
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: $overlay-medium;
display: flex;
justify-content: center;
align-items: center;
z-index: $z-index-modal-backdrop;
padding: $unit-2x;
}
.modal {
background-color: $white;
border-radius: $card-corner-radius;
box-shadow: 0 4px 12px $shadow-medium;
position: relative;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
&.modal-auto {
width: auto;
}
&.modal-small {
width: 100%;
max-width: 400px;
}
&.modal-medium {
width: 100%;
max-width: 600px;
}
&.modal-large {
width: 100%;
max-width: 800px;
}
&.modal-jumbo {
width: 90vw;
max-width: 1400px;
height: 80vh;
}
&.modal-full {
width: 100%;
max-width: 1200px;
height: 90vh;
}
}
</style>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import BaseModal from './BaseModal.svelte'
import Button from './Button.svelte'
interface Props {
@ -23,22 +24,22 @@
function handleConfirm() {
onConfirm()
isOpen = false
}
function handleCancel() {
isOpen = false
onCancel?.()
}
function handleBackdropClick() {
isOpen = false
onCancel?.()
}
</script>
{#if isOpen}
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<BaseModal
bind:isOpen
size="small"
onClose={handleCancel}
class="delete-confirmation-modal"
>
<div class="modal-body">
<h2>{title}</h2>
<p>{message}</p>
<div class="modal-actions">
@ -50,30 +51,12 @@
</Button>
</div>
</div>
</div>
{/if}
</BaseModal>
<style lang="scss">
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-modal;
}
.modal {
background: white;
border-radius: $unit-2x;
:global(.delete-confirmation-modal) {
.modal-body {
padding: $unit-4x;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
h2 {
margin: 0 0 $unit-2x;
@ -94,4 +77,5 @@
gap: $unit-2x;
justify-content: flex-end;
}
}
</style>

View file

@ -1,11 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import BaseModal from './BaseModal.svelte'
import Button from './Button.svelte'
// Convert CSS transition durations to milliseconds for Svelte transitions
const TRANSITION_FAST_MS = 150 // $transition-fast: 0.15s
interface Props {
isOpen: boolean
size?: 'small' | 'medium' | 'large' | 'jumbo' | 'full'
@ -28,60 +24,15 @@
isOpen = false
onClose?.()
}
function handleBackdropClick() {
if (closeOnBackdrop) {
handleClose()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && closeOnEscape) {
handleClose()
}
}
// Effect to handle body scroll locking
$effect(() => {
if (isOpen) {
// Save current scroll position
const scrollY = window.scrollY
// Lock body scroll
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
return () => {
// Restore body scroll
const scrollY = document.body.style.top
document.body.style.position = ''
document.body.style.top = ''
document.body.style.width = ''
document.body.style.overflow = ''
// Restore scroll position
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || '0') * -1)
}
}
}
})
onMount(() => {
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('keydown', handleKeydown)
}
})
let modalClass = $derived(`modal-${size}`)
</script>
{#if isOpen}
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: TRANSITION_FAST_MS }}>
<div class="modal {modalClass}" on:click|stopPropagation transition:fade={{ duration: TRANSITION_FAST_MS }}>
<BaseModal
bind:isOpen
{size}
{closeOnBackdrop}
{closeOnEscape}
{onClose}
>
{#if showCloseButton}
<Button
variant="ghost"
@ -111,63 +62,9 @@
<div class="modal-content">
<slot />
</div>
</div>
</div>
{/if}
</BaseModal>
<style lang="scss">
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: $z-index-modal-backdrop;
padding: $unit-2x;
}
.modal {
background-color: white;
border-radius: $card-corner-radius;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
&.modal-small {
width: 100%;
max-width: 400px;
}
&.modal-medium {
width: 100%;
max-width: 600px;
}
&.modal-large {
width: 100%;
max-width: 800px;
}
&.modal-jumbo {
width: 90vw;
max-width: 1400px;
height: 80vh;
}
&.modal-full {
width: 100%;
max-width: 1200px;
height: 90vh;
}
}
:global(.close-button) {
position: absolute !important;
top: $unit-2x;