feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
60
src/lib/ui/CollapsibleCard.svelte
Normal file
60
src/lib/ui/CollapsibleCard.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { Card, CardHeader, CardBody, Button, Icon } from '@sveltestrap/sveltestrap';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Standardized collapsible panel card used by every side-panel feature.
|
||||
* The header shows a title and a caret; clicking the header toggles body
|
||||
* visibility. The `initialCollapsed` prop seeds state but callers can
|
||||
* drive it externally via `bind:collapsed` if they want to persist state.
|
||||
*/
|
||||
interface Props {
|
||||
title?: string;
|
||||
collapsed?: boolean;
|
||||
headerClass?: string;
|
||||
bodyClass?: string;
|
||||
variant?: 'primary' | 'secondary' | 'light';
|
||||
headerExtra?: Snippet;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title = '',
|
||||
collapsed = $bindable(false),
|
||||
headerClass = '',
|
||||
bodyClass = '',
|
||||
variant = 'primary',
|
||||
headerExtra,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="mb-2 collapsible-card">
|
||||
<CardHeader
|
||||
class="d-flex justify-content-between align-items-center card-header p-1 px-3 bg-{variant} text-white {headerClass}"
|
||||
style="cursor:pointer;">
|
||||
<button
|
||||
type="button"
|
||||
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0 text-white"
|
||||
style="width:100%;"
|
||||
aria-expanded={!collapsed}
|
||||
onclick={toggle}>
|
||||
<b class="card-title mb-0 text-white p-0">{title}</b>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{#if headerExtra}{@render headerExtra()}{/if}
|
||||
<Button class="p-0" size="sm" color={variant}>
|
||||
<Icon name={collapsed ? 'caret-left-fill' : 'caret-down-fill'} class="text-white" />
|
||||
</Button>
|
||||
</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if !collapsed}
|
||||
<CardBody class={bodyClass}>
|
||||
{@render children?.()}
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
49
src/lib/ui/ConfirmationPrompt.svelte
Normal file
49
src/lib/ui/ConfirmationPrompt.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from '@sveltestrap/sveltestrap';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?: string;
|
||||
cancelVariant?: string;
|
||||
onconfirm?: () => void;
|
||||
oncancel?: () => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
title = 'Confirm',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmVariant = 'primary',
|
||||
cancelVariant = 'secondary',
|
||||
onconfirm,
|
||||
oncancel,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
onconfirm?.();
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
oncancel?.();
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
|
||||
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if children}{@render children()}{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color={cancelVariant} on:click={handleCancel}>{cancelText}</Button>
|
||||
<Button color={confirmVariant} on:click={handleConfirm}>{confirmText}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
56
src/lib/ui/EditableCell.svelte
Normal file
56
src/lib/ui/EditableCell.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: number | null;
|
||||
onchange?: () => void;
|
||||
valuePrefix?: string;
|
||||
valueSuffix?: string;
|
||||
emptyValue?: number | null;
|
||||
emptyPlaceholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onchange = () => {},
|
||||
valuePrefix = '',
|
||||
valueSuffix = '',
|
||||
emptyValue = null,
|
||||
emptyPlaceholder = '-',
|
||||
}: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (editing && inputEl) inputEl.focus();
|
||||
});
|
||||
|
||||
function start() {
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
editing = false;
|
||||
onchange();
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') stop();
|
||||
else if (event.key === 'Escape') editing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<td onclick={start} onfocusin={start}>
|
||||
{#if editing}
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm border-0"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
onblur={stop}
|
||||
onkeydown={keydown} />
|
||||
{:else if value === emptyValue || value === null || value === undefined}
|
||||
<span class="text-muted">{emptyPlaceholder}</span>
|
||||
{:else}
|
||||
<span>{valuePrefix}{value}{valueSuffix}</span>
|
||||
{/if}
|
||||
</td>
|
||||
33
src/lib/ui/LabelGroup.svelte
Normal file
33
src/lib/ui/LabelGroup.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
label?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { id, label = '', class: className = '', children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="label-group {className}" {...rest}>
|
||||
<div class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none label-header">
|
||||
<div class="border-top" style="width: 10px;"></div>
|
||||
<span class="small text-nowrap ms-2">{label}</span>
|
||||
<div class="flex-fill border-top ms-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 border border-top-0 label-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.label-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
</style>
|
||||
38
src/lib/ui/PanelContainer.svelte
Normal file
38
src/lib/ui/PanelContainer.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Fixed-position side panel attached to the map. Stops mouse/touch
|
||||
* propagation so interactions inside the panel don't pan the map.
|
||||
*/
|
||||
interface Props {
|
||||
position?: 'left' | 'right';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { position = 'left', children }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | null = $state(null);
|
||||
|
||||
export function getElement(): HTMLDivElement | null {
|
||||
return element;
|
||||
}
|
||||
|
||||
function stop(e: Event) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="panel-container-{position}"
|
||||
onclick={stop}
|
||||
ondblclick={stop}
|
||||
onmousedown={stop}
|
||||
ontouchstart={stop}
|
||||
onwheel={stop}
|
||||
role="complementary">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
191
src/lib/ui/SelectSearchable.svelte
Normal file
191
src/lib/ui/SelectSearchable.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts" generics="V">
|
||||
import { onMount } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type Option = { value: V; label: string };
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
options?: Option[];
|
||||
selected?: V | null;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
onChange?: (value: V | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id = 'select-searchable',
|
||||
options = [],
|
||||
selected = $bindable<V | null>(null),
|
||||
placeholder = 'Select an option...',
|
||||
searchPlaceholder = 'Search...',
|
||||
disabled = false,
|
||||
clearable = false,
|
||||
onChange,
|
||||
class: className = '',
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let selectElement = $state<HTMLElement>();
|
||||
let dropdownElement = $state<HTMLElement>();
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
let filteredOptions = $derived(
|
||||
options.filter((o) => o.label.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
let selectedLabel = $derived(options.find((o) => o.value === selected)?.label ?? '');
|
||||
|
||||
function positionDropdown() {
|
||||
if (!selectElement || !dropdownElement) return;
|
||||
const rect = selectElement.getBoundingClientRect();
|
||||
const height = dropdownElement.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const placeBelow = spaceBelow >= height || spaceBelow >= spaceAbove;
|
||||
dropdownStyle = [
|
||||
'position:fixed',
|
||||
`top:${placeBelow ? rect.bottom : 'auto'}${typeof rect.bottom === 'number' && placeBelow ? 'px' : ''}`,
|
||||
`bottom:${placeBelow ? 'auto' : `${window.innerHeight - rect.top}px`}`,
|
||||
`left:${rect.left}px`,
|
||||
`min-width:${rect.width}px`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
searchTerm = '';
|
||||
queueMicrotask(() => {
|
||||
positionDropdown();
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pick(option: Option) {
|
||||
selected = option.value;
|
||||
isOpen = false;
|
||||
searchTerm = '';
|
||||
onChange?.(option.value);
|
||||
}
|
||||
|
||||
function clear(e: Event) {
|
||||
e.stopPropagation();
|
||||
selected = null;
|
||||
onChange?.(null);
|
||||
}
|
||||
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (selectElement && !selectElement.contains(e.target as Node)) isOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
const listener = () => positionDropdown();
|
||||
window.addEventListener('scroll', listener, true);
|
||||
window.addEventListener('resize', listener);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', listener, true);
|
||||
window.removeEventListener('resize', listener);
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => positionDropdown());
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleOutside} />
|
||||
|
||||
<div
|
||||
bind:this={selectElement}
|
||||
{id}
|
||||
class="form-control form-select select-container {className}"
|
||||
class:disabled
|
||||
class:show={isOpen}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
onclick={toggle}
|
||||
onkeydown={(e) => e.key === 'Enter' && toggle()}
|
||||
{...restProps}>
|
||||
<span class:text-muted={selected == null}>{selectedLabel || placeholder}</span>
|
||||
|
||||
{#if clearable && selected != null}
|
||||
<button type="button" class="clear-btn" tabindex="-1" aria-label="Clear selection" onclick={clear}>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOpen}
|
||||
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
||||
<div class="p-2">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchTerm}
|
||||
onclick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
<div class="options-list">
|
||||
{#each filteredOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item small"
|
||||
class:active={option.value === selected}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
pick(option);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={option.value === selected}>
|
||||
{option.label}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="dropdown-item text-muted disabled">No options found</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.select-container {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
.dropdown-menu {
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
}
|
||||
.options-list {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2rem;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2a2a2a;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.clear-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
56
src/lib/ui/SpoilerGroup.svelte
Normal file
56
src/lib/ui/SpoilerGroup.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
label?: string;
|
||||
expanded?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label = '',
|
||||
expanded = $bindable(false),
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="spoiler-group {className}" {...rest}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}>
|
||||
<span class="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon">{expanded ? '−' : '+'}</span>
|
||||
<span class="small text-nowrap ms-1">{label}</span>
|
||||
<div class="flex-fill border-top ms-1"></div>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="p-2 border border-top-0 spoiler-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="padding-top: 0.75em;"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spoiler-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.spoiler-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
.spoiler-icon {
|
||||
line-height: 1;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
.btn:hover .spoiler-icon {
|
||||
color: var(--bs-dark) !important;
|
||||
}
|
||||
</style>
|
||||
48
src/lib/ui/TabBar.svelte
Normal file
48
src/lib/ui/TabBar.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts" generics="T extends string">
|
||||
import { Icon } from '@sveltestrap/sveltestrap';
|
||||
|
||||
interface Tab {
|
||||
id: T;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
active: T;
|
||||
justify?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
let { tabs, active = $bindable(), justify = 'start' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-{justify} mb-1 gap-1">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
|
||||
class:active={active === tab.id}
|
||||
onclick={() => (active = tab.id)}>
|
||||
<Icon name={tab.icon} class="custom-tab-icon" />
|
||||
<span class="custom-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-tab {
|
||||
width: 4.5rem;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.custom-tab.active,
|
||||
.custom-tab:hover {
|
||||
background-color: var(--bs-primary) !important;
|
||||
color: var(--bs-btn-active-color);
|
||||
}
|
||||
|
||||
.custom-tab-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
40
src/lib/ui/Toast.svelte
Normal file
40
src/lib/ui/Toast.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
|
||||
import { toasts, removeToast, type ToastColor } from './toasts';
|
||||
|
||||
const ICONS: Record<ToastColor, string> = {
|
||||
primary: 'info-circle-fill',
|
||||
secondary: 'info-circle-fill',
|
||||
success: 'check-circle-fill',
|
||||
danger: 'exclamation-triangle-fill',
|
||||
warning: 'exclamation-circle-fill',
|
||||
info: 'info-circle-fill',
|
||||
light: 'lightbulb',
|
||||
dark: 'question',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<Toast
|
||||
isOpen={true}
|
||||
autohide={!toast.persistent}
|
||||
delay={5000}
|
||||
color={toast.color}
|
||||
on:close={() => removeToast(toast.id)}>
|
||||
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color}`}>
|
||||
<Icon slot="icon" name={ICONS[toast.color]} class="me-2" color={toast.color} />
|
||||
{toast.header}
|
||||
</ToastHeader>
|
||||
<ToastBody>
|
||||
{toast.body}
|
||||
</ToastBody>
|
||||
</Toast>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
z-index: 1090;
|
||||
}
|
||||
</style>
|
||||
338
src/lib/ui/editor/ListEditor.svelte
Normal file
338
src/lib/ui/editor/ListEditor.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script module lang="ts">
|
||||
export interface ListEditorConfig {
|
||||
showTable?: boolean;
|
||||
closeOnSave?: boolean;
|
||||
closeOnDelete?: boolean;
|
||||
searchBy?: string[];
|
||||
labels?: {
|
||||
item?: string;
|
||||
itemGenitive?: string;
|
||||
items?: string;
|
||||
add?: string;
|
||||
edit?: string;
|
||||
save?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
cancel?: string;
|
||||
close?: string;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListEditorApi<T> {
|
||||
save: (item: T) => Promise<T>;
|
||||
update: (item: T) => Promise<T>;
|
||||
delete: (item: T) => Promise<void>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends { id: number; name: string }">
|
||||
import { TableHandler } from '@vincjo/datatables';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Alert,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
Input,
|
||||
InputGroup,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { addToast } from '../toasts';
|
||||
import ConfirmationPrompt from '../ConfirmationPrompt.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
items?: T[];
|
||||
itemFactory: () => T;
|
||||
api: ListEditorApi<T>;
|
||||
config?: ListEditorConfig;
|
||||
onClose?: () => void;
|
||||
onSave?: (item: T) => void;
|
||||
onSelect?: (item: T) => void;
|
||||
tableHeader: Snippet;
|
||||
tableRow: Snippet<[{ row: T }]>;
|
||||
formFields: Snippet<[{ item: T }]>;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS = {
|
||||
item: 'item',
|
||||
itemGenitive: 'item',
|
||||
items: 'items',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
update: 'Update',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
searchPlaceholder: 'Search...',
|
||||
};
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
items = $bindable([] as T[]),
|
||||
itemFactory,
|
||||
api,
|
||||
config = {},
|
||||
onClose = () => {},
|
||||
onSave = (_i: T) => {},
|
||||
onSelect = (_i: T) => {},
|
||||
tableHeader,
|
||||
tableRow,
|
||||
formFields,
|
||||
}: Props = $props();
|
||||
|
||||
const labels = $derived({ ...DEFAULT_LABELS, ...(config.labels ?? {}) });
|
||||
const searchBy = $derived((config.searchBy ?? ['name']) as (keyof T)[]);
|
||||
const showTableProp = $derived(config.showTable ?? false);
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isAlertVisible = $state(false);
|
||||
let isConfirmationVisible = $state(false);
|
||||
let isTableVisible = $state(config.showTable ?? false);
|
||||
let alertText = $state('');
|
||||
let selectedItem = $state<T | null>(null);
|
||||
let currentItem = $state<T>(itemFactory());
|
||||
|
||||
const table = $derived(new TableHandler(items, { rowsPerPage: 10 }));
|
||||
const search = $derived(table.createSearch(searchBy));
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(items);
|
||||
});
|
||||
|
||||
export function open(item: T | null = null, showTable: boolean = showTableProp) {
|
||||
if (item) {
|
||||
handleEdit(item);
|
||||
} else {
|
||||
resetForm(false);
|
||||
}
|
||||
isOpen = true;
|
||||
isTableVisible = showTable;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEdit(item: T) {
|
||||
selectedItem = item;
|
||||
currentItem = { ...item };
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm(clearSelection = true) {
|
||||
if (clearSelection) selectedItem = null;
|
||||
currentItem = itemFactory();
|
||||
isEditing = false;
|
||||
hideAlert();
|
||||
}
|
||||
|
||||
function handleSelect(item: T) {
|
||||
onSelect(item);
|
||||
close();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (isEditing && selectedItem) {
|
||||
const updated = await api.update(currentItem);
|
||||
items = items.map((i) => (i.id === updated.id ? updated : i));
|
||||
toastOk('updated', updated.name);
|
||||
onSave(updated);
|
||||
} else {
|
||||
const saved = await api.save(currentItem);
|
||||
items = [...items, saved];
|
||||
toastOk('saved', saved.name);
|
||||
onSave(saved);
|
||||
}
|
||||
resetForm();
|
||||
if (config.closeOnSave) close();
|
||||
} catch (error: unknown) {
|
||||
showAlert(`Error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item: T) {
|
||||
selectedItem = item;
|
||||
isConfirmationVisible = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedItem) return;
|
||||
try {
|
||||
await api.delete(selectedItem);
|
||||
items = items.filter((i) => i.id !== selectedItem!.id);
|
||||
toastOk('deleted', selectedItem.name);
|
||||
if (config.closeOnDelete) close();
|
||||
} catch (error: unknown) {
|
||||
showAlert(`Error: ${(error as Error).message}`);
|
||||
} finally {
|
||||
isConfirmationVisible = false;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
isAlertVisible = true;
|
||||
alertText = message;
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
isAlertVisible = false;
|
||||
alertText = '';
|
||||
}
|
||||
|
||||
function toastOk(action: string, name: string) {
|
||||
const itemLabel = labels.item.charAt(0).toUpperCase() + labels.item.slice(1);
|
||||
addToast({
|
||||
header: `${itemLabel} ${action}`,
|
||||
body: `${itemLabel} "${name}" ${action}.`,
|
||||
color: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
const modalTitle = $derived(
|
||||
isEditing
|
||||
? `${labels.edit} ${labels.itemGenitive}`
|
||||
: isTableVisible
|
||||
? `${labels.items}`
|
||||
: `${labels.add} ${labels.itemGenitive}`,
|
||||
);
|
||||
|
||||
const submitButtonText = $derived(isEditing ? labels.update : labels.save);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={close}
|
||||
size="lg"
|
||||
fade={false}
|
||||
backdrop={true}
|
||||
scrollable
|
||||
class={isConfirmationVisible ? 'modal-tinted' : ''}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{modalTitle}</h5>
|
||||
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if isTableVisible}
|
||||
<div class="position-relative mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm pe-5"
|
||||
placeholder={labels.searchPlaceholder}
|
||||
bind:value={search.value}
|
||||
oninput={() => search.set()} />
|
||||
{#if search.value}
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
|
||||
onclick={() => {
|
||||
search.value = '';
|
||||
search.set();
|
||||
}}>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div bind:this={table.element} class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
{@render tableHeader()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table.rows as row (row.id)}
|
||||
<tr>
|
||||
{@render tableRow({ row: row as T })}
|
||||
<td class="fit">
|
||||
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onclick={() => handleSelect(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-success px-1">
|
||||
<Icon name="check-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() => handleEdit(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-primary px-1">
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onclick={() => confirmDelete(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-danger px-1">
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => table.setPage('previous')} />
|
||||
</PaginationItem>
|
||||
{#each table.pagesWithEllipsis as page}
|
||||
<PaginationItem active={table.currentPage === page}>
|
||||
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => table.setPage('next')} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
<hr />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Alert color="danger" isOpen={isAlertVisible} toggle={hideAlert} fade={false} class="mb-2">
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
{@render formFields({ item: currentItem })}
|
||||
<div class="d-grid gap-2 d-md-flex mt-3">
|
||||
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
|
||||
{#if isEditing}
|
||||
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
|
||||
{labels.cancel}
|
||||
</Button>
|
||||
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
|
||||
{labels.delete}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button color="secondary" size="sm" type="button" onclick={close}>{labels.close}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={isConfirmationVisible}
|
||||
title={`${labels.delete} ${labels.itemGenitive}`}
|
||||
confirmText={labels.delete}
|
||||
cancelText={labels.cancel}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => (isConfirmationVisible = false)}>
|
||||
<p>Delete "{selectedItem?.name}"?</p>
|
||||
</ConfirmationPrompt>
|
||||
13
src/lib/ui/index.ts
Normal file
13
src/lib/ui/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export { default as CollapsibleCard } from './CollapsibleCard.svelte';
|
||||
export { default as PanelContainer } from './PanelContainer.svelte';
|
||||
export { default as TabBar } from './TabBar.svelte';
|
||||
export { default as Toast } from './Toast.svelte';
|
||||
export { default as ConfirmationPrompt } from './ConfirmationPrompt.svelte';
|
||||
export { default as SelectSearchable } from './SelectSearchable.svelte';
|
||||
export { default as SpoilerGroup } from './SpoilerGroup.svelte';
|
||||
export { default as LabelGroup } from './LabelGroup.svelte';
|
||||
export { default as EditableCell } from './EditableCell.svelte';
|
||||
export { default as ListEditor } from './editor/ListEditor.svelte';
|
||||
export type { ListEditorConfig, ListEditorApi } from './editor/ListEditor.svelte';
|
||||
export { addToast, removeToast, toasts } from './toasts';
|
||||
export type { Toast as ToastMessage, ToastColor, ToastInit } from './toasts';
|
||||
54
src/lib/ui/toasts.ts
Normal file
54
src/lib/ui/toasts.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'light'
|
||||
| 'dark';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
header: string;
|
||||
body: string;
|
||||
color: ToastColor;
|
||||
persistent: boolean;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface ToastInit {
|
||||
header: string;
|
||||
body: string;
|
||||
color?: ToastColor;
|
||||
persistent?: boolean;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
|
||||
export function addToast(init: ToastInit): string {
|
||||
const id = crypto.randomUUID();
|
||||
toasts.update((all) => [
|
||||
...all,
|
||||
{
|
||||
id,
|
||||
header: init.header,
|
||||
body: init.body,
|
||||
color: init.color ?? 'info',
|
||||
persistent: init.persistent ?? false,
|
||||
onRemove: init.onRemove,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeToast(id: string): void {
|
||||
toasts.update((all) => {
|
||||
const t = all.find((x) => x.id === id);
|
||||
t?.onRemove?.(id);
|
||||
return all.filter((x) => x.id !== id);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue