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