add selection mode store and components for bulk actions
This commit is contained in:
parent
eeee7170ce
commit
22d459f475
4 changed files with 309 additions and 0 deletions
60
src/lib/components/collection/BulkDeleteConfirmModal.svelte
Normal file
60
src/lib/components/collection/BulkDeleteConfirmModal.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Dialog from '$lib/components/ui/Dialog.svelte'
|
||||||
|
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
|
||||||
|
import ModalBody from '$lib/components/ui/ModalBody.svelte'
|
||||||
|
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
count: number
|
||||||
|
entityType: string
|
||||||
|
deleting?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
count,
|
||||||
|
entityType,
|
||||||
|
deleting = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const itemLabel = $derived(count === 1 ? entityType.slice(0, -1) : entityType)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open>
|
||||||
|
{#snippet children()}
|
||||||
|
<ModalHeader title="Delete {count} {itemLabel}?" />
|
||||||
|
<ModalBody>
|
||||||
|
<p class="message">
|
||||||
|
Are you sure you want to remove {count}
|
||||||
|
{itemLabel} from your collection? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter
|
||||||
|
{onCancel}
|
||||||
|
cancelDisabled={deleting}
|
||||||
|
primaryAction={{
|
||||||
|
label: deleting ? 'Deleting...' : 'Yes, delete',
|
||||||
|
onclick: onConfirm,
|
||||||
|
destructive: true,
|
||||||
|
disabled: deleting
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-regular;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import {
|
||||||
|
SELECTION_MODE_KEY,
|
||||||
|
type SelectionModeContext
|
||||||
|
} from '$lib/stores/selectionMode.svelte'
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
children: Snippet
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, children, onClick }: Props = $props()
|
||||||
|
|
||||||
|
const selectionMode = getContext<SelectionModeContext | undefined>(SELECTION_MODE_KEY)
|
||||||
|
|
||||||
|
const isSelected = $derived(selectionMode?.isSelected(id) ?? false)
|
||||||
|
|
||||||
|
function handleCheckboxChange() {
|
||||||
|
selectionMode?.toggle(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckboxClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick() {
|
||||||
|
onClick?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="selectable-card" class:selected={isSelected} class:selection-active={selectionMode?.isActive}>
|
||||||
|
{#if selectionMode?.isActive}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="checkbox-overlay" onclick={handleCheckboxClick}>
|
||||||
|
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} size="small" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="card-content" onclick={handleCardClick}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.selectable-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: $unit-half;
|
||||||
|
left: $unit-half;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: $item-corner;
|
||||||
|
padding: $unit-fourth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable-card.selected .card-content {
|
||||||
|
outline: 2px solid var(--accent-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable-card.selection-active .card-content {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
src/lib/components/collection/SelectableCollectionRow.svelte
Normal file
80
src/lib/components/collection/SelectableCollectionRow.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import {
|
||||||
|
SELECTION_MODE_KEY,
|
||||||
|
type SelectionModeContext
|
||||||
|
} from '$lib/stores/selectionMode.svelte'
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
children: Snippet
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, children, onClick }: Props = $props()
|
||||||
|
|
||||||
|
const selectionMode = getContext<SelectionModeContext | undefined>(SELECTION_MODE_KEY)
|
||||||
|
|
||||||
|
const isSelected = $derived(selectionMode?.isSelected(id) ?? false)
|
||||||
|
|
||||||
|
function handleCheckboxChange() {
|
||||||
|
selectionMode?.toggle(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckboxClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick() {
|
||||||
|
onClick?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="selectable-row" class:selected={isSelected} class:selection-active={selectionMode?.isActive}>
|
||||||
|
{#if selectionMode?.isActive}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="checkbox-cell" onclick={handleCheckboxClick}>
|
||||||
|
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} size="small" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="row-content" onclick={handleRowClick}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
|
||||||
|
.selectable-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable-row.selected .row-content {
|
||||||
|
outline: 2px solid var(--accent-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable-row.selection-active .row-content {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
src/lib/stores/selectionMode.svelte.ts
Normal file
87
src/lib/stores/selectionMode.svelte.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Selection mode store for collection bulk operations.
|
||||||
|
* Used to manage multi-select state across collection pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EntityType = 'characters' | 'weapons' | 'summons' | 'artifacts'
|
||||||
|
|
||||||
|
export interface SelectionModeContext {
|
||||||
|
readonly isActive: boolean
|
||||||
|
readonly entityType: EntityType | null
|
||||||
|
readonly selectedIds: Set<string>
|
||||||
|
readonly selectedCount: number
|
||||||
|
enter: (type: EntityType) => void
|
||||||
|
exit: () => void
|
||||||
|
toggle: (id: string) => void
|
||||||
|
selectAll: (ids: string[]) => void
|
||||||
|
clearSelection: () => void
|
||||||
|
isSelected: (id: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a selection mode context for managing bulk selection state.
|
||||||
|
* Should be created in the collection layout and provided via Svelte context.
|
||||||
|
*/
|
||||||
|
export function createSelectionModeContext(): SelectionModeContext {
|
||||||
|
let isActive = $state(false)
|
||||||
|
let entityType = $state<EntityType | null>(null)
|
||||||
|
let selectedIds = $state<Set<string>>(new Set())
|
||||||
|
|
||||||
|
return {
|
||||||
|
get isActive() {
|
||||||
|
return isActive
|
||||||
|
},
|
||||||
|
get entityType() {
|
||||||
|
return entityType
|
||||||
|
},
|
||||||
|
get selectedIds() {
|
||||||
|
return selectedIds
|
||||||
|
},
|
||||||
|
get selectedCount() {
|
||||||
|
return selectedIds.size
|
||||||
|
},
|
||||||
|
|
||||||
|
enter(type: EntityType) {
|
||||||
|
isActive = true
|
||||||
|
entityType = type
|
||||||
|
selectedIds = new Set()
|
||||||
|
},
|
||||||
|
|
||||||
|
exit() {
|
||||||
|
isActive = false
|
||||||
|
entityType = null
|
||||||
|
selectedIds = new Set()
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(id: string) {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (newSet.has(id)) {
|
||||||
|
newSet.delete(id)
|
||||||
|
} else {
|
||||||
|
newSet.add(id)
|
||||||
|
}
|
||||||
|
selectedIds = newSet
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll(ids: string[]) {
|
||||||
|
selectedIds = new Set(ids)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
selectedIds = new Set()
|
||||||
|
},
|
||||||
|
|
||||||
|
isSelected(id: string) {
|
||||||
|
return selectedIds.has(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SELECTION_MODE_KEY = Symbol('selection-mode')
|
||||||
|
|
||||||
|
/** Context key for child pages to provide their loaded item IDs to the layout */
|
||||||
|
export const LOADED_IDS_KEY = Symbol('loaded-ids')
|
||||||
|
|
||||||
|
export interface LoadedIdsContext {
|
||||||
|
setIds: (ids: string[]) => void
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue