summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src/lib/selection
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib/selection')
-rw-r--r--frontend/src/lib/selection/Selectable.svelte26
-rw-r--r--frontend/src/lib/selection/Selection.svelte.ts121
-rw-r--r--frontend/src/lib/selection/SelectionOverlay.svelte12
3 files changed, 143 insertions, 16 deletions
diff --git a/frontend/src/lib/selection/Selectable.svelte b/frontend/src/lib/selection/Selectable.svelte
index 48b6ac7..4705f44 100644
--- a/frontend/src/lib/selection/Selectable.svelte
+++ b/frontend/src/lib/selection/Selectable.svelte
@@ -1,18 +1,20 @@
<script lang="ts">
- import { getSelectionContext } from '$lib/Selection';
+ import type { Snippet } from 'svelte';
+ import { getSelectionContext } from './Selection.svelte';
- export let id: number;
- export let index: number;
+ interface Props {
+ id: number;
+ index: number;
+ edit?: ((id: number) => void) | undefined;
+ children?: Snippet<[{ onclick: (event: MouseEvent) => void; selected: boolean }]>;
+ }
- export let edit: ((id: number) => void) | undefined = undefined;
+ let { id, index, edit = undefined, children }: Props = $props();
+ let selection = getSelectionContext();
- const selection = getSelectionContext();
-
- $: selected = $selection.contains(id);
-
- const handle = (event: MouseEvent) => {
- if ($selection.active) {
- $selection = $selection.update(index, event.shiftKey);
+ const onclick = (event: MouseEvent) => {
+ if (selection.active) {
+ selection.update(index, event.shiftKey);
event.preventDefault();
} else if (edit) {
edit(id);
@@ -21,4 +23,4 @@
};
</script>
-<slot {handle} {selected} />
+{@render children?.({ onclick, selected: selection.contains(id) })}
diff --git a/frontend/src/lib/selection/Selection.svelte.ts b/frontend/src/lib/selection/Selection.svelte.ts
new file mode 100644
index 0000000..dc294d0
--- /dev/null
+++ b/frontend/src/lib/selection/Selection.svelte.ts
@@ -0,0 +1,121 @@
+import { getContext, setContext } from 'svelte';
+import { SvelteSet } from 'svelte/reactivity';
+import { range } from '../Utils';
+
+interface Selectable {
+ id: number;
+}
+
+export function initSelectionContext<T extends Selectable>(
+ typename: string,
+ toName: (item: T) => string,
+ selectable?: (item: T) => boolean
+) {
+ return setContext('selection', new ItemSelection(typename, toName, selectable));
+}
+
+export function getSelectionContext<T extends Selectable>() {
+ return getContext<ItemSelection<T>>('selection');
+}
+
+export class ItemSelection<T extends Selectable> {
+ active = $state(false);
+ view: T[] = $state([]);
+
+ #ids = $state(new SvelteSet<number>());
+ #masked = $derived(new SvelteSet([...this.#ids].filter((i) => this.#indexOf(i) >= 0)));
+
+ typename: string;
+ #toName: (item: T) => string;
+ selectable: (item: T) => boolean;
+
+ constructor(
+ typename: string,
+ toName: (item: T) => string,
+ selectable: (item: T) => boolean = () => true
+ ) {
+ this.typename = typename;
+ this.#toName = toName;
+ this.selectable = selectable;
+ }
+
+ #indexOf = (id: number) => this.view.findIndex((v) => v.id === id);
+
+ update = (index: number, shift: boolean) => {
+ const id = this.view[index].id;
+
+ const selectableRange = (first: number, last: number) =>
+ range(first, last)
+ .filter((i) => this.selectable(this.view[i]))
+ .map((i) => this.view[i].id);
+
+ if (shift) {
+ const indices = this.indices;
+
+ const first = indices.at(0);
+ const last = indices.at(-1);
+
+ if (first === undefined || last === undefined) {
+ this.#ids.add(id);
+ } else if (index === first || index === last) {
+ this.#ids.clear();
+ } else if (index > last) {
+ this.#ids = new SvelteSet([...this.#ids, ...selectableRange(last, index)]);
+ } else if (index < last) {
+ this.#ids = new SvelteSet([...this.#ids, ...selectableRange(index, last)]);
+ }
+ } else {
+ if (this.#ids.has(id)) {
+ this.#ids.delete(id);
+ } else {
+ this.#ids.add(id);
+ }
+ }
+ };
+
+ toggle = () => {
+ this.active = !this.active;
+
+ if (!this.active) {
+ this.none();
+ }
+ };
+
+ all = () => {
+ this.#ids = new SvelteSet(this.view.filter(this.selectable).map((i) => i.id));
+ };
+
+ none = () => {
+ this.#ids.clear();
+ this.#masked.clear();
+ };
+
+ clear = () => {
+ this.active = false;
+ this.none();
+ };
+
+ contains(id: number) {
+ return this.#masked.has(id);
+ }
+
+ get ids() {
+ return [...this.#masked];
+ }
+
+ get size() {
+ return this.#masked.size;
+ }
+
+ get indices() {
+ return [...this.#ids].map(this.#indexOf).filter((i) => i >= 0);
+ }
+
+ get items() {
+ return this.indices.map((i) => this.view[i]);
+ }
+
+ get names() {
+ return this.items.map(this.#toName);
+ }
+}
diff --git a/frontend/src/lib/selection/SelectionOverlay.svelte b/frontend/src/lib/selection/SelectionOverlay.svelte
index 04ff382..97421b0 100644
--- a/frontend/src/lib/selection/SelectionOverlay.svelte
+++ b/frontend/src/lib/selection/SelectionOverlay.svelte
@@ -1,7 +1,11 @@
<script lang="ts">
- export let selected: boolean;
- export let position: 'top' | 'right' | 'left' | 'bottom';
- export let centered = false;
+ interface Props {
+ selected: boolean;
+ position: 'top' | 'right' | 'left' | 'bottom';
+ centered?: boolean;
+ }
+
+ let { selected, position, centered = false }: Props = $props();
</script>
{#if selected}
@@ -9,7 +13,7 @@
class:items-center={centered}
class="{position} pointer-events-none absolute z-[1] flex bg-emerald-700/95"
>
- <span class="icon-base icon-[material-symbols--check] text-[2rem]" />
+ <span class="icon-base icon-[material-symbols--check] text-[2rem]"></span>
</div>
{/if}