import '../templating.js';
import { inject, injectable } from '@joist/di';
import { attr, css, element, html, listen, query } from '@joist/element';
import { Changes, effect } from '@joist/observable';
import { bind } from '@joist/templating';
import { HttpService } from '../services/http.service.js';
import { filenameFromResponse } from './filename.js';
declare global {
interface HTMLElementTagNameMap {
'usa-file-input': USAFileInputElement;
}
}
@injectable({
name: 'USAFileInputElementCtx',
})
@element({
tagName: 'usa-file-input',
shadowDom: [
css`
* {
box-sizing: border-box;
}
:host {
display: block;
max-width: 30rem;
position: relative;
margin-bottom: 1.5rem;
}
label {
display: block;
}
input {
cursor: pointer;
left: 0;
margin: 0;
max-width: none;
position: absolute;
text-indent: -999em;
width: 100%;
z-index: 1;
bottom: 0;
top: 0;
}
input:focus {
outline: 0.25rem solid #2491ff;
outline-offset: 0;
}
label slot.label {
font-size: 1.06rem;
line-height: 1.3;
display: block;
font-weight: 400;
margin-bottom: 0.5rem;
}
:host(.dragenter) .box {
border-color: #2491ff;
}
:host([disabled]) {
opacity: 0.6;
}
:host([disabled]) .box {
border-color: #c9c9c9;
background: #f5f5f5;
cursor: not-allowed;
}
:host([disabled]) input,
:host([disabled]) .link {
cursor: not-allowed;
pointer-events: none;
}
.link {
color: #005ea2;
cursor: pointer;
text-decoration: underline;
}
.link:hover {
color: #1a4480;
}
.box {
border: 1px dashed #adadad;
border-radius: 0;
display: flex;
font-size: 0.93rem;
position: relative;
text-align: center;
width: 100%;
height: 5.2rem;
align-items: center;
justify-content: center;
}
.container {
position: relative;
}
`,
html`
`,
],
})
export class USAFileInputElement extends HTMLElement {
static formAssociated = true;
@attr()
@bind()
accessor name = '';
@attr()
@bind()
accessor multiple = false;
@attr()
@bind()
accessor accept = '';
@attr()
@bind()
accessor url = '';
@attr()
@bind()
accessor required = false;
@attr()
@bind()
accessor disabled = false;
@bind()
accessor files: FileList | null = null;
#http = inject(HttpService);
#internals = this.attachInternals();
#input = query('input');
connectedCallback() {
this.syncFormValues();
this.#loadFromUrl();
}
@effect()
async onURLChanged(changes: Changes) {
if (changes.has('url')) {
this.#loadFromUrl();
}
}
@effect()
async formValuesChange(changes: Changes) {
if (changes.has('files') || changes.has('name')) {
this.syncFormValues();
}
}
async syncFormValues() {
const input = this.#input();
const formData = new FormData();
if (this.files?.length) {
for (const file of this.files) {
formData.append(this.name, file);
}
}
this.#internals.setFormValue(formData);
await Promise.resolve();
if (input.validationMessage) {
this.#internals.setValidity({ customError: true }, input.validationMessage, input);
} else {
this.#internals.setValidity({});
}
}
@listen('input')
onInputChange(e: Event) {
e.stopPropagation();
const input = this.#input();
if (input.files && input.files.length) {
this.files = input.files;
this.dispatchEvent(new Event('input', { bubbles: true }));
}
}
@listen('dragenter')
onDragEnter() {
this.classList.add('dragenter');
}
@listen('dragleave')
onDragLeave() {
this.classList.remove('dragenter');
}
@listen('drop')
onDrop(e: DragEvent) {
this.classList.remove('dragenter');
if (e.dataTransfer?.items) {
e.preventDefault();
const data = new DataTransfer();
for (const item of e.dataTransfer.items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && this.#isFileAccepted(file)) {
data.items.add(file);
}
}
}
this.files = data.files;
this.dispatchEvent(new Event('input', { bubbles: true }));
}
}
#isFileAccepted(file: File): boolean {
// If no accept property is set, accept all files
if (!this.accept) {
return true;
}
// Split the accept string by comma and trim whitespace
const acceptedTypes = this.accept.split(',').map((type) => type.trim());
for (const acceptType of acceptedTypes) {
if (acceptType.endsWith('/*')) {
// Handle wildcard types like "image/*" or "audio/*"
const mainType = acceptType.split('/')[0];
if (file.type.startsWith(mainType + '/')) {
return true;
}
} else if (acceptType.startsWith('.')) {
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
return true;
}
}
// Handle exact MIME type match
if (file.type === acceptType) {
return true;
}
}
return false;
}
async #loadFromUrl(): Promise {
if (!this.url) {
return void 0;
}
const http = this.#http();
const res = await http.fetch(this.url);
const blob = await res.blob();
// Determine filename from Content-Disposition header when available
const filename = filenameFromResponse(res);
const file = new File([blob], filename, { type: blob.type });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.files = dataTransfer.files;
this.dispatchEvent(new Event('input', { bubbles: true }));
return void 0;
}
}