@skra/ui 0.6.0 → 0.8.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,894 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Directive, inject, Injectable, ElementRef, output, signal, computed, effect, afterNextRender, ViewEncapsulation, ChangeDetectionStrategy, Component, DOCUMENT, contentChild, contentChildren, DestroyRef, input } from '@angular/core';
3
+ import { IdGeneratorService, SkraFieldContext } from '@skra/ui/field';
4
+
5
+ // Slot markers of the FileUpload family (ADR 020 Smart Slots v2.0). Each is detected
6
+ // through a Signal Query so the owning component renders a slot's wrapper only when the
7
+ // slot is filled — never an empty node. Entry-local (the Button / Accordion precedent)
8
+ // to avoid a cross-entry marker dependency. Import the markers you use alongside the
9
+ // component: projection works on the attribute alone, but slot DETECTION needs the class.
10
+ // ─── <skra-file-upload> ──────────────────────────────────────────────────────
11
+ /** Leading icon of `<skra-file-upload>`'s face. Decorative — the consumer marks it `aria-hidden`. */
12
+ class SkraFileUploadStart {
13
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadStart, deps: [], target: i0.ɵɵFactoryTarget.Directive });
14
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileUploadStart, isStandalone: true, selector: "[skra-start]", ngImport: i0 });
15
+ }
16
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadStart, decorators: [{
17
+ type: Directive,
18
+ args: [{
19
+ selector: '[skra-start]',
20
+ standalone: true,
21
+ }]
22
+ }] });
23
+ /**
24
+ * REQUIRED. The visible action text of `<skra-file-upload>` ("Choose a video"). It joins the
25
+ * native control's accessible name through `aria-labelledby` (ADR 0056 D4, WCAG 2.5.3), so a
26
+ * voice-control user can say what they see.
27
+ */
28
+ class SkraFileUploadAction {
29
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadAction, deps: [], target: i0.ɵɵFactoryTarget.Directive });
30
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileUploadAction, isStandalone: true, selector: "[skra-file-upload-action]", ngImport: i0 });
31
+ }
32
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadAction, decorators: [{
33
+ type: Directive,
34
+ args: [{
35
+ selector: '[skra-file-upload-action]',
36
+ standalone: true,
37
+ }]
38
+ }] });
39
+ /**
40
+ * The drop instruction ("or drag it here"). Its presence switches the face from the compact
41
+ * row to the drop-zone layout — an instruction is what invites dragging. Hidden on devices
42
+ * with no fine pointer, where "drag" means nothing.
43
+ */
44
+ class SkraFileUploadInstruction {
45
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadInstruction, deps: [], target: i0.ɵɵFactoryTarget.Directive });
46
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileUploadInstruction, isStandalone: true, selector: "[skra-file-upload-instruction]", ngImport: i0 });
47
+ }
48
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadInstruction, decorators: [{
49
+ type: Directive,
50
+ args: [{
51
+ selector: '[skra-file-upload-instruction]',
52
+ standalone: true,
53
+ }]
54
+ }] });
55
+ /**
56
+ * The selection summary in the page's language ("recording.mp4, 2.3 GB"). Rendered inside the
57
+ * composite's always-present `role="status"` region and linked to the control as a description,
58
+ * so a change is announced and the user agent's browser-language status is not the only one.
59
+ */
60
+ class SkraFileUploadStatus {
61
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadStatus, deps: [], target: i0.ɵɵFactoryTarget.Directive });
62
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileUploadStatus, isStandalone: true, selector: "[skra-file-upload-status]", ngImport: i0 });
63
+ }
64
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadStatus, decorators: [{
65
+ type: Directive,
66
+ args: [{
67
+ selector: '[skra-file-upload-status]',
68
+ standalone: true,
69
+ }]
70
+ }] });
71
+ // ─── li[skraFileItem] ────────────────────────────────────────────────────────
72
+ /** Leading visual of a file row — an icon or a consumer-made thumbnail (`URL.createObjectURL`). */
73
+ class SkraFileItemStart {
74
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemStart, deps: [], target: i0.ɵɵFactoryTarget.Directive });
75
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileItemStart, isStandalone: true, selector: "[skra-start]", ngImport: i0 });
76
+ }
77
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemStart, decorators: [{
78
+ type: Directive,
79
+ args: [{
80
+ selector: '[skra-start]',
81
+ standalone: true,
82
+ }]
83
+ }] });
84
+ /** Row detail under the name — typically `progress[skraProgress]` or a status line. */
85
+ class SkraFileItemDetail {
86
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemDetail, deps: [], target: i0.ɵɵFactoryTarget.Directive });
87
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileItemDetail, isStandalone: true, selector: "[skra-file-item-detail]", ngImport: i0 });
88
+ }
89
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemDetail, decorators: [{
90
+ type: Directive,
91
+ args: [{
92
+ selector: '[skra-file-item-detail]',
93
+ standalone: true,
94
+ }]
95
+ }] });
96
+ /**
97
+ * The row's error text. Its presence is what paints the row invalid
98
+ * (`data-skra-validation="invalid"`), so the colour never appears without the words
99
+ * (WCAG 1.4.1 by construction).
100
+ */
101
+ class SkraFileItemError {
102
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemError, deps: [], target: i0.ɵɵFactoryTarget.Directive });
103
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileItemError, isStandalone: true, selector: "[skra-file-item-error]", ngImport: i0 });
104
+ }
105
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemError, decorators: [{
106
+ type: Directive,
107
+ args: [{
108
+ selector: '[skra-file-item-error]',
109
+ standalone: true,
110
+ }]
111
+ }] });
112
+ /** Trailing row actions — the consumer's `button[skraButton]`s (cancel / retry / remove). */
113
+ class SkraFileItemEnd {
114
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemEnd, deps: [], target: i0.ɵɵFactoryTarget.Directive });
115
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileItemEnd, isStandalone: true, selector: "[skra-end]", ngImport: i0 });
116
+ }
117
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItemEnd, decorators: [{
118
+ type: Directive,
119
+ args: [{
120
+ selector: '[skra-end]',
121
+ standalone: true,
122
+ }]
123
+ }] });
124
+
125
+ // The two "How" helpers of the FileUpload family — pure, framework-free and SSR-safe (no
126
+ // DOM access; `File` is used as a type and read through `name` / `type` only). Every
127
+ // consumer otherwise reimplements them, and each has a subtle edge that is easy to get
128
+ // wrong: 1000 vs 1024, a rounded value that crosses a unit boundary, the HTML `accept`
129
+ // grammar, and files the platform reports with no MIME type at all.
130
+ /** SI units, 1 kB = 1000 B — the `Intl` unit identifiers, and what macOS / iOS show. */
131
+ const UNITS = [
132
+ 'byte',
133
+ 'kilobyte',
134
+ 'megabyte',
135
+ 'gigabyte',
136
+ 'terabyte',
137
+ 'petabyte',
138
+ ];
139
+ /**
140
+ * Format a byte count for display in `locale` (`undefined` → the host default, the ADR 0050
141
+ * §5 precedent): `2,3 GB` in `pl`, `2.3 GB` in `en`, one fractional digit at most.
142
+ *
143
+ * SI units (1 kB = 1000 B) because `Intl` names only those and macOS / iOS report sizes the
144
+ * same way. The unit label is `Intl`'s own short form, so it is translated and pluralised by
145
+ * the platform's locale data, never by Skra (e.g. `999 byte` in English, `999 B` in Polish —
146
+ * CLDR's choice, not ours). A multi-GB `File.size` is a safe integer; nothing here rounds
147
+ * the input.
148
+ *
149
+ * @throws RangeError for a negative or non-finite count — a size that cannot exist is a bug
150
+ * in the caller, and printing `NaN byte` would hide it.
151
+ */
152
+ function skraFormatFileSize(bytes, locale) {
153
+ if (!Number.isFinite(bytes) || bytes < 0) {
154
+ throw new RangeError(`skraFormatFileSize: expected a finite, non-negative byte count, got ${bytes}.`);
155
+ }
156
+ let index = 0;
157
+ while (index < UNITS.length - 1 && bytes >= 1000 ** (index + 1))
158
+ index += 1;
159
+ // A value that ROUNDS up to 1000 of its unit reads as the next unit: 999 950 B is
160
+ // "1 MB", not "1,000 kB".
161
+ if (index < UNITS.length - 1 &&
162
+ Math.round((bytes / 1000 ** index) * 10) / 10 >= 1000) {
163
+ index += 1;
164
+ }
165
+ return new Intl.NumberFormat(locale, {
166
+ style: 'unit',
167
+ unit: UNITS[index],
168
+ unitDisplay: 'short',
169
+ maximumFractionDigits: 1,
170
+ }).format(bytes / 1000 ** index);
171
+ }
172
+ function parseAccept(accept) {
173
+ const tokens = [];
174
+ for (const raw of accept.split(',')) {
175
+ const token = raw.trim().toLowerCase();
176
+ if (token.length > 1 && token.startsWith('.')) {
177
+ tokens.push({ kind: 'extension', value: token });
178
+ continue;
179
+ }
180
+ // `type/subtype` or `type/*`; parameters (`;charset=…`) never take part in a match.
181
+ const mime = token.split(';')[0].trim();
182
+ const slash = mime.indexOf('/');
183
+ if (slash <= 0 || slash === mime.length - 1)
184
+ continue;
185
+ const subtype = mime.slice(slash + 1);
186
+ if (subtype === '*') {
187
+ tokens.push({ kind: 'wildcard', value: mime.slice(0, slash + 1) });
188
+ }
189
+ else {
190
+ tokens.push({ kind: 'mime', value: mime });
191
+ }
192
+ }
193
+ return tokens;
194
+ }
195
+ /**
196
+ * Does `file` satisfy `accept`, under the HTML attribute's own semantics?
197
+ *
198
+ * - `.ext` — a case-insensitive suffix of the file NAME;
199
+ * - `type/subtype` — the file's MIME type, case-insensitive, parameters ignored;
200
+ * - `type/*` — any subtype of `type`.
201
+ *
202
+ * An empty `accept`, or one with no token the grammar recognises, accepts everything — the
203
+ * picker applies no filter in that case either.
204
+ *
205
+ * **Typeless files** (`File.type === ''` — common for `.mkv` / `.heic` on some engines) are
206
+ * matched by extension tokens only, and PASS when `accept` has no extension token (spec Q-5).
207
+ * A false rejection of a legitimate 4 GB recording costs more than a junk file the consumer
208
+ * and the server reject anyway.
209
+ *
210
+ * `accept` is never a guarantee: every platform lets a user bypass it. Validate on the server.
211
+ */
212
+ function skraFileMatchesAccept(file, accept) {
213
+ const tokens = parseAccept(accept);
214
+ if (!tokens.length)
215
+ return true;
216
+ const name = file.name.toLowerCase();
217
+ const type = file.type.trim().toLowerCase().split(';')[0].trim();
218
+ const extensions = tokens.filter((t) => t.kind === 'extension');
219
+ if (extensions.some((t) => name.endsWith(t.value)))
220
+ return true;
221
+ if (!type)
222
+ return extensions.length === 0;
223
+ return tokens.some((t) => (t.kind === 'mime' && t.value === type) ||
224
+ (t.kind === 'wildcard' && type.startsWith(t.value)));
225
+ }
226
+
227
+ /**
228
+ * Internal — NOT part of the public barrel. Partition one selection into accepted and
229
+ * rejected files, enforcing exactly what the platform's own picker enforces and nothing more
230
+ * (size, file count caps and duplicates are product policy — the consumer's, in `filesChange`).
231
+ *
232
+ * Precedence, one reason per file:
233
+ * 1. `directory` — a dropped folder is never a file;
234
+ * 2. `count` — more than one file on a control without `multiple` rejects ALL of them: a
235
+ * single-file control never silently keeps one of N (spec Q-6);
236
+ * 3. `type` — the file does not satisfy `accept` (`skraFileMatchesAccept`, typeless → Q-5).
237
+ *
238
+ * Order is preserved within both lists.
239
+ */
240
+ function partitionFiles(candidates, constraints) {
241
+ const accepted = [];
242
+ const rejected = [];
243
+ const tooMany = !constraints.multiple &&
244
+ candidates.filter((c) => !c.isDirectory).length > 1;
245
+ for (const { file, isDirectory } of candidates) {
246
+ if (isDirectory)
247
+ rejected.push({ file, reason: 'directory' });
248
+ else if (tooMany)
249
+ rejected.push({ file, reason: 'count' });
250
+ else if (skraFileMatchesAccept(file, constraints.accept))
251
+ accepted.push(file);
252
+ else
253
+ rejected.push({ file, reason: 'type' });
254
+ }
255
+ return { accepted, rejected };
256
+ }
257
+
258
+ /**
259
+ * Internal — provided by `<skra-file-upload>` per instance, injected `{ optional: true }` by
260
+ * the projected `input[skraFileInput]` (ADR 013 §3.6 contextual wiring). Carries ids only —
261
+ * never a value, never state (ADR 037; the composite owns no state, ADR 027 §3.8).
262
+ *
263
+ * Its PRESENCE is what puts the input into overlay mode (`data-skra-in-upload`, ADR 027
264
+ * §3.9b): Emulated encapsulation lets no parent style projected content, and DI keeps the
265
+ * rule in the atom's own stylesheet instead of a global slice (no O-8 bridge cost).
266
+ */
267
+ class SkraFileUploadContext {
268
+ base = inject(IdGeneratorService).nextId('skra-file-upload');
269
+ /** The face's visible action text — the second half of the control's name (ADR 0056 D4). */
270
+ actionId = `${this.base}-action`;
271
+ /** The always-present status region — a description of the control, never its name. */
272
+ statusId = `${this.base}-status`;
273
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadContext, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
274
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadContext });
275
+ }
276
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUploadContext, decorators: [{
277
+ type: Injectable
278
+ }] });
279
+
280
+ /** True when a drag carries files from the OS — not text, not a link, not an in-page node. */
281
+ function carriesFiles(event) {
282
+ const types = event.dataTransfer?.types;
283
+ return !!types && Array.from(types).includes('Files');
284
+ }
285
+ /**
286
+ * `input[type="file"][skraFileInput]` — the native file control (spec
287
+ * `component-file-upload-spec.md`; ADR 0056 Group B). A Component-on-a-native-host
288
+ * (ADR 040): `<input>` is void, so `template: ''`, and `file-input.scss` is scoped to the
289
+ * host by Emulated encapsulation — the box from `comp.field.*`, the user agent's
290
+ * `::file-selector-button` painted from `comp.field.file.*` (ADR 0056 B1–B5).
291
+ *
292
+ * **No inputs.** Every constraint is a NATIVE attribute on the consumer's own host —
293
+ * `accept`, `multiple`, `capture`, `required`, `disabled`, `name`, `webkitdirectory`
294
+ * (native-attr-first, Iron Boundary #9). Skra reads them at the moment of a selection; it
295
+ * never mirrors them.
296
+ *
297
+ * **Agnostic value (ADR 037).** The selection is REPORTED, never held: `filesChange` carries
298
+ * the normalised files, `filesRejected` what the normalisation turned down. Native
299
+ * `(change)` keeps working and reports the RAW selection; `filesChange` is the normalised one.
300
+ *
301
+ * **Normalisation** — Skra enforces exactly what the platform's own picker enforces, on
302
+ * every path, and nothing more (size / count caps / duplicates are product policy):
303
+ *
304
+ * | Rule | Picker (`change`) | Drop (`drop` on the host) |
305
+ * | --- | --- | --- |
306
+ * | `accept` mismatch → `type` | possible (Windows "All files", Android) | the browser applies no `accept` to drops |
307
+ * | >1 file without `multiple` → `count` | impossible | possible — ALL rejected |
308
+ * | a directory → `directory` | impossible | possible |
309
+ * | assignment | `input.files` left as the platform set it | `input.files` = the ACCEPTED files, then `input` + `change` are dispatched |
310
+ *
311
+ * A drop where nothing is accepted leaves the existing selection untouched and emits only
312
+ * `filesRejected` — like a cancelled picker, it is not a change.
313
+ *
314
+ * **Field wiring (ADR 013 §3.7)** — the `skraInput` wiring verbatim: optional
315
+ * `SkraFieldContext`; `id` / `disabled` written from guarded `effect()`s so a standalone
316
+ * consumer's own attributes survive; `aria-describedby` / `aria-invalid` / `aria-required`
317
+ * host bindings. Readonly: the native `readonly` attribute does not apply to `type="file"`
318
+ * (HTML) and `aria-readonly` is not exposed for its `button` role (G-4, measured), so it is
319
+ * `aria-disabled="true"` — the focusable soft-disable — plus a behaviour guard that blocks the
320
+ * picker (`click`, keys other than `Tab`) and drops.
321
+ *
322
+ * **Known limitation (G-4, measured in Chromium):** "required" cannot reach assistive
323
+ * technology on this control — neither `aria-required` (kept: harmless, and the field's
324
+ * contract) nor native `required` is exposed for the `button` role. Say "required" in visible
325
+ * text (the label or the hint) as well.
326
+ *
327
+ * **Inside `<skra-file-upload>`** (ADR 0056 Group D) the composite's context is injected:
328
+ * the host reflects `data-skra-in-upload` (ADR 027 §3.9b) and its own stylesheet makes it a
329
+ * transparent overlay over the painted face (`opacity: 0` only — D2); its name becomes
330
+ * "field label + visible action text" via `aria-labelledby` (D4, WCAG 2.5.3) and the
331
+ * composite's status region joins its description.
332
+ *
333
+ * <skra-field>
334
+ * <label skraLabelFor>Session recording</label>
335
+ * <input skraFileInput type="file" accept="video/*" #picker (filesChange)="onFiles($event)" />
336
+ * </skra-field>
337
+ */
338
+ class SkraFileInput {
339
+ el = inject(ElementRef);
340
+ context = inject(SkraFieldContext, { optional: true });
341
+ upload = inject(SkraFileUploadContext, { optional: true });
342
+ /** User picked or dropped files. Fires on user interaction only, after normalisation. */
343
+ filesChange = output();
344
+ /** Files the normalisation rejected. Fires only when at least one was rejected. */
345
+ filesRejected = output();
346
+ inUpload = this.upload !== null;
347
+ dragOver = signal(false, /* @ts-ignore */
348
+ ...(ngDevMode ? [{ debugName: "dragOver" }] : /* istanbul ignore next */ []));
349
+ // dragenter / dragleave also fire when the pointer crosses into the user agent's own
350
+ // button inside the control (retargeted to the host), and Safari reports a null
351
+ // `relatedTarget` on every dragleave — so a depth counter, not relatedTarget, decides
352
+ // when the drag has really left (spec G-5).
353
+ dragDepth = 0;
354
+ softDisabled = computed(() => !!this.context?.isReadonly() && !this.context.isDisabled(), /* @ts-ignore */
355
+ ...(ngDevMode ? [{ debugName: "softDisabled" }] : /* istanbul ignore next */ []));
356
+ describedBy = computed(() => {
357
+ const ids = [this.context?.describedByIds(), this.upload?.statusId].filter(Boolean);
358
+ return ids.length ? ids.join(' ') : null;
359
+ }, /* @ts-ignore */
360
+ ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
361
+ // ADR 0056 D4 — field label first, the face's action text second; the action alone when
362
+ // there is no field. Outside the composite the name is the consumer's `<label>` (Path 1).
363
+ labelledBy = computed(() => {
364
+ if (!this.upload)
365
+ return null;
366
+ return [this.context?.labelId(), this.upload.actionId]
367
+ .filter(Boolean)
368
+ .join(' ');
369
+ }, /* @ts-ignore */
370
+ ...(ngDevMode ? [{ debugName: "labelledBy" }] : /* istanbul ignore next */ []));
371
+ constructor() {
372
+ const el = this.el.nativeElement;
373
+ // Field-cascaded native properties — only bound when a field is present, so a
374
+ // standalone consumer keeps full control of its own `id` / `disabled` (ADR 013 §3.7).
375
+ if (this.context) {
376
+ const ctx = this.context;
377
+ effect(() => {
378
+ el.id = ctx.fieldId();
379
+ });
380
+ effect(() => {
381
+ el.disabled = ctx.isDisabled();
382
+ });
383
+ }
384
+ // Dev-only: inside the composite, `aria-labelledby` names the control and OVERRIDES a
385
+ // `<label for>` — so outside <skra-field> a consumer's own label would vanish from the
386
+ // name. A read of the rendered control, never a write.
387
+ if (this.upload &&
388
+ !this.context &&
389
+ (typeof ngDevMode === 'undefined' || ngDevMode)) {
390
+ afterNextRender(() => {
391
+ if (el.labels?.length) {
392
+ console.warn('[skraFileInput] inside <skra-file-upload> without <skra-field>, the control is named by ' +
393
+ 'its visible action alone (aria-labelledby, ADR 0056 D4) — which overrides your ' +
394
+ '<label for>. Wrap it in <skra-field> with label[skraLabelFor] to get "label + action".');
395
+ }
396
+ });
397
+ }
398
+ }
399
+ /**
400
+ * Reset the native selection (`value = ''`) so the same file can be picked again — the
401
+ * queue model (`filesChange` → enqueue → `clear()`). Programmatic: emits nothing.
402
+ * Reach it through a template reference: `#picker` on the host resolves to this component.
403
+ */
404
+ clear() {
405
+ this.el.nativeElement.value = '';
406
+ }
407
+ get blocked() {
408
+ return this.el.nativeElement.disabled || !!this.context?.isReadonly();
409
+ }
410
+ /**
411
+ * The native constraints, read at selection time. A `webkitdirectory` picker hands over
412
+ * EVERY file of the chosen folder whether or not `multiple` is set, so it counts as
413
+ * multiple — otherwise each folder pick would be rejected whole as `count`.
414
+ */
415
+ constraints() {
416
+ const el = this.el.nativeElement;
417
+ return { accept: el.accept, multiple: el.multiple || el.webkitdirectory };
418
+ }
419
+ /**
420
+ * The picker path — and the drop path's own dispatched `change`, already normalised.
421
+ *
422
+ * Order is part of the contract, the same on both paths: `filesChange` FIRST, then
423
+ * `filesRejected`. A consumer that clears its error on a new selection and sets it on a
424
+ * rejection then ends on the rejection message, not on a cleared one. A selection where
425
+ * EVERY file was rejected emits no `filesChange` at all (like a fully rejected drop): it
426
+ * changed nothing the consumer can use. An empty selection with no rejection (a picker
427
+ * that reports a cleared selection) still emits `filesChange([])`.
428
+ */
429
+ onChange() {
430
+ const el = this.el.nativeElement;
431
+ const { accepted, rejected } = partitionFiles(Array.from(el.files ?? [], (file) => ({ file, isDirectory: false })), this.constraints());
432
+ if (accepted.length || !rejected.length)
433
+ this.filesChange.emit(accepted);
434
+ if (rejected.length)
435
+ this.filesRejected.emit(rejected);
436
+ }
437
+ /** Readonly: `preventDefault` on `click` keeps the picker closed, for pointer and keyboard alike. */
438
+ blockWhenReadonly(event) {
439
+ if (this.context?.isReadonly())
440
+ event.preventDefault();
441
+ }
442
+ /** Readonly: block keyboard activation; let `Tab` through. */
443
+ blockKeyWhenReadonly(event) {
444
+ if (this.context?.isReadonly() && event.key !== 'Tab') {
445
+ event.preventDefault();
446
+ }
447
+ }
448
+ onDragEnter(event) {
449
+ if (!carriesFiles(event) || this.blocked)
450
+ return;
451
+ event.preventDefault();
452
+ this.dragDepth += 1;
453
+ this.dragOver.set(true);
454
+ }
455
+ onDragOver(event) {
456
+ if (!carriesFiles(event))
457
+ return;
458
+ // Always cancelled for a file drag: on a blocked control that is what stops the user
459
+ // agent's own drop handling (which would assign the files); `dropEffect` tells the
460
+ // user why nothing will happen.
461
+ event.preventDefault();
462
+ if (event.dataTransfer) {
463
+ event.dataTransfer.dropEffect = this.blocked ? 'none' : 'copy';
464
+ }
465
+ if (!this.blocked) {
466
+ // A dragover with no counted dragenter (the control became enabled mid-drag) still
467
+ // owes the leave that clears it.
468
+ if (this.dragDepth === 0)
469
+ this.dragDepth = 1;
470
+ this.dragOver.set(true);
471
+ }
472
+ }
473
+ onDragLeave() {
474
+ if (this.dragDepth === 0)
475
+ return;
476
+ this.dragDepth -= 1;
477
+ if (this.dragDepth === 0)
478
+ this.dragOver.set(false);
479
+ }
480
+ resetDrag() {
481
+ this.dragDepth = 0;
482
+ this.dragOver.set(false);
483
+ }
484
+ /** The drop path — normalise, then assign the ACCEPTED files and replay the native events. */
485
+ onDrop(event) {
486
+ if (!carriesFiles(event))
487
+ return;
488
+ event.preventDefault();
489
+ this.resetDrag();
490
+ if (this.blocked || !event.dataTransfer)
491
+ return;
492
+ const el = this.el.nativeElement;
493
+ const { accepted, rejected } = partitionFiles(this.candidatesOf(event.dataTransfer), this.constraints());
494
+ if (accepted.length) {
495
+ const transfer = new DataTransfer();
496
+ for (const file of accepted)
497
+ transfer.items.add(file);
498
+ el.files = transfer.files;
499
+ // The native event stream stays the one source of truth: consumers bound through
500
+ // `(change)`, `[formControl]` or `[(ngModel)]` see a drop exactly like a pick, and
501
+ // `onChange()` emits `filesChange` once, from the same place for both paths.
502
+ el.dispatchEvent(new Event('input', { bubbles: true }));
503
+ el.dispatchEvent(new Event('change', { bubbles: true }));
504
+ }
505
+ // Last, as on the picker path (see onChange).
506
+ if (rejected.length)
507
+ this.filesRejected.emit(rejected);
508
+ }
509
+ /**
510
+ * Directory detection has to happen INSIDE the `drop` handler: `webkitGetAsEntry()` is the
511
+ * only cross-engine signal (a dropped folder arrives as a `File` too), and the item list
512
+ * is emptied once the event returns.
513
+ */
514
+ candidatesOf(transfer) {
515
+ const items = transfer.items
516
+ ? Array.from(transfer.items).filter((i) => i.kind === 'file')
517
+ : [];
518
+ if (!items.length) {
519
+ return Array.from(transfer.files, (file) => ({
520
+ file,
521
+ isDirectory: false,
522
+ }));
523
+ }
524
+ const candidates = [];
525
+ for (const item of items) {
526
+ const file = item.getAsFile();
527
+ if (!file)
528
+ continue;
529
+ const entry = typeof item.webkitGetAsEntry === 'function'
530
+ ? item.webkitGetAsEntry()
531
+ : null;
532
+ candidates.push({ file, isDirectory: !!entry?.isDirectory });
533
+ }
534
+ return candidates;
535
+ }
536
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
537
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.1", type: SkraFileInput, isStandalone: true, selector: "input[type=\"file\"][skraFileInput]", outputs: { filesChange: "filesChange", filesRejected: "filesRejected" }, host: { listeners: { "change": "onChange()", "click": "blockWhenReadonly($event)", "keydown": "blockKeyWhenReadonly($event)", "dragenter": "onDragEnter($event)", "dragover": "onDragOver($event)", "dragleave": "onDragLeave()", "drop": "onDrop($event)", "dragend": "resetDrag()" }, properties: { "attr.aria-describedby": "describedBy()", "attr.aria-labelledby": "labelledBy()", "attr.aria-invalid": "context?.hasError() ? \"true\" : null", "attr.aria-required": "context?.isRequired() ? \"true\" : null", "attr.aria-disabled": "softDisabled() ? \"true\" : null", "attr.data-skra-dragover": "dragOver() ? \"\" : null", "attr.data-skra-in-upload": "inUpload ? \"\" : null" } }, ngImport: i0, template: '', isInline: true, styles: [":host{background:var(--skra-comp-field-background);color:var(--skra-comp-field-foreground);border-color:var(--skra-comp-field-border-color);border-width:var(--skra-comp-field-border-width);border-style:var(--skra-comp-field-border-style);border-radius:var(--skra-comp-field-radius);box-sizing:border-box;padding-block:calc(var(--skra-comp-field-padding-block) - var(--skra-comp-field-file-action-padding-block) - var(--skra-comp-field-file-action-border-width));min-block-size:calc(1lh + 2 * var(--skra-comp-field-padding-block) + 2 * var(--skra-comp-field-border-width));padding-inline:var(--skra-comp-field-padding-inline);box-shadow:var(--skra-comp-field-shadow);max-inline-size:100%;font:inherit;cursor:pointer;outline:none;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),border-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}@media(hover:hover)and (pointer:fine){:host(:hover:not(:disabled):not([aria-disabled=true])){background:var(--skra-comp-field-background-hover);border-color:var(--skra-comp-field-border-color-hover)}:host(:hover:not(:disabled):not([aria-disabled=true]))::file-selector-button{background:var(--skra-comp-field-file-action-background-hover)}}:host(:focus-visible){border-color:var(--skra-comp-field-focus-ring-color);outline:var(--skra-comp-field-focus-ring-width) solid var(--skra-comp-field-focus-ring-color);outline-offset:0}:host([data-skra-dragover]){background:var(--skra-comp-field-file-background-dragover);border-color:var(--skra-comp-field-file-border-color-dragover);color:var(--skra-comp-field-file-foreground-dragover)}:host(:disabled){cursor:not-allowed;opacity:var(--skra-sys-opacity-disabled)}:host([aria-disabled=true]){background:var(--skra-comp-field-background-readonly);border-color:var(--skra-comp-field-border-color-readonly);cursor:default}:host([aria-invalid=true]){border-color:var(--skra-comp-field-error-color)}:host([aria-invalid=true]:focus-visible){outline-color:var(--skra-comp-field-error-color)}:host::file-selector-button{background:var(--skra-comp-field-file-action-background);color:var(--skra-comp-field-file-action-foreground);border-color:var(--skra-comp-field-file-action-border-color);border-width:var(--skra-comp-field-file-action-border-width);border-style:var(--skra-comp-field-file-action-border-style);border-radius:var(--skra-comp-field-file-action-radius);padding-block:var(--skra-comp-field-file-action-padding-block);padding-inline:var(--skra-comp-field-file-action-padding-inline);cursor:pointer;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}:host(:active:not(:disabled):not([aria-disabled=true]))::file-selector-button{background:var(--skra-comp-field-file-action-background-active)}:host(:disabled)::file-selector-button{cursor:not-allowed}:host([aria-disabled=true])::file-selector-button{cursor:default}:host([data-skra-in-upload]){position:absolute;inset:0;box-sizing:border-box;inline-size:100%;block-size:100%;min-block-size:0;opacity:0;cursor:pointer}:host([data-skra-in-upload]:disabled){cursor:not-allowed}:host([data-skra-in-upload][aria-disabled=true]){cursor:default}@media(prefers-reduced-motion:reduce){:host{transition:none}:host::file-selector-button{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
538
+ }
539
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileInput, decorators: [{
540
+ type: Component,
541
+ args: [{ selector: 'input[type="file"][skraFileInput]', standalone: true, template: '', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, host: {
542
+ // ARIA relationships — Skra-owned, present only while a field / the composite cascades them.
543
+ '[attr.aria-describedby]': 'describedBy()',
544
+ '[attr.aria-labelledby]': 'labelledBy()',
545
+ '[attr.aria-invalid]': 'context?.hasError() ? "true" : null',
546
+ '[attr.aria-required]': 'context?.isRequired() ? "true" : null',
547
+ // Readonly → `aria-disabled` (spec Q-4's fallback, taken 2026-10-31 on the G-4
548
+ // measurement): native `readonly` does not apply to type="file", and `aria-readonly` is
549
+ // not exposed for the role a file input has (`button`) — a screen reader heard nothing.
550
+ // `aria-disabled` alone is the sanctioned soft-disable (boundary #9 exception): the
551
+ // control stays focusable, AT hears "unavailable", the guard below makes it true. Never
552
+ // alongside native `disabled`, which already says it.
553
+ '[attr.aria-disabled]': 'softDisabled() ? "true" : null',
554
+ // ADR 027 §3.9a — a Pointer state with no pseudo-class (`:drop()` never shipped).
555
+ '[attr.data-skra-dragover]': 'dragOver() ? "" : null',
556
+ // ADR 027 §3.9b — structural placement marker, static for the instance's lifetime.
557
+ '[attr.data-skra-in-upload]': 'inUpload ? "" : null',
558
+ '(change)': 'onChange()',
559
+ '(click)': 'blockWhenReadonly($event)',
560
+ '(keydown)': 'blockKeyWhenReadonly($event)',
561
+ '(dragenter)': 'onDragEnter($event)',
562
+ '(dragover)': 'onDragOver($event)',
563
+ '(dragleave)': 'onDragLeave()',
564
+ '(drop)': 'onDrop($event)',
565
+ '(dragend)': 'resetDrag()',
566
+ }, styles: [":host{background:var(--skra-comp-field-background);color:var(--skra-comp-field-foreground);border-color:var(--skra-comp-field-border-color);border-width:var(--skra-comp-field-border-width);border-style:var(--skra-comp-field-border-style);border-radius:var(--skra-comp-field-radius);box-sizing:border-box;padding-block:calc(var(--skra-comp-field-padding-block) - var(--skra-comp-field-file-action-padding-block) - var(--skra-comp-field-file-action-border-width));min-block-size:calc(1lh + 2 * var(--skra-comp-field-padding-block) + 2 * var(--skra-comp-field-border-width));padding-inline:var(--skra-comp-field-padding-inline);box-shadow:var(--skra-comp-field-shadow);max-inline-size:100%;font:inherit;cursor:pointer;outline:none;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),border-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}@media(hover:hover)and (pointer:fine){:host(:hover:not(:disabled):not([aria-disabled=true])){background:var(--skra-comp-field-background-hover);border-color:var(--skra-comp-field-border-color-hover)}:host(:hover:not(:disabled):not([aria-disabled=true]))::file-selector-button{background:var(--skra-comp-field-file-action-background-hover)}}:host(:focus-visible){border-color:var(--skra-comp-field-focus-ring-color);outline:var(--skra-comp-field-focus-ring-width) solid var(--skra-comp-field-focus-ring-color);outline-offset:0}:host([data-skra-dragover]){background:var(--skra-comp-field-file-background-dragover);border-color:var(--skra-comp-field-file-border-color-dragover);color:var(--skra-comp-field-file-foreground-dragover)}:host(:disabled){cursor:not-allowed;opacity:var(--skra-sys-opacity-disabled)}:host([aria-disabled=true]){background:var(--skra-comp-field-background-readonly);border-color:var(--skra-comp-field-border-color-readonly);cursor:default}:host([aria-invalid=true]){border-color:var(--skra-comp-field-error-color)}:host([aria-invalid=true]:focus-visible){outline-color:var(--skra-comp-field-error-color)}:host::file-selector-button{background:var(--skra-comp-field-file-action-background);color:var(--skra-comp-field-file-action-foreground);border-color:var(--skra-comp-field-file-action-border-color);border-width:var(--skra-comp-field-file-action-border-width);border-style:var(--skra-comp-field-file-action-border-style);border-radius:var(--skra-comp-field-file-action-radius);padding-block:var(--skra-comp-field-file-action-padding-block);padding-inline:var(--skra-comp-field-file-action-padding-inline);cursor:pointer;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}:host(:active:not(:disabled):not([aria-disabled=true]))::file-selector-button{background:var(--skra-comp-field-file-action-background-active)}:host(:disabled)::file-selector-button{cursor:not-allowed}:host([aria-disabled=true])::file-selector-button{cursor:default}:host([data-skra-in-upload]){position:absolute;inset:0;box-sizing:border-box;inline-size:100%;block-size:100%;min-block-size:0;opacity:0;cursor:pointer}:host([data-skra-in-upload]:disabled){cursor:not-allowed}:host([data-skra-in-upload][aria-disabled=true]){cursor:default}@media(prefers-reduced-motion:reduce){:host{transition:none}:host::file-selector-button{transition:none}}\n"] }]
567
+ }], ctorParameters: () => [], propDecorators: { filesChange: [{ type: i0.Output, args: ["filesChange"] }], filesRejected: [{ type: i0.Output, args: ["filesRejected"] }] } });
568
+
569
+ /**
570
+ * Internal — NOT part of the public barrel. The page-navigation guard of the FileUpload
571
+ * family (spec Q-3, architect-reviewed 2026-10-31: spec-level, not an ADR).
572
+ *
573
+ * A file dropped a few pixels OUTSIDE a drop zone makes the browser navigate to it — midway
574
+ * through a multi-GB upload, that destroys the session. While at least one
575
+ * `<skra-file-upload>` is mounted, this cancels `dragover` / `drop` on the document, and only
576
+ * for drags that:
577
+ * - carry `Files` (text, links and in-page drags are never touched);
578
+ * - nothing nearer has already handled (`defaultPrevented` — every drop target, including
579
+ * `input[skraFileInput]`, cancels the events it accepts);
580
+ * - do not land on a target that accepts files natively: ANY `input[type="file"]` (Skra's or
581
+ * not) and editable content keep their own drop behaviour.
582
+ *
583
+ * It is the library's FIRST document-level listener, so the rules are binding here: scoped
584
+ * exactly as above, ref-counted (installed once for any number of instances, removed with the
585
+ * last), browser-only (installed from `afterNextRender`, so never under SSR). A second
586
+ * component needing one lifts these rules into an ADR. react-dropzone ships the same default
587
+ * (`preventDropOnDocument`).
588
+ */
589
+ class SkraFileDropGuard {
590
+ doc = inject(DOCUMENT);
591
+ holders = 0;
592
+ // Cancel ONLY — no `dropEffect` write. Cancelling `dragover` + `drop` is what stops the
593
+ // navigation; writing `dropEffect = 'none'` here would also silently kill a consumer's own
594
+ // page-wide drop target on `window` (or a later `document` listener) that cancels without
595
+ // setting `dropEffect` itself — the browser would never fire its `drop` (review 2026-11-01).
596
+ onDragOver = (event) => {
597
+ if (this.guards(event))
598
+ event.preventDefault();
599
+ };
600
+ onDrop = (event) => {
601
+ if (this.guards(event))
602
+ event.preventDefault();
603
+ };
604
+ /** One mounted composite more. Installs the listeners on the first. */
605
+ acquire() {
606
+ this.holders += 1;
607
+ if (this.holders > 1)
608
+ return;
609
+ this.doc.addEventListener('dragover', this.onDragOver);
610
+ this.doc.addEventListener('drop', this.onDrop);
611
+ }
612
+ /** One mounted composite fewer. Removes the listeners with the last. */
613
+ release() {
614
+ if (this.holders === 0)
615
+ return;
616
+ this.holders -= 1;
617
+ if (this.holders > 0)
618
+ return;
619
+ this.doc.removeEventListener('dragover', this.onDragOver);
620
+ this.doc.removeEventListener('drop', this.onDrop);
621
+ }
622
+ guards(event) {
623
+ if (event.defaultPrevented)
624
+ return false;
625
+ const types = event.dataTransfer?.types;
626
+ if (!types || !Array.from(types).includes('Files'))
627
+ return false;
628
+ const target = event.target;
629
+ if (target?.closest?.('input[type="file"]'))
630
+ return false;
631
+ if (target?.isContentEditable)
632
+ return false;
633
+ return true;
634
+ }
635
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileDropGuard, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
636
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileDropGuard, providedIn: 'root' });
637
+ }
638
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileDropGuard, decorators: [{
639
+ type: Injectable,
640
+ args: [{ providedIn: 'root' }]
641
+ }] });
642
+
643
+ /**
644
+ * `<skra-file-upload>` — the optional enhancement over a projected `input[skraFileInput]`
645
+ * (spec `component-file-upload-spec.md`; ADR 0056 Group D). It paints a localisable face —
646
+ * the action and the instruction in the PAGE's language, from tokens — enlarges the control
647
+ * into a drop zone, and hosts the live selection-status region.
648
+ *
649
+ * Why it exists: the native control's two strings ("Choose File" / "No file chosen") come
650
+ * from the browser's UI language, not the page's `lang`, and no CSS or attribute can replace
651
+ * them (ADR 0056 §1.6). The native control stays THE control (D2): transparent
652
+ * (`opacity: 0`, its own stylesheet, switched on by this component's context), covering the
653
+ * whole face, it remains the focus target, the pointer target, the drop target and the node
654
+ * assistive technology reads — so `required`, `aria-invalid`, the validation bubble, label
655
+ * activation, form submission and the iOS camera / library sheet all keep working.
656
+ *
657
+ * **State-READING** (ADR 027 §3.8, ADR 055): no inputs, no outputs; every state it paints —
658
+ * hover, focus-visible, drag-over, disabled, readonly, invalid — is read from the projected
659
+ * control through `:has()`. It owns no state, so it mirrors no ARIA.
660
+ *
661
+ * **Layout follows content (ADR 020).** No `[skra-file-upload-instruction]` → a compact,
662
+ * field-like row (`[icon] [action] status`); with one → the drop-zone layout (stacked,
663
+ * centred, dashed). Both accept drops. There is no look input (skin philosophy).
664
+ *
665
+ * **Never project interactive content** into it — the transparent control covers the whole
666
+ * face and swallows the clicks (D3). The file list is a SIBLING.
667
+ *
668
+ * **Page-navigation guard.** While one is mounted, a FILE drag dropped outside every drop
669
+ * target is cancelled instead of navigating the page to the file (spec Q-3; text / link
670
+ * drags, native file inputs and editable content are untouched).
671
+ *
672
+ * <skra-field [hasError]="!!error()">
673
+ * <label skraLabelFor>Session recording</label>
674
+ * <skra-file-upload>
675
+ * <span skra-file-upload-action>Choose a video</span>
676
+ * <span skra-file-upload-instruction>or drag it here</span>
677
+ * <span skra-file-upload-status>{{ summary() }}</span>
678
+ * <input skraFileInput type="file" accept="video/*" (filesChange)="onFiles($event)" />
679
+ * </skra-file-upload>
680
+ * <skra-error>{{ error() }}</skra-error>
681
+ * </skra-field>
682
+ */
683
+ class SkraFileUpload {
684
+ ctx = inject(SkraFileUploadContext);
685
+ host = inject(ElementRef);
686
+ // `descendants: false` — projection takes top-level content only, so detection must too
687
+ // (ADR 020: a slot marker nested deeper is never projected and must not render a wrapper).
688
+ start = contentChild(SkraFileUploadStart, { ...(ngDevMode ? { debugName: "start" } : /* istanbul ignore next */ {}), descendants: false });
689
+ action = contentChild(SkraFileUploadAction, { ...(ngDevMode ? { debugName: "action" } : /* istanbul ignore next */ {}), descendants: false });
690
+ instruction = contentChild(SkraFileUploadInstruction, { ...(ngDevMode ? { debugName: "instruction" } : /* istanbul ignore next */ {}), descendants: false });
691
+ status = contentChild(SkraFileUploadStatus, { ...(ngDevMode ? { debugName: "status" } : /* istanbul ignore next */ {}), descendants: false });
692
+ // Direct children only (the projection slot takes nothing else) vs anywhere inside.
693
+ inputs = contentChildren(SkraFileInput, /* @ts-ignore */
694
+ ...(ngDevMode ? [{ debugName: "inputs" }] : /* istanbul ignore next */ []));
695
+ nestedInputs = contentChildren(SkraFileInput, { ...(ngDevMode ? { debugName: "nestedInputs" } : /* istanbul ignore next */ {}), descendants: true });
696
+ hasStart = computed(() => !!this.start(), /* @ts-ignore */
697
+ ...(ngDevMode ? [{ debugName: "hasStart" }] : /* istanbul ignore next */ []));
698
+ hasInstruction = computed(() => !!this.instruction(), /* @ts-ignore */
699
+ ...(ngDevMode ? [{ debugName: "hasInstruction" }] : /* istanbul ignore next */ []));
700
+ hasStatus = computed(() => !!this.status(), /* @ts-ignore */
701
+ ...(ngDevMode ? [{ debugName: "hasStatus" }] : /* istanbul ignore next */ []));
702
+ constructor() {
703
+ // The page-navigation guard (spec Q-3): a missed drop must not navigate away from a
704
+ // multi-GB upload. Browser-only by construction — `afterNextRender` never runs under SSR
705
+ // — and ref-counted across every mounted composite.
706
+ const guard = inject(SkraFileDropGuard);
707
+ const destroyRef = inject(DestroyRef);
708
+ afterNextRender(() => {
709
+ guard.acquire();
710
+ destroyRef.onDestroy(() => guard.release());
711
+ });
712
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
713
+ effect(() => this.warnMisuse());
714
+ afterNextRender(() => this.warnInteractiveFace());
715
+ }
716
+ }
717
+ /**
718
+ * Dev-only, ADR 0056 D3: the face is paint. Anything interactive projected into it sits
719
+ * UNDER the transparent control, so the control swallows its clicks — and inside the
720
+ * `aria-hidden` face it is hidden from assistive technology while still focusable. A read of
721
+ * the rendered face, never a write.
722
+ */
723
+ warnInteractiveFace() {
724
+ const face = this.host.nativeElement.querySelector('.skra-file-upload-face');
725
+ const offender = face?.querySelector('a[href], button, input, select, textarea, [tabindex], [contenteditable]');
726
+ if (offender) {
727
+ console.error(`[skra-file-upload] interactive content (<${offender.tagName.toLowerCase()}>) is ` +
728
+ 'projected into the face. The transparent control covers the whole face and swallows ' +
729
+ 'its clicks, and the face is aria-hidden (ADR 0056 D3). Put row actions in a SIBLING ' +
730
+ 'list (ul[skraStack] of li[skraFileItem]).');
731
+ }
732
+ }
733
+ /** Dev-only: the composite cannot work without exactly one direct-child control and an action. */
734
+ warnMisuse() {
735
+ const direct = this.inputs().length;
736
+ const all = this.nestedInputs().length;
737
+ if (all === 0) {
738
+ console.error('[skra-file-upload] needs a direct-child <input type="file" skraFileInput> — it is the ' +
739
+ 'control; the component only paints a face over it (ADR 0056 Group D). Import ' +
740
+ 'SkraFileInput and project the input as a direct child.');
741
+ }
742
+ else if (direct !== all) {
743
+ console.error('[skra-file-upload] the input[skraFileInput] must be a DIRECT child — wrapped in another ' +
744
+ 'element it is never projected, and nothing covers the face.');
745
+ }
746
+ else if (direct > 1) {
747
+ console.error('[skra-file-upload] takes exactly ONE input[skraFileInput]; use `multiple` on it for ' +
748
+ 'several files.');
749
+ }
750
+ if (!this.action()) {
751
+ console.error('[skra-file-upload] needs a [skra-file-upload-action] (e.g. <span skra-file-upload-action>' +
752
+ 'Choose a video</span>) — it is the visible action and half of the control’s accessible ' +
753
+ 'name (ADR 0056 D4). Import SkraFileUploadAction so it can be detected.');
754
+ }
755
+ }
756
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUpload, deps: [], target: i0.ɵɵFactoryTarget.Component });
757
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.1", type: SkraFileUpload, isStandalone: true, selector: "skra-file-upload", host: { properties: { "attr.data-skra-has-start": "hasStart() ? \"\" : null", "attr.data-skra-has-instruction": "hasInstruction() ? \"\" : null", "attr.data-skra-has-status": "hasStatus() ? \"\" : null" } }, providers: [SkraFileUploadContext], queries: [{ propertyName: "start", first: true, predicate: SkraFileUploadStart, isSignal: true }, { propertyName: "action", first: true, predicate: SkraFileUploadAction, isSignal: true }, { propertyName: "instruction", first: true, predicate: SkraFileUploadInstruction, isSignal: true }, { propertyName: "status", first: true, predicate: SkraFileUploadStatus, isSignal: true }, { propertyName: "inputs", predicate: SkraFileInput, isSignal: true }, { propertyName: "nestedInputs", predicate: SkraFileInput, descendants: true, isSignal: true }], ngImport: i0, template: "<!--\r\n The FACE (ADR 0056 D3) \u2014 paint, not a widget: aria-hidden, nothing focusable. The native\r\n control projected below covers all of it (D2) and is the only thing a user or AT operates.\r\n-->\r\n<span class=\"skra-file-upload-face\" aria-hidden=\"true\">\r\n @if (hasStart()) {\r\n <span class=\"skra-file-upload-icon\"\r\n ><ng-content select=\"[skra-start]\"\r\n /></span>\r\n }\r\n <!-- Referenced by the control's aria-labelledby (D4): the visible action text IS in the name. -->\r\n <span class=\"skra-file-upload-action\" [id]=\"ctx.actionId\"\r\n ><ng-content select=\"[skra-file-upload-action]\"\r\n /></span>\r\n @if (hasInstruction()) {\r\n <span class=\"skra-file-upload-instruction\"\r\n ><ng-content select=\"[skra-file-upload-instruction]\"\r\n /></span>\r\n }\r\n</span>\r\n<!--\r\n ALWAYS rendered: a live region must exist before its content changes, or the first change is\r\n not announced. Outside the aria-hidden face (D3's one exception) and linked to the control as a\r\n description. Role only \u2014 the implicit `status` politeness, no explicit aria-live (ADR 0052).\r\n-->\r\n<span class=\"skra-file-upload-status\" role=\"status\" [id]=\"ctx.statusId\"\r\n ><ng-content select=\"[skra-file-upload-status]\"\r\n/></span>\r\n<ng-content select=\"input[skraFileInput]\" />\r\n", styles: [":host{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:var(--skra-comp-field-file-gap);box-sizing:border-box;min-block-size:max(var(--skra-comp-field-file-min-block-size),1lh + 2 * var(--skra-comp-field-padding-block) + 2 * var(--skra-comp-field-border-width));max-inline-size:100%;background:var(--skra-comp-field-background);color:var(--skra-comp-field-file-status-color);border-radius:var(--skra-comp-field-radius);padding-block:calc(var(--skra-comp-field-padding-block) + var(--skra-comp-field-border-width) - var(--skra-comp-field-file-action-padding-block) - var(--skra-comp-field-file-action-border-width));padding-inline:calc(var(--skra-comp-field-padding-inline) + var(--skra-comp-field-border-width));box-shadow:var(--skra-comp-field-shadow);cursor:pointer;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}:host:before{content:\"\";position:absolute;inset:0;border-color:var(--skra-comp-field-border-color);border-width:var(--skra-comp-field-border-width);border-style:var(--skra-comp-field-border-style);border-radius:inherit;pointer-events:none;transition:border-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}.skra-file-upload-face{display:inline-flex;flex:0 1 auto;align-items:center;gap:var(--skra-comp-field-file-gap);min-inline-size:0;max-inline-size:100%}.skra-file-upload-icon{display:inline-flex;flex:none;font-size:var(--skra-comp-field-file-icon-size);line-height:1}.skra-file-upload-action{flex:0 1 auto;min-inline-size:0;overflow-wrap:break-word;background:var(--skra-comp-field-file-action-background);color:var(--skra-comp-field-file-action-foreground);border-color:var(--skra-comp-field-file-action-border-color);border-width:var(--skra-comp-field-file-action-border-width);border-style:var(--skra-comp-field-file-action-border-style);border-radius:var(--skra-comp-field-file-action-radius);padding-block:var(--skra-comp-field-file-action-padding-block);padding-inline:var(--skra-comp-field-file-action-padding-inline);font-family:var(--skra-sys-typography-role-label-medium-font-family);font-size:var(--skra-sys-typography-role-label-medium-font-size);font-weight:var(--skra-sys-typography-role-label-medium-font-weight);line-height:var(--skra-sys-typography-role-label-medium-line-height);letter-spacing:var(--skra-sys-typography-role-label-medium-letter-spacing);transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}.skra-file-upload-instruction{color:var(--skra-comp-field-file-instruction-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-upload-status{flex:1 1 50%;min-inline-size:0;overflow-wrap:anywhere;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing)}:host(:not([data-skra-has-status])) .skra-file-upload-status{display:none}:host([data-skra-has-instruction]){flex-direction:column;justify-content:center;gap:var(--skra-comp-field-file-zone-gap);padding-block:calc(var(--skra-comp-field-file-zone-padding-block) + var(--skra-comp-field-border-width));padding-inline:calc(var(--skra-comp-field-file-zone-padding-inline) + var(--skra-comp-field-border-width));text-align:center}:host([data-skra-has-instruction]):before{border-style:var(--skra-comp-field-file-zone-border-style)}:host([data-skra-has-instruction]) .skra-file-upload-status{flex:none;max-inline-size:100%}:host([data-skra-has-instruction]) .skra-file-upload-face{flex-direction:column;gap:var(--skra-comp-field-file-zone-gap)}@media not (any-pointer:fine){.skra-file-upload-instruction{display:none}}@media(hover:hover)and (pointer:fine){:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])){background:var(--skra-comp-field-background-hover)}:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])):before{border-color:var(--skra-comp-field-border-color-hover)}:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])) .skra-file-upload-action{background:var(--skra-comp-field-file-action-background-hover)}}:host:has(input:focus-visible){outline:var(--skra-comp-field-focus-ring-width) solid var(--skra-comp-field-focus-ring-color);outline-offset:0}:host:has(input:focus-visible):before{border-color:var(--skra-comp-field-focus-ring-color)}:host:has(input[data-skra-dragover]){background:var(--skra-comp-field-file-background-dragover);color:var(--skra-comp-field-file-foreground-dragover)}:host:has(input[data-skra-dragover]):before{border-color:var(--skra-comp-field-file-border-color-dragover)}:host:has(input[data-skra-dragover]) .skra-file-upload-instruction{color:var(--skra-comp-field-file-foreground-dragover)}:host:has(input:disabled){cursor:not-allowed;opacity:var(--skra-sys-opacity-disabled)}:host:has(input[aria-disabled=true]){background:var(--skra-comp-field-background-readonly);cursor:default}:host:has(input[aria-disabled=true]):before{border-color:var(--skra-comp-field-border-color-readonly)}:host:has(input[aria-disabled=true]) .skra-file-upload-instruction{display:none;color:var(--skra-comp-field-file-status-color)}:host:has(input[aria-invalid=true]):before{border-color:var(--skra-comp-field-error-color)}:host:has(input[aria-invalid=true]:focus-visible){outline-color:var(--skra-comp-field-error-color)}@media(prefers-reduced-motion:reduce){:host,:host:before,.skra-file-upload-action{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
758
+ }
759
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileUpload, decorators: [{
760
+ type: Component,
761
+ args: [{ selector: 'skra-file-upload', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, providers: [SkraFileUploadContext], host: {
762
+ // Slot presence (ADR 020 / ADR 027 §3.6 — exempt from the State Surface).
763
+ '[attr.data-skra-has-start]': 'hasStart() ? "" : null',
764
+ '[attr.data-skra-has-instruction]': 'hasInstruction() ? "" : null',
765
+ '[attr.data-skra-has-status]': 'hasStatus() ? "" : null',
766
+ }, template: "<!--\r\n The FACE (ADR 0056 D3) \u2014 paint, not a widget: aria-hidden, nothing focusable. The native\r\n control projected below covers all of it (D2) and is the only thing a user or AT operates.\r\n-->\r\n<span class=\"skra-file-upload-face\" aria-hidden=\"true\">\r\n @if (hasStart()) {\r\n <span class=\"skra-file-upload-icon\"\r\n ><ng-content select=\"[skra-start]\"\r\n /></span>\r\n }\r\n <!-- Referenced by the control's aria-labelledby (D4): the visible action text IS in the name. -->\r\n <span class=\"skra-file-upload-action\" [id]=\"ctx.actionId\"\r\n ><ng-content select=\"[skra-file-upload-action]\"\r\n /></span>\r\n @if (hasInstruction()) {\r\n <span class=\"skra-file-upload-instruction\"\r\n ><ng-content select=\"[skra-file-upload-instruction]\"\r\n /></span>\r\n }\r\n</span>\r\n<!--\r\n ALWAYS rendered: a live region must exist before its content changes, or the first change is\r\n not announced. Outside the aria-hidden face (D3's one exception) and linked to the control as a\r\n description. Role only \u2014 the implicit `status` politeness, no explicit aria-live (ADR 0052).\r\n-->\r\n<span class=\"skra-file-upload-status\" role=\"status\" [id]=\"ctx.statusId\"\r\n ><ng-content select=\"[skra-file-upload-status]\"\r\n/></span>\r\n<ng-content select=\"input[skraFileInput]\" />\r\n", styles: [":host{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:var(--skra-comp-field-file-gap);box-sizing:border-box;min-block-size:max(var(--skra-comp-field-file-min-block-size),1lh + 2 * var(--skra-comp-field-padding-block) + 2 * var(--skra-comp-field-border-width));max-inline-size:100%;background:var(--skra-comp-field-background);color:var(--skra-comp-field-file-status-color);border-radius:var(--skra-comp-field-radius);padding-block:calc(var(--skra-comp-field-padding-block) + var(--skra-comp-field-border-width) - var(--skra-comp-field-file-action-padding-block) - var(--skra-comp-field-file-action-border-width));padding-inline:calc(var(--skra-comp-field-padding-inline) + var(--skra-comp-field-border-width));box-shadow:var(--skra-comp-field-shadow);cursor:pointer;transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing),color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}:host:before{content:\"\";position:absolute;inset:0;border-color:var(--skra-comp-field-border-color);border-width:var(--skra-comp-field-border-width);border-style:var(--skra-comp-field-border-style);border-radius:inherit;pointer-events:none;transition:border-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}.skra-file-upload-face{display:inline-flex;flex:0 1 auto;align-items:center;gap:var(--skra-comp-field-file-gap);min-inline-size:0;max-inline-size:100%}.skra-file-upload-icon{display:inline-flex;flex:none;font-size:var(--skra-comp-field-file-icon-size);line-height:1}.skra-file-upload-action{flex:0 1 auto;min-inline-size:0;overflow-wrap:break-word;background:var(--skra-comp-field-file-action-background);color:var(--skra-comp-field-file-action-foreground);border-color:var(--skra-comp-field-file-action-border-color);border-width:var(--skra-comp-field-file-action-border-width);border-style:var(--skra-comp-field-file-action-border-style);border-radius:var(--skra-comp-field-file-action-radius);padding-block:var(--skra-comp-field-file-action-padding-block);padding-inline:var(--skra-comp-field-file-action-padding-inline);font-family:var(--skra-sys-typography-role-label-medium-font-family);font-size:var(--skra-sys-typography-role-label-medium-font-size);font-weight:var(--skra-sys-typography-role-label-medium-font-weight);line-height:var(--skra-sys-typography-role-label-medium-line-height);letter-spacing:var(--skra-sys-typography-role-label-medium-letter-spacing);transition:background-color var(--skra-comp-field-motion-duration) var(--skra-comp-field-motion-easing)}.skra-file-upload-instruction{color:var(--skra-comp-field-file-instruction-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-upload-status{flex:1 1 50%;min-inline-size:0;overflow-wrap:anywhere;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing)}:host(:not([data-skra-has-status])) .skra-file-upload-status{display:none}:host([data-skra-has-instruction]){flex-direction:column;justify-content:center;gap:var(--skra-comp-field-file-zone-gap);padding-block:calc(var(--skra-comp-field-file-zone-padding-block) + var(--skra-comp-field-border-width));padding-inline:calc(var(--skra-comp-field-file-zone-padding-inline) + var(--skra-comp-field-border-width));text-align:center}:host([data-skra-has-instruction]):before{border-style:var(--skra-comp-field-file-zone-border-style)}:host([data-skra-has-instruction]) .skra-file-upload-status{flex:none;max-inline-size:100%}:host([data-skra-has-instruction]) .skra-file-upload-face{flex-direction:column;gap:var(--skra-comp-field-file-zone-gap)}@media not (any-pointer:fine){.skra-file-upload-instruction{display:none}}@media(hover:hover)and (pointer:fine){:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])){background:var(--skra-comp-field-background-hover)}:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])):before{border-color:var(--skra-comp-field-border-color-hover)}:host:hover:not(:has(input:disabled)):not(:has(input[aria-disabled=true])) .skra-file-upload-action{background:var(--skra-comp-field-file-action-background-hover)}}:host:has(input:focus-visible){outline:var(--skra-comp-field-focus-ring-width) solid var(--skra-comp-field-focus-ring-color);outline-offset:0}:host:has(input:focus-visible):before{border-color:var(--skra-comp-field-focus-ring-color)}:host:has(input[data-skra-dragover]){background:var(--skra-comp-field-file-background-dragover);color:var(--skra-comp-field-file-foreground-dragover)}:host:has(input[data-skra-dragover]):before{border-color:var(--skra-comp-field-file-border-color-dragover)}:host:has(input[data-skra-dragover]) .skra-file-upload-instruction{color:var(--skra-comp-field-file-foreground-dragover)}:host:has(input:disabled){cursor:not-allowed;opacity:var(--skra-sys-opacity-disabled)}:host:has(input[aria-disabled=true]){background:var(--skra-comp-field-background-readonly);cursor:default}:host:has(input[aria-disabled=true]):before{border-color:var(--skra-comp-field-border-color-readonly)}:host:has(input[aria-disabled=true]) .skra-file-upload-instruction{display:none;color:var(--skra-comp-field-file-status-color)}:host:has(input[aria-invalid=true]):before{border-color:var(--skra-comp-field-error-color)}:host:has(input[aria-invalid=true]:focus-visible){outline-color:var(--skra-comp-field-error-color)}@media(prefers-reduced-motion:reduce){:host,:host:before,.skra-file-upload-action{transition:none}}\n"] }]
767
+ }], ctorParameters: () => [], propDecorators: { start: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileUploadStart), { ...{
768
+ descendants: false,
769
+ }, isSignal: true }] }], action: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileUploadAction), { ...{
770
+ descendants: false,
771
+ }, isSignal: true }] }], instruction: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileUploadInstruction), { ...{
772
+ descendants: false,
773
+ }, isSignal: true }] }], status: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileUploadStatus), { ...{
774
+ descendants: false,
775
+ }, isSignal: true }] }], inputs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SkraFileInput), { isSignal: true }] }], nestedInputs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SkraFileInput), { ...{
776
+ descendants: true,
777
+ }, isSignal: true }] }] } });
778
+
779
+ /**
780
+ * Internal — NOT part of the public barrel. Split a file name at its LAST dot, for display.
781
+ *
782
+ * - `clip.final.mp4` → `clip.final` + `.mp4` (only the last dot separates);
783
+ * - `.env` → no extension (a dotfile is all name — the dot is at index 0);
784
+ * - `README`, `x.` → no extension (none, or an empty one after a trailing dot).
785
+ *
786
+ * Purely visual: the row also renders the WHOLE name for assistive technology, so the split
787
+ * (and the ellipsis) is never heard.
788
+ */
789
+ function splitFileName(name) {
790
+ const dot = name.lastIndexOf('.');
791
+ if (dot <= 0 || dot === name.length - 1)
792
+ return { stem: name, ext: '' };
793
+ return { stem: name.slice(0, dot), ext: name.slice(dot) };
794
+ }
795
+
796
+ /**
797
+ * `li[skraFileItem]` — a display row for ONE consumer-owned file (spec
798
+ * `component-file-upload-spec.md`). A Component-on-a-native-host (ADR 012 / ADR 040): the
799
+ * `<li>` keeps its list-item semantics inside the consumer's `ul[skraStack]`.
800
+ *
801
+ * Separate from `<skra-file-upload>` by SRP: a row outlives the selection (it tracks an
802
+ * upload), its source of truth is the consumer's queue — not `input.files` — and it is a
803
+ * display list item, not a form control. **Skra never uploads**: progress, retry, cancel and
804
+ * removal are the consumer's, projected into the row's slots.
805
+ *
806
+ * - **Name**, middle-truncated with the extension always visible — pure CSS, no measurement.
807
+ * The visual split is `aria-hidden`; an `[skra-sr-only]` copy carries the whole name.
808
+ * - **Size** — `skraFormatFileSize(size, locale)`: SI units, `Intl` short labels, the
809
+ * `locale` input or the host default (the ADR 0050 §5 precedent).
810
+ * - **Invalid is READ, never owned** (ADR 027 §3.8, spec Q-7): the row paints
811
+ * `data-skra-validation="invalid"` only while `[skra-file-item-error]` text is projected, so
812
+ * the colour never appears without the words (WCAG 1.4.1 by construction). No `aria-invalid`
813
+ * — no role an `<li>` holds supports it.
814
+ *
815
+ * <ul skraStack>
816
+ * @for (u of uploads(); track u.id) {
817
+ * <li skraFileItem [name]="u.file.name" [size]="u.file.size" locale="pl-PL">
818
+ * <progress skraProgress skra-file-item-detail [value]="u.sent" [max]="u.file.size"
819
+ * [attr.aria-label]="'Uploading ' + u.file.name"></progress>
820
+ * @if (u.error) { <span skra-file-item-error>{{ u.error }}</span> }
821
+ * <button skraButton skra-end emphasis="low" (click)="cancel(u)">
822
+ * Cancel<span skra-sr-only> {{ u.file.name }}</span>
823
+ * </button>
824
+ * </li>
825
+ * }
826
+ * </ul>
827
+ */
828
+ class SkraFileItem {
829
+ /** The file name, shown as stem + extension. */
830
+ name = input.required(/* @ts-ignore */
831
+ ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
832
+ /** Size in bytes, formatted for display. `undefined` → no size shown. */
833
+ size = input(undefined, /* @ts-ignore */
834
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
835
+ /** `Intl` locale for the size. `undefined` → the host default (ADR 0050 §5 precedent). */
836
+ locale = input(undefined, /* @ts-ignore */
837
+ ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
838
+ // `descendants: false` — `<ng-content select>` projects only TOP-LEVEL content, so detection
839
+ // must look no deeper: an icon marked `[skra-start]` inside an end-slot button would
840
+ // otherwise report a start slot and render an empty wrapper (ADR 020).
841
+ start = contentChild(SkraFileItemStart, { ...(ngDevMode ? { debugName: "start" } : /* istanbul ignore next */ {}), descendants: false });
842
+ detail = contentChild(SkraFileItemDetail, { ...(ngDevMode ? { debugName: "detail" } : /* istanbul ignore next */ {}), descendants: false });
843
+ error = contentChild(SkraFileItemError, { ...(ngDevMode ? { debugName: "error" } : /* istanbul ignore next */ {}), descendants: false });
844
+ end = contentChild(SkraFileItemEnd, { ...(ngDevMode ? { debugName: "end" } : /* istanbul ignore next */ {}), descendants: false });
845
+ hasStart = computed(() => !!this.start(), /* @ts-ignore */
846
+ ...(ngDevMode ? [{ debugName: "hasStart" }] : /* istanbul ignore next */ []));
847
+ hasDetail = computed(() => !!this.detail(), /* @ts-ignore */
848
+ ...(ngDevMode ? [{ debugName: "hasDetail" }] : /* istanbul ignore next */ []));
849
+ hasError = computed(() => !!this.error(), /* @ts-ignore */
850
+ ...(ngDevMode ? [{ debugName: "hasError" }] : /* istanbul ignore next */ []));
851
+ hasEnd = computed(() => !!this.end(), /* @ts-ignore */
852
+ ...(ngDevMode ? [{ debugName: "hasEnd" }] : /* istanbul ignore next */ []));
853
+ parts = computed(() => splitFileName(this.name()), /* @ts-ignore */
854
+ ...(ngDevMode ? [{ debugName: "parts" }] : /* istanbul ignore next */ []));
855
+ // A size that cannot exist is shown as no size, not as an exception in the template:
856
+ // the row is display, and the helper's RangeError is for the caller who computes sizes.
857
+ sizeLabel = computed(() => {
858
+ const bytes = this.size();
859
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
860
+ return '';
861
+ return skraFormatFileSize(bytes, this.locale());
862
+ }, /* @ts-ignore */
863
+ ...(ngDevMode ? [{ debugName: "sizeLabel" }] : /* istanbul ignore next */ []));
864
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItem, deps: [], target: i0.ɵɵFactoryTarget.Component });
865
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.1", type: SkraFileItem, isStandalone: true, selector: "li[skraFileItem]", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-skra-has-start": "hasStart() ? \"\" : null", "attr.data-skra-has-detail": "hasDetail() ? \"\" : null", "attr.data-skra-has-end": "hasEnd() ? \"\" : null", "attr.data-skra-validation": "hasError() ? \"invalid\" : null" } }, queries: [{ propertyName: "start", first: true, predicate: SkraFileItemStart, isSignal: true }, { propertyName: "detail", first: true, predicate: SkraFileItemDetail, isSignal: true }, { propertyName: "error", first: true, predicate: SkraFileItemError, isSignal: true }, { propertyName: "end", first: true, predicate: SkraFileItemEnd, isSignal: true }], ngImport: i0, template: "@if (hasStart()) {\r\n<span class=\"skra-file-item-start\"><ng-content select=\"[skra-start]\" /></span>\r\n}\r\n<span class=\"skra-file-item-body\">\r\n <!--\r\n The name, middle-truncated with the extension always visible. The split pieces are\r\n aria-hidden; the whole name is read from the sr-only copy, so a screen reader never hears\r\n the split or the ellipsis. `dir=\"auto\"` lets an RTL name order its own pieces.\r\n -->\r\n <span class=\"skra-file-item-name\" dir=\"auto\">\r\n <span class=\"skra-file-item-stem\" aria-hidden=\"true\"\r\n >{{ parts().stem }}</span\r\n >\r\n @if (parts().ext) {\r\n <span class=\"skra-file-item-ext\" aria-hidden=\"true\">{{ parts().ext }}</span>\r\n }\r\n <span skra-sr-only>{{ name() }}</span>\r\n </span>\r\n @if (sizeLabel()) {\r\n <span class=\"skra-file-item-meta\">{{ sizeLabel() }}</span>\r\n } @if (hasDetail()) {\r\n <span class=\"skra-file-item-detail\"\r\n ><ng-content select=\"[skra-file-item-detail]\"\r\n /></span>\r\n } @if (hasError()) {\r\n <span class=\"skra-file-item-error\"\r\n ><ng-content select=\"[skra-file-item-error]\"\r\n /></span>\r\n }\r\n</span>\r\n@if (hasEnd()) {\r\n<span class=\"skra-file-item-end\"><ng-content select=\"[skra-end]\" /></span>\r\n}\r\n", styles: [":host{display:flex;flex-wrap:wrap;align-items:center;gap:var(--skra-comp-file-item-gap);box-sizing:border-box;min-inline-size:0;background:var(--skra-comp-file-item-background);color:var(--skra-comp-file-item-foreground);border-color:var(--skra-comp-file-item-border-color);border-width:var(--skra-comp-file-item-border-width);border-style:var(--skra-comp-file-item-border-style);border-radius:var(--skra-comp-file-item-radius);padding-block:var(--skra-comp-file-item-padding-block);padding-inline:var(--skra-comp-file-item-padding-inline);box-shadow:var(--skra-comp-file-item-shadow);transition:border-color var(--skra-comp-file-item-motion-duration) var(--skra-comp-file-item-motion-easing)}.skra-file-item-start{display:inline-flex;flex:none;align-items:center;justify-content:center;font-size:var(--skra-comp-file-item-icon-size);line-height:1}.skra-file-item-body{display:flex;flex:1 1 50%;flex-direction:column;min-inline-size:0}.skra-file-item-name{display:flex;min-inline-size:0;white-space:nowrap;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing)}.skra-file-item-stem{flex:0 1 auto;min-inline-size:0;overflow:hidden;text-overflow:ellipsis}.skra-file-item-ext{flex:none}.skra-file-item-meta{color:var(--skra-comp-file-item-meta-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-item-detail{display:block;min-inline-size:0}.skra-file-item-error{color:var(--skra-comp-file-item-error-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-item-end{display:inline-flex;flex:1 1 auto;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:var(--skra-comp-file-item-gap);min-inline-size:0;max-inline-size:100%}:host([data-skra-validation=invalid]){border-color:var(--skra-comp-file-item-border-color-validation-invalid)}@media(prefers-reduced-motion:reduce){:host{transition:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
866
+ }
867
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: SkraFileItem, decorators: [{
868
+ type: Component,
869
+ args: [{ selector: 'li[skraFileItem]', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, host: {
870
+ // Slot presence (ADR 020 / ADR 027 §3.6 — exempt).
871
+ '[attr.data-skra-has-start]': 'hasStart() ? "" : null',
872
+ '[attr.data-skra-has-detail]': 'hasDetail() ? "" : null',
873
+ '[attr.data-skra-has-end]': 'hasEnd() ? "" : null',
874
+ // Validation READ from the error slot (ADR 027 §3.8) — the canonical value, never
875
+ // `data-skra-has-error` (ADR 027 iron boundary).
876
+ '[attr.data-skra-validation]': 'hasError() ? "invalid" : null',
877
+ }, template: "@if (hasStart()) {\r\n<span class=\"skra-file-item-start\"><ng-content select=\"[skra-start]\" /></span>\r\n}\r\n<span class=\"skra-file-item-body\">\r\n <!--\r\n The name, middle-truncated with the extension always visible. The split pieces are\r\n aria-hidden; the whole name is read from the sr-only copy, so a screen reader never hears\r\n the split or the ellipsis. `dir=\"auto\"` lets an RTL name order its own pieces.\r\n -->\r\n <span class=\"skra-file-item-name\" dir=\"auto\">\r\n <span class=\"skra-file-item-stem\" aria-hidden=\"true\"\r\n >{{ parts().stem }}</span\r\n >\r\n @if (parts().ext) {\r\n <span class=\"skra-file-item-ext\" aria-hidden=\"true\">{{ parts().ext }}</span>\r\n }\r\n <span skra-sr-only>{{ name() }}</span>\r\n </span>\r\n @if (sizeLabel()) {\r\n <span class=\"skra-file-item-meta\">{{ sizeLabel() }}</span>\r\n } @if (hasDetail()) {\r\n <span class=\"skra-file-item-detail\"\r\n ><ng-content select=\"[skra-file-item-detail]\"\r\n /></span>\r\n } @if (hasError()) {\r\n <span class=\"skra-file-item-error\"\r\n ><ng-content select=\"[skra-file-item-error]\"\r\n /></span>\r\n }\r\n</span>\r\n@if (hasEnd()) {\r\n<span class=\"skra-file-item-end\"><ng-content select=\"[skra-end]\" /></span>\r\n}\r\n", styles: [":host{display:flex;flex-wrap:wrap;align-items:center;gap:var(--skra-comp-file-item-gap);box-sizing:border-box;min-inline-size:0;background:var(--skra-comp-file-item-background);color:var(--skra-comp-file-item-foreground);border-color:var(--skra-comp-file-item-border-color);border-width:var(--skra-comp-file-item-border-width);border-style:var(--skra-comp-file-item-border-style);border-radius:var(--skra-comp-file-item-radius);padding-block:var(--skra-comp-file-item-padding-block);padding-inline:var(--skra-comp-file-item-padding-inline);box-shadow:var(--skra-comp-file-item-shadow);transition:border-color var(--skra-comp-file-item-motion-duration) var(--skra-comp-file-item-motion-easing)}.skra-file-item-start{display:inline-flex;flex:none;align-items:center;justify-content:center;font-size:var(--skra-comp-file-item-icon-size);line-height:1}.skra-file-item-body{display:flex;flex:1 1 50%;flex-direction:column;min-inline-size:0}.skra-file-item-name{display:flex;min-inline-size:0;white-space:nowrap;font-family:var(--skra-sys-typography-role-body-medium-font-family);font-size:var(--skra-sys-typography-role-body-medium-font-size);font-weight:var(--skra-sys-typography-role-body-medium-font-weight);line-height:var(--skra-sys-typography-role-body-medium-line-height);letter-spacing:var(--skra-sys-typography-role-body-medium-letter-spacing)}.skra-file-item-stem{flex:0 1 auto;min-inline-size:0;overflow:hidden;text-overflow:ellipsis}.skra-file-item-ext{flex:none}.skra-file-item-meta{color:var(--skra-comp-file-item-meta-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-item-detail{display:block;min-inline-size:0}.skra-file-item-error{color:var(--skra-comp-file-item-error-color);font-family:var(--skra-sys-typography-role-body-small-font-family);font-size:var(--skra-sys-typography-role-body-small-font-size);font-weight:var(--skra-sys-typography-role-body-small-font-weight);line-height:var(--skra-sys-typography-role-body-small-line-height);letter-spacing:var(--skra-sys-typography-role-body-small-letter-spacing)}.skra-file-item-end{display:inline-flex;flex:1 1 auto;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:var(--skra-comp-file-item-gap);min-inline-size:0;max-inline-size:100%}:host([data-skra-validation=invalid]){border-color:var(--skra-comp-file-item-border-color-validation-invalid)}@media(prefers-reduced-motion:reduce){:host{transition:none}}\n"] }]
878
+ }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], start: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileItemStart), { ...{
879
+ descendants: false,
880
+ }, isSignal: true }] }], detail: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileItemDetail), { ...{
881
+ descendants: false,
882
+ }, isSignal: true }] }], error: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileItemError), { ...{
883
+ descendants: false,
884
+ }, isSignal: true }] }], end: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SkraFileItemEnd), { ...{ descendants: false }, isSignal: true }] }] } });
885
+
886
+ // Public API for @skra/ui/file-upload — the FileUpload family (spec
887
+ // adr/specs/component-file-upload-spec.md; ADR 0056 Groups B + D).
888
+
889
+ /**
890
+ * Generated bundle index. Do not edit.
891
+ */
892
+
893
+ export { SkraFileInput, SkraFileItem, SkraFileItemDetail, SkraFileItemEnd, SkraFileItemError, SkraFileItemStart, SkraFileUpload, SkraFileUploadAction, SkraFileUploadInstruction, SkraFileUploadStart, SkraFileUploadStatus, skraFileMatchesAccept, skraFormatFileSize };
894
+ //# sourceMappingURL=skra-ui-file-upload.mjs.map