@sehv-oss/basketball-upload 1.0.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.
@@ -0,0 +1,2600 @@
1
+ //#region src/game/config.ts
2
+ /**
3
+ * Physics constants. Lengths are in rim units (`u`, the rim width in pixels)
4
+ * and times in seconds, so a shot behaves the same at any rendered size.
5
+ *
6
+ * Calibrated against the reference design: its aiming dots are 1/15 s apart,
7
+ * advance ≈0.116 u horizontally and lose ≈0.095 u of rise per dot.
8
+ */
9
+ const PHYSICS = {
10
+ /**
11
+ * Fixed simulation step. Preview and flight both advance by exactly this.
12
+ */
13
+ step: 1 / 120,
14
+ /**
15
+ * Downward acceleration, u/s².
16
+ */
17
+ gravity: 21,
18
+ /**
19
+ * Launch speed per unit of pull, 1/s. Raised per layout by `launchPower`.
20
+ */
21
+ power: 12,
22
+ /**
23
+ * Headroom of a full pull over the speed of the assisted shot.
24
+ */
25
+ powerHeadroom: 1.4,
26
+ /**
27
+ * Longest pull that still adds speed, u.
28
+ */
29
+ maxPull: 1,
30
+ /**
31
+ * Shorter pulls cancel the shot instead of firing it, u.
32
+ */
33
+ minPull: .12,
34
+ /**
35
+ * Pull beyond the maximum keeps moving the card, damped by this factor.
36
+ */
37
+ pullStretch: .15,
38
+ /**
39
+ * Time between two aiming dots, s.
40
+ */
41
+ dotInterval: 1 / 15,
42
+ /**
43
+ * The preview stops after this long when nothing is hit, s.
44
+ */
45
+ maxPreviewTime: 2,
46
+ /**
47
+ * A shot still in the air after this long counts as a miss, s.
48
+ */
49
+ maxFlightTime: 5,
50
+ /**
51
+ * Collision radius of the card around the rim, u.
52
+ */
53
+ hitRadius: .17,
54
+ /**
55
+ * Half the thickness of the rim, u.
56
+ */
57
+ rimRadius: .02,
58
+ /**
59
+ * Distance from the card center to the floor and walls, u.
60
+ */
61
+ floorRadius: .35,
62
+ /**
63
+ * Card scale when it reaches the hoop: it travels away from the viewer.
64
+ */
65
+ depthScale: .7,
66
+ /**
67
+ * A card rising faster than this (u/s) is still in front of the hoop:
68
+ * the board and the rim only stop cards around the top of their arc or falling.
69
+ */
70
+ hoopDepthRise: 1.5,
71
+ /**
72
+ * Velocity kept after touching the board (the orange square).
73
+ */
74
+ board: {
75
+ x: .25,
76
+ y: .3
77
+ },
78
+ restitution: {
79
+ rim: .45,
80
+ floor: .35,
81
+ wall: .5
82
+ },
83
+ /**
84
+ * Horizontal velocity kept on each floor bounce.
85
+ */
86
+ floorFriction: .8,
87
+ /**
88
+ * A missed card stops bouncing after this many floor contacts.
89
+ */
90
+ maxFloorBounces: 2,
91
+ /**
92
+ * Assisted shots peak this far above the top of the square, u.
93
+ */
94
+ assistApex: .5,
95
+ /**
96
+ * Spin in degrees per second, per u/s of horizontal speed.
97
+ */
98
+ spin: {
99
+ factor: 160,
100
+ min: 180,
101
+ max: 540
102
+ }
103
+ };
104
+ //#endregion
105
+ //#region src/game/court.ts
106
+ function relative(rectangle, origin) {
107
+ return {
108
+ x: rectangle.x - origin.x,
109
+ y: rectangle.y - origin.y,
110
+ width: rectangle.width,
111
+ height: rectangle.height
112
+ };
113
+ }
114
+ /**
115
+ * Builds a court from client rectangles (`getBoundingClientRect`).
116
+ */
117
+ function courtFromRectangles(rectangles) {
118
+ const { host } = rectangles;
119
+ const rim = relative(rectangles.rim, host);
120
+ const net = relative(rectangles.net, host);
121
+ const spot = relative(rectangles.spot, host);
122
+ return {
123
+ unit: rim.width,
124
+ bounds: {
125
+ x: 0,
126
+ y: 0,
127
+ width: host.width,
128
+ height: host.height
129
+ },
130
+ board: relative(rectangles.board, host),
131
+ square: relative(rectangles.square, host),
132
+ rim: {
133
+ left: rim.x,
134
+ right: rim.x + rim.width,
135
+ y: rim.y + rim.height / 2
136
+ },
137
+ netBottom: net.y + net.height,
138
+ rest: {
139
+ x: spot.x + spot.width / 2,
140
+ y: spot.y + spot.height / 2
141
+ }
142
+ };
143
+ }
144
+ /**
145
+ * Middle of the rim, on the rim plane.
146
+ */
147
+ function rimCenter(court) {
148
+ return {
149
+ x: (court.rim.left + court.rim.right) / 2,
150
+ y: court.rim.y
151
+ };
152
+ }
153
+ /**
154
+ * Whether `point` is inside `rectangle`, edges included.
155
+ */
156
+ function contains(rectangle, point) {
157
+ return point.x >= rectangle.x && point.x <= rectangle.x + rectangle.width && point.y >= rectangle.y && point.y <= rectangle.y + rectangle.height;
158
+ }
159
+ //#endregion
160
+ //#region src/upload/accept.ts
161
+ /**
162
+ * Whether a file matches an `accept` string, with the semantics of
163
+ * `<input type="file" accept>`: comma separated extensions (`.pdf`), MIME
164
+ * types (`application/pdf`) and wildcards (`image/*`). Empty accepts all.
165
+ */
166
+ function matchesAccept(file, accept) {
167
+ const tokens = (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter(Boolean);
168
+ if (tokens.length === 0) return true;
169
+ const name = file.name.toLowerCase();
170
+ const type = file.type.toLowerCase();
171
+ return tokens.some((token) => {
172
+ if (token === "*" || token === "*/*") return true;
173
+ if (token.startsWith(".")) return name.endsWith(token);
174
+ if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
175
+ return type === token;
176
+ });
177
+ }
178
+ //#endregion
179
+ //#region src/file-types/defaults.ts
180
+ /**
181
+ * Built-in file types, checked after the ones you register.
182
+ */
183
+ const defaultFileTypes = [
184
+ {
185
+ kind: "pdf",
186
+ match: ".pdf,application/pdf",
187
+ label: "PDF",
188
+ color: "#d8374e"
189
+ },
190
+ {
191
+ kind: "image",
192
+ match: "image/*,.png,.jpg,.jpeg,.gif,.webp,.avif,.svg",
193
+ color: "#2f7cf6",
194
+ artwork: "thumbnail"
195
+ },
196
+ {
197
+ kind: "video",
198
+ match: "video/*",
199
+ color: "#8b5cf6"
200
+ },
201
+ {
202
+ kind: "audio",
203
+ match: "audio/*",
204
+ color: "#e39a0b"
205
+ },
206
+ {
207
+ kind: "sheet",
208
+ match: ".csv,.tsv,.xls,.xlsx,.ods,.numbers,text/csv",
209
+ color: "#1f9d55"
210
+ },
211
+ {
212
+ kind: "doc",
213
+ match: ".doc,.docx,.odt,.rtf,.md,.txt,.pages,text/plain,text/markdown",
214
+ color: "#2563eb"
215
+ },
216
+ {
217
+ kind: "slides",
218
+ match: ".ppt,.pptx,.key,.odp",
219
+ color: "#ea580c"
220
+ },
221
+ {
222
+ kind: "archive",
223
+ match: ".zip,.rar,.7z,.tar,.gz,.tgz,.bz2,application/zip",
224
+ label: (file) => file.name.toLowerCase().endsWith(".tar.gz") ? "TGZ" : extensionLabel(file.name, "ZIP"),
225
+ color: "#6b7280"
226
+ },
227
+ {
228
+ kind: "code",
229
+ match: ".json,.js,.mjs,.cjs,.ts,.tsx,.jsx,.html,.css,.xml,.yaml,.yml,.py,.go,.rs,.java,.c,.cpp,.sh",
230
+ color: "#475569"
231
+ }
232
+ ];
233
+ /**
234
+ * The extension in capitals, at most 4 characters, or `fallback`.
235
+ */
236
+ function extensionLabel(name, fallback) {
237
+ const dot = name.lastIndexOf(".");
238
+ return ((dot > 0 ? name.slice(dot + 1) : "") || fallback).slice(0, 4).toUpperCase();
239
+ }
240
+ //#endregion
241
+ //#region src/file-types/registry.ts
242
+ const KIND = /^[a-z][a-z0-9-]*$/;
243
+ /**
244
+ * Registered globally, most recent first.
245
+ */
246
+ const registered = [];
247
+ /**
248
+ * Throws when a file type cannot be used: its kind ends up in part names.
249
+ */
250
+ function assertFileType(type) {
251
+ if (!KIND.test(type.kind)) throw new TypeError(`Invalid file type kind "${type.kind}": use lowercase letters, digits and dashes, starting with a letter.`);
252
+ }
253
+ /**
254
+ * Adds a file type for every `<basketball-upload>` on the page. It wins over the
255
+ * built-in types and over the ones registered before it; types set on an
256
+ * element (`fileTypes`) still win over it. Returns a function that removes it.
257
+ */
258
+ function registerFileType(type) {
259
+ assertFileType(type);
260
+ registered.unshift(type);
261
+ return () => {
262
+ const index = registered.indexOf(type);
263
+ if (index !== -1) registered.splice(index, 1);
264
+ };
265
+ }
266
+ function matches(type, file) {
267
+ return typeof type.match === "function" ? type.match(file) : matchesAccept(file, type.match);
268
+ }
269
+ function resolve$1(type, file) {
270
+ const label = typeof type.label === "function" ? type.label(file) : type.label ?? extensionLabel(file.name, type.kind);
271
+ return {
272
+ kind: type.kind,
273
+ label,
274
+ color: type.color ?? null,
275
+ artwork: type.artwork ?? "lines"
276
+ };
277
+ }
278
+ /**
279
+ * The look of a file: the first match among `instanceTypes`, then the
280
+ * registered types, then the built-in ones, then a neutral `file`.
281
+ */
282
+ function resolveFileType(file, instanceTypes = []) {
283
+ for (const types of [
284
+ instanceTypes,
285
+ registered,
286
+ defaultFileTypes
287
+ ]) for (const type of types) if (matches(type, file)) return resolve$1(type, file);
288
+ return {
289
+ kind: "file",
290
+ label: extensionLabel(file.name, "FILE"),
291
+ color: null,
292
+ artwork: "lines"
293
+ };
294
+ }
295
+ //#endregion
296
+ //#region src/upload/event-emitter.ts
297
+ /**
298
+ * Minimal typed emitter. Listeners run synchronously, in subscription order.
299
+ */
300
+ var EventEmitter = class {
301
+ #listeners = /* @__PURE__ */ new Map();
302
+ /**
303
+ * Subscribes to an event. Returns a function that unsubscribes.
304
+ */
305
+ on(type, listener) {
306
+ let listeners = this.#listeners.get(type);
307
+ if (!listeners) {
308
+ listeners = /* @__PURE__ */ new Set();
309
+ this.#listeners.set(type, listeners);
310
+ }
311
+ listeners.add(listener);
312
+ return () => {
313
+ listeners.delete(listener);
314
+ };
315
+ }
316
+ /**
317
+ * Calls the listeners of an event with its payload.
318
+ */
319
+ emit(type, detail) {
320
+ const listeners = this.#listeners.get(type);
321
+ if (!listeners) return;
322
+ for (const listener of [...listeners]) listener(detail);
323
+ }
324
+ };
325
+ /**
326
+ * Files accepted by the hoop, and their uploads. Items are immutable
327
+ * snapshots; every change replaces the item and emits `change`.
328
+ */
329
+ var UploadQueue = class extends EventEmitter {
330
+ #concurrency;
331
+ #items = [];
332
+ #controllers = /* @__PURE__ */ new Map();
333
+ #nextId = 0;
334
+ constructor(options = {}) {
335
+ super();
336
+ this.uploader = options.uploader ?? null;
337
+ this.#concurrency = normalizeConcurrency(options.concurrency);
338
+ }
339
+ /**
340
+ * Every item, in the order the files were added.
341
+ */
342
+ get items() {
343
+ return this.#items;
344
+ }
345
+ /**
346
+ * Uploads running at the same time: a whole number from 1, otherwise
347
+ * `DEFAULT_CONCURRENCY`. Raising it starts queued uploads at once.
348
+ */
349
+ get concurrency() {
350
+ return this.#concurrency;
351
+ }
352
+ set concurrency(value) {
353
+ this.#concurrency = normalizeConcurrency(value);
354
+ this.#pump();
355
+ }
356
+ /**
357
+ * The current snapshot of an item.
358
+ */
359
+ get(id) {
360
+ return this.#items.find((item) => item.id === id);
361
+ }
362
+ /**
363
+ * Adds a file: `queued` with an uploader, which starts as soon as a slot is
364
+ * free, otherwise `ready`. Returns the item as it is once added, possibly
365
+ * already `uploading`.
366
+ */
367
+ add(file) {
368
+ this.#nextId += 1;
369
+ const id = `upload-${this.#nextId}`;
370
+ const item = {
371
+ id,
372
+ file,
373
+ status: this.uploader ? "queued" : "ready",
374
+ progress: this.uploader ? 0 : 1,
375
+ response: void 0,
376
+ error: void 0
377
+ };
378
+ this.#items = [...this.#items, item];
379
+ this.emit("change", this.#items);
380
+ this.#pump();
381
+ return this.get(id) ?? item;
382
+ }
383
+ /**
384
+ * Uploads a failed item again. Returns whether there was one to retry.
385
+ */
386
+ retry(id) {
387
+ const item = this.get(id);
388
+ if (!item || item.status !== "error" || !this.uploader) return false;
389
+ this.#update(id, {
390
+ status: "queued",
391
+ progress: 0,
392
+ error: void 0
393
+ });
394
+ this.#pump();
395
+ return true;
396
+ }
397
+ /**
398
+ * Removes an item, aborting its upload.
399
+ */
400
+ remove(id) {
401
+ if (!this.get(id)) return false;
402
+ this.#controllers.get(id)?.abort();
403
+ this.#controllers.delete(id);
404
+ this.#items = this.#items.filter((item) => item.id !== id);
405
+ this.emit("change", this.#items);
406
+ this.#pump();
407
+ return true;
408
+ }
409
+ /**
410
+ * Removes every item, aborting the uploads in flight.
411
+ */
412
+ clear() {
413
+ for (const controller of this.#controllers.values()) controller.abort();
414
+ this.#controllers.clear();
415
+ if (this.#items.length === 0) return;
416
+ this.#items = [];
417
+ this.emit("change", this.#items);
418
+ }
419
+ #pump() {
420
+ if (!this.uploader) return;
421
+ let active = this.#items.filter((item) => item.status === "uploading").length;
422
+ for (const item of this.#items) {
423
+ if (active >= this.#concurrency) return;
424
+ if (item.status !== "queued") continue;
425
+ this.#start(item, this.uploader);
426
+ active += 1;
427
+ }
428
+ }
429
+ #start(item, uploader) {
430
+ const { id, file } = item;
431
+ const controller = new AbortController();
432
+ const { signal } = controller;
433
+ this.#controllers.set(id, controller);
434
+ this.#update(id, {
435
+ status: "uploading",
436
+ progress: 0
437
+ });
438
+ this.#emitItem("start", id);
439
+ const onProgress = (loaded, total = file.size) => {
440
+ if (signal.aborted) return;
441
+ const progress = total > 0 ? Math.min(1, Math.max(0, loaded / total)) : 0;
442
+ this.#update(id, { progress });
443
+ this.#emitItem("progress", id);
444
+ };
445
+ let upload;
446
+ try {
447
+ upload = Promise.resolve(uploader(file, {
448
+ signal,
449
+ onProgress
450
+ }));
451
+ } catch (error) {
452
+ upload = Promise.reject(error);
453
+ }
454
+ upload.then((response) => {
455
+ if (signal.aborted) return;
456
+ this.#controllers.delete(id);
457
+ this.#update(id, {
458
+ status: "uploaded",
459
+ progress: 1,
460
+ response
461
+ });
462
+ this.#emitItem("success", id);
463
+ this.#pump();
464
+ }, (error) => {
465
+ if (signal.aborted) return;
466
+ this.#controllers.delete(id);
467
+ this.#update(id, {
468
+ status: "error",
469
+ error
470
+ });
471
+ this.#emitItem("error", id);
472
+ this.#pump();
473
+ });
474
+ }
475
+ #update(id, patch) {
476
+ this.#items = this.#items.map((item) => item.id === id ? {
477
+ ...item,
478
+ ...patch
479
+ } : item);
480
+ this.emit("change", this.#items);
481
+ }
482
+ #emitItem(type, id) {
483
+ const item = this.get(id);
484
+ if (item) this.emit(type, item);
485
+ }
486
+ };
487
+ function normalizeConcurrency(value) {
488
+ return value !== void 0 && Number.isFinite(value) && value >= 1 ? Math.floor(value) : 3;
489
+ }
490
+ //#endregion
491
+ //#region src/game/simulate.ts
492
+ /**
493
+ * A body at `position`, leaving at `velocity` (pixels per second).
494
+ */
495
+ function launch(position, velocity) {
496
+ return {
497
+ x: position.x,
498
+ y: position.y,
499
+ velocityX: velocity.x,
500
+ velocityY: velocity.y,
501
+ steps: 0,
502
+ boarded: false
503
+ };
504
+ }
505
+ /**
506
+ * Seconds since launch.
507
+ */
508
+ function timeOf(body) {
509
+ return body.steps * PHYSICS.step;
510
+ }
511
+ /**
512
+ * Advances a body by one fixed step (`PHYSICS.step`).
513
+ */
514
+ function step(body, court) {
515
+ const timeStep = PHYSICS.step;
516
+ const { unit } = court;
517
+ const events = [];
518
+ let velocityX = body.velocityX;
519
+ let velocityY = body.velocityY + PHYSICS.gravity * unit * timeStep;
520
+ let x = body.x + velocityX * timeStep;
521
+ let y = body.y + velocityY * timeStep;
522
+ let boarded = body.boarded;
523
+ const steps = body.steps + 1;
524
+ const { left, right, y: rimY } = court.rim;
525
+ const center = rimCenter(court).x;
526
+ const hit = PHYSICS.hitRadius * unit;
527
+ const atHoopDepth = body.velocityY > -PHYSICS.hoopDepthRise * unit;
528
+ if (body.y < rimY && y >= rimY) {
529
+ const fraction = (rimY - body.y) / (y - body.y);
530
+ const crossX = body.x + (x - body.x) * fraction;
531
+ if (Math.abs(crossX - center) < (right - left) / 2 - hit * .6) return {
532
+ body: {
533
+ x: crossX,
534
+ y: rimY,
535
+ velocityX,
536
+ velocityY,
537
+ steps,
538
+ boarded
539
+ },
540
+ events: ["score"]
541
+ };
542
+ }
543
+ const reach = hit + PHYSICS.rimRadius * unit;
544
+ for (const end of atHoopDepth ? [left, right] : []) {
545
+ const offsetX = x - end;
546
+ const offsetY = y - rimY;
547
+ const distance = Math.hypot(offsetX, offsetY);
548
+ if (distance >= reach || distance === 0) continue;
549
+ const normalX = offsetX / distance;
550
+ const normalY = offsetY / distance;
551
+ x = end + normalX * reach;
552
+ y = rimY + normalY * reach;
553
+ const along = velocityX * normalX + velocityY * normalY;
554
+ if (along < 0) {
555
+ velocityX -= (1 + PHYSICS.restitution.rim) * along * normalX;
556
+ velocityY -= (1 + PHYSICS.restitution.rim) * along * normalY;
557
+ }
558
+ events.push("rim");
559
+ }
560
+ if (!boarded && atHoopDepth && contains(court.square, {
561
+ x,
562
+ y
563
+ })) {
564
+ boarded = true;
565
+ velocityX *= PHYSICS.board.x;
566
+ velocityY *= PHYSICS.board.y;
567
+ events.push("board");
568
+ }
569
+ const { bounds } = court;
570
+ const edge = PHYSICS.floorRadius * unit;
571
+ const floor = bounds.y + bounds.height - edge;
572
+ if (y > floor) {
573
+ y = floor;
574
+ if (velocityY > 0) velocityY = -velocityY * PHYSICS.restitution.floor;
575
+ velocityX *= PHYSICS.floorFriction;
576
+ events.push("floor");
577
+ }
578
+ if (x < bounds.x + edge) {
579
+ x = bounds.x + edge;
580
+ velocityX = Math.abs(velocityX) * PHYSICS.restitution.wall;
581
+ events.push("wall");
582
+ } else if (x > bounds.x + bounds.width - edge) {
583
+ x = bounds.x + bounds.width - edge;
584
+ velocityX = -Math.abs(velocityX) * PHYSICS.restitution.wall;
585
+ events.push("wall");
586
+ }
587
+ if (y < bounds.y + edge && velocityY < 0) {
588
+ y = bounds.y + edge;
589
+ velocityY = Math.abs(velocityY) * PHYSICS.restitution.wall;
590
+ events.push("wall");
591
+ }
592
+ return {
593
+ body: {
594
+ x,
595
+ y,
596
+ velocityX,
597
+ velocityY,
598
+ steps,
599
+ boarded
600
+ },
601
+ events
602
+ };
603
+ }
604
+ /**
605
+ * Runs the very simulation the flight will run, until its first event.
606
+ * The dots are therefore exactly where the card is going to be.
607
+ */
608
+ function previewPath(start, court) {
609
+ const every = Math.round(PHYSICS.dotInterval / PHYSICS.step);
610
+ const limit = Math.round(PHYSICS.maxPreviewTime / PHYSICS.step);
611
+ const dots = [];
612
+ let body = start;
613
+ while (body.steps < limit) {
614
+ const result = step(body, court);
615
+ body = result.body;
616
+ const [event] = result.events;
617
+ if (event) {
618
+ dots.push({
619
+ x: body.x,
620
+ y: body.y,
621
+ steps: body.steps
622
+ });
623
+ return {
624
+ dots,
625
+ end: event,
626
+ steps: body.steps
627
+ };
628
+ }
629
+ if (body.steps % every === 0) dots.push({
630
+ x: body.x,
631
+ y: body.y,
632
+ steps: body.steps
633
+ });
634
+ }
635
+ return {
636
+ dots,
637
+ end: null,
638
+ steps: body.steps
639
+ };
640
+ }
641
+ //#endregion
642
+ //#region src/game/vector.ts
643
+ function scale(vector, factor) {
644
+ return {
645
+ x: vector.x * factor,
646
+ y: vector.y * factor
647
+ };
648
+ }
649
+ function length(vector) {
650
+ return Math.hypot(vector.x, vector.y);
651
+ }
652
+ /**
653
+ * `vector` turned by `degrees`, clockwise on screen (y points down).
654
+ */
655
+ function rotate(vector, degrees) {
656
+ const radians = degrees * Math.PI / 180;
657
+ const cos = Math.cos(radians);
658
+ const sin = Math.sin(radians);
659
+ return {
660
+ x: vector.x * cos - vector.y * sin,
661
+ y: vector.x * sin + vector.y * cos
662
+ };
663
+ }
664
+ /**
665
+ * `vector` shortened to `max` when longer; unchanged otherwise.
666
+ */
667
+ function limit(vector, max) {
668
+ const size = length(vector);
669
+ return size > max ? scale(vector, max / size) : vector;
670
+ }
671
+ //#endregion
672
+ //#region src/game/slingshot.ts
673
+ /**
674
+ * Where the card is drawn while pulled: it follows the pointer up to the
675
+ * maximum pull, then only by a fraction of the extra distance.
676
+ */
677
+ function stretchPull(pull, unit) {
678
+ const max = PHYSICS.maxPull * unit;
679
+ const size = length(pull);
680
+ if (size <= max) return pull;
681
+ return scale(pull, (max + (size - max) * PHYSICS.pullStretch) / size);
682
+ }
683
+ /**
684
+ * Pulls shorter than this cancel the shot.
685
+ */
686
+ function isPullEnough(pull, unit) {
687
+ return length(pull) >= PHYSICS.minPull * unit;
688
+ }
689
+ /**
690
+ * Launch velocity of a pull, in px/s: opposite to the pull, like a slingshot.
691
+ */
692
+ function pullToVelocity(pull, unit, power) {
693
+ return scale(limit(pull, PHYSICS.maxPull * unit), -power);
694
+ }
695
+ /**
696
+ * Aiming with the keyboard: `pull` turned by `degrees` (clockwise on screen)
697
+ * and lengthened by `extra` rim units, between the shortest and the longest pull.
698
+ */
699
+ function steerPull(pull, degrees, extra, unit) {
700
+ const size = length(pull);
701
+ const steered = Math.min(PHYSICS.maxPull * unit, Math.max(PHYSICS.minPull * unit, size + extra * unit));
702
+ return scale(rotate(pull, degrees), steered / size);
703
+ }
704
+ /**
705
+ * Velocity, in px/s, of a shot from `start` that peaks above the square and
706
+ * comes down on the center of the rim. Used for keyboard shots, and to size
707
+ * the slingshot to the layout.
708
+ */
709
+ function solveAssistedShot(start, court) {
710
+ const gravity = PHYSICS.gravity * court.unit;
711
+ const target = rimCenter(court);
712
+ const apex = Math.min(start.y, court.square.y) - PHYSICS.assistApex * court.unit;
713
+ const rise = start.y - apex;
714
+ const fall = target.y - apex;
715
+ const time = Math.sqrt(2 * rise / gravity) + Math.sqrt(2 * fall / gravity);
716
+ return {
717
+ x: (target.x - start.x) / time,
718
+ y: -Math.sqrt(2 * gravity * rise)
719
+ };
720
+ }
721
+ /**
722
+ * The pull that launches the assisted shot from where cards rest.
723
+ */
724
+ function assistedPull(court) {
725
+ return scale(solveAssistedShot(court.rest, court), -1 / launchPower(court));
726
+ }
727
+ /**
728
+ * Speed per unit of pull for this layout: at least the default, and enough
729
+ * for a full pull to comfortably reach the hoop from where cards rest.
730
+ */
731
+ function launchPower(court) {
732
+ const needed = length(solveAssistedShot(court.rest, court));
733
+ return Math.max(PHYSICS.power, needed * PHYSICS.powerHeadroom / (PHYSICS.maxPull * court.unit));
734
+ }
735
+ /**
736
+ * Spin in degrees per second for a launch velocity in px/s.
737
+ */
738
+ function spinFor(velocity, unit) {
739
+ const speed = Math.abs(velocity.x) / unit * PHYSICS.spin.factor;
740
+ const spin = Math.min(PHYSICS.spin.max, Math.max(PHYSICS.spin.min, speed));
741
+ return velocity.x < 0 ? -spin : spin;
742
+ }
743
+ //#endregion
744
+ //#region src/element/motion.ts
745
+ /**
746
+ * Whether the user asked for reduced motion. `false` where `matchMedia` is
747
+ * missing.
748
+ */
749
+ function prefersReducedMotion() {
750
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
751
+ }
752
+ /**
753
+ * The individual transform properties of a pose, for a box of the given size.
754
+ */
755
+ function poseStyle(pose, width, height) {
756
+ return {
757
+ translate: `${pose.x - width / 2}px ${pose.y - height / 2}px`,
758
+ rotate: `${pose.rotation}deg`,
759
+ scale: String(pose.scale)
760
+ };
761
+ }
762
+ /**
763
+ * Runs a Web Animation and resolves when it ends or is cancelled.
764
+ * Resolves at once where the Web Animations API is missing.
765
+ */
766
+ async function play(element, keyframes, options = {}) {
767
+ if (typeof element.animate !== "function") return;
768
+ const { commit = false, ...timing } = options;
769
+ const animation = element.animate(keyframes, commit ? {
770
+ ...timing,
771
+ fill: "forwards"
772
+ } : timing);
773
+ try {
774
+ await animation.finished;
775
+ } catch {
776
+ return;
777
+ }
778
+ if (!commit) return;
779
+ try {
780
+ animation.commitStyles();
781
+ } catch {}
782
+ animation.cancel();
783
+ }
784
+ /**
785
+ * The nearest whole turn, so a spinning card settles upright without unwinding.
786
+ */
787
+ function uprightOf(rotation) {
788
+ return Math.round(rotation / 360) * 360;
789
+ }
790
+ /**
791
+ * Cards lift a little while held.
792
+ */
793
+ const GRAB_SCALE = 1.04;
794
+ /**
795
+ * Tilt while pulling, in degrees per rim unit of horizontal pull.
796
+ */
797
+ const PULL_TILT = 14;
798
+ /**
799
+ * What each arrow does to the keyboard aim: degrees to turn it by, and rim
800
+ * units to lengthen its pull by.
801
+ */
802
+ const STEERING = {
803
+ ArrowLeft: [-2, 0],
804
+ ArrowRight: [2, 0],
805
+ ArrowUp: [0, .05],
806
+ ArrowDown: [0, -.05]
807
+ };
808
+ /**
809
+ * Rotation of a card pulled by `pull`: it leans back against the pull.
810
+ */
811
+ function tiltOf(pull, unit) {
812
+ return Math.max(-1, Math.min(1, pull.x / unit)) * PULL_TILT;
813
+ }
814
+ /**
815
+ * Aiming with the pointer or the keyboard, assisted shots, and the flight of
816
+ * the card.
817
+ */
818
+ var ShotController = class {
819
+ #host;
820
+ #card = null;
821
+ #frame = 0;
822
+ #endAim = null;
823
+ constructor(host) {
824
+ this.#host = host;
825
+ }
826
+ /**
827
+ * Starts aiming: the card follows the pointer like a slingshot pouch.
828
+ */
829
+ grab(card, event) {
830
+ if (this.#card || event.button !== 0) return;
831
+ const court = this.#host.measure();
832
+ if (!court) return;
833
+ event.preventDefault();
834
+ const { element } = card;
835
+ try {
836
+ element.setPointerCapture(event.pointerId);
837
+ } catch {}
838
+ const { signal, aim, end } = this.#takeAim(card, court, { follow: true });
839
+ signal.addEventListener("abort", () => {
840
+ if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
841
+ });
842
+ const origin = {
843
+ x: event.clientX,
844
+ y: event.clientY
845
+ };
846
+ element.addEventListener("pointermove", (moved) => {
847
+ if (moved.pointerId !== event.pointerId) return;
848
+ aim({
849
+ x: moved.clientX - origin.x,
850
+ y: moved.clientY - origin.y
851
+ });
852
+ }, { signal });
853
+ element.addEventListener("pointerup", (released) => {
854
+ if (released.pointerId === event.pointerId) end(true);
855
+ }, { signal });
856
+ element.addEventListener("pointercancel", (cancelled) => {
857
+ if (cancelled.pointerId === event.pointerId) end(false);
858
+ }, { signal });
859
+ element.addEventListener("lostpointercapture", (lost) => {
860
+ if (lost.pointerId === event.pointerId) end(false);
861
+ }, { signal });
862
+ }
863
+ /**
864
+ * Starts aiming from the keyboard, at the assisted shot: the arrows turn it
865
+ * and change its strength, and releasing the key that started it shoots.
866
+ */
867
+ hold(card, event) {
868
+ if (this.#card || event.repeat) return;
869
+ const court = this.#host.measure();
870
+ if (!court) return;
871
+ const { element } = card;
872
+ const { signal, aim, end } = this.#takeAim(card, court, { follow: false });
873
+ let pull = assistedPull(court);
874
+ aim(pull);
875
+ element.addEventListener("keydown", (pressed) => {
876
+ const steering = STEERING[pressed.key];
877
+ if (!steering) return;
878
+ pressed.preventDefault();
879
+ pull = steerPull(pull, steering[0], steering[1], court.unit);
880
+ aim(pull);
881
+ }, { signal });
882
+ element.addEventListener("keyup", (released) => {
883
+ if (released.key === event.key) end(true);
884
+ }, { signal });
885
+ element.addEventListener("blur", () => end(false), { signal });
886
+ }
887
+ /**
888
+ * Drops the current aim; the card goes back to the court.
889
+ */
890
+ cancelAim() {
891
+ this.#endAim?.();
892
+ }
893
+ /**
894
+ * A perfect shot from the court, for `shoot()`.
895
+ */
896
+ shoot(card) {
897
+ if (this.#card) return false;
898
+ const court = this.#host.measure();
899
+ if (!court) return false;
900
+ this.#card = card;
901
+ const pose = {
902
+ ...court.rest,
903
+ rotation: -6,
904
+ scale: 1
905
+ };
906
+ card.place(pose);
907
+ const body = launch(pose, solveAssistedShot(court.rest, court));
908
+ this.#host.trajectory.show(previewPath(body, court).dots, court.unit);
909
+ this.#host.launched(card);
910
+ this.#fly(card, body, pose.rotation, court);
911
+ return true;
912
+ }
913
+ /**
914
+ * Stops whatever is going on, leaving the card where it is.
915
+ */
916
+ stop() {
917
+ this.#endAim?.();
918
+ cancelAnimationFrame(this.#frame);
919
+ this.#frame = 0;
920
+ const card = this.#card;
921
+ this.#card = null;
922
+ this.#host.trajectory.clear();
923
+ this.#host.setFlying(false);
924
+ this.#host.setOverBoard(false);
925
+ return card;
926
+ }
927
+ #fly(card, start, rotation, court) {
928
+ if (prefersReducedMotion()) {
929
+ this.#card = null;
930
+ this.#host.trajectory.clear();
931
+ this.#host.scored(card, {
932
+ x: start.x,
933
+ y: start.y,
934
+ rotation,
935
+ scale: 1
936
+ }, court);
937
+ return;
938
+ }
939
+ this.#host.setFlying(true);
940
+ const preview = previewPath(start, court);
941
+ const depthSteps = preview.end === "board" || preview.end === "rim" || preview.end === "score" ? Math.max(1, preview.steps) : Math.round(.6 / PHYSICS.step);
942
+ let body = start;
943
+ let angle = rotation;
944
+ let spin = spinFor({
945
+ x: start.velocityX,
946
+ y: start.velocityY
947
+ }, court.unit);
948
+ let bounces = 0;
949
+ let carry = 0;
950
+ let last = performance.now();
951
+ const pose = () => ({
952
+ x: body.x,
953
+ y: body.y,
954
+ rotation: angle,
955
+ scale: 1 - (1 - PHYSICS.depthScale) * Math.min(1, body.steps / depthSteps)
956
+ });
957
+ const finish = (scored) => {
958
+ this.#frame = 0;
959
+ this.#host.trajectory.clear();
960
+ this.#host.setFlying(false);
961
+ this.#host.setOverBoard(false);
962
+ const final = pose();
963
+ card.place(final);
964
+ if (scored) {
965
+ this.#card = null;
966
+ this.#host.scored(card, final, court);
967
+ return;
968
+ }
969
+ this.#returnToRest(card, final, court).then(() => {
970
+ this.#card = null;
971
+ this.#host.missed(card);
972
+ });
973
+ };
974
+ const frame = (now) => {
975
+ carry += Math.min(.1, Math.max(0, (now - last) / 1e3));
976
+ last = now;
977
+ while (carry >= PHYSICS.step) {
978
+ carry -= PHYSICS.step;
979
+ const result = step(body, court);
980
+ body = result.body;
981
+ angle += spin * PHYSICS.step;
982
+ if (result.events.includes("score")) {
983
+ finish(true);
984
+ return;
985
+ }
986
+ if (result.events.includes("board") || result.events.includes("rim")) spin *= .6;
987
+ if (result.events.includes("floor")) {
988
+ bounces += 1;
989
+ spin *= .5;
990
+ }
991
+ if (bounces > PHYSICS.maxFloorBounces || timeOf(body) > PHYSICS.maxFlightTime) {
992
+ finish(false);
993
+ return;
994
+ }
995
+ }
996
+ card.place(pose());
997
+ this.#host.trajectory.hideBefore(body.steps);
998
+ this.#host.setOverBoard(contains(court.board, body));
999
+ this.#frame = requestAnimationFrame(frame);
1000
+ };
1001
+ this.#frame = requestAnimationFrame(frame);
1002
+ }
1003
+ /**
1004
+ * Holds `card` and draws the shot of each pull it is given, until the aim
1005
+ * ends. With `follow`, the card moves with the pull; otherwise it stays on
1006
+ * its spot.
1007
+ */
1008
+ #takeAim(card, court, { follow }) {
1009
+ this.#card = card;
1010
+ this.#host.setAiming(true);
1011
+ const power = launchPower(court);
1012
+ const listeners = new AbortController();
1013
+ let pull = {
1014
+ x: 0,
1015
+ y: 0
1016
+ };
1017
+ let pose = {
1018
+ ...court.rest,
1019
+ rotation: -6,
1020
+ scale: GRAB_SCALE
1021
+ };
1022
+ card.place(pose);
1023
+ const aim = (to) => {
1024
+ pull = to;
1025
+ const shown = stretchPull(pull, court.unit);
1026
+ pose = {
1027
+ x: court.rest.x + (follow ? shown.x : 0),
1028
+ y: court.rest.y + (follow ? shown.y : 0),
1029
+ rotation: -6 + tiltOf(shown, court.unit),
1030
+ scale: GRAB_SCALE
1031
+ };
1032
+ card.place(pose);
1033
+ if (isPullEnough(pull, court.unit)) {
1034
+ const body = launch(pose, pullToVelocity(pull, court.unit, power));
1035
+ this.#host.trajectory.show(previewPath(body, court).dots, court.unit);
1036
+ } else this.#host.trajectory.clear();
1037
+ };
1038
+ const end = (shoot) => {
1039
+ listeners.abort();
1040
+ this.#endAim = null;
1041
+ this.#host.setAiming(false);
1042
+ if (shoot && isPullEnough(pull, court.unit)) {
1043
+ const body = launch(pose, pullToVelocity(pull, court.unit, power));
1044
+ this.#host.launched(card);
1045
+ this.#fly(card, body, pose.rotation, court);
1046
+ return;
1047
+ }
1048
+ this.#host.trajectory.clear();
1049
+ this.#returnToRest(card, pose, court).then(() => {
1050
+ this.#card = null;
1051
+ });
1052
+ };
1053
+ this.#endAim = () => end(false);
1054
+ return {
1055
+ signal: listeners.signal,
1056
+ aim,
1057
+ end
1058
+ };
1059
+ }
1060
+ async #returnToRest(card, from, court) {
1061
+ const rest = {
1062
+ ...court.rest,
1063
+ rotation: -6,
1064
+ scale: 1
1065
+ };
1066
+ const { width, height } = card.size();
1067
+ await play(card.element, [poseStyle(from, width, height), poseStyle(rest, width, height)], {
1068
+ duration: prefersReducedMotion() ? 0 : 520,
1069
+ easing: "cubic-bezier(0.34, 1.4, 0.64, 1)",
1070
+ commit: true
1071
+ });
1072
+ card.release();
1073
+ }
1074
+ };
1075
+ //#endregion
1076
+ //#region src/element/dom.ts
1077
+ /**
1078
+ * An HTML element with its attributes and children.
1079
+ */
1080
+ function createElement(tag, attributes = {}, children = []) {
1081
+ const element = document.createElement(tag);
1082
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
1083
+ element.append(...children);
1084
+ return element;
1085
+ }
1086
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1087
+ /**
1088
+ * An SVG element with its attributes and children, in the SVG namespace.
1089
+ */
1090
+ function createSvgElement(tag, attributes = {}, children = []) {
1091
+ const element = document.createElementNS(SVG_NAMESPACE, tag);
1092
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, String(value));
1093
+ element.append(...children);
1094
+ return element;
1095
+ }
1096
+ const icons = {
1097
+ upload: "M12 15V4.5m0 0L8 8.5m4-4 4 4M5 14v3.5A2.5 2.5 0 0 0 7.5 20h9a2.5 2.5 0 0 0 2.5-2.5V14",
1098
+ check: "M7.5 12.5l3 3 6-6.5",
1099
+ retry: "M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"
1100
+ };
1101
+ /**
1102
+ * A 24×24 stroked icon, hidden from assistive technology.
1103
+ */
1104
+ function icon(name, attributes = {}) {
1105
+ return createSvgElement("svg", {
1106
+ viewBox: "0 0 24 24",
1107
+ "aria-hidden": "true",
1108
+ focusable: "false",
1109
+ class: "icon",
1110
+ ...attributes
1111
+ }, [createSvgElement("path", { d: icons[name] })]);
1112
+ }
1113
+ /**
1114
+ * Replaces the children of a slot: its fallback content.
1115
+ */
1116
+ function setFallback(slot, text) {
1117
+ if (slot.textContent !== text) slot.textContent = text;
1118
+ }
1119
+ //#endregion
1120
+ //#region src/element/messages.ts
1121
+ /**
1122
+ * The copy of the reference design, in English.
1123
+ */
1124
+ const defaultMessages = {
1125
+ title: "Upload files",
1126
+ description: "Drag and drop, or take the shot.",
1127
+ prompt: "Drop files here",
1128
+ hint: "or take the shot",
1129
+ dropzone: "Choose files to upload",
1130
+ counter: "Uploaded",
1131
+ shoot: (name) => `Shoot ${name}`,
1132
+ ready: "Ready",
1133
+ queued: "Waiting…",
1134
+ uploading: "Uploading…",
1135
+ uploaded: "Uploaded",
1136
+ failed: "Upload failed",
1137
+ retry: (name) => `Retry ${name}`,
1138
+ progress: (name) => `${name} upload progress`,
1139
+ required: "Add at least one file.",
1140
+ scored: (name) => `${name}: nothing but net.`,
1141
+ missed: (name) => `Missed ${name}. Take the shot again.`,
1142
+ rejected: (name, reason) => reason === "type" ? `${name} is not an accepted file type.` : reason === "size" ? `${name} is too large.` : `${name} is one file too many.`,
1143
+ complete: (name) => `${name} uploaded.`,
1144
+ error: (name) => `${name} failed to upload.`
1145
+ };
1146
+ //#endregion
1147
+ //#region src/element/styles/styles.ts
1148
+ const SOURCES = [
1149
+ "@layer tokens, reset, layout, components, states, motion;\n",
1150
+ "@layer tokens {\n :host {\n color-scheme: light dark;\n\n --_background: var(\n --basketball-upload-background,\n light-dark(#eff1f5, #0f1115)\n );\n --_foreground: var(\n --basketball-upload-foreground,\n light-dark(#0f1115, #f4f5f7)\n );\n --_muted-foreground: var(\n --basketball-upload-muted-foreground,\n light-dark(#6b6f78, #a3a8b2)\n );\n --_subtle-foreground: var(\n --basketball-upload-subtle-foreground,\n light-dark(#989a9f, #7c818b)\n );\n --_surface: var(--basketball-upload-surface, light-dark(#ffffff, #181b21));\n --_border: var(--basketball-upload-border, light-dark(#cbccd1, #3a3f48));\n --_radius: var(--basketball-upload-radius, 1.125rem);\n\n --_accent: var(--basketball-upload-accent, light-dark(#f0612e, #f26a39));\n --_accent-strong: var(\n --basketball-upload-accent-strong,\n light-dark(#bd4d19, #c4521f)\n );\n --_accent-surface: var(\n --basketball-upload-accent-surface,\n light-dark(#fff4ef, color-mix(in oklab, #f26a39 14%, #181b21))\n );\n --_trajectory: var(\n --basketball-upload-trajectory,\n color-mix(in oklab, var(--_accent) 60%, transparent)\n );\n --_net: var(--basketball-upload-net, light-dark(#a9abb3, #6b717c));\n\n --_counter-background: var(\n --basketball-upload-counter-background,\n light-dark(#e3e7ed, #232730)\n );\n --_track: var(--basketball-upload-track, light-dark(#e6e8ec, #2a2e36));\n --_success: var(--basketball-upload-success, light-dark(#2fb06d, #34c07a));\n --_danger: var(--basketball-upload-danger, light-dark(#d8374e, #f0606f));\n\n --_file-surface: var(--basketball-upload-file-surface, #ffffff);\n --_file-line: var(--basketball-upload-file-line, #e4e5e9);\n --_file-fold: var(--basketball-upload-file-fold, #e9eaee);\n --_file-badge: var(--basketball-upload-file-badge, #6b7280);\n\n --_shadow: var(\n --basketball-upload-shadow,\n 0 1px 2px rgb(15 17 21 / 0.04),\n 0 0.5rem 1.5rem rgb(15 17 21 / 0.07)\n );\n --_focus-ring: var(\n --basketball-upload-focus-ring,\n 2px solid var(--_accent)\n );\n --_font-family: var(\n --basketball-upload-font-family,\n Inter,\n ui-sans-serif,\n system-ui,\n sans-serif\n );\n\n --_gutter: var(--basketball-upload-gutter, clamp(1.25rem, 8cqi, 3.75rem));\n --_hoop-size: var(\n --basketball-upload-hoop-size,\n min(25rem, 100cqi - 2 * var(--_gutter))\n );\n --_card-width: calc(var(--_hoop-size) * 0.2375);\n --_card-height: calc(var(--_hoop-size) * 0.2875);\n }\n\n :host([theme='light']) {\n color-scheme: light;\n }\n\n :host([theme='dark']) {\n color-scheme: dark;\n }\n}\n",
1151
+ "@layer reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n [hidden] {\n display: none !important;\n }\n\n h2,\n p,\n ul {\n margin: 0;\n padding: 0;\n }\n\n ul {\n list-style: none;\n }\n\n button {\n margin: 0;\n padding: 0;\n font: inherit;\n color: inherit;\n background: none;\n border: 0;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n }\n\n img {\n display: block;\n max-inline-size: 100%;\n }\n\n svg {\n display: block;\n overflow: visible;\n }\n\n .icon {\n fill: none;\n stroke: currentColor;\n stroke-width: 1.75;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n .visually-hidden {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n}\n",
1152
+ "@layer layout {\n :host {\n display: flex;\n flex-direction: column;\n position: relative;\n container-type: inline-size;\n min-block-size: 36rem;\n font-family: var(--_font-family);\n font-size: 1rem;\n line-height: 1.4;\n color: var(--_foreground);\n background: var(--_background);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n\n :host([hidden]) {\n display: none;\n }\n\n .frame {\n position: relative;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n padding: var(--_gutter);\n }\n\n .header {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n }\n\n .hoop {\n position: relative;\n flex: none;\n align-self: center;\n inline-size: var(--_hoop-size);\n aspect-ratio: 400 / 268;\n margin-block-start: calc(var(--_hoop-size) * 0.1125);\n }\n\n .court {\n position: relative;\n flex: 1 1 auto;\n min-block-size: calc(var(--_hoop-size) * 0.96);\n }\n\n .frame:has(.list:not([hidden])) {\n padding-block-end: calc(var(--_gutter) * 0.65);\n\n & .court {\n min-block-size: calc(var(--_hoop-size) * 0.8);\n }\n }\n\n .spot {\n position: absolute;\n inset-inline-start: calc(var(--_hoop-size) * 0.13);\n inset-block-end: calc(var(--_hoop-size) * 0.3);\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n pointer-events: none;\n }\n\n .list {\n display: grid;\n flex: none;\n gap: 0.75rem;\n }\n\n .layer {\n position: absolute;\n inset: 0;\n z-index: 2;\n pointer-events: none;\n }\n}\n",
1153
+ "@layer components {\n .title {\n font-size: clamp(1.5rem, 3cqi + 1rem, 2rem);\n font-weight: 700;\n line-height: 1.25;\n letter-spacing: -0.015em;\n text-wrap: balance;\n }\n\n .description {\n margin-block-start: 0.1875rem;\n font-size: clamp(1rem, 1cqi + 0.85rem, 1.125rem);\n color: var(--_muted-foreground);\n text-wrap: balance;\n }\n\n .counter {\n display: inline-flex;\n flex: none;\n margin-block-start: 0.375rem;\n align-items: center;\n gap: 0.625rem;\n padding: 0.3125rem 0.875rem;\n border-radius: 999px;\n background: var(--_counter-background);\n white-space: nowrap;\n }\n\n .counter-label {\n font-size: 0.84375rem;\n color: var(--_muted-foreground);\n }\n\n .counter-value {\n display: inline-block;\n min-inline-size: 1ch;\n font-size: 1.0625rem;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n text-align: center;\n }\n\n .dropzone {\n position: absolute;\n inset: 0;\n z-index: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n padding-block-start: 7.5%;\n border-radius: var(--_radius);\n color: var(--_muted-foreground);\n background: var(--_surface);\n transition: background-color 180ms ease;\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 4px;\n }\n\n &:disabled {\n cursor: not-allowed;\n }\n }\n\n .dropzone-outline {\n position: absolute;\n inset: 0;\n inline-size: 100%;\n block-size: 100%;\n pointer-events: none;\n\n & rect {\n fill: none;\n stroke: var(--_border);\n stroke-width: 1.5;\n stroke-dasharray: 8 6;\n transition: stroke 180ms ease;\n }\n }\n\n .dropzone-icon {\n inline-size: 2.125rem;\n block-size: 2.125rem;\n stroke-width: 1.6;\n }\n\n .prompt {\n margin-block-start: 0.6875rem;\n font-size: clamp(1.125rem, 2cqi + 0.8rem, 1.375rem);\n font-weight: 500;\n line-height: 1.25;\n letter-spacing: -0.01em;\n color: var(--_foreground);\n }\n\n .hint {\n margin-block-start: 0.25rem;\n font-size: 0.9375rem;\n color: var(--_subtle-foreground);\n }\n\n .square {\n position: absolute;\n inset-inline-start: 33%;\n inset-block-start: 64.6%;\n z-index: 0;\n inline-size: 34%;\n block-size: 29.9%;\n border: 3px solid var(--_accent);\n border-radius: 3px;\n pointer-events: none;\n }\n\n .rim {\n position: absolute;\n inset-inline-start: 29.5%;\n inset-block-start: 100%;\n inline-size: 41%;\n translate: 0 -55%;\n pointer-events: none;\n }\n\n .rim-arc {\n fill: none;\n stroke-width: 5.5;\n stroke-linecap: round;\n }\n\n .rim-back {\n z-index: 1;\n\n & .rim-arc {\n stroke: color-mix(in oklab, var(--_accent) 40%, var(--_accent-strong));\n }\n }\n\n .rim-bracket {\n fill: var(--_accent-strong);\n }\n\n .rim-front {\n z-index: 3;\n\n & .rim-arc {\n stroke: var(--_accent);\n }\n }\n\n .net {\n position: absolute;\n inset-inline-start: 29.5%;\n inset-block-start: 101.5%;\n z-index: 3;\n inline-size: 41%;\n aspect-ratio: 164 / 104;\n transform-origin: 50% 0;\n pointer-events: none;\n\n & line {\n stroke: var(--_net);\n stroke-width: 1.75;\n stroke-linecap: round;\n vector-effect: non-scaling-stroke;\n }\n }\n\n .score-pop {\n position: absolute;\n inset-inline-start: 68%;\n inset-block-start: 59.5%;\n z-index: 4;\n font-size: calc(var(--_hoop-size) * 0.08);\n font-weight: 800;\n line-height: 1;\n letter-spacing: -0.02em;\n color: var(--_accent);\n opacity: 0;\n pointer-events: none;\n }\n}\n",
1154
+ "@layer components {\n .card {\n --_depth: 0;\n --_fold: calc(var(--_card-width) * 0.2);\n\n position: absolute;\n top: 0;\n left: 0;\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n translate: calc(var(--_rest-x, 0px) - 50% - var(--_depth) * 0.45rem)\n calc(var(--_rest-y, 0px) - 50% + var(--_depth) * 0.2rem);\n /* Keep -6deg in sync with REST_ROTATION in shot-controller.ts. */\n rotate: calc(-6deg - var(--_depth) * 7deg);\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-drag: none;\n cursor: grab;\n filter: drop-shadow(0 0.5rem 0.75rem rgb(15 17 21 / 0.13));\n\n &[data-depth='1'] {\n --_depth: 1;\n }\n\n &[data-depth='2'] {\n --_depth: 2;\n }\n\n &[data-depth='hidden'] {\n visibility: hidden;\n }\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 4px;\n border-radius: calc(var(--_card-width) * 0.1);\n }\n }\n\n .card-paper {\n position: absolute;\n inset: 0;\n border-radius: calc(var(--_card-width) * 0.1);\n background: var(--_file-surface);\n clip-path: polygon(\n 0 0,\n calc(100% - var(--_fold)) 0,\n 100% var(--_fold),\n 100% 100%,\n 0 100%\n );\n\n &::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n inline-size: var(--_fold);\n block-size: var(--_fold);\n border-end-start-radius: calc(var(--_fold) * 0.22);\n background: linear-gradient(\n to bottom left,\n transparent 50%,\n var(--_file-fold) 50%\n );\n }\n }\n\n .card-artwork {\n position: absolute;\n inset: 22% 18% 40% 14%;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: calc(var(--_card-height) * 0.06);\n\n &[data-artwork='thumbnail'] {\n inset: 12% 12% 34% 12%;\n }\n }\n\n .line {\n display: block;\n block-size: calc(var(--_card-height) * 0.05);\n border-radius: 999px;\n background: var(--_file-line);\n\n &:nth-child(2) {\n inline-size: 82%;\n }\n\n &:nth-child(3) {\n inline-size: 92%;\n }\n }\n\n .thumbnail {\n inline-size: 100%;\n block-size: 100%;\n object-fit: cover;\n border-radius: calc(var(--_card-width) * 0.05);\n }\n\n .card-badge {\n position: absolute;\n inset-inline-start: 12%;\n inset-block-end: 12.5%;\n padding: 0.18em 0.55em;\n border-radius: 0.32em;\n font-size: calc(var(--_card-width) * 0.12);\n font-weight: 700;\n line-height: 1.35;\n letter-spacing: 0.02em;\n color: #ffffff;\n background: var(--_file-color, var(--_file-badge));\n white-space: nowrap;\n }\n\n .trajectory {\n position: absolute;\n inset: 0;\n inline-size: 100%;\n block-size: 100%;\n z-index: 0;\n overflow: visible;\n }\n\n .dot {\n fill: var(--_trajectory);\n }\n}\n",
1155
+ "@layer components {\n .item {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr) minmax(5rem, 11.25rem) 1.875rem;\n align-items: center;\n gap: 0.5rem 0.9375rem;\n padding: 1.0625rem 1.25rem 1.0625rem 1.375rem;\n border-radius: 1rem;\n background: var(--_surface);\n box-shadow: var(--_shadow);\n transition:\n opacity 320ms ease,\n translate 320ms cubic-bezier(0.22, 1, 0.36, 1);\n\n @starting-style {\n opacity: 0;\n translate: 0 0.75rem;\n }\n }\n\n @container (inline-size < 30rem) {\n .item {\n grid-template-columns: auto minmax(0, 1fr) 1.875rem;\n }\n\n .item-progress {\n grid-column: 1 / -1;\n grid-row: 2;\n }\n\n .item-end {\n grid-column: 3;\n grid-row: 1;\n }\n }\n\n .item-icon {\n --_card-width: 1.8125rem;\n --_card-height: 2.375rem;\n --_fold: 0.5625rem;\n\n position: relative;\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n filter: drop-shadow(0 1px 1px rgb(15 17 21 / 0.08));\n }\n\n .item-paper {\n position: absolute;\n inset: 0;\n border-radius: 0.1875rem;\n background: color-mix(\n in oklab,\n var(--_file-line) 45%,\n var(--_file-surface)\n );\n clip-path: polygon(\n 0 0,\n calc(100% - var(--_fold)) 0,\n 100% var(--_fold),\n 100% 100%,\n 0 100%\n );\n\n &::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n inline-size: var(--_fold);\n block-size: var(--_fold);\n background: linear-gradient(\n to bottom left,\n transparent 50%,\n var(--_file-line) 50%\n );\n }\n }\n\n .item-artwork {\n position: absolute;\n inset: 24% 16% 42% 16%;\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n\n & .line {\n block-size: 0.125rem;\n background: var(--_file-line);\n }\n\n &[data-artwork='thumbnail'] {\n inset: 8% 8% 36% 8%;\n }\n }\n\n .item-badge {\n position: absolute;\n inset-inline-start: 12%;\n inset-block-end: 12%;\n padding: 0 0.25em;\n border-radius: 0.2em;\n font-size: 0.375rem;\n font-weight: 700;\n line-height: 1.5;\n color: #ffffff;\n background: var(--_file-color, var(--_file-badge));\n }\n\n .item-meta {\n display: grid;\n gap: 0.125rem;\n min-inline-size: 0;\n }\n\n .item-name {\n overflow: hidden;\n font-size: clamp(1rem, 1.5cqi + 0.5rem, 1.1875rem);\n font-weight: 600;\n line-height: 1.3;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .item-size {\n font-size: 0.875rem;\n color: var(--_subtle-foreground);\n font-variant-numeric: tabular-nums;\n }\n\n .item-progress {\n display: grid;\n gap: 0.8125rem;\n }\n\n .item-status {\n font-size: 0.875rem;\n line-height: 1.2;\n color: var(--_subtle-foreground);\n }\n\n .progress {\n display: block;\n block-size: 0.3125rem;\n overflow: hidden;\n border-radius: 999px;\n background: var(--_track);\n }\n\n .progress-bar {\n display: block;\n block-size: 100%;\n border-radius: inherit;\n background: var(--_accent);\n transform-origin: 0 50%;\n scale: 0 1;\n transition:\n scale 240ms ease-out,\n background-color 240ms ease;\n }\n\n .item-end {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n }\n\n .item-check {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n border-radius: 50%;\n color: #ffffff;\n background: var(--_success);\n\n & .icon {\n inline-size: 1.3125rem;\n block-size: 1.3125rem;\n stroke-width: 2.75;\n }\n }\n\n .item-retry {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n border-radius: 50%;\n color: var(--_danger);\n\n &:hover {\n background: color-mix(in oklab, var(--_danger) 12%, transparent);\n }\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 2px;\n }\n\n & .icon {\n inline-size: 1.25rem;\n block-size: 1.25rem;\n }\n }\n\n .item[data-status='ready'] .progress-bar {\n background: var(--_subtle-foreground);\n }\n\n .item[data-status='uploaded'] {\n & .item-status {\n color: var(--_success);\n }\n\n & .progress-bar {\n background: var(--_success);\n }\n }\n\n .item[data-status='error'] {\n & .item-status {\n color: var(--_danger);\n }\n\n & .progress-bar {\n background: var(--_danger);\n }\n }\n}\n",
1156
+ "@layer states {\n :host(:state(drop-target)) .dropzone {\n background: var(--_accent-surface);\n\n & .dropzone-outline rect {\n stroke: var(--_accent);\n }\n }\n\n :host(:state(aiming)) {\n cursor: grabbing;\n\n & .card {\n cursor: grabbing;\n }\n }\n\n :host(:state(aiming)),\n :host(:state(flying)) {\n user-select: none;\n }\n\n :host(:state(disabled)) {\n & .frame {\n cursor: not-allowed;\n }\n\n & .dropzone,\n & .card {\n filter: grayscale(1);\n opacity: 0.55;\n }\n }\n}\n",
1157
+ "@layer motion {\n @keyframes basketball-upload-shake {\n 0%,\n 100% {\n translate: 0 0;\n }\n 20% {\n translate: -0.375rem 0;\n }\n 40% {\n translate: 0.3125rem 0;\n }\n 60% {\n translate: -0.1875rem 0;\n }\n 80% {\n translate: 0.125rem 0;\n }\n }\n\n :host(:state(rejected)) .dropzone {\n animation: basketball-upload-shake 420ms ease-in-out;\n }\n\n @media (prefers-reduced-motion: reduce) {\n :host(:state(rejected)) .dropzone {\n animation: none;\n }\n\n .item,\n .progress-bar,\n .dropzone,\n .dropzone-outline rect {\n transition: none;\n }\n }\n}\n"
1158
+ ];
1159
+ let sheet;
1160
+ /**
1161
+ * One constructed stylesheet shared by every element, created on first use rather than at module evaluation:
1162
+ * the module must be importable where `CSSStyleSheet` does not exist (SSR).
1163
+ */
1164
+ function getStyleSheet() {
1165
+ if (!sheet) {
1166
+ sheet = new CSSStyleSheet();
1167
+ sheet.replaceSync(SOURCES.join("\n"));
1168
+ }
1169
+ return sheet;
1170
+ }
1171
+ //#endregion
1172
+ //#region src/element/views/card.ts
1173
+ /**
1174
+ * The CSS color of a file type: its token when set, its own color otherwise.
1175
+ */
1176
+ function fileColor(type) {
1177
+ return `var(--basketball-upload-file-${type.kind}, ${type.color ?? "var(--_file-badge)"})`;
1178
+ }
1179
+ function lines() {
1180
+ return Array.from({ length: 3 }, () => createElement("span", { class: "line" }));
1181
+ }
1182
+ /**
1183
+ * The body of a file card: text lines, a thumbnail, or a node of your own.
1184
+ */
1185
+ function createArtwork(file, type, attributes) {
1186
+ const element = createElement("span", attributes);
1187
+ let url = null;
1188
+ const revoke = () => {
1189
+ if (url) URL.revokeObjectURL(url);
1190
+ url = null;
1191
+ };
1192
+ if (type.artwork === "thumbnail" && typeof URL.createObjectURL === "function") {
1193
+ url = URL.createObjectURL(file);
1194
+ const image = createElement("img", {
1195
+ class: "thumbnail",
1196
+ alt: "",
1197
+ src: url,
1198
+ draggable: "false",
1199
+ decoding: "async"
1200
+ });
1201
+ image.addEventListener("load", revoke, { once: true });
1202
+ image.addEventListener("error", () => {
1203
+ revoke();
1204
+ element.dataset.artwork = "lines";
1205
+ image.replaceWith(...lines());
1206
+ }, { once: true });
1207
+ element.dataset.artwork = "thumbnail";
1208
+ element.append(image);
1209
+ } else if (typeof type.artwork === "function") {
1210
+ element.dataset.artwork = "custom";
1211
+ element.append(type.artwork(file));
1212
+ } else {
1213
+ element.dataset.artwork = "lines";
1214
+ element.append(...lines());
1215
+ }
1216
+ return {
1217
+ element,
1218
+ dispose: revoke
1219
+ };
1220
+ }
1221
+ function createCard(file, type, label) {
1222
+ const artwork = createArtwork(file, type, {
1223
+ class: "card-artwork",
1224
+ part: "file-artwork"
1225
+ });
1226
+ const element = createElement("button", {
1227
+ type: "button",
1228
+ class: "card",
1229
+ part: `file file-${type.kind}`,
1230
+ "data-kind": type.kind,
1231
+ "aria-label": label
1232
+ }, [createElement("span", { class: "card-paper" }, [artwork.element, createElement("span", {
1233
+ class: "card-badge",
1234
+ part: "file-badge"
1235
+ }, [type.label])])]);
1236
+ element.style.setProperty("--_file-color", fileColor(type));
1237
+ const size = () => ({
1238
+ width: element.offsetWidth,
1239
+ height: element.offsetHeight
1240
+ });
1241
+ return {
1242
+ element,
1243
+ file,
1244
+ type,
1245
+ size,
1246
+ place(pose) {
1247
+ const { width, height } = size();
1248
+ const style = poseStyle(pose, width, height);
1249
+ element.style.translate = style.translate;
1250
+ element.style.rotate = style.rotate;
1251
+ element.style.scale = style.scale;
1252
+ },
1253
+ release() {
1254
+ element.style.removeProperty("translate");
1255
+ element.style.removeProperty("rotate");
1256
+ element.style.removeProperty("scale");
1257
+ element.style.removeProperty("opacity");
1258
+ },
1259
+ dispose() {
1260
+ artwork.dispose();
1261
+ element.remove();
1262
+ }
1263
+ };
1264
+ }
1265
+ //#endregion
1266
+ //#region src/element/views/header.ts
1267
+ function createHeader() {
1268
+ const title = createElement("slot", { name: "title" });
1269
+ const description = createElement("slot", { name: "description" });
1270
+ const counterLabel = createElement("span", {
1271
+ class: "counter-label",
1272
+ part: "counter-label"
1273
+ });
1274
+ const counterValue = createElement("span", {
1275
+ class: "counter-value",
1276
+ part: "counter-value"
1277
+ }, ["0"]);
1278
+ const element = createElement("header", {
1279
+ class: "header",
1280
+ part: "header"
1281
+ }, [createElement("div", { class: "heading" }, [createElement("h2", {
1282
+ class: "title",
1283
+ part: "title"
1284
+ }, [title]), createElement("p", {
1285
+ class: "description",
1286
+ part: "description"
1287
+ }, [description])]), createElement("p", {
1288
+ class: "counter",
1289
+ part: "counter"
1290
+ }, [counterLabel, counterValue])]);
1291
+ let count = 0;
1292
+ return {
1293
+ element,
1294
+ update(messages) {
1295
+ setFallback(title, messages.title);
1296
+ setFallback(description, messages.description);
1297
+ counterLabel.textContent = messages.counter;
1298
+ },
1299
+ setCount(value, bump) {
1300
+ if (value === count) return;
1301
+ const increased = value > count;
1302
+ count = value;
1303
+ counterValue.textContent = String(value);
1304
+ if (bump && increased) play(counterValue, [
1305
+ { scale: "1" },
1306
+ { scale: "1.35" },
1307
+ { scale: "1" }
1308
+ ], {
1309
+ duration: 360,
1310
+ easing: "cubic-bezier(0.34, 1.56, 0.64, 1)"
1311
+ });
1312
+ }
1313
+ };
1314
+ }
1315
+ //#endregion
1316
+ //#region src/element/views/hoop.ts
1317
+ /**
1318
+ * Net in a 164×104 box: the rim width on top, narrowing towards the bottom.
1319
+ */
1320
+ function netLines() {
1321
+ const top = [
1322
+ 3,
1323
+ 29.3,
1324
+ 55.7,
1325
+ 82,
1326
+ 108.3,
1327
+ 134.7,
1328
+ 161
1329
+ ];
1330
+ const bottom = [
1331
+ 31,
1332
+ 48,
1333
+ 65,
1334
+ 82,
1335
+ 99,
1336
+ 116,
1337
+ 133
1338
+ ];
1339
+ const [topY, bottomY] = [2, 102];
1340
+ const lines = [];
1341
+ const line = (fromX, fromY, toX, toY) => {
1342
+ lines.push(createSvgElement("line", {
1343
+ x1: fromX,
1344
+ y1: fromY,
1345
+ x2: toX,
1346
+ y2: toY
1347
+ }));
1348
+ };
1349
+ for (let index = 0; index < top.length; index += 1) {
1350
+ const x = top[index] ?? 0;
1351
+ const left = bottom[index - 1];
1352
+ const right = bottom[index + 1];
1353
+ if (left !== void 0) line(x, topY, left, bottomY);
1354
+ if (right !== void 0) line(x, topY, right, bottomY);
1355
+ }
1356
+ line(top[0] ?? 0, topY, bottom[0] ?? 0, bottomY);
1357
+ line(top[top.length - 1] ?? 0, topY, bottom[bottom.length - 1] ?? 0, bottomY);
1358
+ for (const y of [42, 70]) {
1359
+ const inset = 28 * ((y - topY) / (bottomY - topY));
1360
+ line(3 + inset, y, 161 - inset, y);
1361
+ }
1362
+ return lines;
1363
+ }
1364
+ function createHoop() {
1365
+ const prompt = createElement("slot", { name: "prompt" });
1366
+ const hint = createElement("slot", { name: "hint" });
1367
+ const dropzone = createElement("button", {
1368
+ type: "button",
1369
+ class: "dropzone",
1370
+ part: "dropzone"
1371
+ }, [
1372
+ createSvgElement("svg", {
1373
+ class: "dropzone-outline",
1374
+ "aria-hidden": "true"
1375
+ }, [createSvgElement("rect", {
1376
+ x: 0,
1377
+ y: 0,
1378
+ width: "100%",
1379
+ height: "100%",
1380
+ rx: "4.5%",
1381
+ ry: "6.7%"
1382
+ })]),
1383
+ icon("upload", {
1384
+ class: "icon dropzone-icon",
1385
+ part: "dropzone-icon"
1386
+ }),
1387
+ createElement("span", {
1388
+ class: "prompt",
1389
+ part: "prompt"
1390
+ }, [prompt]),
1391
+ createElement("span", {
1392
+ class: "hint",
1393
+ part: "hint"
1394
+ }, [hint])
1395
+ ]);
1396
+ const square = createElement("div", {
1397
+ class: "square",
1398
+ part: "backboard-square"
1399
+ });
1400
+ const rimBack = createSvgElement("svg", {
1401
+ class: "rim rim-back",
1402
+ part: "rim",
1403
+ viewBox: "0 0 164 18",
1404
+ "aria-hidden": "true"
1405
+ }, [createSvgElement("path", {
1406
+ class: "rim-arc",
1407
+ d: "M3 10 A79 6.5 0 0 1 161 10"
1408
+ }), createSvgElement("rect", {
1409
+ class: "rim-bracket",
1410
+ x: 70,
1411
+ y: .5,
1412
+ width: 24,
1413
+ height: 10,
1414
+ rx: 1.5
1415
+ })]);
1416
+ const rim = createSvgElement("svg", {
1417
+ class: "rim rim-front",
1418
+ part: "rim",
1419
+ viewBox: "0 0 164 18",
1420
+ "aria-hidden": "true"
1421
+ }, [createSvgElement("path", {
1422
+ class: "rim-arc",
1423
+ d: "M3 10 A79 6.5 0 0 0 161 10"
1424
+ })]);
1425
+ const net = createSvgElement("svg", {
1426
+ class: "net",
1427
+ part: "net",
1428
+ viewBox: "0 0 164 104",
1429
+ preserveAspectRatio: "none",
1430
+ "aria-hidden": "true"
1431
+ }, netLines());
1432
+ const scorePop = createElement("span", {
1433
+ class: "score-pop",
1434
+ part: "score-pop",
1435
+ "aria-hidden": "true"
1436
+ }, ["+1"]);
1437
+ return {
1438
+ element: createElement("div", {
1439
+ class: "hoop",
1440
+ part: "hoop"
1441
+ }, [
1442
+ dropzone,
1443
+ square,
1444
+ rimBack,
1445
+ rim,
1446
+ net,
1447
+ scorePop
1448
+ ]),
1449
+ dropzone,
1450
+ square,
1451
+ rim,
1452
+ net,
1453
+ update(messages) {
1454
+ setFallback(prompt, messages.prompt);
1455
+ setFallback(hint, messages.hint);
1456
+ dropzone.setAttribute("aria-label", messages.dropzone);
1457
+ },
1458
+ celebrate(reducedMotion) {
1459
+ play(scorePop, reducedMotion ? [
1460
+ { opacity: 0 },
1461
+ {
1462
+ opacity: 1,
1463
+ offset: .2
1464
+ },
1465
+ {
1466
+ opacity: 1,
1467
+ offset: .7
1468
+ },
1469
+ { opacity: 0 }
1470
+ ] : [
1471
+ {
1472
+ opacity: 0,
1473
+ translate: "0 0.3em",
1474
+ scale: "0.6"
1475
+ },
1476
+ {
1477
+ opacity: 1,
1478
+ translate: "0 0",
1479
+ scale: "1.1",
1480
+ offset: .12
1481
+ },
1482
+ {
1483
+ opacity: 1,
1484
+ translate: "0 -0.1em",
1485
+ scale: "1",
1486
+ offset: .75
1487
+ },
1488
+ {
1489
+ opacity: 0,
1490
+ translate: "0 -0.45em",
1491
+ scale: "1"
1492
+ }
1493
+ ], {
1494
+ duration: 1700,
1495
+ easing: "ease-out"
1496
+ });
1497
+ },
1498
+ swish() {
1499
+ play(net, [
1500
+ { scale: "1 1" },
1501
+ {
1502
+ scale: "0.94 1.22",
1503
+ offset: .45
1504
+ },
1505
+ {
1506
+ scale: "1.02 0.96",
1507
+ offset: .78
1508
+ },
1509
+ { scale: "1 1" }
1510
+ ], {
1511
+ duration: 700,
1512
+ easing: "ease-out"
1513
+ });
1514
+ }
1515
+ };
1516
+ }
1517
+ //#endregion
1518
+ //#region src/element/views/live-region.ts
1519
+ /**
1520
+ * Polite announcements for what only shows as motion: scores, misses, uploads.
1521
+ */
1522
+ function createLiveRegion() {
1523
+ const element = createElement("div", {
1524
+ class: "visually-hidden",
1525
+ role: "status",
1526
+ "aria-live": "polite"
1527
+ });
1528
+ let frame = 0;
1529
+ return {
1530
+ element,
1531
+ announce(message) {
1532
+ element.textContent = "";
1533
+ if (typeof requestAnimationFrame !== "function") {
1534
+ element.textContent = message;
1535
+ return;
1536
+ }
1537
+ cancelAnimationFrame(frame);
1538
+ frame = requestAnimationFrame(() => {
1539
+ element.textContent = message;
1540
+ });
1541
+ }
1542
+ };
1543
+ }
1544
+ //#endregion
1545
+ //#region src/element/views/trajectory.ts
1546
+ /**
1547
+ * Dot radius, in rim units: 3.6 px on the 164 px rim of the design.
1548
+ */
1549
+ const DOT_RADIUS = .022;
1550
+ function createTrajectory() {
1551
+ const element = createSvgElement("svg", {
1552
+ class: "trajectory",
1553
+ part: "trajectory",
1554
+ "aria-hidden": "true"
1555
+ });
1556
+ const circles = [];
1557
+ let shown = [];
1558
+ return {
1559
+ element,
1560
+ show(dots, unit) {
1561
+ shown = dots;
1562
+ while (circles.length < dots.length) {
1563
+ const circle = createSvgElement("circle", { class: "dot" });
1564
+ circles.push(circle);
1565
+ element.append(circle);
1566
+ }
1567
+ const radius = Math.max(1.5, unit * DOT_RADIUS).toFixed(2);
1568
+ circles.forEach((circle, index) => {
1569
+ const dot = dots[index];
1570
+ circle.toggleAttribute("hidden", !dot);
1571
+ if (!dot) return;
1572
+ circle.setAttribute("cx", dot.x.toFixed(1));
1573
+ circle.setAttribute("cy", dot.y.toFixed(1));
1574
+ circle.setAttribute("r", radius);
1575
+ });
1576
+ },
1577
+ hideBefore(steps) {
1578
+ shown.forEach((dot, index) => {
1579
+ if (dot.steps <= steps) circles[index]?.setAttribute("hidden", "");
1580
+ });
1581
+ },
1582
+ clear() {
1583
+ shown = [];
1584
+ for (const circle of circles) circle.setAttribute("hidden", "");
1585
+ }
1586
+ };
1587
+ }
1588
+ //#endregion
1589
+ //#region src/upload/format.ts
1590
+ const UNITS = [
1591
+ "B",
1592
+ "KB",
1593
+ "MB",
1594
+ "GB",
1595
+ "TB"
1596
+ ];
1597
+ /**
1598
+ * Human readable size in decimal units, the way file managers show it: `2_400_000` → `2.4 MB`.
1599
+ * `locale` formats the number (`2,4 MB` in `de`); it defaults to the runtime's.
1600
+ */
1601
+ function formatBytes(bytes, locale) {
1602
+ let value = bytes > 0 ? bytes : 0;
1603
+ let unit = 0;
1604
+ while (value >= (unit === 0 ? 999.5 : 999.95) && unit < UNITS.length - 1) {
1605
+ value /= 1e3;
1606
+ unit += 1;
1607
+ }
1608
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: unit === 0 ? 0 : 1 }).format(value)} ${UNITS[unit]}`;
1609
+ }
1610
+ //#endregion
1611
+ //#region src/element/views/upload-list.ts
1612
+ function statusText(item, messages) {
1613
+ switch (item.status) {
1614
+ case "ready": return messages.ready;
1615
+ case "queued": return messages.queued;
1616
+ case "uploading": return messages.uploading;
1617
+ case "uploaded": return messages.uploaded;
1618
+ case "error": return messages.failed;
1619
+ }
1620
+ }
1621
+ function createRow(item, type, options) {
1622
+ const { file } = item;
1623
+ const artwork = createArtwork(file, type, { class: "item-artwork" });
1624
+ const thumbnail = createElement("span", {
1625
+ class: "item-icon",
1626
+ part: `item-icon item-icon-${type.kind}`,
1627
+ "aria-hidden": "true"
1628
+ }, [createElement("span", { class: "item-paper" }, [artwork.element, createElement("span", { class: "item-badge" }, [type.label])])]);
1629
+ thumbnail.style.setProperty("--_file-color", fileColor(type));
1630
+ const status = createElement("span", {
1631
+ class: "item-status",
1632
+ part: "item-status"
1633
+ });
1634
+ const bar = createElement("span", { class: "progress-bar" });
1635
+ const progress = createElement("span", {
1636
+ class: "progress",
1637
+ part: "progress",
1638
+ role: "progressbar",
1639
+ "aria-valuemin": "0",
1640
+ "aria-valuemax": "100"
1641
+ }, [bar]);
1642
+ const check = createElement("span", {
1643
+ class: "item-check",
1644
+ "aria-hidden": "true"
1645
+ }, [icon("check")]);
1646
+ const retry = createElement("button", {
1647
+ type: "button",
1648
+ class: "item-retry",
1649
+ part: "retry"
1650
+ }, [icon("retry")]);
1651
+ retry.addEventListener("click", () => options.onRetry(item.id));
1652
+ const element = createElement("li", {
1653
+ class: "item",
1654
+ part: "item",
1655
+ "data-kind": type.kind
1656
+ }, [
1657
+ thumbnail,
1658
+ createElement("span", { class: "item-meta" }, [createElement("span", {
1659
+ class: "item-name",
1660
+ part: "item-name"
1661
+ }, [file.name]), createElement("span", {
1662
+ class: "item-size",
1663
+ part: "item-size"
1664
+ }, [formatBytes(file.size, options.locale())])]),
1665
+ createElement("span", { class: "item-progress" }, [status, progress]),
1666
+ createElement("span", { class: "item-end" }, [check, retry])
1667
+ ]);
1668
+ return {
1669
+ element,
1670
+ update(next, messages) {
1671
+ element.dataset.status = next.status;
1672
+ status.textContent = statusText(next, messages);
1673
+ const percent = Math.round(next.progress * 100);
1674
+ progress.setAttribute("aria-valuenow", String(percent));
1675
+ progress.setAttribute("aria-label", messages.progress(file.name));
1676
+ bar.style.scale = `${next.progress} 1`;
1677
+ check.hidden = next.status !== "uploaded";
1678
+ retry.hidden = next.status !== "error";
1679
+ retry.setAttribute("aria-label", messages.retry(file.name));
1680
+ retry.title = messages.retry(file.name);
1681
+ },
1682
+ dispose() {
1683
+ artwork.dispose();
1684
+ element.remove();
1685
+ }
1686
+ };
1687
+ }
1688
+ /**
1689
+ * The list of files in the basket, with their upload progress.
1690
+ */
1691
+ function createUploadList(options) {
1692
+ const element = createElement("ul", {
1693
+ class: "list",
1694
+ part: "list",
1695
+ role: "list"
1696
+ });
1697
+ element.hidden = true;
1698
+ const rows = /* @__PURE__ */ new Map();
1699
+ return {
1700
+ element,
1701
+ render(items, messages) {
1702
+ const present = /* @__PURE__ */ new Set();
1703
+ for (const item of items) {
1704
+ present.add(item.id);
1705
+ let row = rows.get(item.id);
1706
+ if (!row) {
1707
+ row = createRow(item, options.resolveType(item.file), options);
1708
+ rows.set(item.id, row);
1709
+ element.append(row.element);
1710
+ }
1711
+ row.update(item, messages);
1712
+ }
1713
+ for (const [id, row] of rows) {
1714
+ if (present.has(id)) continue;
1715
+ row.dispose();
1716
+ rows.delete(id);
1717
+ }
1718
+ element.hidden = items.length === 0;
1719
+ },
1720
+ dispose() {
1721
+ for (const row of rows.values()) row.dispose();
1722
+ rows.clear();
1723
+ }
1724
+ };
1725
+ }
1726
+ //#endregion
1727
+ //#region src/element/basketball-upload-element.ts
1728
+ /**
1729
+ * Cards drawn on the court: the top one and two underneath.
1730
+ */
1731
+ const VISIBLE_STACK = 3;
1732
+ /**
1733
+ * Delay between the cards of a multi-file dunk, ms.
1734
+ */
1735
+ const DUNK_STAGGER = 150;
1736
+ /**
1737
+ * `HTMLElement` is missing where there is no DOM (SSR). Extending a stand-in
1738
+ * keeps this module importable there; the class is only ever instantiated by
1739
+ * `customElements.define`, which those environments do not have either.
1740
+ */
1741
+ const BaseElement = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
1742
+ function hasFiles(event) {
1743
+ return event.dataTransfer?.types.includes("Files") ?? false;
1744
+ }
1745
+ function numberAttribute(value) {
1746
+ if (value === null || value.trim() === "") return null;
1747
+ const number = Number(value);
1748
+ return Number.isFinite(number) && number >= 0 ? number : null;
1749
+ }
1750
+ /**
1751
+ * `<basketball-upload>`: a file upload where the dropzone is a backboard.
1752
+ * Drop files on it, or drop them on the court and take the shot.
1753
+ */
1754
+ var BasketballUploadElement = class extends BaseElement {
1755
+ static {
1756
+ this.formAssociated = true;
1757
+ }
1758
+ static {
1759
+ this.observedAttributes = [
1760
+ "concurrency",
1761
+ "disabled",
1762
+ "name",
1763
+ "required"
1764
+ ];
1765
+ }
1766
+ #internals;
1767
+ #queue = new UploadQueue();
1768
+ #header = createHeader();
1769
+ #hoop = createHoop();
1770
+ #trajectory = createTrajectory();
1771
+ #liveRegion = createLiveRegion();
1772
+ #list;
1773
+ #layer;
1774
+ #court;
1775
+ #spot;
1776
+ #input;
1777
+ #shots;
1778
+ /**
1779
+ * Cards waiting on the court, bottom of the stack first.
1780
+ */
1781
+ #staged = [];
1782
+ /**
1783
+ * Cards on their way into the basket: dunks, shots in the air, scores.
1784
+ */
1785
+ #inPlay = /* @__PURE__ */ new Set();
1786
+ /**
1787
+ * Scored cards fading out of the net: their files are already in the basket.
1788
+ */
1789
+ #leaving = /* @__PURE__ */ new Set();
1790
+ #messages = defaultMessages;
1791
+ #fileTypes = [];
1792
+ #resizeObserver = null;
1793
+ #formDisabled = false;
1794
+ #changeSignature = "";
1795
+ #states = {
1796
+ dragging: false,
1797
+ dropOverBoard: false,
1798
+ flightOverBoard: false,
1799
+ scoring: 0,
1800
+ aiming: false,
1801
+ flying: false,
1802
+ rejected: false
1803
+ };
1804
+ #rejectedTimer;
1805
+ constructor() {
1806
+ super();
1807
+ this.#internals = typeof this.attachInternals === "function" ? this.attachInternals() : null;
1808
+ this.#list = createUploadList({
1809
+ resolveType: (file) => this.#resolveType(file),
1810
+ onRetry: (id) => this.retryItem(id),
1811
+ locale: () => this.closest("[lang]")?.getAttribute("lang") ?? void 0
1812
+ });
1813
+ this.#spot = createElement("div", {
1814
+ class: "spot",
1815
+ "aria-hidden": "true"
1816
+ });
1817
+ this.#court = createElement("div", { class: "court" }, [this.#spot]);
1818
+ this.#layer = createElement("div", { class: "layer" }, [this.#trajectory.element]);
1819
+ this.#input = createElement("input", {
1820
+ type: "file",
1821
+ hidden: "",
1822
+ tabindex: "-1",
1823
+ "aria-hidden": "true"
1824
+ });
1825
+ const root = this.attachShadow({ mode: "open" });
1826
+ root.adoptedStyleSheets = [getStyleSheet()];
1827
+ root.append(createElement("div", {
1828
+ class: "frame",
1829
+ part: "frame"
1830
+ }, [
1831
+ this.#header.element,
1832
+ this.#hoop.element,
1833
+ this.#court,
1834
+ this.#list.element,
1835
+ this.#layer
1836
+ ]), this.#liveRegion.element, this.#input);
1837
+ this.#shots = new ShotController({
1838
+ trajectory: this.#trajectory,
1839
+ measure: () => this.#measure(),
1840
+ setAiming: (on) => this.#setState("aiming", on),
1841
+ setFlying: (on) => this.#setState("flying", on),
1842
+ setOverBoard: (on) => {
1843
+ this.#states.flightOverBoard = on;
1844
+ this.#syncStates();
1845
+ },
1846
+ launched: (card) => this.#launched(card),
1847
+ scored: (card, pose, court) => void this.#score(card, pose, court),
1848
+ missed: (card) => this.#missed(card)
1849
+ });
1850
+ this.#hoop.dropzone.addEventListener("click", () => this.openPicker());
1851
+ this.#input.addEventListener("change", () => {
1852
+ const files = [...this.#input.files ?? []];
1853
+ this.#input.value = "";
1854
+ if (this.instant) this.#dunk(files);
1855
+ else this.#stage(files);
1856
+ });
1857
+ this.addEventListener("dragenter", this.#handleDragOver);
1858
+ this.addEventListener("dragover", this.#handleDragOver);
1859
+ this.addEventListener("dragleave", this.#handleDragLeave);
1860
+ this.addEventListener("drop", this.#handleDrop);
1861
+ this.#queue.on("change", (items) => this.#handleItems(items));
1862
+ this.#queue.on("start", (item) => this.#emit("upload-start", { item }));
1863
+ this.#queue.on("progress", (item) => this.#emit("upload-progress", { item }));
1864
+ this.#queue.on("success", (item) => {
1865
+ this.#emit("upload-success", { item });
1866
+ this.#liveRegion.announce(this.#messages.complete(item.file.name));
1867
+ });
1868
+ this.#queue.on("error", (item) => {
1869
+ this.#emit("upload-error", { item });
1870
+ this.#liveRegion.announce(this.#messages.error(item.file.name));
1871
+ });
1872
+ this.#applyMessages();
1873
+ this.#syncForm();
1874
+ }
1875
+ /**
1876
+ * `light`, `dark`, or `system` (the default) to follow `prefers-color-scheme`.
1877
+ */
1878
+ get theme() {
1879
+ const value = this.getAttribute("theme");
1880
+ return value === "light" || value === "dark" ? value : "system";
1881
+ }
1882
+ set theme(value) {
1883
+ if (value === "light" || value === "dark") this.setAttribute("theme", value);
1884
+ else this.removeAttribute("theme");
1885
+ }
1886
+ /**
1887
+ * Accepted files, with the syntax of `<input type="file" accept>`.
1888
+ */
1889
+ get accept() {
1890
+ return this.getAttribute("accept") ?? "";
1891
+ }
1892
+ set accept(value) {
1893
+ if (value) this.setAttribute("accept", value);
1894
+ else this.removeAttribute("accept");
1895
+ }
1896
+ /**
1897
+ * Several files at a time. Without it, a new file replaces the previous one.
1898
+ */
1899
+ get multiple() {
1900
+ return this.hasAttribute("multiple");
1901
+ }
1902
+ set multiple(value) {
1903
+ this.toggleAttribute("multiple", Boolean(value));
1904
+ }
1905
+ /**
1906
+ * Largest accepted file, in bytes.
1907
+ */
1908
+ get maxSize() {
1909
+ return numberAttribute(this.getAttribute("max-size"));
1910
+ }
1911
+ set maxSize(value) {
1912
+ if (value === null || value === void 0) this.removeAttribute("max-size");
1913
+ else this.setAttribute("max-size", String(value));
1914
+ }
1915
+ /**
1916
+ * Most files at once, staged and in the basket.
1917
+ */
1918
+ get maxFiles() {
1919
+ return numberAttribute(this.getAttribute("max-files"));
1920
+ }
1921
+ set maxFiles(value) {
1922
+ if (value === null || value === void 0) this.removeAttribute("max-files");
1923
+ else this.setAttribute("max-files", String(value));
1924
+ }
1925
+ /**
1926
+ * Form field the files are submitted under.
1927
+ */
1928
+ get name() {
1929
+ return this.getAttribute("name") ?? "";
1930
+ }
1931
+ set name(value) {
1932
+ if (value) this.setAttribute("name", value);
1933
+ else this.removeAttribute("name");
1934
+ }
1935
+ /**
1936
+ * The form is invalid while the basket is empty.
1937
+ */
1938
+ get required() {
1939
+ return this.hasAttribute("required");
1940
+ }
1941
+ set required(value) {
1942
+ this.toggleAttribute("required", Boolean(value));
1943
+ }
1944
+ /**
1945
+ * Ignores files and shots. A disabled `<fieldset>` does the same, without
1946
+ * changing this property.
1947
+ */
1948
+ get disabled() {
1949
+ return this.hasAttribute("disabled");
1950
+ }
1951
+ set disabled(value) {
1952
+ this.toggleAttribute("disabled", Boolean(value));
1953
+ }
1954
+ /**
1955
+ * No shooting: every file goes straight into the basket.
1956
+ */
1957
+ get instant() {
1958
+ return this.hasAttribute("instant");
1959
+ }
1960
+ set instant(value) {
1961
+ this.toggleAttribute("instant", Boolean(value));
1962
+ }
1963
+ /**
1964
+ * Uploads running at the same time.
1965
+ */
1966
+ get concurrency() {
1967
+ return this.#queue.concurrency;
1968
+ }
1969
+ set concurrency(value) {
1970
+ if (value === null || value === void 0) this.removeAttribute("concurrency");
1971
+ else this.setAttribute("concurrency", String(value));
1972
+ }
1973
+ /**
1974
+ * Sends each file in the basket. Without one, files only travel with their form.
1975
+ */
1976
+ get uploader() {
1977
+ return this.#queue.uploader;
1978
+ }
1979
+ set uploader(value) {
1980
+ this.#queue.uploader = value ?? null;
1981
+ }
1982
+ /**
1983
+ * Copy and accessible names. Assign a partial object to override some: it is
1984
+ * merged over the defaults, not over the previous value, and keys set to
1985
+ * `undefined` keep their default.
1986
+ */
1987
+ get messages() {
1988
+ return this.#messages;
1989
+ }
1990
+ set messages(value) {
1991
+ const overrides = Object.fromEntries(Object.entries(value ?? {}).filter(([, message]) => message !== void 0));
1992
+ this.#messages = {
1993
+ ...defaultMessages,
1994
+ ...overrides
1995
+ };
1996
+ this.#applyMessages();
1997
+ }
1998
+ /**
1999
+ * File types for this element only, checked before the registered ones.
2000
+ */
2001
+ get fileTypes() {
2002
+ return this.#fileTypes;
2003
+ }
2004
+ set fileTypes(value) {
2005
+ const types = value ?? [];
2006
+ types.forEach(assertFileType);
2007
+ this.#fileTypes = types;
2008
+ }
2009
+ /**
2010
+ * Files in the basket and their uploads.
2011
+ */
2012
+ get items() {
2013
+ return this.#queue.items;
2014
+ }
2015
+ /**
2016
+ * Files in the basket: the value submitted with the form.
2017
+ */
2018
+ get files() {
2019
+ return this.#queue.items.map((item) => item.file);
2020
+ }
2021
+ /**
2022
+ * The `<form>` the element belongs to, or `null`.
2023
+ */
2024
+ get form() {
2025
+ return this.#internals?.form ?? null;
2026
+ }
2027
+ /**
2028
+ * As on native controls: `valueMissing` while `required` and empty.
2029
+ * `undefined` where `ElementInternals` is missing.
2030
+ */
2031
+ get validity() {
2032
+ return this.#internals?.validity;
2033
+ }
2034
+ /**
2035
+ * `messages.required` while `required` and empty, otherwise empty.
2036
+ */
2037
+ get validationMessage() {
2038
+ return this.#internals?.validationMessage ?? "";
2039
+ }
2040
+ /**
2041
+ * Whether the form validates the element, as on native controls: not while
2042
+ * disabled.
2043
+ */
2044
+ get willValidate() {
2045
+ return this.#internals?.willValidate ?? false;
2046
+ }
2047
+ /**
2048
+ * Whether the element is valid, firing `invalid` when it is not. Always
2049
+ * valid where `ElementInternals` is missing.
2050
+ */
2051
+ checkValidity() {
2052
+ return this.#internals?.checkValidity() ?? true;
2053
+ }
2054
+ /**
2055
+ * Like `checkValidity()`, and shows the message on the dropzone when invalid.
2056
+ */
2057
+ reportValidity() {
2058
+ return this.#internals?.reportValidity() ?? true;
2059
+ }
2060
+ /**
2061
+ * Opens the file picker. The chosen files land on the court (or in the basket, with `instant`).
2062
+ */
2063
+ openPicker() {
2064
+ if (this.#isDisabled) return;
2065
+ this.#input.accept = this.accept;
2066
+ this.#input.multiple = this.multiple;
2067
+ this.#input.click();
2068
+ }
2069
+ /**
2070
+ * Puts files on the court, ready to be shot (with `instant`, straight into
2071
+ * the basket). Files that do not pass `accept`, `max-size` or `max-files`
2072
+ * fire `file-reject` instead.
2073
+ */
2074
+ stage(files) {
2075
+ if (this.instant) this.#dunk([...files]);
2076
+ else this.#stage([...files]);
2077
+ }
2078
+ /**
2079
+ * Puts files straight into the basket. Files that do not pass `accept`,
2080
+ * `max-size` or `max-files` fire `file-reject` instead.
2081
+ */
2082
+ dunk(files) {
2083
+ this.#dunk([...files]);
2084
+ }
2085
+ /**
2086
+ * Shoots the card on top of the court with a perfect shot. Returns `false`
2087
+ * when it cannot: no card on the court, disabled, another card held or in
2088
+ * the air, or the element not laid out.
2089
+ */
2090
+ shoot() {
2091
+ const card = this.#staged.at(-1);
2092
+ return card !== void 0 && !this.#isDisabled && this.#shots.shoot(card);
2093
+ }
2094
+ /**
2095
+ * Uploads a failed file again. Returns `false` when `id` is not an item in
2096
+ * error, or there is no `uploader`.
2097
+ */
2098
+ retryItem(id) {
2099
+ return this.#queue.retry(id);
2100
+ }
2101
+ /**
2102
+ * Takes a file out of the basket, aborting its upload. Returns `false` when
2103
+ * there is no item with this `id`.
2104
+ */
2105
+ removeItem(id) {
2106
+ return this.#queue.remove(id);
2107
+ }
2108
+ /**
2109
+ * Empties the court and the basket, aborting uploads.
2110
+ */
2111
+ clear() {
2112
+ this.#shots.stop()?.dispose();
2113
+ for (const staged of this.#staged) staged.dispose();
2114
+ for (const playing of this.#inPlay) playing.dispose();
2115
+ for (const leaving of this.#leaving) leaving.dispose();
2116
+ this.#staged = [];
2117
+ this.#inPlay.clear();
2118
+ this.#leaving.clear();
2119
+ this.#states.scoring = 0;
2120
+ this.#syncStates();
2121
+ this.#queue.clear();
2122
+ }
2123
+ connectedCallback() {
2124
+ if (typeof ResizeObserver !== "undefined") {
2125
+ this.#resizeObserver = new ResizeObserver(() => this.#syncRest());
2126
+ this.#resizeObserver.observe(this.#court);
2127
+ this.#resizeObserver.observe(this.#layer);
2128
+ }
2129
+ this.#syncRest();
2130
+ this.#applyDisabled();
2131
+ }
2132
+ disconnectedCallback() {
2133
+ this.#resizeObserver?.disconnect();
2134
+ this.#resizeObserver = null;
2135
+ const card = this.#shots.stop();
2136
+ if (card && !this.#staged.includes(card)) {
2137
+ this.#inPlay.delete(card);
2138
+ card.release();
2139
+ this.#staged.push(card);
2140
+ this.#renderStack();
2141
+ }
2142
+ }
2143
+ attributeChangedCallback(name, _oldValue, value) {
2144
+ switch (name) {
2145
+ case "concurrency":
2146
+ this.#queue.concurrency = numberAttribute(value) ?? 3;
2147
+ break;
2148
+ case "disabled":
2149
+ this.#applyDisabled();
2150
+ break;
2151
+ case "name":
2152
+ case "required": this.#syncForm();
2153
+ }
2154
+ }
2155
+ formResetCallback() {
2156
+ this.clear();
2157
+ }
2158
+ formDisabledCallback(disabled) {
2159
+ this.#formDisabled = disabled;
2160
+ this.#applyDisabled();
2161
+ }
2162
+ get #isDisabled() {
2163
+ return this.disabled || this.#formDisabled;
2164
+ }
2165
+ #emit(type, detail) {
2166
+ this.dispatchEvent(new CustomEvent(type, {
2167
+ detail,
2168
+ bubbles: true
2169
+ }));
2170
+ }
2171
+ #resolveType(file) {
2172
+ return resolveFileType(file, this.#fileTypes);
2173
+ }
2174
+ #setState(name, on) {
2175
+ this.#states[name] = on;
2176
+ this.#syncStates();
2177
+ }
2178
+ #toggleState(name, on) {
2179
+ const states = this.#internals?.states;
2180
+ if (!states) return;
2181
+ try {
2182
+ if (on) states.add(name);
2183
+ else states.delete(name);
2184
+ } catch {}
2185
+ }
2186
+ #syncStates() {
2187
+ const states = this.#states;
2188
+ this.#toggleState("dragging", states.dragging);
2189
+ this.#toggleState("drop-target", states.dragging && states.dropOverBoard || states.flightOverBoard || states.scoring > 0);
2190
+ this.#toggleState("aiming", states.aiming);
2191
+ this.#toggleState("flying", states.flying);
2192
+ this.#toggleState("scoring", states.scoring > 0);
2193
+ this.#toggleState("rejected", states.rejected);
2194
+ this.#toggleState("disabled", this.#isDisabled);
2195
+ }
2196
+ #applyMessages() {
2197
+ this.#header.update(this.#messages);
2198
+ this.#hoop.update(this.#messages);
2199
+ this.#list.render(this.#queue.items, this.#messages);
2200
+ for (const card of this.#staged) card.element.setAttribute("aria-label", this.#messages.shoot(card.file.name));
2201
+ this.#syncForm();
2202
+ }
2203
+ #applyDisabled() {
2204
+ const disabled = this.#isDisabled;
2205
+ this.#hoop.dropzone.disabled = disabled;
2206
+ if (this.#internals) this.#internals.ariaDisabled = disabled ? "true" : null;
2207
+ if (disabled) this.#shots.cancelAim();
2208
+ this.#renderStack();
2209
+ this.#syncStates();
2210
+ }
2211
+ /**
2212
+ * Validates incoming files; rejected ones are reported, accepted ones returned.
2213
+ */
2214
+ #admit(files) {
2215
+ if (this.#isDisabled) return [];
2216
+ const max = this.multiple ? this.maxFiles ?? Infinity : 1;
2217
+ const used = this.multiple ? this.#queue.items.length + this.#staged.length + this.#inPlay.size : 0;
2218
+ const maxSize = this.maxSize;
2219
+ const accepted = [];
2220
+ for (const file of files) {
2221
+ let reason = null;
2222
+ if (!matchesAccept(file, this.accept)) reason = "type";
2223
+ else if (maxSize !== null && file.size > maxSize) reason = "size";
2224
+ else if (used + accepted.length >= max) reason = "count";
2225
+ if (reason) this.#reject(file, reason);
2226
+ else accepted.push(file);
2227
+ }
2228
+ if (!this.multiple && accepted.length > 0) this.clear();
2229
+ return accepted;
2230
+ }
2231
+ #reject(file, reason) {
2232
+ this.#emit("file-reject", {
2233
+ file,
2234
+ reason
2235
+ });
2236
+ this.#liveRegion.announce(this.#messages.rejected(file.name, reason));
2237
+ this.#states.rejected = true;
2238
+ this.#syncStates();
2239
+ clearTimeout(this.#rejectedTimer);
2240
+ this.#rejectedTimer = setTimeout(() => {
2241
+ this.#states.rejected = false;
2242
+ this.#syncStates();
2243
+ }, 450);
2244
+ }
2245
+ #createCard(file) {
2246
+ const card = createCard(file, this.#resolveType(file), this.#messages.shoot(file.name));
2247
+ const { element } = card;
2248
+ element.addEventListener("pointerdown", (event) => {
2249
+ if (this.#isDisabled || !this.#staged.includes(card)) return;
2250
+ this.#shots.grab(card, event);
2251
+ });
2252
+ element.addEventListener("keydown", (event) => {
2253
+ if (event.key === "Escape") {
2254
+ this.#shots.cancelAim();
2255
+ return;
2256
+ }
2257
+ if (event.key !== "Enter" && event.key !== " ") return;
2258
+ event.preventDefault();
2259
+ if (!this.#isDisabled && this.#staged.at(-1) === card) this.#shots.hold(card, event);
2260
+ });
2261
+ element.addEventListener("click", (event) => event.preventDefault());
2262
+ return card;
2263
+ }
2264
+ #stage(files, clientPoint) {
2265
+ const accepted = this.#admit(files);
2266
+ if (accepted.length === 0) return;
2267
+ const origin = clientPoint ? this.#toLayer(clientPoint) : null;
2268
+ for (const file of accepted) {
2269
+ const card = this.#createCard(file);
2270
+ this.#staged.push(card);
2271
+ this.#layer.append(card.element);
2272
+ }
2273
+ this.#renderStack();
2274
+ if (!origin || prefersReducedMotion()) return;
2275
+ for (const card of this.#staged.slice(-accepted.length)) {
2276
+ const rest = card.element.getBoundingClientRect();
2277
+ const layer = this.#layer.getBoundingClientRect();
2278
+ const offsetX = origin.x - (rest.left - layer.left + rest.width / 2);
2279
+ const offsetY = origin.y - (rest.top - layer.top + rest.height / 2);
2280
+ play(card.element, [{ transform: `translate(${offsetX}px, ${offsetY}px)` }, { transform: "none" }], {
2281
+ duration: 380,
2282
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)"
2283
+ });
2284
+ }
2285
+ }
2286
+ #dunk(files, clientPoint) {
2287
+ this.#admit(files).forEach((file, index) => {
2288
+ const card = this.#createCard(file);
2289
+ card.element.tabIndex = -1;
2290
+ card.element.inert = true;
2291
+ this.#inPlay.add(card);
2292
+ this.#layer.append(card.element);
2293
+ setTimeout(() => void this.#runDunk(card, clientPoint), index * DUNK_STAGGER);
2294
+ });
2295
+ }
2296
+ async #runDunk(card, clientPoint) {
2297
+ if (!this.#inPlay.has(card)) return;
2298
+ const court = this.#measure();
2299
+ if (!court) {
2300
+ this.#inPlay.delete(card);
2301
+ card.dispose();
2302
+ this.#queue.add(card.file);
2303
+ return;
2304
+ }
2305
+ const center = rimCenter(court);
2306
+ const from = {
2307
+ ...clientPoint ? this.#toLayer(clientPoint) : {
2308
+ x: center.x,
2309
+ y: court.board.y + court.board.height * .3
2310
+ },
2311
+ rotation: -10,
2312
+ scale: .9
2313
+ };
2314
+ const above = {
2315
+ x: center.x,
2316
+ y: court.square.y + court.square.height * .35,
2317
+ rotation: 6,
2318
+ scale: PHYSICS.depthScale
2319
+ };
2320
+ card.place(from);
2321
+ this.#states.scoring += 1;
2322
+ this.#syncStates();
2323
+ if (!prefersReducedMotion()) {
2324
+ const { width, height } = card.size();
2325
+ await play(card.element, [poseStyle(from, width, height), poseStyle(above, width, height)], {
2326
+ duration: 320,
2327
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
2328
+ commit: true
2329
+ });
2330
+ }
2331
+ if (!this.#inPlay.has(card)) return;
2332
+ this.#states.scoring -= 1;
2333
+ await this.#score(card, above, court);
2334
+ }
2335
+ #launched(card) {
2336
+ const hadFocus = this.shadowRoot?.activeElement === card.element;
2337
+ this.#staged = this.#staged.filter((staged) => staged !== card);
2338
+ this.#inPlay.add(card);
2339
+ card.element.inert = true;
2340
+ this.#renderStack();
2341
+ if (hadFocus) (this.#staged.at(-1)?.element ?? this.#hoop.dropzone).focus();
2342
+ }
2343
+ #missed(card) {
2344
+ if (!this.#inPlay.delete(card)) return;
2345
+ card.element.inert = false;
2346
+ this.#staged.push(card);
2347
+ this.#renderStack();
2348
+ this.#emit("shot", {
2349
+ file: card.file,
2350
+ result: "miss"
2351
+ });
2352
+ this.#liveRegion.announce(this.#messages.missed(card.file.name));
2353
+ }
2354
+ /**
2355
+ * Through the net, then into the basket: the "+1", the counter and the
2356
+ * upload only come once the card is out of the net, as in the design.
2357
+ */
2358
+ async #score(card, from, court) {
2359
+ if (!this.#inPlay.has(card)) return;
2360
+ const reduced = prefersReducedMotion();
2361
+ this.#states.scoring += 1;
2362
+ this.#syncStates();
2363
+ if (!reduced) {
2364
+ const { width, height } = card.size();
2365
+ const below = {
2366
+ x: rimCenter(court).x,
2367
+ y: court.netBottom + height * PHYSICS.depthScale / 2 - height * .06,
2368
+ rotation: uprightOf(from.rotation),
2369
+ scale: PHYSICS.depthScale
2370
+ };
2371
+ this.#hoop.swish();
2372
+ await play(card.element, [poseStyle(from, width, height), poseStyle(below, width, height)], {
2373
+ duration: 600,
2374
+ easing: "cubic-bezier(0.45, 0, 0.7, 0.6)",
2375
+ commit: true
2376
+ });
2377
+ }
2378
+ if (!this.#inPlay.has(card)) return;
2379
+ this.#states.scoring -= 1;
2380
+ this.#syncStates();
2381
+ this.#hoop.celebrate(reduced);
2382
+ this.#inPlay.delete(card);
2383
+ this.#leaving.add(card);
2384
+ this.#queue.add(card.file);
2385
+ this.#emit("shot", {
2386
+ file: card.file,
2387
+ result: "score"
2388
+ });
2389
+ this.#liveRegion.announce(this.#messages.scored(card.file.name));
2390
+ await play(card.element, [
2391
+ { opacity: 1 },
2392
+ {
2393
+ opacity: .6,
2394
+ offset: .2
2395
+ },
2396
+ {
2397
+ opacity: .6,
2398
+ offset: .6
2399
+ },
2400
+ { opacity: 0 }
2401
+ ], {
2402
+ duration: reduced ? 240 : 1200,
2403
+ commit: true
2404
+ });
2405
+ this.#leaving.delete(card);
2406
+ card.dispose();
2407
+ }
2408
+ #renderStack() {
2409
+ const top = this.#staged.length - 1;
2410
+ this.#staged.forEach((card, index) => {
2411
+ const depth = top - index;
2412
+ const { element } = card;
2413
+ element.dataset.depth = depth < VISIBLE_STACK ? String(depth) : "hidden";
2414
+ element.style.zIndex = String(index + 1);
2415
+ const interactive = depth === 0 && !this.#isDisabled;
2416
+ element.inert = !interactive;
2417
+ element.tabIndex = interactive ? 0 : -1;
2418
+ });
2419
+ }
2420
+ #handleItems(items) {
2421
+ this.#list.render(items, this.#messages);
2422
+ this.#header.setCount(items.length, !prefersReducedMotion());
2423
+ this.#syncForm();
2424
+ const signature = items.map((item) => `${item.id}:${item.status}`).join();
2425
+ if (signature === this.#changeSignature) return;
2426
+ this.#changeSignature = signature;
2427
+ this.#emit("change", { items });
2428
+ }
2429
+ #syncForm() {
2430
+ const internals = this.#internals;
2431
+ if (!internals) return;
2432
+ const files = this.files;
2433
+ const name = this.name;
2434
+ if (name && files.length > 0) {
2435
+ const data = new FormData();
2436
+ for (const file of files) data.append(name, file);
2437
+ internals.setFormValue(data);
2438
+ } else internals.setFormValue(null);
2439
+ if (this.required && files.length === 0) internals.setValidity({ valueMissing: true }, this.#messages.required, this.#hoop.dropzone);
2440
+ else internals.setValidity({});
2441
+ }
2442
+ /**
2443
+ * Converts client coordinates to the card layer's.
2444
+ */
2445
+ #toLayer(point) {
2446
+ const layer = this.#layer.getBoundingClientRect();
2447
+ return {
2448
+ x: point.x - layer.left,
2449
+ y: point.y - layer.top
2450
+ };
2451
+ }
2452
+ #measure() {
2453
+ const host = this.#layer.getBoundingClientRect();
2454
+ if (host.width === 0 || host.height === 0) return null;
2455
+ const court = courtFromRectangles({
2456
+ host,
2457
+ board: this.#hoop.dropzone.getBoundingClientRect(),
2458
+ square: this.#hoop.square.getBoundingClientRect(),
2459
+ rim: this.#hoop.rim.getBoundingClientRect(),
2460
+ net: this.#hoop.net.getBoundingClientRect(),
2461
+ spot: this.#spot.getBoundingClientRect()
2462
+ });
2463
+ return court.unit > 0 ? court : null;
2464
+ }
2465
+ /**
2466
+ * Publishes where staged cards rest, for the CSS that draws them there.
2467
+ */
2468
+ #syncRest() {
2469
+ const layer = this.#layer.getBoundingClientRect();
2470
+ const spot = this.#spot.getBoundingClientRect();
2471
+ if (layer.width === 0) return;
2472
+ const x = spot.left - layer.left + spot.width / 2;
2473
+ const y = spot.top - layer.top + spot.height / 2;
2474
+ this.#layer.style.setProperty("--_rest-x", `${x}px`);
2475
+ this.#layer.style.setProperty("--_rest-y", `${y}px`);
2476
+ }
2477
+ #isOverBoard(point) {
2478
+ const board = this.#hoop.dropzone.getBoundingClientRect();
2479
+ return point.x >= board.left && point.x <= board.right && point.y >= board.top && point.y <= board.bottom;
2480
+ }
2481
+ #handleDragOver = (dragEvent) => {
2482
+ const event = dragEvent;
2483
+ if (!hasFiles(event)) return;
2484
+ event.preventDefault();
2485
+ if (event.dataTransfer) event.dataTransfer.dropEffect = this.#isDisabled ? "none" : "copy";
2486
+ if (this.#isDisabled) return;
2487
+ this.#states.dragging = true;
2488
+ this.#states.dropOverBoard = this.#isOverBoard({
2489
+ x: event.clientX,
2490
+ y: event.clientY
2491
+ });
2492
+ this.#syncStates();
2493
+ };
2494
+ #handleDragLeave = (dragEvent) => {
2495
+ const event = dragEvent;
2496
+ const box = this.getBoundingClientRect();
2497
+ if (event.clientX > box.left && event.clientX < box.right && event.clientY > box.top && event.clientY < box.bottom) return;
2498
+ this.#states.dragging = false;
2499
+ this.#states.dropOverBoard = false;
2500
+ this.#syncStates();
2501
+ };
2502
+ #handleDrop = (dragEvent) => {
2503
+ const event = dragEvent;
2504
+ if (!hasFiles(event)) return;
2505
+ event.preventDefault();
2506
+ const point = {
2507
+ x: event.clientX,
2508
+ y: event.clientY
2509
+ };
2510
+ const overBoard = this.#isOverBoard(point);
2511
+ this.#states.dragging = false;
2512
+ this.#states.dropOverBoard = false;
2513
+ this.#syncStates();
2514
+ const files = [...event.dataTransfer?.files ?? []];
2515
+ if (overBoard || this.instant) this.#dunk(files, point);
2516
+ else this.#stage(files, point);
2517
+ };
2518
+ };
2519
+ //#endregion
2520
+ //#region src/element/register.ts
2521
+ /**
2522
+ * Name the element is registered with when no `tagName` is given.
2523
+ */
2524
+ const DEFAULT_TAG_NAME = "basketball-upload";
2525
+ /**
2526
+ * Defines the custom element. Idempotent, and a no-op where custom elements
2527
+ * do not exist (SSR), so it is safe to call from any module.
2528
+ */
2529
+ function registerBasketballUpload(options = {}) {
2530
+ const tagName = options.tagName ?? "basketball-upload";
2531
+ if (typeof customElements === "undefined") return;
2532
+ if (customElements.get(tagName)) return;
2533
+ customElements.define(tagName, tagName === "basketball-upload" ? BasketballUploadElement : class extends BasketballUploadElement {});
2534
+ }
2535
+ //#endregion
2536
+ //#region src/upload/xhr.ts
2537
+ /**
2538
+ * Rejection reason of `createXhrUploader` for a non-2xx response or a network error.
2539
+ */
2540
+ var UploadError = class extends Error {
2541
+ constructor(status, body) {
2542
+ super(status === 0 ? "Network error" : `Upload failed with status ${status}`);
2543
+ this.name = "UploadError";
2544
+ this.status = status;
2545
+ this.body = body;
2546
+ }
2547
+ };
2548
+ function resolve(value, file) {
2549
+ return typeof value === "function" ? value(file) : value;
2550
+ }
2551
+ function parseResponse(request) {
2552
+ if (!(request.getResponseHeader("content-type") ?? "").includes("json")) return request.responseText;
2553
+ try {
2554
+ return JSON.parse(request.responseText);
2555
+ } catch {
2556
+ return request.responseText;
2557
+ }
2558
+ }
2559
+ /**
2560
+ * An `Uploader` built on `XMLHttpRequest`, the one browser API that reports
2561
+ * the progress of a request body. Resolves with the parsed JSON response (or
2562
+ * its text), rejects with an `UploadError`, or with the abort reason.
2563
+ */
2564
+ function createXhrUploader(options) {
2565
+ return (file, { signal, onProgress }) => new Promise((resolvePromise, reject) => {
2566
+ if (signal.aborted) {
2567
+ reject(signal.reason);
2568
+ return;
2569
+ }
2570
+ const request = new XMLHttpRequest();
2571
+ request.open(options.method ?? "POST", String(resolve(options.url, file)));
2572
+ request.withCredentials = options.withCredentials ?? false;
2573
+ for (const [name, value] of Object.entries(resolve(options.headers ?? {}, file))) request.setRequestHeader(name, value);
2574
+ request.upload.addEventListener("progress", (event) => {
2575
+ onProgress(event.loaded, event.lengthComputable ? event.total : void 0);
2576
+ });
2577
+ request.addEventListener("load", () => {
2578
+ if (request.status >= 200 && request.status < 300) resolvePromise(parseResponse(request));
2579
+ else reject(new UploadError(request.status, request.responseText));
2580
+ });
2581
+ request.addEventListener("error", () => reject(new UploadError(0, "")));
2582
+ request.addEventListener("abort", () => reject(signal.reason));
2583
+ const abort = () => request.abort();
2584
+ signal.addEventListener("abort", abort, { once: true });
2585
+ request.addEventListener("loadend", () => signal.removeEventListener("abort", abort));
2586
+ const fieldName = options.fieldName === void 0 ? "file" : options.fieldName;
2587
+ if (fieldName === null) {
2588
+ request.send(file);
2589
+ return;
2590
+ }
2591
+ const body = new FormData();
2592
+ for (const [name, value] of Object.entries(resolve(options.fields ?? {}, file))) body.append(name, value);
2593
+ body.append(fieldName, file, file.name);
2594
+ request.send(body);
2595
+ });
2596
+ }
2597
+ //#endregion
2598
+ export { BasketballUploadElement, DEFAULT_TAG_NAME, UploadError, createXhrUploader, defaultFileTypes, defaultMessages, formatBytes, matchesAccept, registerBasketballUpload, registerFileType, resolveFileType };
2599
+
2600
+ //# sourceMappingURL=basketball-upload.js.map