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:
parent
ba50981e09
commit
fa52bb716d
4 changed files with 232 additions and 196 deletions
|
|
@ -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
|
||||
|
|
|
|||
155
src/lib/components/admin/BaseModal.svelte
Normal file
155
src/lib/components/admin/BaseModal.svelte
Normal 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>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import BaseModal from './BaseModal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -23,75 +24,58 @@
|
|||
|
||||
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()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div class="modal-actions">
|
||||
<Button variant="secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
<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">
|
||||
<Button variant="secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</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;
|
||||
}
|
||||
:global(.delete-confirmation-modal) {
|
||||
.modal-body {
|
||||
padding: $unit-4x;
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
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;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: $gray-10;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $unit-2x;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: $gray-10;
|
||||
p {
|
||||
margin: 0 0 $unit-4x;
|
||||
color: $gray-20;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-4x;
|
||||
color: $gray-20;
|
||||
line-height: 1.5;
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
@ -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,146 +24,47 @@
|
|||
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 }}>
|
||||
{#if showCloseButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
class="close-button"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
<BaseModal
|
||||
bind:isOpen
|
||||
{size}
|
||||
{closeOnBackdrop}
|
||||
{closeOnEscape}
|
||||
{onClose}
|
||||
>
|
||||
{#if showCloseButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
class="close-button"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="modal-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<slot />
|
||||
</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;
|
||||
|
|
@ -179,4 +76,4 @@
|
|||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Loading…
Reference in a new issue