refactor modal components to use header/body/footer pattern

UserSettingsModal now uses ModalHeader, ModalBody, ModalFooter.
Dialog simplified. Button tweaks.
This commit is contained in:
Justin Edmund 2025-12-13 14:34:20 -08:00
parent eaea344db4
commit 4418157ca0
3 changed files with 109 additions and 176 deletions

View file

@ -2,6 +2,9 @@
<script lang="ts">
import Dialog from './ui/Dialog.svelte'
import ModalHeader from './ui/ModalHeader.svelte'
import ModalBody from './ui/ModalBody.svelte'
import ModalFooter from './ui/ModalFooter.svelte'
import Select from './ui/Select.svelte'
import Switch from './ui/switch/Switch.svelte'
import Button from './ui/Button.svelte'
@ -10,7 +13,7 @@
import type { UserCookie } from '$lib/types/UserCookie'
import { setUserCookie } from '$lib/auth/cookies'
import { invalidateAll } from '$app/navigation'
interface Props {
open: boolean
onOpenChange?: (open: boolean) => void
@ -93,8 +96,7 @@
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
// Handle form submission
async function handleSave(e: Event) {
e.preventDefault()
async function handleSave() {
error = null
saving = true
@ -160,101 +162,98 @@
}
</script>
<Dialog
bind:open
{...onOpenChange ? { onOpenChange } : {}}
title="@{username}"
description="Account Settings"
>
<Dialog bind:open {...onOpenChange ? { onOpenChange } : {}}>
{#snippet children()}
<form onsubmit={handleSave} class="settings-form">
{#if error}
<div class="error-message">{error}</div>
{/if}
<ModalHeader title="Account settings" description="@{username}" />
<div class="form-fields">
<!-- Picture Selection with Preview -->
<div class="picture-section">
<div class="current-avatar">
<img
src={`/profile/${picture}.png`}
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{element}"
<ModalBody>
<div class="settings-form">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="form-fields">
<!-- Picture Selection with Preview -->
<div class="picture-section">
<div class="current-avatar">
<img
src={`/profile/${picture}.png`}
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{element}"
/>
</div>
<Select
bind:value={picture}
options={pictureOptions}
label="Avatar"
placeholder="Select an avatar"
fullWidth
contained
/>
</div>
<!-- Element Selection -->
<Select
bind:value={picture}
options={pictureOptions}
label="Avatar"
placeholder="Select an avatar"
bind:value={element}
options={elementOptions}
label="Element"
placeholder="Select an element"
fullWidth
contained
/>
<!-- Gender Selection -->
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
contained
/>
<!-- Language Selection -->
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
contained
/>
<!-- Theme Selection -->
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
contained
/>
<!-- Admin Mode (only for admins) -->
{#if role === 9}
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
</label>
</div>
{/if}
</div>
<!-- Element Selection -->
<Select
bind:value={element}
options={elementOptions}
label="Element"
placeholder="Select an element"
fullWidth
contained
/>
<!-- Gender Selection -->
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
contained
/>
<!-- Language Selection -->
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
contained
/>
<!-- Theme Selection -->
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
contained
/>
<!-- Admin Mode (only for admins) -->
{#if role === 9}
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
</label>
</div>
{/if}
</div>
</ModalBody>
<div class="form-actions">
<ModalFooter>
{#snippet children()}
<Button variant="ghost" onclick={handleClose} disabled={saving}>Cancel</Button>
<Button type="submit" variant="primary" disabled={saving}>
<Button onclick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
{/snippet}
{#snippet footer()}
<!-- Empty footer, actions are in the form -->
{/snippet}
</ModalFooter>
{/snippet}
</Dialog>
@ -263,7 +262,6 @@
@use '$src/themes/colors' as colors;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
@use '$src/themes/effects' as effects;
.settings-form {
display: flex;
@ -331,7 +329,7 @@
align-items: center;
justify-content: space-between;
padding: spacing.$unit-2x;
background-color: var(--input-bg);
background-color: var(--input-bound-bg);
border-radius: layout.$card-corner;
span {
@ -341,14 +339,6 @@
}
}
.form-actions {
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
padding-top: spacing.$unit-2x;
border-top: 1px solid var(--border-color);
}
:global(fieldset) {
border: none;
padding: 0;

View file

@ -139,6 +139,8 @@
</span>
{:else if iconOnly && icon}
<Icon name={icon} size={iconSizes[size]} />
{:else if iconOnly && children}
{@render children()}
{/if}
{#if rightAccessory}
@ -364,24 +366,24 @@
gap: 0;
aspect-ratio: 1;
padding: calc($unit * 1.5); // Default square padding
}
&.small {
padding: $unit !important; // Override size padding
width: calc($unit * 3.5);
height: calc($unit * 3.5);
}
:global([data-button-root].iconOnly.small) {
padding: $unit !important; // Override size padding
width: 30px;
height: 30px;
}
&.medium {
padding: calc($unit * 1.5) !important; // Override size padding
width: calc($unit * 5.5);
height: calc($unit * 5.5);
}
:global([data-button-root].iconOnly.medium) {
padding: calc($unit * 1.5) !important; // Override size padding
width: calc($unit * 5.5);
height: calc($unit * 5.5);
}
&.large {
padding: $unit-2x !important; // Override size padding
width: calc($unit * 6.5);
height: calc($unit * 6.5);
}
:global([data-button-root].iconOnly.large) {
padding: $unit-2x !important; // Override size padding
width: calc($unit * 6.5);
height: calc($unit * 6.5);
}
// Save button special states

View file

@ -1,28 +1,21 @@
<script lang="ts">
import { Dialog as DialogBase } from 'bits-ui'
import type { Snippet } from 'svelte'
import Icon from '$lib/components/Icon.svelte'
type DialogSize = 'default' | 'large'
interface DialogProps {
open: boolean
onOpenChange?: (open: boolean) => void
title?: string
description?: string
size?: DialogSize
children: Snippet
footer?: Snippet
}
let {
open = $bindable(false),
onOpenChange,
title,
description,
size = 'default',
children,
footer
children
}: DialogProps = $props()
const sizeClass = $derived(size === 'large' ? 'dialog-content-large' : '')
@ -37,26 +30,11 @@
<DialogBase.Portal>
<DialogBase.Overlay class="dialog-overlay" />
<DialogBase.Content class="dialog-content {sizeClass}">
{#if title}
<DialogBase.Title class="dialog-title">{title}</DialogBase.Title>
{/if}
{#if description}
<DialogBase.Description class="dialog-description">{description}</DialogBase.Description>
{/if}
<DialogBase.Close class="dialog-close">
<span aria-hidden="true">×</span>
</DialogBase.Close>
<div class="dialog-body">
{@render children()}
</div>
{#if footer}
<div class="dialog-footer">
{@render footer()}
</div>
{/if}
{@render children()}
</DialogBase.Content>
</DialogBase.Portal>
</DialogBase.Root>
@ -80,32 +58,18 @@
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--app-bg, white);
background: white;
border-radius: $card-corner;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 90vh;
width: 500px;
width: 540px;
z-index: 101;
animation: slide-up $duration-standard ease-out;
display: flex;
flex-direction: column;
}
:global(.dialog-title) {
font-size: $font-large;
font-weight: $bold;
margin: 0;
padding: $unit-2x;
padding-bottom: 0;
color: var(--text-primary);
}
:global(.dialog-description) {
font-size: $font-regular;
color: var(--text-secondary);
padding: 0 $unit-2x;
margin: $unit 0;
overflow: hidden;
padding: 0;
}
:global(.dialog-close) {
@ -123,6 +87,7 @@
color: var(--text-secondary);
cursor: pointer;
border-radius: $item-corner-small;
z-index: 1;
@include smooth-transition($duration-standard, all);
&:hover {
@ -135,20 +100,6 @@
}
}
:global(.dialog-body) {
padding: $unit-2x;
overflow-y: auto;
flex: 1;
}
:global(.dialog-footer) {
padding: $unit-2x;
padding-top: 0;
display: flex;
gap: $unit;
justify-content: flex-end;
}
// Large dialog variant for collection modals, etc.
:global(.dialog-content-large) {
width: 90vw;
@ -157,16 +108,6 @@
max-height: 85vh;
}
:global(.dialog-content-large .dialog-body) {
padding: $unit-3x;
}
:global(.dialog-content-large .dialog-footer) {
padding: $unit-3x;
padding-top: $unit-2x;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
}
@keyframes fade-in {
from {
opacity: 0;
@ -186,4 +127,4 @@
transform: translate(-50%, -50%);
}
}
</style>
</style>