@raylang/elements 0.0.0-stage → 0.3.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.
Files changed (95) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +12 -2
  4. package/base.css +111 -0
  5. package/components/accordion/accordion.js +197 -0
  6. package/components/alert/alert.js +112 -0
  7. package/components/app-bar/app-bar.js +168 -0
  8. package/components/app-shell/app-shell.js +198 -0
  9. package/components/aspect-ratio/aspect-ratio.js +59 -0
  10. package/components/attachment/attachment.js +333 -0
  11. package/components/avatar/avatar.js +96 -0
  12. package/components/badge/badge.js +63 -0
  13. package/components/blockquote/blockquote.js +128 -0
  14. package/components/bottom-nav/bottom-nav.js +309 -0
  15. package/components/breadcrumbs/breadcrumbs.js +175 -0
  16. package/components/button/button.js +283 -0
  17. package/components/button-group/button-group.js +72 -0
  18. package/components/card/card.js +73 -0
  19. package/components/carousel/carousel.js +435 -0
  20. package/components/center/center.js +26 -0
  21. package/components/checkbox/checkbox.js +152 -0
  22. package/components/checkbox-group/checkbox-group.js +152 -0
  23. package/components/code/code.js +574 -0
  24. package/components/color-picker/color-picker.js +775 -0
  25. package/components/combobox/combobox.js +563 -0
  26. package/components/command-palette/command-palette.js +510 -0
  27. package/components/container/container.js +41 -0
  28. package/components/date-picker/date-picker.js +594 -0
  29. package/components/dialog/dialog.js +329 -0
  30. package/components/drawer/drawer.js +139 -0
  31. package/components/empty-state/empty-state.js +66 -0
  32. package/components/file-upload/file-upload.js +589 -0
  33. package/components/grid/grid.js +96 -0
  34. package/components/group/group.js +40 -0
  35. package/components/icon/icon.js +58 -0
  36. package/components/icon-button/icon-button.js +30 -0
  37. package/components/indicator/indicator.js +112 -0
  38. package/components/input/input.js +85 -0
  39. package/components/kbd/kbd.js +60 -0
  40. package/components/menu/menu.js +296 -0
  41. package/components/nav-link/nav-link.js +159 -0
  42. package/components/notification/notification.js +591 -0
  43. package/components/number-input/number-input.js +241 -0
  44. package/components/pagination/pagination.js +194 -0
  45. package/components/password-input/password-input.js +85 -0
  46. package/components/path-input/path-input.js +137 -0
  47. package/components/popover/popover.js +85 -0
  48. package/components/progress/progress.js +109 -0
  49. package/components/radio/radio.js +253 -0
  50. package/components/scroll-area/scroll-area.js +158 -0
  51. package/components/search/search.js +157 -0
  52. package/components/segmented-control/segmented-control.js +190 -0
  53. package/components/select/select.js +506 -0
  54. package/components/separator/separator.js +58 -0
  55. package/components/sidebar/sidebar.js +252 -0
  56. package/components/skeleton/skeleton.js +90 -0
  57. package/components/slider/slider.js +155 -0
  58. package/components/spinner/spinner.js +39 -0
  59. package/components/split/split.js +221 -0
  60. package/components/stack/stack.js +46 -0
  61. package/components/stat/stat.js +79 -0
  62. package/components/statusbar/statusbar.js +110 -0
  63. package/components/stepper/stepper.js +239 -0
  64. package/components/switch/switch.js +45 -0
  65. package/components/table/table.js +328 -0
  66. package/components/tabs/tabs.js +224 -0
  67. package/components/textarea/textarea.js +105 -0
  68. package/components/theme-toggle/theme-toggle.js +78 -0
  69. package/components/timeline/timeline.js +150 -0
  70. package/components/toggle/toggle.js +130 -0
  71. package/components/toggle-group/toggle-group.js +216 -0
  72. package/components/tooltip/tooltip.js +101 -0
  73. package/core/charts.js +107 -0
  74. package/core/color.js +110 -0
  75. package/core/core.js +556 -0
  76. package/core/dismiss.js +69 -0
  77. package/core/floating.js +195 -0
  78. package/core/focus.js +70 -0
  79. package/core/form.js +235 -0
  80. package/core/host.js +200 -0
  81. package/core/hotkey.js +40 -0
  82. package/core/icons.js +104 -0
  83. package/core/motion.js +73 -0
  84. package/core/position.js +91 -0
  85. package/core/raylang.js +167 -0
  86. package/core/scroll.js +58 -0
  87. package/core/styles.js +211 -0
  88. package/core.js +16 -0
  89. package/custom-elements.json +18545 -0
  90. package/index.js +74 -0
  91. package/package.json +312 -4
  92. package/ray-elements.d.ts +2570 -0
  93. package/tokens.css +796 -0
  94. package/tokens.json +2083 -0
  95. package/vscode.html-custom-data.json +5499 -0
