@axium/client 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/styles.css CHANGED
@@ -50,7 +50,7 @@ button {
50
50
  cursor: pointer;
51
51
 
52
52
  &:hover {
53
- background-color: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
53
+ background-color: var(--bg-accent);
54
54
  }
55
55
  }
56
56
 
@@ -145,7 +145,7 @@ details {
145
145
  dialog {
146
146
  border-radius: 1em;
147
147
  background: var(--bg-menu);
148
- border: 1px solid #8888;
148
+ border: var(--border-elevated);
149
149
  padding: 1em;
150
150
  max-width: calc(100% - 2em);
151
151
  word-wrap: normal;
@@ -177,11 +177,11 @@ progress::-webkit-progress-bar {
177
177
  }
178
178
 
179
179
  progress::-webkit-progress-value {
180
- background-color: hsl(0 0 var(--fg-light));
180
+ background-color: var(--fg-normal);
181
181
  }
182
182
 
183
183
  progress::-moz-progress-bar {
184
- background-color: hsl(0 0 var(--fg-light));
184
+ background-color: var(--fg-normal);
185
185
  }
186
186
 
187
187
  ::selection,
@@ -233,7 +233,7 @@ div.success {
233
233
 
234
234
  .danger {
235
235
  border: var(--border-error);
236
- background-color: hsl(0 20 calc(var(--bg-light) + var(--light-step)));
236
+ background-color: hsl(from var(--bg-strong) 0 s l);
237
237
  color: hsl(0 33 var(--fg-light));
238
238
  --fill: hsl(0 33 var(--fg-light));
239
239
  accent-color: hsl(0 33 var(--fg-light));
@@ -252,8 +252,8 @@ div.success {
252
252
  }
253
253
 
254
254
  :popover-open {
255
- border: 1px solid hsl(var(--hue) 10 calc(var(--fg-light) - var(--light-step)));
256
- background-color: hsl(from var(--bg-menu) h s l / 95%);
255
+ border: var(--border-elevated);
256
+ background-color: var(--bg-elevated);
257
257
  border-radius: 0.5em;
258
258
  padding: 0.25em;
259
259
  margin: 0;
@@ -275,6 +275,14 @@ div.success {
275
275
  cursor: pointer;
276
276
  }
277
277
  }
278
+
279
+ .menu-header {
280
+ padding: 0.5em 0.75em;
281
+ font-size: 0.85em;
282
+ font-weight: bold;
283
+ color: var(--fg-normal);
284
+ user-select: none;
285
+ }
278
286
  }
279
287
 
280
288
  .icon-text {
@@ -283,6 +291,47 @@ div.success {
283
291
  gap: 1em;
284
292
  }
285
293
 
294
+ .drag-indicator {
295
+ position: fixed;
296
+ z-index: 100;
297
+ display: inline-flex;
298
+ align-items: center;
299
+ gap: 0.5em;
300
+ pointer-events: none;
301
+ padding: 0.5em 0.75em;
302
+ border-radius: 0.5em;
303
+ background-color: var(--bg-elevated);
304
+ border: var(--border-elevated);
305
+ transform: translate(0.75em, 0.75em);
306
+ max-width: 20em;
307
+ white-space: nowrap;
308
+
309
+ > :not(.drag-name) {
310
+ flex-shrink: 0;
311
+ }
312
+
313
+ .drag-name {
314
+ overflow: hidden;
315
+ text-overflow: ellipsis;
316
+ }
317
+
318
+ .drag-count {
319
+ font-weight: bold;
320
+ }
321
+ }
322
+
323
+ .drag-over {
324
+ outline: 2px solid var(--fg-accent);
325
+ outline-offset: -2px;
326
+ border-radius: 0.5em;
327
+ }
328
+
329
+ body.dragging-items,
330
+ body.dragging-items * {
331
+ cursor: grabbing !important;
332
+ user-select: none;
333
+ }
334
+
286
335
  a:has(button, div) {
287
336
  display: contents;
288
337
  }
@@ -290,7 +339,6 @@ a:has(button, div) {
290
339
  .version {
291
340
  font-family: monospace;
292
341
  font-size: 0.9em;
293
- color: #aaa;
294
342
  }
295
343
 
296
344
  h1,
@@ -300,13 +348,15 @@ h4,
300
348
  h5,
301
349
  h6 {
302
350
  .version {
351
+ opacity: 80%;
303
352
  margin-left: 1em;
304
353
  }
305
354
  }
306
355
 
307
356
  .version::before {
308
357
  content: 'v';
309
- color: #888;
358
+ color: var(--fg-disabled);
359
+ opacity: 80%;
310
360
  }
311
361
 
312
362
  .inline-button {
package/assets/theme.css CHANGED
@@ -35,6 +35,7 @@ html {
35
35
  --bg-accent: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
36
36
  --bg-alt: hsl(var(--hue) 5 calc(var(--bg-light) + var(--light-step)));
37
37
  --bg-strong: hsl(var(--hue) 20 calc(var(--bg-light) + var(--light-step)));
38
+ --bg-elevated: hsl(from var(--bg-menu) h s l / 95%);
38
39
 
39
40
  --bg-error: hsl(0 40 var(--bg-light-status));
40
41
  --bg-warning: hsl(60 40 var(--bg-light-status));
@@ -52,6 +53,7 @@ html {
52
53
  --border-accent: 1px solid hsl(var(--hue) 10 calc(var(--bg-light) + (var(--light-step) * 3)));
53
54
  --border-alt: 1px solid var(--bg-alt);
54
55
  --border-strong: 1px solid hsl(var(--hue) 20 calc(var(--bg-light) + (var(--light-step) * 3)));
56
+ --border-elevated: 1px solid hsl(var(--hue) 10 calc(var(--fg-light) - var(--light-step)));
55
57
 
56
58
  --border-error: 1px solid hsl(0 50 var(--fg-light));
57
59
  --border-warning: 1px solid hsl(60 50 var(--fg-light));
@@ -0,0 +1,31 @@
1
+ export interface TriggerModifiers {
2
+ ctrl?: boolean;
3
+ shift?: boolean;
4
+ alt?: boolean;
5
+ meta?: boolean;
6
+ }
7
+ export interface MouseTrigger extends TriggerModifiers {
8
+ button: number;
9
+ }
10
+ /** @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location */
11
+ export declare const enum KeyLocation {
12
+ Standard = 0,
13
+ Left = 1,
14
+ Right = 2,
15
+ Numpad = 3
16
+ }
17
+ export interface KeyboardTrigger extends TriggerModifiers {
18
+ key: string;
19
+ location?: KeyLocation;
20
+ }
21
+ export type Trigger = KeyboardTrigger | MouseTrigger;
22
+ export interface WithAction<TData extends unknown[]> {
23
+ action(...args: TData): unknown;
24
+ }
25
+ export interface KeyboardHandler<TData extends unknown[]> extends KeyboardTrigger, WithAction<TData> {
26
+ }
27
+ export interface MouseHandler<TData extends unknown[]> extends MouseTrigger, WithAction<TData> {
28
+ }
29
+ export type Handler<TData extends unknown[]> = KeyboardHandler<TData> | MouseHandler<TData>;
30
+ export declare function isMatch(trigger: Trigger, event: KeyboardEvent | MouseEvent): boolean;
31
+ export declare function find<T extends Trigger>(triggers: T[], event: KeyboardEvent | MouseEvent): T | undefined;
@@ -0,0 +1,24 @@
1
+ /** @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location */
2
+ export var KeyLocation;
3
+ (function (KeyLocation) {
4
+ KeyLocation[KeyLocation["Standard"] = 0] = "Standard";
5
+ KeyLocation[KeyLocation["Left"] = 1] = "Left";
6
+ KeyLocation[KeyLocation["Right"] = 2] = "Right";
7
+ KeyLocation[KeyLocation["Numpad"] = 3] = "Numpad";
8
+ })(KeyLocation || (KeyLocation = {}));
9
+ export function isMatch(trigger, event) {
10
+ if (('ctrl' in trigger && trigger.ctrl !== event.ctrlKey) ||
11
+ ('shift' in trigger && trigger.shift !== event.shiftKey) ||
12
+ ('alt' in trigger && trigger.alt !== event.altKey) ||
13
+ ('meta' in trigger && trigger.meta !== event.metaKey))
14
+ return false;
15
+ return 'key' in trigger
16
+ ? 'key' in event && event.key === trigger.key && (!('location' in trigger) || trigger.location === event.location)
17
+ : 'button' in event && event.button === trigger.button;
18
+ }
19
+ export function find(triggers, event) {
20
+ for (const trigger of triggers) {
21
+ if (isMatch(trigger, event))
22
+ return trigger;
23
+ }
24
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './animate.js';
2
2
  export * from './clipboard.js';
3
+ export * as controls from './controls.js';
package/dist/gui/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './animate.js';
2
2
  export * from './clipboard.js';
3
+ export * as controls from './controls.js';
@@ -6,7 +6,7 @@
6
6
  import Discovery, * as discovery from './Discovery.svelte';
7
7
  import Icon from './Icon.svelte';
8
8
  import UserCard from './UserCard.svelte';
9
- import { closeOnBackGesture } from './attachments.js';
9
+ import { closeOnBackGesture } from './attachments/gestures.js';
10
10
  import { toast, toastStatus } from './toast.js';
11
11
 
12
12
  interface Props extends HTMLDialogAttributes {
package/lib/Audio.svelte CHANGED
@@ -3,7 +3,7 @@
3
3
  import type { Snippet } from 'svelte';
4
4
  import Icon from './Icon.svelte';
5
5
  import MediaControls from './MediaControls.svelte';
6
- import { getMetadata, MediaState, type MediaPicture, type MediaProps } from './media.svelte.js';
6
+ import { getMetadata, MediaState, type MediaPicture, type MediaProps } from './reactive/media.svelte.js';
7
7
 
8
8
  interface Props extends MediaProps {
9
9
  cover?: boolean;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { text } from '@axium/client';
3
3
  import type { HTMLDialogAttributes } from 'svelte/elements';
4
- import { closeOnBackGesture } from './attachments.js';
4
+ import { closeOnBackGesture } from './attachments/gestures.js';
5
5
 
6
6
  let {
7
7
  children,
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { formatDuration } from '@axium/core';
3
3
  import Icon from './Icon.svelte';
4
- import type { MediaState } from './media.svelte.js';
4
+ import type { MediaState } from './reactive/media.svelte.js';
5
5
  import type { Snippet } from 'svelte';
6
6
 
7
7
  interface Props {
package/lib/Video.svelte CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import Icon from './Icon.svelte';
4
4
  import MediaControls from './MediaControls.svelte';
5
- import { MediaState, type MediaProps } from './media.svelte.js';
5
+ import { MediaState, type MediaProps } from './reactive/media.svelte.js';
6
6
 
7
7
  interface Props extends MediaProps {
8
8
  extraControls?: Snippet;
@@ -0,0 +1,123 @@
1
+ import { mount, unmount } from 'svelte';
2
+ import type { Attachment } from 'svelte/attachments';
3
+ import Icon from '../Icon.svelte';
4
+
5
+ export interface ContextMenuItem {
6
+ /** Icon name */
7
+ i?: string;
8
+ text: string;
9
+ danger?: boolean;
10
+ action(): unknown;
11
+ }
12
+
13
+ /** A non-interactive label, e.g. to describe what it acts on. */
14
+ export interface ContextMenuHeader {
15
+ header: string;
16
+ }
17
+
18
+ export type ContextMenuEntry = ContextMenuItem | ContextMenuHeader | false | null | undefined;
19
+
20
+ /**
21
+ * Attach a context menu to an element with the given actions.
22
+ *
23
+ * Pass a function instead of a fixed list to have the menu rebuilt every time it opens — useful when
24
+ * the available actions or their labels depend on state that changes after the menu is attached (e.g. a
25
+ * selection).
26
+ */
27
+ export function contextMenu(...menuItems: ContextMenuEntry[] | [() => ContextMenuEntry[]]): Attachment<HTMLElement> {
28
+ return function _attachContextMenu(element: HTMLElement) {
29
+ const menu = document.createElement('div');
30
+ menu.popover = 'auto';
31
+ menu.className = 'context-menu';
32
+
33
+ const mountedIcons = new Set<Record<string, any>>();
34
+
35
+ function build(items: ContextMenuEntry[]) {
36
+ for (const item of items) {
37
+ if (!item) continue;
38
+
39
+ if ('header' in item) {
40
+ const header = document.createElement('div');
41
+ header.classList.add('menu-header');
42
+ header.appendChild(document.createTextNode(item.header));
43
+ menu.appendChild(header);
44
+ continue;
45
+ }
46
+
47
+ const div = document.createElement('div');
48
+ div.classList.add('icon-text', 'menu-item');
49
+ if (item.danger) div.classList.add('danger');
50
+
51
+ div.onclick = e => {
52
+ e.stopPropagation();
53
+ menu.hidePopover();
54
+ item.action();
55
+ };
56
+
57
+ if (item.i) mountedIcons.add(mount<any, {}>(Icon, { target: div, props: { i: item.i } }));
58
+
59
+ div.appendChild(document.createTextNode(item.text));
60
+
61
+ menu.appendChild(div);
62
+ }
63
+ }
64
+
65
+ if (typeof menuItems[0] != 'function') build(menuItems as ContextMenuEntry[]);
66
+
67
+ document.body.appendChild(menu);
68
+
69
+ let _forcePopover = false;
70
+
71
+ element.oncontextmenu = (e: MouseEvent) => {
72
+ for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
73
+ if (node instanceof HTMLDialogElement || (node.popover && node !== menu)) return;
74
+ }
75
+
76
+ e.preventDefault();
77
+ e.stopPropagation();
78
+
79
+ if (typeof menuItems[0] == 'function') {
80
+ for (const icon of mountedIcons) unmount(icon);
81
+ mountedIcons.clear();
82
+ menu.replaceChildren();
83
+ build(menuItems[0]());
84
+ }
85
+
86
+ menu.showPopover();
87
+ _forcePopover = true;
88
+
89
+ const x = e.clientX;
90
+ const y = e.clientY;
91
+
92
+ menu.style.left = x + 'px';
93
+ menu.style.top = y + 'px';
94
+
95
+ const rect = menu.getBoundingClientRect();
96
+ if (rect.right > window.innerWidth) {
97
+ menu.style.left = '';
98
+ menu.style.right = window.innerWidth - x + 'px';
99
+ }
100
+ if (rect.bottom > window.innerHeight) {
101
+ menu.style.top = '';
102
+ menu.style.bottom = window.innerHeight - y + 'px';
103
+ }
104
+ };
105
+
106
+ /**
107
+ * Workaround for https://github.com/whatwg/html/issues/10905
108
+ * @todo Remove when the problem is fixed.
109
+ */
110
+ element.onpointerup = (e: PointerEvent) => {
111
+ if (!_forcePopover) return;
112
+ e.stopPropagation();
113
+ e.preventDefault();
114
+ menu.togglePopover();
115
+ _forcePopover = false;
116
+ };
117
+
118
+ return function _disposeContextMenu() {
119
+ for (const icon of mountedIcons) unmount(icon);
120
+ menu.remove();
121
+ };
122
+ };
123
+ }
@@ -0,0 +1,257 @@
1
+ import { mount, unmount } from 'svelte';
2
+ import type { Attachment } from 'svelte/attachments';
3
+ import Icon from '../Icon.svelte';
4
+ import { SvelteSet } from 'svelte/reactivity';
5
+ import type { Selection } from './selection.js';
6
+
7
+ /** A grabbable item, used to populate the floating drag indicator. */
8
+ export interface Item {
9
+ id: string;
10
+ name: string;
11
+ icon?: string;
12
+ }
13
+
14
+ /**
15
+ * Coordinates a press-and-hold drag of one or more things onto a {@link target}.
16
+ *
17
+ * Things are made grabbable with the {@link source} attachment,
18
+ * and targets accept them with {@link target}.
19
+ *
20
+ * While a drag is in progress a floating indicator follows the pointer showing
21
+ * the grabbed item's name and icon, with a bold `+ N` when several are grabbed.
22
+ *
23
+ * The grabbed elements expose the `dragging` class for styling.
24
+ */
25
+ export class Controller extends SvelteSet<string> implements Disposable {
26
+ #indicator?: HTMLElement;
27
+ #indicatorIcon?: Record<string, any>;
28
+
29
+ /** Registered drop targets, keyed by their element. */
30
+ readonly #targets = new Map<HTMLElement, (ids: string[]) => unknown>();
31
+
32
+ #activeTarget?: HTMLElement;
33
+
34
+ /** Whether a drag is currently in progress. */
35
+ get active(): boolean {
36
+ return this.size > 0;
37
+ }
38
+
39
+ _registerTarget(element: HTMLElement, onDrop: (ids: string[]) => unknown): () => void {
40
+ this.#targets.set(element, onDrop);
41
+ return () => this.#targets.delete(element);
42
+ }
43
+
44
+ /** Begin dragging the given ids, showing a floating indicator labelled for `lead`. */
45
+ begin(ids: string[], lead: Item): void {
46
+ if (!ids.length) return;
47
+ for (const id of ids) this.add(id);
48
+
49
+ const indicator = document.createElement('div');
50
+ indicator.className = 'drag-indicator';
51
+ if (lead.icon) this.#indicatorIcon = mount<any, {}>(Icon, { target: indicator, props: { i: lead.icon } });
52
+
53
+ const name = document.createElement('span');
54
+ name.className = 'drag-name';
55
+ name.textContent = lead.name;
56
+ indicator.appendChild(name);
57
+
58
+ if (ids.length > 1) {
59
+ const extra = document.createElement('b');
60
+ extra.className = 'drag-count';
61
+ extra.textContent = `+ ${ids.length - 1}`;
62
+ indicator.appendChild(extra);
63
+ }
64
+
65
+ document.body.appendChild(indicator);
66
+ this.#indicator = indicator;
67
+ document.body.classList.add('dragging-items');
68
+ }
69
+
70
+ /** Move the floating indicator and update which drop target is under the pointer. */
71
+ move(x: number, y: number): void {
72
+ if (!this.#indicator) return;
73
+ this.#indicator.style.left = x + 'px';
74
+ this.#indicator.style.top = y + 'px';
75
+
76
+ // Find the drop target under the pointer (ignoring the indicator itself, which is pointer-events: none).
77
+ let target: HTMLElement | undefined;
78
+ for (let node = document.elementFromPoint(x, y) as HTMLElement | null; node; node = node.parentElement) {
79
+ if (this.#targets.has(node)) {
80
+ target = node;
81
+ break;
82
+ }
83
+ }
84
+
85
+ if (target == this.#activeTarget) return;
86
+ this.#activeTarget?.classList.remove('drag-over');
87
+ this.#activeTarget = target;
88
+ this.#activeTarget?.classList.add('drag-over');
89
+ }
90
+
91
+ /** Finish the drag, dropping onto the active target (if any), and clean up. */
92
+ end(): void {
93
+ const onDrop = this.#activeTarget && this.#targets.get(this.#activeTarget);
94
+ try {
95
+ if (onDrop && this.size) onDrop([...this]);
96
+ } finally {
97
+ this.dispose();
98
+ }
99
+ }
100
+
101
+ /** Abort the drag without dropping. */
102
+ cancel(): void {
103
+ this.dispose();
104
+ }
105
+
106
+ dispose(): void {
107
+ this.clear();
108
+ this.#activeTarget?.classList.remove('drag-over');
109
+ this.#activeTarget = undefined;
110
+ if (this.#indicatorIcon) unmount(this.#indicatorIcon);
111
+ this.#indicatorIcon = undefined;
112
+ this.#indicator?.remove();
113
+ this.#indicator = undefined;
114
+ document.body.classList.remove('dragging-items');
115
+ }
116
+
117
+ [Symbol.dispose]() {
118
+ this.dispose();
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The default drag-and-drop controller.
124
+ */
125
+ const defaultController = new Controller();
126
+
127
+ export { defaultController as controller };
128
+
129
+ /** How long, in milliseconds, a touch must be held before it starts a drag (rather than scrolling). */
130
+ const HOLD_DELAY = 400;
131
+
132
+ /**
133
+ * Make an element a draggable source for a {@link Controller}.
134
+ *
135
+ * Pressing and dragging the pointer out of the item grabs either the current {@link Selection} (when
136
+ * this item is part of a non-empty selection) or just this item, then follows the pointer until release.
137
+ */
138
+ export function source(
139
+ item: Item,
140
+ selectedIds: Iterable<string> = [item.id],
141
+ controller: Controller = defaultController
142
+ ): Attachment<HTMLElement> {
143
+ const selection = Array.from(selectedIds);
144
+ if (!selection.length) selection.push(item.id);
145
+
146
+ return function _attachDraggable(element: HTMLElement) {
147
+ let lastX = 0,
148
+ lastY = 0,
149
+ started = false,
150
+ pointerId: number | undefined,
151
+ holdTimer: number | undefined;
152
+
153
+ function _clearTimer() {
154
+ clearTimeout(holdTimer);
155
+ holdTimer = undefined;
156
+ pointerId = undefined;
157
+ }
158
+
159
+ function start() {
160
+ if (started || pointerId === undefined) return;
161
+ started = true;
162
+
163
+ controller.begin(selection, item);
164
+ element.setPointerCapture(pointerId);
165
+ controller.move(lastX, lastY);
166
+ }
167
+
168
+ function onPointerDown(e: PointerEvent) {
169
+ if (e.button !== 0) return;
170
+ for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
171
+ if (node.hasAttribute('data-no-select')) return;
172
+ }
173
+ lastX = e.clientX;
174
+ lastY = e.clientY;
175
+ started = false;
176
+ pointerId = e.pointerId;
177
+
178
+ // Touch presses scroll the list, so require a deliberate press-and-hold before grabbing.
179
+ if (e.pointerType === 'touch') holdTimer = setTimeout(start, HOLD_DELAY);
180
+ }
181
+
182
+ // The drag begins once the pointer leaves the item it was pressed on.
183
+ function onPointerLeave(e: PointerEvent) {
184
+ if (pointerId === undefined || e.pointerId !== pointerId || started) return;
185
+ // A touch that leaves the item before the hold fires is a scroll, not a drag.
186
+ if (holdTimer !== undefined) return _clearTimer();
187
+
188
+ lastX = e.clientX;
189
+ lastY = e.clientY;
190
+ start();
191
+ }
192
+
193
+ function onPointerMove(e: PointerEvent) {
194
+ if (pointerId === undefined || e.pointerId !== pointerId || !started) return;
195
+ lastX = e.clientX;
196
+ lastY = e.clientY;
197
+ e.preventDefault();
198
+ controller.move(e.clientX, e.clientY);
199
+ }
200
+
201
+ function onPointerUp(e: PointerEvent) {
202
+ if (pointerId === undefined || e.pointerId !== pointerId) return;
203
+ if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId);
204
+ _clearTimer();
205
+ if (started) {
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ suppressClick = true;
209
+ controller.end();
210
+ }
211
+ started = false;
212
+ }
213
+
214
+ function onPointerCancel() {
215
+ _clearTimer();
216
+ if (started) controller.cancel();
217
+ started = false;
218
+ }
219
+
220
+ // A drag may be followed by a synthetic click; swallow it so it doesn't re-select or open the item.
221
+ let suppressClick = false;
222
+ function onClick(e: MouseEvent) {
223
+ if (!suppressClick) return;
224
+ suppressClick = false;
225
+ e.preventDefault();
226
+ e.stopPropagation();
227
+ }
228
+
229
+ element.addEventListener('pointerdown', onPointerDown);
230
+ element.addEventListener('pointerleave', onPointerLeave);
231
+ element.addEventListener('pointermove', onPointerMove);
232
+ element.addEventListener('pointerup', onPointerUp);
233
+ element.addEventListener('pointercancel', onPointerCancel);
234
+ element.addEventListener('click', onClick, { capture: true });
235
+
236
+ return () => {
237
+ element.removeEventListener('pointerdown', onPointerDown);
238
+ element.removeEventListener('pointerleave', onPointerLeave);
239
+ element.removeEventListener('pointermove', onPointerMove);
240
+ element.removeEventListener('pointerup', onPointerUp);
241
+ element.removeEventListener('pointercancel', onPointerCancel);
242
+ element.removeEventListener('click', onClick, { capture: true });
243
+ };
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Register an element as a drop target for a {@link DragController}.
249
+ *
250
+ * When grabbed items are released over the element, `onDrop` is called with the dragged ids. While a
251
+ * drag hovers the element it carries the `drag-over` class.
252
+ */
253
+ export function target(onDrop: (ids: string[]) => unknown, controller: Controller = defaultController): Attachment<HTMLElement> {
254
+ return function _attachDropTarget(element: HTMLElement) {
255
+ return controller._registerTarget(element, onDrop);
256
+ };
257
+ }
@@ -0,0 +1,36 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+
3
+ /**
4
+ * Enabled the use of back gestures to close dialogs
5
+ */
6
+ export function closeOnBackGesture(element: HTMLDialogElement) {
7
+ if (!globalThis.history) {
8
+ throw new Error('Can not attach back gesture handling because the History API is unavailable');
9
+ }
10
+
11
+ const showModal = element.showModal.bind(element);
12
+
13
+ let closedByBack = false,
14
+ historyKey = Math.random().toString(16).slice(2);
15
+
16
+ element.showModal = () => {
17
+ closedByBack = false;
18
+ /* popstate's state will be the "upcoming" state.
19
+ i.e, the state after having pushed 1 then 2 would be 1 */
20
+ history.replaceState(historyKey, '');
21
+ history.pushState(null, '');
22
+ showModal();
23
+ };
24
+
25
+ element.addEventListener('close', () => {
26
+ if (closedByBack) return;
27
+ history.replaceState(null, '');
28
+ });
29
+
30
+ window.addEventListener('popstate', e => {
31
+ if (e.state != historyKey) return;
32
+ closedByBack = true;
33
+ element.close();
34
+ });
35
+ }
36
+ closeOnBackGesture satisfies Attachment<HTMLDialogElement>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Svelte attachments
3
+ * @see https://svelte.dev/docs/svelte/@attach
4
+ */
5
+ import type { Attachment } from 'svelte/attachments';
6
+
7
+ export * from './context-menu.js';
8
+ export * as drag from './drag-and-drop.js';
9
+ export * from './gestures.js';
10
+ export * from './selection.js';
11
+
12
+ /**
13
+ * Dynamically resize `<textarea>`s based on content.
14
+ * @todo Remove this once `field-sizing` works everywhere
15
+ */
16
+ export function dynamicRows(max: number = 40, min: number = 3): Attachment<HTMLTextAreaElement> {
17
+ return function _attachDynamicRows(element: HTMLTextAreaElement) {
18
+ element.style.resize = 'none';
19
+ element.style.fieldSizing = 'content';
20
+ element.style.height = 'max-content';
21
+ element.style.overflowY = 'scroll';
22
+
23
+ function update() {
24
+ if (!element.value) return;
25
+ element.rows = Math.max(Math.min(element.value.split('\n').length, max), min);
26
+ }
27
+
28
+ if (!navigator.userAgent.includes('Firefox')) return;
29
+ update();
30
+ element.addEventListener('input', update);
31
+ element.addEventListener('keyup', update);
32
+ return () => {
33
+ element.removeEventListener('input', update);
34
+ element.removeEventListener('keyup', update);
35
+ };
36
+ };
37
+ }
@@ -0,0 +1,138 @@
1
+ import { controls } from '@axium/client/gui';
2
+ import type { Attachment } from 'svelte/attachments';
3
+ import { SvelteSet } from 'svelte/reactivity';
4
+
5
+ /** A keyboard control bound to a {@link Selection}, e.g. `F2` to rename or `Ctrl+C` to copy. */
6
+ export type SelectionControl = controls.KeyboardHandler<[selection: Selection]>;
7
+
8
+ /**
9
+ * Manages a reusable, list-wide selection across items rendered with the {@link selectable} attachment.
10
+ *
11
+ * Click semantics (matching common file managers):
12
+ * - Plain click: select only the clicked item, clearing the rest.
13
+ * - Ctrl/Cmd click: toggle the clicked item's selection.
14
+ * - Shift click: select the range between the last-clicked item and the clicked item, clearing everything outside it.
15
+ */
16
+ export class Selection extends SvelteSet<string> {
17
+ /** The ordered list of ids, set by the caller so ranges (shift-click) can be computed. */
18
+ #order: string[] = [];
19
+
20
+ /** The id of the last item interacted with, used as the anchor for shift-click ranges. */
21
+ #anchor?: string;
22
+
23
+ constructor(
24
+ /** Keyboard controls dispatched by {@link handleKeyboard}. */
25
+ protected readonly controls: SelectionControl[] = []
26
+ ) {
27
+ super();
28
+ }
29
+
30
+ /**
31
+ * Update the ordered list of item ids. Call this whenever the rendered order changes
32
+ * (e.g. after sorting or loading a new directory).
33
+ */
34
+ setOrder(ids: string[]): void {
35
+ this.#order = ids;
36
+ for (const id of this) if (!ids.includes(id)) this.delete(id);
37
+ if (this.#anchor && !ids.includes(this.#anchor)) this.#anchor = undefined;
38
+ }
39
+
40
+ /** Clear the entire selection. */
41
+ clear(): void {
42
+ super.clear();
43
+ this.#anchor = undefined;
44
+ }
45
+
46
+ add(id: string): this {
47
+ super.add(id);
48
+ this.#anchor = id;
49
+ return this;
50
+ }
51
+
52
+ /** Apply selection behavior for a click on `id` with the given modifier keys. */
53
+ handleClick(id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void {
54
+ if (e.shiftKey && this.#anchor) {
55
+ const start = this.#order.indexOf(this.#anchor);
56
+ const end = this.#order.indexOf(id);
57
+ if (start != -1 && end != -1) {
58
+ const [lo, hi] = start < end ? [start, end] : [end, start];
59
+ const range = new Set(this.#order.slice(lo, hi + 1));
60
+ super.clear();
61
+ for (const rangeId of range) super.add(rangeId);
62
+ return;
63
+ }
64
+ }
65
+
66
+ if (e.ctrlKey || e.metaKey) {
67
+ if (this.has(id)) this.delete(id);
68
+ else this.add(id);
69
+ return;
70
+ }
71
+
72
+ super.clear();
73
+ this.add(id);
74
+ }
75
+
76
+ /**
77
+ * Dispatch a keyboard event to a matching control, if any. Returns whether a control handled it.
78
+ */
79
+ handleKeyboard(e: KeyboardEvent): boolean {
80
+ const control = controls.find(this.controls, e);
81
+ if (!control) return false;
82
+
83
+ control.action(this);
84
+ return true;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Wire an element into a {@link Selection} as the item identified by `id`.
90
+ *
91
+ * Reflects selection state via the `selected` class and handles select-on-click (with shift/ctrl/meta
92
+ * modifiers). The handler runs in the capture phase so it can be combined with other click handlers on
93
+ * the element that perform navigation/preview only when the click was not a selection gesture.
94
+ */
95
+ export function selectable(selection: Selection, id: string): Attachment<HTMLElement> {
96
+ return function _attachSelectable(element: HTMLElement) {
97
+ function onclick(e: MouseEvent) {
98
+ for (let node = e.target as HTMLElement | null; node && node != element; node = node.parentElement)
99
+ if (node.hasAttribute('data-no-select')) return;
100
+
101
+ if (e.shiftKey || e.ctrlKey || e.metaKey) {
102
+ e.preventDefault();
103
+ e.stopPropagation();
104
+ }
105
+ selection.handleClick(id, e);
106
+ }
107
+
108
+ element.addEventListener('click', onclick, { capture: true });
109
+ return () => element.removeEventListener('click', onclick, { capture: true });
110
+ };
111
+ }
112
+
113
+ /** Elements that consume keyboard input themselves, and so should suppress selection controls. */
114
+ function isTypingTarget(target: EventTarget | null): boolean {
115
+ if (!(target instanceof HTMLElement)) return false;
116
+ return target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
117
+ }
118
+
119
+ /**
120
+ * Register a {@link Selection}'s keyboard controls (e.g. `F2` to rename) on the document.
121
+ *
122
+ * This is separate from {@link selectable} so the controls work regardless of which element is focused.
123
+ * Controls are ignored while the user is typing in an input, textarea, or other editable element.
124
+ */
125
+ export function selectionControls(selection: Selection): Attachment {
126
+ return function _attachSelectionControls() {
127
+ function onkeydown(e: KeyboardEvent) {
128
+ if (isTypingTarget(e.target)) return;
129
+ if (selection.handleKeyboard(e)) {
130
+ e.preventDefault();
131
+ e.stopPropagation();
132
+ }
133
+ }
134
+
135
+ document.addEventListener('keydown', onkeydown);
136
+ return () => document.removeEventListener('keydown', onkeydown);
137
+ };
138
+ }
@@ -0,0 +1,77 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+
3
+ interface StoredClipboard<T extends string | boolean | bigint | number> {
4
+ isCopy: boolean;
5
+ values: T[];
6
+ }
7
+
8
+ /**
9
+ * A reactive, cross-tab clipboard for "cut" and "copy" of values.
10
+ *
11
+ * State is mirrored to `localStorage` so it is shared across all tabs of the origin.
12
+ */
13
+ export class SyncedClipboard<T extends string | boolean | bigint | number> extends SvelteSet<T> {
14
+ /** The current clipboard mode, or `undefined` when the clipboard is empty. Reactive. */
15
+ isCopy = $state<boolean>();
16
+
17
+ constructor(protected readonly key = 'clipboard') {
18
+ super();
19
+
20
+ this.load();
21
+
22
+ addEventListener('storage', e => {
23
+ if (e.key == this.key) this.load();
24
+ });
25
+ }
26
+
27
+ /** Whether the clipboard currently holds anything. */
28
+ get active(): boolean {
29
+ return typeof this.isCopy === 'boolean' && this.size > 0;
30
+ }
31
+
32
+ /** Place the given values on the clipboard to be moved on paste. */
33
+ cut(values: Iterable<T>): void {
34
+ this.isCopy = false;
35
+ this.set([...values]);
36
+ }
37
+
38
+ /** Place the given values on the clipboard to be duplicated on paste. */
39
+ copy(values: Iterable<T>): void {
40
+ this.isCopy = true;
41
+ this.set([...values]);
42
+ }
43
+
44
+ /** Empty the clipboard. */
45
+ clear(): void {
46
+ this.isCopy = undefined;
47
+ super.clear();
48
+ if (typeof localStorage != 'undefined') localStorage.removeItem(this.key);
49
+ }
50
+
51
+ protected set(values: T[]): void {
52
+ super.clear();
53
+ for (const value of values) this.add(value);
54
+ if (typeof localStorage != 'undefined')
55
+ localStorage.setItem(this.key, JSON.stringify({ isCopy: this.isCopy!, values } satisfies StoredClipboard<T>));
56
+ }
57
+
58
+ protected load(): void {
59
+ if (typeof localStorage == 'undefined') return;
60
+
61
+ const raw = localStorage.getItem(this.key);
62
+ if (!raw) {
63
+ this.isCopy = undefined;
64
+ super.clear();
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const { isCopy, values } = JSON.parse(raw) as StoredClipboard<T>;
70
+ this.isCopy = isCopy;
71
+ for (const value of this) if (!values.includes(value)) this.delete(value);
72
+ for (const value of values) this.add(value);
73
+ } catch {
74
+ this.clear();
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from './clipboard.svelte.js';
2
+ export * from './media.svelte.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -38,7 +38,8 @@
38
38
  "./components": "./lib/index.js",
39
39
  "./components/*": "./lib/*.svelte",
40
40
  "./toast": "./lib/toast.js",
41
- "./attachments": "./lib/attachments.js",
41
+ "./attachments": "./lib/attachments/index.js",
42
+ "./reactive": "./lib/reactive/index.svelte.js",
42
43
  "./styles/*": "./styles/*.css"
43
44
  },
44
45
  "scripts": {
package/styles/list.css CHANGED
@@ -45,6 +45,7 @@ div.list-header {
45
45
  overflow: hidden;
46
46
  text-wrap: nowrap;
47
47
  flex: 0 0 auto;
48
+ user-select: none;
48
49
  }
49
50
 
50
51
  .list-item:not(.list-header, :first-child) {
@@ -52,9 +53,17 @@ div.list-header {
52
53
  }
53
54
 
54
55
  .list-item:not(.list-header):hover {
56
+ background-color: var(--bg-strong);
57
+ }
58
+
59
+ .list-item.selected {
55
60
  background-color: var(--bg-alt);
56
61
  }
57
62
 
63
+ .list-item.dragging {
64
+ opacity: 0.5;
65
+ }
66
+
58
67
  p.list-empty {
59
68
  text-align: center;
60
69
  color: #888;
@@ -1,158 +0,0 @@
1
- /**
2
- * Svelte attachments
3
- * @see https://svelte.dev/docs/svelte/@attach
4
- */
5
- import { mount, unmount } from 'svelte';
6
- import type { Attachment } from 'svelte/attachments';
7
- import Icon from './Icon.svelte';
8
-
9
- export interface ContextMenuItem {
10
- /** Icon name */
11
- i?: string;
12
- text: string;
13
- danger?: boolean;
14
- action(): unknown;
15
- }
16
-
17
- /**
18
- * Attach a context menu to an element with the given actions
19
- */
20
- export function contextMenu(...menuItems: (ContextMenuItem | false | null | undefined)[]): Attachment<HTMLElement> {
21
- return function _attachContextMenu(element: HTMLElement) {
22
- const menu = document.createElement('div');
23
- menu.popover = 'auto';
24
- menu.className = 'context-menu';
25
-
26
- const mountedIcons = new Set<Record<string, any>>();
27
-
28
- for (const item of menuItems) {
29
- if (!item) continue;
30
- const div = document.createElement('div');
31
- div.classList.add('icon-text', 'menu-item');
32
- if (item.danger) div.classList.add('danger');
33
-
34
- div.onclick = e => {
35
- e.stopPropagation();
36
- menu.hidePopover();
37
- item.action();
38
- };
39
-
40
- if (item.i) mountedIcons.add(mount<any, {}>(Icon, { target: div, props: { i: item.i } }));
41
-
42
- div.appendChild(document.createTextNode(item.text));
43
-
44
- menu.appendChild(div);
45
- }
46
-
47
- document.body.appendChild(menu);
48
-
49
- let _forcePopover = false;
50
-
51
- element.oncontextmenu = (e: MouseEvent) => {
52
- for (let node = e.target as HTMLElement | null; node && node !== element; node = node.parentElement) {
53
- if (node instanceof HTMLDialogElement || (node.popover && node !== menu)) return;
54
- }
55
-
56
- e.preventDefault();
57
- e.stopPropagation();
58
-
59
- menu.showPopover();
60
- _forcePopover = true;
61
-
62
- const x = e.clientX;
63
- const y = e.clientY;
64
-
65
- menu.style.left = x + 'px';
66
- menu.style.top = y + 'px';
67
-
68
- const rect = menu.getBoundingClientRect();
69
- if (rect.right > window.innerWidth) {
70
- menu.style.left = '';
71
- menu.style.right = window.innerWidth - x + 'px';
72
- }
73
- if (rect.bottom > window.innerHeight) {
74
- menu.style.top = '';
75
- menu.style.bottom = window.innerHeight - y + 'px';
76
- }
77
- };
78
-
79
- /**
80
- * Workaround for https://github.com/whatwg/html/issues/10905
81
- * @todo Remove when the problem is fixed.
82
- */
83
- element.onpointerup = (e: PointerEvent) => {
84
- if (!_forcePopover) return;
85
- e.stopPropagation();
86
- e.preventDefault();
87
- menu.togglePopover();
88
- _forcePopover = false;
89
- };
90
-
91
- return function _disposeContextMenu() {
92
- for (const icon of mountedIcons) unmount(icon);
93
- menu.remove();
94
- };
95
- };
96
- }
97
-
98
- /**
99
- * Dynamically resize `<textarea>`s based on content.
100
- * @todo Remove this once `field-sizing` works everywhere
101
- */
102
- export function dynamicRows(max: number = 40, min: number = 3): Attachment<HTMLTextAreaElement> {
103
- return function _attachDynamicRows(element: HTMLTextAreaElement) {
104
- element.style.resize = 'none';
105
- element.style.fieldSizing = 'content';
106
- element.style.height = 'max-content';
107
- element.style.overflowY = 'scroll';
108
-
109
- function update() {
110
- if (!element.value) return;
111
- element.rows = Math.max(Math.min(element.value.split('\n').length, max), min);
112
- }
113
-
114
- if (!navigator.userAgent.includes('Firefox')) return;
115
- update();
116
- element.addEventListener('input', update);
117
- element.addEventListener('keyup', update);
118
- return () => {
119
- element.removeEventListener('input', update);
120
- element.removeEventListener('keyup', update);
121
- };
122
- };
123
- }
124
-
125
- /**
126
- * Enabled the use of back gestures to close dialogs
127
- */
128
- export function closeOnBackGesture(element: HTMLDialogElement) {
129
- if (!globalThis.history) {
130
- throw new Error('Can not attach back gesture handling because the History API is unavailable');
131
- }
132
-
133
- const showModal = element.showModal.bind(element);
134
-
135
- let closedByBack = false,
136
- historyKey = Math.random().toString(16).slice(2);
137
-
138
- element.showModal = () => {
139
- closedByBack = false;
140
- /* popstate's state will be the "upcoming" state.
141
- i.e, the state after having pushed 1 then 2 would be 1 */
142
- history.replaceState(historyKey, '');
143
- history.pushState(null, '');
144
- showModal();
145
- };
146
-
147
- element.addEventListener('close', () => {
148
- if (closedByBack) return;
149
- history.replaceState(null, '');
150
- });
151
-
152
- window.addEventListener('popstate', e => {
153
- if (e.state != historyKey) return;
154
- closedByBack = true;
155
- element.close();
156
- });
157
- }
158
- closeOnBackGesture satisfies Attachment<HTMLDialogElement>;