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

View file

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

View file

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