@@ -0,0 +1,589 @@
1
+ // <ray-file-upload>: a drop zone to pick files (shadcn/Mantine Dropzone).
2
+ // Drag files onto it, click it, press Enter/Space on it or paste into it;
3
+ // the chosen files are listed below as <ray-attachment> rows (image
4
+ // thumbnails, size, remove button) and submitted with its <form>.
5
+ //
6
+ // `accept`, `max-size` and `max-files` are checked as files arrive: a drag
7
+ // that can only be refused turns the zone red before the drop, and refused
8
+ // files fire ray-reject and are explained under the zone. Without `multiple`
9
+ // a new file replaces the current one.
10
+ //
11
+ // Uploading is the app's job: attachmentFor(file) returns the row of a file,
12
+ // whose `progress`, `status` and `error` show how its upload goes.
13
+ //
14
+ // upload.addEventListener("change", () => {
15
+ // for (const file of upload.lastChange.added) send(file, (pct) => { upload.attachmentFor(file).progress = pct; });
16
+ // });
17
+ //
18
+ // With `native` and a native host (core/host.js, as in a std/ui window with
19
+ // core/raylang.js), browsing opens the system dialog and gives paths instead
20
+ // of bytes: those files are HostFile objects, listed in `paths` and submitted
21
+ // as their path, for the program to read. Drop and paste keep giving Files.
22
+
23
+ import { css, define } from "../../core/core.js";
24
+ import { FormControl, fieldStyles } from "../../core/form.js";
25
+ import { applyColor } from "../../core/styles.js";
26
+ import { collapseOut } from "../../core/motion.js";
27
+ import { formatBytes } from "../attachment/attachment.js";
28
+ import { getHost, hostCan, acceptFilters, HostFile } from "../../core/host.js";
29
+
30
+ const styles = css`
31
+ :host {
32
+ --_fs: var(--ray-font-size-sm);
33
+ --_zone-bg: color-mix(in srgb, var(--ray-muted) 45%, var(--ray-background));
34
+ --_dash: color-mix(in srgb, var(--ray-muted-foreground) 40%, var(--ray-border));
35
+ }
36
+ .zone {
37
+ position: relative;
38
+ display: flex;
39
+ flex-direction: column;
40
+ align-items: center;
41
+ justify-content: center;
42
+ gap: var(--ray-space-3);
43
+ width: 100%;
44
+ min-height: 11rem;
45
+ margin: 0;
46
+ padding: var(--ray-space-6) var(--ray-space-4);
47
+ font: inherit;
48
+ font-size: var(--_fs);
49
+ line-height: var(--ray-line-height-sm);
50
+ color: var(--ray-foreground);
51
+ text-align: center;
52
+ background: var(--_zone-bg);
53
+ border: 1.5px dashed var(--_dash);
54
+ border-radius: var(--ray-radius-lg);
55
+ cursor: pointer;
56
+ transition:
57
+ border-color var(--ray-duration-fast) var(--ray-easing-standard),
58
+ background var(--ray-duration-fast) var(--ray-easing-standard),
59
+ transform var(--ray-duration-base) var(--ray-easing-out);
60
+ }
61
+ .zone:hover { border-color: color-mix(in srgb, var(--_filled) 55%, var(--ray-border)); background: color-mix(in srgb, var(--_filled) 4%, var(--_zone-bg)); }
62
+ .zone:focus-visible { outline: var(--ray-focus-ring-width) solid var(--ray-ring); outline-offset: 2px; }
63
+ .zone:disabled { cursor: not-allowed; opacity: 0.6; }
64
+ .zone:disabled:hover { border-color: var(--_dash); background: var(--_zone-bg); }
65
+ .zone[data-invalid] { border-color: var(--ray-destructive); }
66
+
67
+ /* The icon sits on a small raised tile that lifts on hover. */
68
+ .glyph {
69
+ display: inline-grid;
70
+ place-items: center;
71
+ flex: none;
72
+ width: 3rem;
73
+ height: 3rem;
74
+ color: var(--ray-muted-foreground);
75
+ background: var(--ray-raised);
76
+ border: 1px solid var(--ray-border);
77
+ border-radius: var(--ray-radius-md);
78
+ box-shadow: var(--ray-shadow-xs);
79
+ --ray-icon-size: 1.25rem;
80
+ transition:
81
+ transform var(--ray-duration-base) var(--ray-easing-out),
82
+ color var(--ray-duration-fast) var(--ray-easing-standard),
83
+ background var(--ray-duration-fast) var(--ray-easing-standard);
84
+ }
85
+ .glyph ::slotted(*) { --ray-icon-size: 1.25rem; }
86
+ .zone:not(:disabled):hover .glyph { transform: translateY(-2px); color: var(--_light-color); }
87
+ .copy { display: grid; gap: var(--ray-space-1); min-width: 0; }
88
+ .title { font-weight: var(--ray-font-weight-medium); }
89
+ .browse {
90
+ color: var(--_light-color);
91
+ text-decoration: underline;
92
+ text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
93
+ text-underline-offset: 3px;
94
+ }
95
+ .zone:hover .browse { text-decoration-color: currentColor; }
96
+ .hint { font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
97
+ /* Touch screens rarely drag files: the title says what a tap does. */
98
+ .tap { display: none; }
99
+ @media (hover: none) and (pointer: coarse) {
100
+ .drop, .browse-or { display: none; }
101
+ .tap { display: inline; }
102
+ }
103
+ .hint:empty { display: none; }
104
+ .button { display: none; }
105
+
106
+ /* Files dragged over it: solid accent border, the tile fills and bobs. */
107
+ :host([data-dragging]) .zone {
108
+ border-style: solid;
109
+ border-color: var(--_filled);
110
+ background: color-mix(in srgb, var(--_filled) 8%, var(--ray-background));
111
+ transform: scale(1.01);
112
+ }
113
+ :host([data-dragging]) .glyph {
114
+ color: var(--_contrast);
115
+ background: var(--_filled);
116
+ border-color: transparent;
117
+ animation: ray-upload-bob 0.9s var(--ray-easing-standard) infinite alternate;
118
+ }
119
+ :host([data-dragging][data-reject]) .zone {
120
+ border-color: var(--ray-destructive);
121
+ background: color-mix(in srgb, var(--ray-destructive) 7%, var(--ray-background));
122
+ }
123
+ :host([data-dragging][data-reject]) .glyph { color: var(--ray-destructive-foreground); background: var(--ray-destructive); animation: none; }
124
+ :host([data-dragging][data-reject]) .title { color: var(--ray-destructive); }
125
+ @keyframes ray-upload-bob { from { transform: translateY(0); } to { transform: translateY(-4px); } }
126
+
127
+ /* variant="compact": one row — tile, text and a Browse button. */
128
+ :host([variant="compact"]) .zone {
129
+ flex-direction: row;
130
+ justify-content: flex-start;
131
+ min-height: 0;
132
+ padding: var(--ray-space-3);
133
+ text-align: start;
134
+ border-radius: var(--ray-radius-md);
135
+ }
136
+ :host([variant="compact"][data-dragging]) .zone { transform: none; }
137
+ :host([variant="compact"]) .glyph { width: 2.5rem; height: 2.5rem; border-radius: var(--ray-radius-default); }
138
+ :host([variant="compact"]) .copy { flex: 1; }
139
+ :host([variant="compact"]) .browse-or { display: none; }
140
+ :host([variant="compact"]) .button {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ flex: none;
144
+ height: var(--ray-control-height-xs);
145
+ padding: 0 var(--ray-space-3);
146
+ font-size: var(--ray-font-size-xs);
147
+ font-weight: var(--ray-font-weight-medium);
148
+ background: var(--ray-default);
149
+ border: 1px solid var(--ray-border);
150
+ border-radius: var(--ray-radius-default);
151
+ transition: background var(--ray-duration-fast) var(--ray-easing-standard);
152
+ }
153
+ :host([variant="compact"]) .zone:not(:disabled):hover .button { background: var(--ray-default-hover); }
154
+
155
+ .files { display: grid; gap: var(--ray-space-2); margin-top: var(--ray-space-3); }
156
+ .files:empty { display: none; }
157
+ .files > ray-attachment { display: flex; animation: ray-upload-in var(--ray-duration-base) var(--ray-easing-out); }
158
+ .files > ray-attachment::part(item) { width: 100%; }
159
+ :host([list="cards"]) .files { display: flex; flex-wrap: wrap; }
160
+ :host([list="cards"]) .files > ray-attachment::part(item) { width: 9rem; }
161
+ :host([list="none"]) .files { display: none; }
162
+ @keyframes ray-upload-in { from { opacity: 0; transform: translateY(-4px); } }
163
+ `;
164
+
165
+ const SIZE = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?\s*$/i;
166
+ const UNITS = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
167
+
168
+ /** Bytes from a number or a size like "10 MB", "512kb"; NaN if neither. */
169
+ export function parseSize(v) {
170
+ if (v === null || v === undefined || v === "") return NaN;
171
+ if (typeof v === "number") return v;
172
+ const m = SIZE.exec(String(v));
173
+ return m ? Math.round(Number(m[1]) * UNITS[(m[2] ?? "b").toLowerCase()]) : NaN;
174
+ }
175
+
176
+ const acceptList = (accept) => String(accept ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
177
+
178
+ const matchesType = (type, pattern) =>
179
+ pattern.endsWith("/*") ? type.startsWith(pattern.slice(0, -1)) : type === pattern;
180
+
181
+ /** Whether a file passes an `accept` list (".pdf", "image/*", "application/json"). */
182
+ export function matchesAccept(file, accept) {
183
+ const list = acceptList(accept);
184
+ if (list.length === 0) return true;
185
+ const name = String(file.name ?? "").toLowerCase();
186
+ const type = String(file.type ?? "").toLowerCase();
187
+ return list.some((a) => (a.startsWith(".") ? name.endsWith(a) : matchesType(type, a)));
188
+ }
189
+
190
+
191
+ const KINDS = { image: "Images", video: "Videos", audio: "Audio", text: "Text files" };
192
+
193
+ /** A short human list of an `accept` value: "PNG, JPG or PDF", "Images". */
194
+ export function describeAccept(accept) {
195
+ const names = [];
196
+ for (const a of acceptList(accept)) {
197
+ let n;
198
+ if (a.startsWith(".")) n = a.slice(1).toUpperCase();
199
+ else if (a.endsWith("/*")) n = KINDS[a.slice(0, -2)] ?? a;
200
+ else n = a.split("/")[1]?.replace(/^x-/, "").split(/[+.]/).pop()?.toUpperCase() ?? a;
201
+ if (!names.includes(n)) names.push(n);
202
+ }
203
+ if (names.length < 2) return names[0] ?? "";
204
+ // "PNG or images", not "PNG or Images".
205
+ for (const [i, n] of names.entries()) if (Object.values(KINDS).includes(n)) names[i] = n.toLowerCase();
206
+ return `${names.slice(0, -1).join(", ")} or ${names.at(-1)}`;
207
+ }
208
+
209
+ export class RayFileUpload extends FormControl {
210
+ static properties = {
211
+ name: { type: String, reflect: true },
212
+ label: { type: String },
213
+ description: { type: String },
214
+ error: { type: String },
215
+ required: { type: Boolean, reflect: true },
216
+ disabled: { type: Boolean, reflect: true },
217
+ accept: { type: String, reflect: true },
218
+ multiple: { type: Boolean, reflect: true },
219
+ native: { type: Boolean, reflect: true },
220
+ maxSize: { type: String, attribute: "max-size" },
221
+ maxFiles: { type: Number, attribute: "max-files" },
222
+ hint: { type: String },
223
+ dropLabel: { type: String, attribute: "drop-label", default: "Drag files here" },
224
+ browseLabel: { type: String, attribute: "browse-label", default: "browse" },
225
+ activeLabel: { type: String, attribute: "active-label", default: "Drop to add files" },
226
+ tapLabel: { type: String, attribute: "tap-label", default: "Choose files" },
227
+ variant: { type: String, reflect: true, default: "default" },
228
+ list: { type: String, reflect: true, default: "rows" },
229
+ color: { type: String, reflect: true },
230
+ };
231
+ static styles = [fieldStyles, styles];
232
+ static template = `
233
+ <span class="label" part="label" id="label" hidden><span class="label-text"></span><span class="required" aria-hidden="true" hidden> *</span></span>
234
+ <div class="description" part="description" id="description" hidden></div>
235
+ <button type="button" class="zone" part="dropzone" aria-labelledby="label title hint pill" aria-describedby="description error">
236
+ <span class="glyph" part="icon"><slot name="icon"><span data-ray-icon="upload"></span></slot></span>
237
+ <span class="copy">
238
+ <span class="title" id="title"></span>
239
+ <span class="hint" id="hint" part="hint"></span>
240
+ </span>
241
+ <span class="button" id="pill"></span>
242
+ </button>
243
+ <input type="file" tabindex="-1" aria-hidden="true" hidden>
244
+ <div class="error" part="error" id="error" aria-live="polite" hidden></div>
245
+ <div class="files" part="files" role="list"></div>`;
246
+
247
+ /** @type {File[]} */
248
+ #files = [];
249
+ #lastChange = null;
250
+ /** @type {Map<File, HTMLElement>} */
251
+ #rows = new Map();
252
+ #rejected = "";
253
+ #refusal = "";
254
+ #depth = 0;
255
+
256
+ constructor() {
257
+ super();
258
+ this.zone = this.$(".zone");
259
+ this.input = this.$("input");
260
+ this.zone.addEventListener("click", () => this.open());
261
+ this.input.addEventListener("change", () => {
262
+ this.#take([...this.input.files]);
263
+ this.input.value = "";
264
+ });
265
+ this.zone.addEventListener("paste", (e) => {
266
+ const files = [...(e.clipboardData?.files ?? [])];
267
+ if (files.length === 0 || this.disabled) return;
268
+ e.preventDefault();
269
+ this.#take(files);
270
+ });
271
+ // The whole host is the drop target, so a drag over the list counts too.
272
+ this.addEventListener("dragenter", (e) => this.#dragEnter(e));
273
+ this.addEventListener("dragover", (e) => this.#dragOver(e));
274
+ this.addEventListener("dragleave", () => {
275
+ if (--this.#depth <= 0) this.#dragEnd();
276
+ });
277
+ this.addEventListener("drop", (e) => {
278
+ if (!this.#hasFiles(e)) return;
279
+ e.preventDefault();
280
+ this.#dragEnd();
281
+ if (!this.effectivelyDisabled) this.#take([...e.dataTransfer.files]);
282
+ });
283
+ // A row's × removes its file (with a collapse) instead of just the row.
284
+ this.$(".files").addEventListener("ray-remove", (e) => {
285
+ e.preventDefault();
286
+ const file = e.detail?.file;
287
+ if (file) this.removeFile(file);
288
+ });
289
+ }
290
+
291
+ /** The chosen files, in the order they were added. */
292
+ get files() {
293
+ return [...this.#files];
294
+ }
295
+
296
+ /** The paths of the files the native dialog chose (see `native`). */
297
+ get paths() {
298
+ return this.#files.filter((f) => f instanceof HostFile).map((f) => f.path);
299
+ }
300
+
301
+ /**
302
+ * What the last change event changed: { added, removed }. null before the
303
+ * first one, and after assigning `files` (which fires nothing).
304
+ */
305
+ get lastChange() {
306
+ return this.#lastChange;
307
+ }
308
+
309
+ set files(list) {
310
+ const next = [...(list ?? [])];
311
+ this.#lastChange = null;
312
+ for (const f of this.#files) if (!next.includes(f)) this.#dropRow(f, false);
313
+ this.#files = next;
314
+ this.#rejected = "";
315
+ this.#renderRows();
316
+ this.requestUpdate();
317
+ }
318
+
319
+ /** Opens the system file picker (the native dialog with `native` and a host). */
320
+ open() {
321
+ if (this.effectivelyDisabled) return;
322
+ if (this.native && hostCan("pick_files")) {
323
+ this.#pickNative();
324
+ return;
325
+ }
326
+ this.input.click();
327
+ }
328
+
329
+ async #pickNative() {
330
+ try {
331
+ const files = await getHost().pickFiles({
332
+ title: this.label ?? "",
333
+ multiple: Boolean(this.multiple),
334
+ filters: acceptFilters(this.accept, describeAccept(this.accept)),
335
+ });
336
+ if (files.length) this.#take(files);
337
+ } catch (e) {
338
+ this.emit("ray-error", { error: e });
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Adds files as if they were dropped: checks them, fires ray-reject for the
344
+ * refused ones and change for the rest. Returns both lists.
345
+ */
346
+ addFiles(list) {
347
+ return this.#take([...(list ?? [])]);
348
+ }
349
+
350
+ /** Removes a file (fires change). */
351
+ removeFile(file) {
352
+ const i = this.#files.indexOf(file);
353
+ if (i < 0) return false;
354
+ this.#files.splice(i, 1);
355
+ this.#dropRow(file, true);
356
+ this.#rejected = "";
357
+ this.requestUpdate();
358
+ this.#changed([], [file]);
359
+ return true;
360
+ }
361
+
362
+ /** Removes every file (fires change if there were any). */
363
+ clear() {
364
+ const removed = this.#files;
365
+ if (removed.length === 0) return;
366
+ this.#files = [];
367
+ for (const f of removed) this.#dropRow(f, false);
368
+ this.#rejected = "";
369
+ this.requestUpdate();
370
+ this.#changed([], removed);
371
+ }
372
+
373
+ /** The <ray-attachment> row of a file: set its progress, status and error. */
374
+ attachmentFor(file) {
375
+ return this.#rows.get(file) ?? null;
376
+ }
377
+
378
+ formResetCallback() {
379
+ this.files = [];
380
+ }
381
+
382
+ /** Effective limit of files: 1 without `multiple`. */
383
+ get #limit() {
384
+ if (!this.multiple) return 1;
385
+ return this.maxFiles > 0 ? Math.floor(this.maxFiles) : Infinity;
386
+ }
387
+
388
+ #take(incoming) {
389
+ const accepted = [];
390
+ const rejected = [];
391
+ const max = parseSize(this.maxSize);
392
+ for (const file of incoming) {
393
+ if (!matchesAccept(file, this.accept)) {
394
+ rejected.push({ file, reason: "type", message: `${file.name} isn't an accepted file type.` });
395
+ } else if (max > 0 && file.size > max) {
396
+ rejected.push({ file, reason: "size", message: `${file.name} is larger than ${formatBytes(max)}.` });
397
+ } else {
398
+ accepted.push(file);
399
+ }
400
+ }
401
+ const removed = [];
402
+ if (!this.multiple) {
403
+ // One file: the newest valid one replaces what was there.
404
+ accepted.splice(0, accepted.length - 1).forEach((file) =>
405
+ rejected.push({ file, reason: "count", message: "Only one file can be added." }));
406
+ if (accepted.length) removed.push(...this.#files);
407
+ } else {
408
+ const room = this.#limit - this.#files.length;
409
+ if (accepted.length > room) {
410
+ const n = this.#limit;
411
+ for (const file of accepted.splice(Math.max(0, room))) {
412
+ rejected.push({ file, reason: "count", message: `Up to ${n} ${n === 1 ? "file" : "files"} can be added.` });
413
+ }
414
+ }
415
+ }
416
+ for (const f of removed) this.#dropRow(f, false);
417
+ this.#files = this.#files.filter((f) => !removed.includes(f)).concat(accepted);
418
+ // Explain refusals, one sentence per reason.
419
+ this.#rejected = [...new Set(rejected.map((r) => r.message))].join(" ");
420
+ if (rejected.length) this.emit("ray-reject", { rejected });
421
+ this.#renderRows();
422
+ this.requestUpdate();
423
+ if (accepted.length || removed.length) this.#changed(accepted, removed);
424
+ return { accepted, rejected };
425
+ }
426
+
427
+ #changed(added, removed) {
428
+ this.#lastChange = { added, removed };
429
+ this.dispatchEvent(new Event("change", { bubbles: true }));
430
+ }
431
+
432
+ #renderRows() {
433
+ const box = this.$(".files");
434
+ for (const file of this.#files) {
435
+ if (this.#rows.has(file)) continue;
436
+ const row = document.createElement("ray-attachment");
437
+ row.setAttribute("role", "listitem");
438
+ row.removable = true;
439
+ row.file = file;
440
+ if (this.list === "cards") row.variant = "card";
441
+ this.#rows.set(file, row);
442
+ box.append(row);
443
+ }
444
+ }
445
+
446
+ async #dropRow(file, animate) {
447
+ const row = this.#rows.get(file);
448
+ if (!row) return;
449
+ this.#rows.delete(file);
450
+ if (animate) {
451
+ const out = collapseOut(row);
452
+ await out.finished;
453
+ }
454
+ row.remove();
455
+ }
456
+
457
+ #hasFiles(e) {
458
+ return [...(e.dataTransfer?.types ?? [])].includes("Files");
459
+ }
460
+
461
+ #dragEnter(e) {
462
+ if (!this.#hasFiles(e) || this.effectivelyDisabled) return;
463
+ e.preventDefault();
464
+ this.#depth++;
465
+ this.setAttribute("data-dragging", "");
466
+ this.#refusal = this.#wouldReject(e.dataTransfer);
467
+ this.toggleAttribute("data-reject", Boolean(this.#refusal));
468
+ this.requestUpdate();
469
+ }
470
+
471
+ #dragOver(e) {
472
+ if (!this.#hasFiles(e)) return;
473
+ e.preventDefault();
474
+ e.dataTransfer.dropEffect = this.effectivelyDisabled || this.hasAttribute("data-reject") ? "none" : "copy";
475
+ }
476
+
477
+ #dragEnd() {
478
+ this.#depth = 0;
479
+ this.removeAttribute("data-dragging");
480
+ this.removeAttribute("data-reject");
481
+ this.requestUpdate();
482
+ }
483
+
484
+ // During a drag only the MIME types and the count are known (no names or
485
+ // sizes): refuse early only what is certainly refused. Returns the title
486
+ // to show, "" when the drop may go through.
487
+ #wouldReject(dt) {
488
+ const items = [...(dt?.items ?? [])].filter((i) => i.kind === "file");
489
+ if (items.length === 0) return "";
490
+ if (this.multiple && items.length > this.#limit - this.#files.length) {
491
+ const room = Math.max(0, this.#limit - this.#files.length);
492
+ return room === 0 ? "No more files can be added" : `Only ${room} more ${room === 1 ? "file" : "files"}`;
493
+ }
494
+ const list = acceptList(this.accept);
495
+ if (list.length === 0 || list.some((a) => a.startsWith("."))) return "";
496
+ const refused = items.every((i) => i.type && !list.some((a) => matchesType(i.type.toLowerCase(), a)));
497
+ return refused ? "File type not allowed" : "";
498
+ }
499
+
500
+ #autoHint() {
501
+ const parts = [];
502
+ const types = describeAccept(this.accept);
503
+ if (types) parts.push(types);
504
+ const max = parseSize(this.maxSize);
505
+ if (max > 0) parts.push(`up to ${formatBytes(max)}${this.multiple ? " each" : ""}`);
506
+ if (this.multiple && this.maxFiles > 0) parts.push(`max. ${Math.floor(this.maxFiles)} files`);
507
+ return parts.join(" · ");
508
+ }
509
+
510
+ update() {
511
+ applyColor(this, this.color);
512
+ const label = this.$(".label");
513
+ label.hidden = !this.label;
514
+ this.$(".label-text").textContent = this.label ?? "";
515
+ this.$(".required").hidden = !this.required;
516
+ const desc = this.$("#description");
517
+ desc.hidden = !this.description;
518
+ desc.textContent = this.description ?? "";
519
+
520
+ // Title: "Drag files here or browse"; while dragging, what a drop does.
521
+ const title = this.$(".title");
522
+ const dragging = this.hasAttribute("data-dragging");
523
+ const refused = dragging && this.hasAttribute("data-reject");
524
+ if (refused) {
525
+ title.textContent = this.#refusal || "File type not allowed";
526
+ } else if (dragging) {
527
+ title.textContent = this.activeLabel;
528
+ } else {
529
+ const or = document.createElement("span");
530
+ or.className = "browse-or";
531
+ or.append(" or ");
532
+ const browse = document.createElement("span");
533
+ browse.className = "browse";
534
+ browse.textContent = this.browseLabel;
535
+ or.append(browse);
536
+ const drop = document.createElement("span");
537
+ drop.className = "drop";
538
+ drop.textContent = this.dropLabel;
539
+ const tap = document.createElement("span");
540
+ tap.className = "tap";
541
+ tap.textContent = this.tapLabel;
542
+ title.replaceChildren(drop, or, tap);
543
+ }
544
+ this.$(".hint").textContent = this.hint ?? this.#autoHint();
545
+ const word = this.browseLabel ?? "browse";
546
+ this.$(".button").textContent = word.charAt(0).toUpperCase() + word.slice(1);
547
+
548
+ const input = this.input;
549
+ input.multiple = Boolean(this.multiple);
550
+ if (this.accept) input.accept = this.accept;
551
+ else input.removeAttribute("accept");
552
+ if (this.name) input.name = this.name;
553
+
554
+ const disabled = this.effectivelyDisabled;
555
+ this.zone.disabled = disabled;
556
+ for (const row of this.#rows.values()) row.disabled = disabled;
557
+
558
+ // The app's error wins over the explanation of refused files.
559
+ const message = this.error || this.#rejected;
560
+ const err = this.$("#error");
561
+ err.hidden = !message;
562
+ err.textContent = message;
563
+ this.zone.toggleAttribute("data-invalid", Boolean(message));
564
+ if (message) this.zone.setAttribute("aria-invalid", "true");
565
+ else this.zone.removeAttribute("aria-invalid");
566
+ // Without a visible label, the host's aria-label names the zone.
567
+ const aria = this.getAttribute("aria-label");
568
+ // The name holds every visible word of the zone (WCAG 2.5.3, label in name).
569
+ this.zone.setAttribute("aria-labelledby", this.label ? "label title hint pill" : "title hint pill");
570
+ if (!this.label && aria) this.zone.setAttribute("aria-label", aria);
571
+ else this.zone.removeAttribute("aria-label");
572
+
573
+ // Form value: every file under `name`.
574
+ if (this.name && this.#files.length) {
575
+ const data = new FormData();
576
+ for (const f of this.#files) data.append(this.name, f instanceof HostFile ? f.path : f);
577
+ this.internals.setFormValue(data);
578
+ } else {
579
+ this.internals.setFormValue(null);
580
+ }
581
+ if (this.required && this.#files.length === 0) {
582
+ this.internals.setValidity({ valueMissing: true }, "Select at least one file.", this.zone);
583
+ } else {
584
+ this.internals.setValidity({});
585
+ }
586
+ }
587
+ }
588
+
589
+ define("ray-file-upload", RayFileUpload);
@@ -0,0 +1,96 @@
1
+ // <ray-grid>: a responsive grid of its children.
2
+ //
3
+ // cols="1 sm:2 lg:4": a count, then counts from a width up (xs 36em,
4
+ // sm 48em, md 62em, lg 75em, xl 88em). The widths are the grid's own, not
5
+ // the window's (container queries): the same grid in a narrow column or a
6
+ // phone-sized frame drops to fewer columns.
7
+ // min-col-width="14rem": instead, as many columns of at least that width as
8
+ // fit (auto-fill).
9
+ // A child spans columns with data-span="2" (or "full"), or plain CSS.
10
+ //
11
+ // It measures its own width (an inline-size container), so it needs one
12
+ // from its parent: in a row (a ray-group, any flex row) give it a width or
13
+ // let it grow.
14
+
15
+ import { RayElement, css, define } from "../../core/core.js";
16
+ import { spaceValue } from "../../core/styles.js";
17
+
18
+ export const BREAKPOINTS = { xs: "36em", sm: "48em", md: "62em", lg: "75em", xl: "88em" };
19
+ const ORDER = ["xs", "sm", "md", "lg", "xl"];
20
+
21
+ // Each width step falls back to the step below it, then to the base count.
22
+ const fallback = (i) => ORDER.slice(0, i + 1).reduce((acc, bp) => `var(--_cols-${bp}, ${acc})`, "var(--_cols)");
23
+ const queries = ORDER.map((bp, i) => `@container ray-grid (min-width: ${BREAKPOINTS[bp]}) { .grid:not([data-auto]) { grid-template-columns: repeat(${fallback(i)}, minmax(0, 1fr)); } }`).join("\n");
24
+ const spans = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((n) => `::slotted([data-span="${n}"]) { grid-column: span ${n}; }`).join("\n");
25
+
26
+ const styles = css`
27
+ :host {
28
+ --_gap: var(--ray-space-md);
29
+ --_cols: 1;
30
+ display: block;
31
+ container: ray-grid / inline-size;
32
+ min-width: 0;
33
+ }
34
+ :host([hidden]) { display: none; }
35
+ .grid {
36
+ display: grid;
37
+ gap: var(--_row-gap, var(--_gap)) var(--_gap);
38
+ grid-template-columns: repeat(var(--_cols), minmax(0, 1fr));
39
+ align-items: var(--_align, stretch);
40
+ }
41
+ .grid[data-auto] { grid-template-columns: repeat(auto-fill, minmax(min(var(--_min), 100%), 1fr)); }
42
+ ${queries}
43
+ ${spans}
44
+ ::slotted([data-span="full"]) { grid-column: 1 / -1; }
45
+ `;
46
+
47
+ /**
48
+ * "1 sm:2 lg:4" (or "3") as { base, sm: 2, lg: 4 }; invalid parts are skipped.
49
+ * @param {string | number | undefined} value
50
+ */
51
+ export function parseCols(value) {
52
+ const out = {};
53
+ for (const part of String(value ?? "").trim().split(/\s+/).filter(Boolean)) {
54
+ const m = part.match(/^(?:(xs|sm|md|lg|xl):)?(\d+)$/);
55
+ if (!m || Number(m[2]) < 1) continue;
56
+ out[m[1] ?? "base"] = Number(m[2]);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const ALIGN = { start: "start", center: "center", end: "end", stretch: "stretch" };
62
+
63
+ export class RayGrid extends RayElement {
64
+ static properties = {
65
+ cols: { type: String, reflect: true, default: "1" },
66
+ minColWidth: { type: String, attribute: "min-col-width" },
67
+ gap: { type: String, reflect: true, default: "md" },
68
+ rowGap: { type: String, attribute: "row-gap" },
69
+ align: { type: String, reflect: true, default: "stretch" },
70
+ };
71
+ static styles = [styles];
72
+ static template = `<div class="grid" part="grid"><slot></slot></div>`;
73
+
74
+ update() {
75
+ const grid = this.$(".grid");
76
+ const cols = parseCols(this.cols);
77
+ this.style.setProperty("--_cols", String(cols.base ?? 1));
78
+ for (const bp of ORDER) {
79
+ if (cols[bp]) this.style.setProperty(`--_cols-${bp}`, String(cols[bp]));
80
+ else this.style.removeProperty(`--_cols-${bp}`);
81
+ }
82
+ const min = spaceValue(this.minColWidth);
83
+ grid.toggleAttribute("data-auto", Boolean(min));
84
+ if (min) this.style.setProperty("--_min", min);
85
+ else this.style.removeProperty("--_min");
86
+ const gap = spaceValue(this.gap);
87
+ if (gap) this.style.setProperty("--_gap", gap);
88
+ else this.style.removeProperty("--_gap");
89
+ const row = spaceValue(this.rowGap);
90
+ if (row) this.style.setProperty("--_row-gap", row);
91
+ else this.style.removeProperty("--_row-gap");
92
+ this.style.setProperty("--_align", ALIGN[this.align] ?? "stretch");
93
+ }
94
+ }
95
+
96
+ define("ray-grid", RayGrid);