@axium/client 0.28.0 → 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 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));
package/dist/cache.js CHANGED
@@ -37,7 +37,7 @@ export class Cache {
37
37
  return this.items.size;
38
38
  }
39
39
  valid(timestamp) {
40
- return !this.options.ttl || !timestamp || timestamp.valueOf() + this.options.ttl < Date.now();
40
+ return !this.options.ttl || !timestamp || timestamp + this.options.ttl > Date.now();
41
41
  }
42
42
  write(key, value) {
43
43
  if (isThenable(value)) {
@@ -11,7 +11,7 @@ export declare class Handle<S extends z.ZodObject> {
11
11
  save(): void;
12
12
  update(): Promise<void>;
13
13
  isValid(): Promise<boolean>;
14
- get data(): z.infer<S>;
14
+ get data(): z.infer<S> | undefined;
15
15
  static [create]<S extends z.ZodObject>(init: Init<S>): Handle<S>;
16
16
  }
17
17
  export interface Init<S extends z.ZodObject> {
package/dist/cli/index.js CHANGED
@@ -52,9 +52,9 @@ program.command('logout').action(async () => {
52
52
  program.command('status').action(() => {
53
53
  if (!config.token)
54
54
  return console.log('Not logged in.');
55
- if (!config.cache)
55
+ const { session } = cache.meta.data || {};
56
+ if (!session)
56
57
  return console.log('No session data available.');
57
- const { session } = cache.meta.data;
58
58
  console.log('Logged in to', new URL(prefix).host);
59
59
  console.log(styleText('whiteBright', 'Session:'), 'valid until', session.expires.toLocaleDateString(), styleText('dim', `(${session.id})`));
60
60
  const { user } = session;
@@ -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 {
@@ -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,
package/lib/Upload.svelte CHANGED
@@ -20,7 +20,7 @@
20
20
  <Icon i={forMime(file.type)} />
21
21
  {#if !progress[i]}
22
22
  <div class="name">
23
- <span>{file.name}</span>
23
+ <span>{file.webkitRelativePath || file.name}</span>
24
24
  </div>
25
25
  <button
26
26
  onclick={e => {
@@ -35,12 +35,12 @@
35
35
  </button>
36
36
  {:else if progress[i][0] == progress[i][1]}
37
37
  <div class="name">
38
- <span>{file.name}</span>
38
+ <span>{file.webkitRelativePath || file.name}</span>
39
39
  </div>
40
40
  <Icon i="cloud-check" />
41
41
  {:else}
42
42
  <div class="name">
43
- <span>{file.name}</span>
43
+ <span>{file.webkitRelativePath || file.name}</span>
44
44
  <progress value={progress[i][0]} max={progress[i][1]}></progress>
45
45
  </div>
46
46
  <Icon i="cloud-arrow-up" />
@@ -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.28.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": {
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@simplewebauthn/browser": "^13.1.0",
57
- "commander": "^14.0.0",
57
+ "commander": "^15.0.0",
58
58
  "music-metadata": "^11.12.3"
59
59
  }
60
60
  }
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>;