@axium/client 0.28.1 → 0.29.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 +59 -9
- package/assets/theme.css +2 -0
- package/lib/AccessControlDialog.svelte +1 -1
- package/lib/FormDialog.svelte +1 -1
- package/lib/attachments/context-menu.ts +123 -0
- package/lib/attachments/drag-and-drop.ts +257 -0
- package/lib/attachments/gestures.ts +36 -0
- package/lib/attachments/index.ts +37 -0
- package/lib/attachments/selection.ts +90 -0
- package/package.json +2 -2
- package/styles/list.css +9 -0
- package/lib/attachments.ts +0 -158
package/assets/styles.css
CHANGED
|
@@ -50,7 +50,7 @@ button {
|
|
|
50
50
|
cursor: pointer;
|
|
51
51
|
|
|
52
52
|
&:hover {
|
|
53
|
-
background-color:
|
|
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:
|
|
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:
|
|
180
|
+
background-color: var(--fg-normal);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
progress::-moz-progress-bar {
|
|
184
|
-
background-color:
|
|
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(
|
|
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:
|
|
256
|
-
background-color:
|
|
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:
|
|
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));
|
|
@@ -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/FormDialog.svelte
CHANGED
|
@@ -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,
|
|
@@ -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,90 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manages a reusable, list-wide selection across items rendered with the {@link selectable} attachment.
|
|
6
|
+
*
|
|
7
|
+
* Click semantics (matching common file managers):
|
|
8
|
+
* - Plain click: select only the clicked item, clearing the rest.
|
|
9
|
+
* - Ctrl/Cmd click: toggle the clicked item's selection.
|
|
10
|
+
* - Shift click: select the range between the last-clicked item and the clicked item, clearing everything outside it.
|
|
11
|
+
*/
|
|
12
|
+
export class Selection extends SvelteSet<string> {
|
|
13
|
+
/** The ordered list of ids, set by the caller so ranges (shift-click) can be computed. */
|
|
14
|
+
#order: string[] = [];
|
|
15
|
+
|
|
16
|
+
/** The id of the last item interacted with, used as the anchor for shift-click ranges. */
|
|
17
|
+
#anchor?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Update the ordered list of item ids. Call this whenever the rendered order changes
|
|
21
|
+
* (e.g. after sorting or loading a new directory).
|
|
22
|
+
*/
|
|
23
|
+
setOrder(ids: string[]): void {
|
|
24
|
+
this.#order = ids;
|
|
25
|
+
for (const id of this) if (!ids.includes(id)) this.delete(id);
|
|
26
|
+
if (this.#anchor && !ids.includes(this.#anchor)) this.#anchor = undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clear the entire selection. */
|
|
30
|
+
clear(): void {
|
|
31
|
+
super.clear();
|
|
32
|
+
this.#anchor = undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Apply selection behavior for a click on `id` with the given modifier keys. */
|
|
36
|
+
handleClick(id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void {
|
|
37
|
+
if (e.shiftKey && this.#anchor) {
|
|
38
|
+
const start = this.#order.indexOf(this.#anchor);
|
|
39
|
+
const end = this.#order.indexOf(id);
|
|
40
|
+
if (start != -1 && end != -1) {
|
|
41
|
+
const [lo, hi] = start < end ? [start, end] : [end, start];
|
|
42
|
+
const range = new Set(this.#order.slice(lo, hi + 1));
|
|
43
|
+
super.clear();
|
|
44
|
+
for (const rangeId of range) this.add(rangeId);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (e.ctrlKey || e.metaKey) {
|
|
50
|
+
if (this.has(id)) this.delete(id);
|
|
51
|
+
else this.add(id);
|
|
52
|
+
this.#anchor = id;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
super.clear();
|
|
57
|
+
this.add(id);
|
|
58
|
+
this.#anchor = id;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wire an element into a {@link Selection} as the item identified by `id`.
|
|
64
|
+
*
|
|
65
|
+
* Reflects selection state via the `selected` class and handles select-on-click (with shift/ctrl/meta
|
|
66
|
+
* modifiers). The handler runs in the capture phase so it can be combined with other click handlers on
|
|
67
|
+
* the element that perform navigation/preview only when the click was not a selection gesture.
|
|
68
|
+
*/
|
|
69
|
+
export function selectable(selection: Selection, id: string): Attachment<HTMLElement> {
|
|
70
|
+
return function _attachSelectable(element: HTMLElement) {
|
|
71
|
+
function onclick(e: MouseEvent) {
|
|
72
|
+
// Let interactive descendants (action buttons, etc.) opt out of triggering selection.
|
|
73
|
+
for (let node = e.target as HTMLElement | null; node && node != element; node = node.parentElement) {
|
|
74
|
+
if (node.hasAttribute('data-no-select')) return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
}
|
|
81
|
+
selection.handleClick(id, e);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
element.addEventListener('click', onclick, { capture: true });
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
element.removeEventListener('click', onclick, { capture: true });
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"author": "James Prevett <jp@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -38,7 +38,7 @@
|
|
|
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
42
|
"./styles/*": "./styles/*.css"
|
|
43
43
|
},
|
|
44
44
|
"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;
|
package/lib/attachments.ts
DELETED
|
@@ -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>;
|