@operato/input 10.29.30 → 10.29.37

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 (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/coverage/lcov-report/base.css +224 -0
  3. package/coverage/lcov-report/block-navigation.js +87 -0
  4. package/coverage/lcov-report/favicon.png +0 -0
  5. package/coverage/lcov-report/index.html +776 -0
  6. package/coverage/lcov-report/prettify.css +1 -0
  7. package/coverage/lcov-report/prettify.js +2 -0
  8. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  9. package/coverage/lcov-report/sorter.js +210 -0
  10. package/coverage/lcov-report/webpack/bootstrap.html +118 -0
  11. package/coverage/lcov-report/webpack/index.html +116 -0
  12. package/coverage/lcov-report/webpack/runtime/define property getters.html +118 -0
  13. package/coverage/lcov-report/webpack/runtime/global.html +118 -0
  14. package/coverage/lcov-report/webpack/runtime/hasOwnProperty shorthand.html +118 -0
  15. package/coverage/lcov-report/webpack/runtime/index.html +161 -0
  16. package/coverage/lcov-report/webpack/runtime/make namespace object.html +118 -0
  17. package/coverage/lcov.info +46428 -0
  18. package/dist/src/ox-form-field.js +5 -0
  19. package/dist/src/ox-form-field.js.map +1 -1
  20. package/dist/src/ox-input-file.d.ts +59 -0
  21. package/dist/src/ox-input-file.js +302 -41
  22. package/dist/src/ox-input-file.js.map +1 -1
  23. package/dist/test/ox-input-file.test.d.ts +1 -0
  24. package/dist/test/ox-input-file.test.js +202 -0
  25. package/dist/test/ox-input-file.test.js.map +1 -0
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +4 -4
  28. package/test/ox-input-file.test.ts +267 -0
  29. package/translations/en.json +9 -0
  30. package/translations/es.json +9 -0
  31. package/translations/ja.json +9 -0
  32. package/translations/ko.json +9 -0
  33. package/translations/ms.json +9 -0
  34. package/translations/zh.json +9 -0
@@ -88,6 +88,11 @@ export class OxFormField extends LitElement {
88
88
  values = [this.value];
89
89
  }
90
90
  values.forEach(value => {
91
+ /* A file goes as itself, name and all; turned to JSON it was "{}". */
92
+ if (value instanceof Blob) {
93
+ formData.append(this.name, value);
94
+ return;
95
+ }
91
96
  formData.append(this.name, typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value));
92
97
  });
93
98
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ox-form-field.js","sourceRoot":"","sources":["../../src/ox-form-field.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement } from 'lit'\nimport { property } from 'lit/decorators.js'\n\n/**\n * 모든 입력 컴포넌트의 기반 계약.\n *\n * ## 상태 계약\n * `disabled` / `readonly` / `required` / `invalid` 는 **attribute 로 reflect** 된다.\n * 그래야 `InputStyles` 의 `:host([disabled])` · `:host([readonly])` · `:host([invalid])` 가\n * **마크업으로 설정했을 때뿐 아니라 프로퍼티로 설정했을 때도** 시각에 반영된다.\n * (reflect 가 없으면 `el.disabled = true` 는 화면에 나타나지 않는다.)\n *\n * ## disabled 와 readonly 의 구분\n * - `disabled`: 상호작용 불가 · 흐리게 · **폼 데이터에 포함되지 않음**(HTML 표준 거동)\n * - `readonly`: 값을 **보고 선택·복사할 수 있으나** 수정 불가 · **폼 데이터에는 포함됨**\n * → 조회 화면을 `disabled` 로 잠그면 흐려지고 값도 전송되지 않으므로 `readonly` 를 쓴다.\n *\n * ## 검증\n * `checkValidity()` 가 `invalid` 를 갱신하고 결과를 반환한다.\n * 컴포넌트별 규칙(형식·범위 등)이 있으면 `validateValue()` 를 오버라이드한다(기본은 `required` 만 검사).\n */\nexport abstract class OxFormField<T = any> extends LitElement {\n @property({ type: String, attribute: true }) name?: string\n @property({ type: Boolean, reflect: true }) disabled?: boolean\n\n /** 값 표시·선택은 되지만 수정 불가. disabled 와 달리 폼 데이터에 포함된다. */\n @property({ type: Boolean, reflect: true }) readonly?: boolean\n\n /** 값이 반드시 있어야 함. checkValidity() 의 기준. */\n @property({ type: Boolean, reflect: true }) required?: boolean\n\n /** 검증 실패 상태. InputStyles 가 error 색으로 표시한다. */\n @property({ type: Boolean, reflect: true }) invalid?: boolean\n\n /** 비어 있을 때 보여줄 안내 문구(컴포넌트가 지원하는 경우). */\n @property({ type: String }) placeholder?: string\n\n @property() value?: T\n\n protected _form: HTMLFormElement | null = null\n protected _formdataEventHandler: EventListener | null = null\n\n override connectedCallback(): void {\n super.connectedCallback()\n\n if (this.name) {\n this._form = this.closest('form')\n if (this._form) {\n this._formdataEventHandler = this.appendFormData.bind(this) as EventListener\n this._form.addEventListener('formdata', this._formdataEventHandler)\n }\n }\n }\n\n override disconnectedCallback(): void {\n super.disconnectedCallback()\n\n if (this._form) {\n this._form.removeEventListener('formdata', this._formdataEventHandler!)\n this._form = null\n this._formdataEventHandler = null\n }\n }\n\n /**\n * 값이 비었는지 판단. 문자열·배열·nullish 를 다룬다.\n * 숫자 `0` 과 `false` 는 **유효한 값**이므로 비었다고 보지 않는다.\n */\n protected isEmpty(): boolean {\n const value = this.value as unknown\n\n if (value === undefined || value === null) return true\n if (typeof value === 'string') return value.trim().length === 0\n if (Array.isArray(value)) return value.length === 0\n\n return false\n }\n\n /**\n * 자체 검증 규칙. 기본은 `required` 만 검사한다.\n * 컴포넌트별 규칙이 있으면 오버라이드한다.\n */\n protected validateValue(): boolean {\n return !(this.required && this.isEmpty())\n }\n\n /**\n * 검증을 실행해 `invalid` 를 갱신하고 결과를 반환한다.\n * 실패 시 `invalid` 이벤트를 올려 폼이 반응할 수 있게 한다.\n */\n checkValidity(): boolean {\n const valid = this.validateValue()\n\n this.invalid = !valid\n if (!valid) {\n this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, composed: true, detail: { field: this.name } }))\n }\n\n return valid\n }\n\n protected appendFormData({ formData }: FormDataEvent): void {\n if (!this.name) return\n /* disabled 컨트롤은 폼 데이터에 포함하지 않는다(HTML 표준 거동).\n readonly 는 수정만 막는 상태이므로 포함한다. */\n if (this.disabled) return\n\n let values: T | T[] = this.value!\n if (!(values instanceof Array)) {\n values = [this.value] as T[]\n }\n\n values.forEach(value => {\n formData.append(\n this.name!,\n typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value)\n )\n })\n }\n}\n"]}
1
+ {"version":3,"file":"ox-form-field.js","sourceRoot":"","sources":["../../src/ox-form-field.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement } from 'lit'\nimport { property } from 'lit/decorators.js'\n\n/**\n * 모든 입력 컴포넌트의 기반 계약.\n *\n * ## 상태 계약\n * `disabled` / `readonly` / `required` / `invalid` 는 **attribute 로 reflect** 된다.\n * 그래야 `InputStyles` 의 `:host([disabled])` · `:host([readonly])` · `:host([invalid])` 가\n * **마크업으로 설정했을 때뿐 아니라 프로퍼티로 설정했을 때도** 시각에 반영된다.\n * (reflect 가 없으면 `el.disabled = true` 는 화면에 나타나지 않는다.)\n *\n * ## disabled 와 readonly 의 구분\n * - `disabled`: 상호작용 불가 · 흐리게 · **폼 데이터에 포함되지 않음**(HTML 표준 거동)\n * - `readonly`: 값을 **보고 선택·복사할 수 있으나** 수정 불가 · **폼 데이터에는 포함됨**\n * → 조회 화면을 `disabled` 로 잠그면 흐려지고 값도 전송되지 않으므로 `readonly` 를 쓴다.\n *\n * ## 검증\n * `checkValidity()` 가 `invalid` 를 갱신하고 결과를 반환한다.\n * 컴포넌트별 규칙(형식·범위 등)이 있으면 `validateValue()` 를 오버라이드한다(기본은 `required` 만 검사).\n */\nexport abstract class OxFormField<T = any> extends LitElement {\n @property({ type: String, attribute: true }) name?: string\n @property({ type: Boolean, reflect: true }) disabled?: boolean\n\n /** 값 표시·선택은 되지만 수정 불가. disabled 와 달리 폼 데이터에 포함된다. */\n @property({ type: Boolean, reflect: true }) readonly?: boolean\n\n /** 값이 반드시 있어야 함. checkValidity() 의 기준. */\n @property({ type: Boolean, reflect: true }) required?: boolean\n\n /** 검증 실패 상태. InputStyles 가 error 색으로 표시한다. */\n @property({ type: Boolean, reflect: true }) invalid?: boolean\n\n /** 비어 있을 때 보여줄 안내 문구(컴포넌트가 지원하는 경우). */\n @property({ type: String }) placeholder?: string\n\n @property() value?: T\n\n protected _form: HTMLFormElement | null = null\n protected _formdataEventHandler: EventListener | null = null\n\n override connectedCallback(): void {\n super.connectedCallback()\n\n if (this.name) {\n this._form = this.closest('form')\n if (this._form) {\n this._formdataEventHandler = this.appendFormData.bind(this) as EventListener\n this._form.addEventListener('formdata', this._formdataEventHandler)\n }\n }\n }\n\n override disconnectedCallback(): void {\n super.disconnectedCallback()\n\n if (this._form) {\n this._form.removeEventListener('formdata', this._formdataEventHandler!)\n this._form = null\n this._formdataEventHandler = null\n }\n }\n\n /**\n * 값이 비었는지 판단. 문자열·배열·nullish 를 다룬다.\n * 숫자 `0` 과 `false` 는 **유효한 값**이므로 비었다고 보지 않는다.\n */\n protected isEmpty(): boolean {\n const value = this.value as unknown\n\n if (value === undefined || value === null) return true\n if (typeof value === 'string') return value.trim().length === 0\n if (Array.isArray(value)) return value.length === 0\n\n return false\n }\n\n /**\n * 자체 검증 규칙. 기본은 `required` 만 검사한다.\n * 컴포넌트별 규칙이 있으면 오버라이드한다.\n */\n protected validateValue(): boolean {\n return !(this.required && this.isEmpty())\n }\n\n /**\n * 검증을 실행해 `invalid` 를 갱신하고 결과를 반환한다.\n * 실패 시 `invalid` 이벤트를 올려 폼이 반응할 수 있게 한다.\n */\n checkValidity(): boolean {\n const valid = this.validateValue()\n\n this.invalid = !valid\n if (!valid) {\n this.dispatchEvent(new CustomEvent('invalid', { bubbles: true, composed: true, detail: { field: this.name } }))\n }\n\n return valid\n }\n\n protected appendFormData({ formData }: FormDataEvent): void {\n if (!this.name) return\n /* disabled 컨트롤은 폼 데이터에 포함하지 않는다(HTML 표준 거동).\n readonly 는 수정만 막는 상태이므로 포함한다. */\n if (this.disabled) return\n\n let values: T | T[] = this.value!\n if (!(values instanceof Array)) {\n values = [this.value] as T[]\n }\n\n values.forEach(value => {\n /* A file goes as itself, name and all; turned to JSON it was \"{}\". */\n if (value instanceof Blob) {\n formData.append(this.name!, value)\n return\n }\n\n formData.append(\n this.name!,\n typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value)\n )\n })\n }\n}\n"]}
@@ -3,22 +3,81 @@
3
3
  */
4
4
  import '@material/web/icon/icon.js';
5
5
  import { PropertyValues } from 'lit';
6
+ import { FileUploadLimits } from '@operato/utils';
6
7
  import { OxFormField } from './ox-form-field.js';
8
+ /**
9
+ * Picks files — the one picker for every screen.
10
+ *
11
+ * Two looks:
12
+ * (default) a drop box, for a screen where the file is the subject (an import, an attachment gallery)
13
+ * quiet text buttons only — 「첨부」, then the picked name with 「바꾸기」「빼기」 — for a screen that is
14
+ * read first (a 360 section, a form row). The drop area is `dropTarget` (the section) and
15
+ * shows only while a file is dragged over it.
16
+ *
17
+ * Both refuse on the spot what the server would refuse after the upload: a type outside `accept`
18
+ * (a drop skips the native filter), a file over the server's size limit, more files than it takes.
19
+ * The limits are the server's own numbers (`fileUploadLimits` in @operato/utils), never written here.
20
+ *
21
+ * The owner uploads; while it does, it sets `progress` (0‒1) and the picker shows it.
22
+ *
23
+ * A panel with its own look styles the quiet buttons through `::part(attach | replace | remove)` and puts its
24
+ * own icon in the attach button through `slot="icon"`. File names are shown as written.
25
+ */
7
26
  export declare class OxInputFile extends OxFormField {
8
27
  static styles: import("lit").CSSResult[];
9
28
  multiple?: boolean;
10
29
  accept?: string;
30
+ /** The drop box's icon (upload when not given); in the quiet look, shown before the attach word only when given. */
11
31
  icon?: string;
12
32
  label?: string;
13
33
  description?: string;
14
34
  hideFileList: boolean;
15
35
  attachFileList: boolean;
36
+ /** Text buttons instead of the drop box — for a screen that is read first. */
37
+ quiet: boolean;
38
+ /** Where a file may be dropped. The picker itself when not given; a 360 passes its section. */
39
+ dropTarget?: HTMLElement;
40
+ /**
41
+ * The owner's own button (a page action) calls `pick()`: the picker draws no button of its own, only what it
42
+ * refused and the upload progress, where the owner placed it.
43
+ */
44
+ outsideTrigger: boolean;
45
+ /**
46
+ * A screen's own limits, handed down by the server (an AI mode's attachment policy) — never written on the screen.
47
+ * The picker uses the tighter of these and the server's upload limits.
48
+ */
49
+ limits?: FileUploadLimits;
50
+ /** The file is read in the browser and never uploaded: the server's upload limits do not apply (accept still does). */
51
+ local: boolean;
52
+ /** Upload progress 0‒1, set by the owner while it uploads; the buttons wait meanwhile. */
53
+ progress?: number;
16
54
  fileInput: HTMLInputElement;
55
+ private refusals;
56
+ private refusalLimits;
57
+ private dropArea?;
58
+ private undoDrop?;
59
+ private previews;
60
+ private onFileDrop;
61
+ private get uploading();
17
62
  render(): import("lit-html").TemplateResult<1>;
63
+ private renderInput;
64
+ private renderBox;
65
+ private renderQuiet;
66
+ private renderKind;
67
+ private renderProgress;
68
+ private renderRefusals;
18
69
  updated(changes: PropertyValues<this>): void;
70
+ connectedCallback(): void;
19
71
  firstUpdated(): void;
72
+ disconnectedCallback(): void;
73
+ private setDropArea;
74
+ private syncPreviews;
20
75
  get files(): any;
76
+ /** Opens the file dialog — for an owner whose own button starts the pick. */
77
+ pick(): void;
21
78
  reset(): void;
79
+ private removeFile;
80
+ private take;
22
81
  _onChangeValue(e: Event): void;
23
82
  _notifyChange(): void;
24
83
  }
@@ -3,25 +3,63 @@
3
3
  */
4
4
  import { __decorate } from "tslib";
5
5
  import '@material/web/icon/icon.js';
6
- import { css, html } from 'lit';
7
- import { customElement, property, query } from 'lit/decorators.js';
8
- import { ScrollbarStyles } from '@operato/styles';
9
- import { FileDropHelper } from '@operato/utils';
6
+ import { css, html, nothing } from 'lit';
7
+ import { customElement, property, query, state } from 'lit/decorators.js';
8
+ import { i18next } from '@operato/i18n';
9
+ import { actionHeight, ButtonStyles, ScrollbarStyles } from '@operato/styles';
10
+ import { FileDropHelper, fileUploadLimits, narrowLimits, sortPickedFiles } from '@operato/utils';
10
11
  import { OxFormField } from './ox-form-field.js';
11
12
  import { InputStyles } from './input-styles.js';
13
+ const megabytes = (bytes) => bytes >= 1048576 ? `${Math.round((bytes / 1048576) * 10) / 10}MB` : `${Math.max(1, Math.round(bytes / 1024))}KB`;
14
+ /**
15
+ * Picks files — the one picker for every screen.
16
+ *
17
+ * Two looks:
18
+ * (default) a drop box, for a screen where the file is the subject (an import, an attachment gallery)
19
+ * quiet text buttons only — 「첨부」, then the picked name with 「바꾸기」「빼기」 — for a screen that is
20
+ * read first (a 360 section, a form row). The drop area is `dropTarget` (the section) and
21
+ * shows only while a file is dragged over it.
22
+ *
23
+ * Both refuse on the spot what the server would refuse after the upload: a type outside `accept`
24
+ * (a drop skips the native filter), a file over the server's size limit, more files than it takes.
25
+ * The limits are the server's own numbers (`fileUploadLimits` in @operato/utils), never written here.
26
+ *
27
+ * The owner uploads; while it does, it sets `progress` (0‒1) and the picker shows it.
28
+ *
29
+ * A panel with its own look styles the quiet buttons through `::part(attach | replace | remove)` and puts its
30
+ * own icon in the attach button through `slot="icon"`. File names are shown as written.
31
+ */
12
32
  let OxInputFile = class OxInputFile extends OxFormField {
13
33
  constructor() {
14
34
  super(...arguments);
15
- this.icon = 'upload';
16
- this.description = 'drop files here!';
17
35
  this.hideFileList = false;
18
36
  this.attachFileList = false;
37
+ /** Text buttons instead of the drop box — for a screen that is read first. */
38
+ this.quiet = false;
39
+ /**
40
+ * The owner's own button (a page action) calls `pick()`: the picker draws no button of its own, only what it
41
+ * refused and the upload progress, where the owner placed it.
42
+ */
43
+ this.outsideTrigger = false;
44
+ /** The file is read in the browser and never uploaded: the server's upload limits do not apply (accept still does). */
45
+ this.local = false;
46
+ this.refusals = [];
47
+ this.refusalLimits = {};
48
+ this.previews = new Map();
49
+ this.onFileDrop = (e) => {
50
+ if (this.disabled || this.readonly || this.uploading) {
51
+ return;
52
+ }
53
+ const files = e.detail;
54
+ this.take(this.multiple ? files : files.slice(0, 1));
55
+ };
19
56
  }
20
57
  static { this.styles = [
21
58
  InputStyles,
22
59
  ScrollbarStyles,
60
+ ButtonStyles,
23
61
  css `
24
- :host {
62
+ :host(:not([quiet])) {
25
63
  display: flex;
26
64
  flex-direction: column;
27
65
  border-radius: var(--md-sys-shape-corner-small);
@@ -29,23 +67,22 @@ let OxInputFile = class OxInputFile extends OxFormField {
29
67
  justify-content: center;
30
68
  padding: calc(var(--ox-input-gap) * 2);
31
69
  min-height: 100px;
32
- text-transform: capitalize;
33
70
 
34
71
  border: 1px dashed var(--md-sys-color-outline-variant);
35
72
  background-color: var(--md-sys-color-surface);
36
73
  font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
37
74
  }
38
- :host > span {
75
+ :host(:not([quiet])) > span {
39
76
  color: var(--md-sys-color-primary);
40
77
  font-weight: var(--md-sys-typescale-label-medium-weight, var(--md-ref-typeface-weight-medium, 500));
41
78
  }
42
79
 
43
- :host > md-icon {
80
+ :host(:not([quiet])) > md-icon {
44
81
  color: var(--md-sys-color-primary);
45
82
  --md-icon-size: var(--file-uploader-icon-size, 36px);
46
83
  }
47
84
 
48
- :host(.candrop) {
85
+ :host(.candrop:not([quiet])) {
49
86
  background-color: var(--md-sys-color-primary-fixed);
50
87
  border: 1px dashed var(--md-sys-color-primary-fixed-dim);
51
88
  }
@@ -58,7 +95,6 @@ let OxInputFile = class OxInputFile extends OxFormField {
58
95
  position: relative;
59
96
  width: auto;
60
97
  border: none;
61
- text-transform: capitalize;
62
98
 
63
99
  padding: var(--file-uploader-label-padding);
64
100
  border-radius: var(--file-uploader-label-border-radius);
@@ -95,27 +131,98 @@ let OxInputFile = class OxInputFile extends OxFormField {
95
131
  li md-icon:active {
96
132
  color: var(--md-sys-color-error);
97
133
  }
134
+
135
+ /* ── quiet ── no box: the words are the control. */
136
+ /* --ox-actions-align: a section heading's actions slot says end, and the refusal line follows it. */
137
+ :host([quiet]) {
138
+ display: inline-flex;
139
+ flex-wrap: wrap;
140
+ align-items: center;
141
+ justify-content: var(--ox-actions-align, start);
142
+ gap: var(--spacing-small, 4px);
143
+ /* The name reads at the size of what is around it (a form row, a heading); the buttons keep theirs. */
144
+ font-size: inherit;
145
+ color: var(--md-sys-color-on-surface);
146
+ border-radius: var(--md-sys-shape-corner-small, 4px);
147
+ }
148
+
149
+ :host([quiet].candrop) {
150
+ outline: 1px dashed var(--md-sys-color-primary);
151
+ outline-offset: 2px;
152
+ }
153
+
154
+ .picked {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: var(--spacing-small, 4px);
158
+ min-width: 0;
159
+ }
160
+
161
+ .picked .name {
162
+ overflow: hidden;
163
+ text-overflow: ellipsis;
164
+ white-space: nowrap;
165
+ max-width: var(--ox-input-file-name-width, 24em);
166
+ }
167
+
168
+ .picked .size,
169
+ .progress {
170
+ color: var(--md-sys-color-on-surface-variant);
171
+ font-variant-numeric: tabular-nums;
172
+ }
173
+
174
+ /* A place that is tapped with a thumb (a phone form) raises it; a heading or a row keeps the cell height. */
175
+ :host([quiet]) button {
176
+ height: var(--ox-input-file-action-height, ${actionHeight('--cell-action-height')});
177
+ }
178
+
179
+ .picked md-icon {
180
+ --md-icon-size: 16px;
181
+ color: var(--md-sys-color-on-surface-variant);
182
+ }
183
+
184
+ .picked img {
185
+ width: 20px;
186
+ height: 20px;
187
+ object-fit: cover;
188
+ border-radius: 2px;
189
+ }
190
+
191
+ .refusal {
192
+ flex-basis: 100%;
193
+ text-align: var(--ox-actions-align, start);
194
+ color: var(--ui-crit, var(--md-sys-color-error));
195
+ }
98
196
  `
99
197
  ]; }
198
+ get uploading() {
199
+ return typeof this.progress === 'number';
200
+ }
100
201
  render() {
202
+ return this.quiet ? this.renderQuiet() : this.renderBox();
203
+ }
204
+ renderInput() {
205
+ return html `<input
206
+ id="input-file"
207
+ type="file"
208
+ accept=${this.accept || ''}
209
+ ?multiple=${this.multiple}
210
+ hidden
211
+ capture="environment"
212
+ @change=${(e) => this._onChangeValue(e)}
213
+ ?disabled=${this.disabled || this.readonly}
214
+ />`;
215
+ }
216
+ renderBox() {
101
217
  var files = this.value || [];
102
218
  return html `
103
219
  <md-icon>${this.icon || 'upload'}</md-icon>
104
220
 
105
- <span>${this.description || 'drop files here!'}</span>
221
+ <span>${this.description || i18next.t('text.drop files here', { defaultValue: 'drop files here!' })}</span>
106
222
 
107
- <input
108
- id="input-file"
109
- type="file"
110
- accept=${this.accept}
111
- ?multiple=${this.multiple}
112
- hidden
113
- capture="environment"
114
- @change=${(e) => this._onChangeValue(e)}
115
- ?disabled=${this.disabled}
116
- />
223
+ ${this.renderInput()}
117
224
 
118
- <label for="input-file">${this.label || 'select files'}</label>
225
+ <label for="input-file">${this.label || i18next.t('label.select files', { defaultValue: 'select files' })}</label>
119
226
 
120
227
  ${!this.hideFileList && files?.length
121
228
  ? html ` <ul>
@@ -127,9 +234,7 @@ let OxInputFile = class OxInputFile extends OxFormField {
127
234
  if (this.disabled) {
128
235
  return;
129
236
  }
130
- files.splice(files.indexOf(file), 1);
131
- this.value = [...files];
132
- this._notifyChange();
237
+ this.removeFile(file);
133
238
  }}
134
239
  >delete_outline</md-icon
135
240
  >
@@ -137,41 +242,173 @@ let OxInputFile = class OxInputFile extends OxFormField {
137
242
  `)}
138
243
  </ul>`
139
244
  : html ``}
245
+ ${this.renderRefusals()} ${this.renderProgress()}
140
246
  `;
141
247
  }
248
+ renderQuiet() {
249
+ const files = this.value || [];
250
+ const busy = this.disabled || this.readonly || this.uploading;
251
+ const shown = this.hideFileList ? [] : files;
252
+ if (this.outsideTrigger) {
253
+ return html `${this.renderInput()} ${this.renderProgress()} ${this.renderRefusals()}`;
254
+ }
255
+ return html `
256
+ ${this.renderInput()}
257
+ ${shown.map(file => html `<span class="picked">
258
+ ${this.renderKind(file)}
259
+ <span class="name" title=${file.name}>${file.name}</span>
260
+ <span class="size">${megabytes(file.size)}</span>
261
+ ${!this.multiple
262
+ ? html `<button type="button" part="replace" quiet small ?disabled=${busy} @click=${() => this.pick()}>
263
+ ${i18next.t('label.replace file', { defaultValue: 'replace' })}
264
+ </button>`
265
+ : nothing}
266
+ <button type="button" part="remove" quiet small danger ?disabled=${busy} @click=${() => this.removeFile(file)}>
267
+ ${i18next.t('label.remove file', { defaultValue: 'remove' })}
268
+ </button>
269
+ </span>`)}
270
+ ${this.multiple || !shown.length
271
+ ? html `<button type="button" part="attach" quiet small ?disabled=${busy} @click=${() => this.pick()}>
272
+ <slot name="icon">${this.icon ? html `<md-icon>${this.icon}</md-icon>` : nothing}</slot>${this.label || i18next.t('label.attach file', { defaultValue: 'attach' })}
273
+ </button>`
274
+ : nothing}
275
+ ${this.renderProgress()} ${this.renderRefusals()}
276
+ `;
277
+ }
278
+ /* A picture shows itself; anything else is told by its kind. */
279
+ renderKind(file) {
280
+ const preview = this.previews.get(file);
281
+ if (preview)
282
+ return html `<img src=${preview} alt="" />`;
283
+ const icon = file.type === 'application/pdf' ? 'picture_as_pdf' : 'draft';
284
+ return html `<md-icon>${icon}</md-icon>`;
285
+ }
286
+ renderProgress() {
287
+ if (!this.uploading)
288
+ return nothing;
289
+ const percent = Math.round(Math.min(1, Math.max(0, this.progress)) * 100);
290
+ return html `<span class="progress" role="status">
291
+ ${i18next.t('text.uploading file', { percent, defaultValue: `uploading ${percent}%` })}
292
+ </span>`;
293
+ }
294
+ renderRefusals() {
295
+ if (!this.refusals.length)
296
+ return nothing;
297
+ const { maxFileSize, maxFiles } = this.refusalLimits;
298
+ return html `<div class="refusal" role="alert">
299
+ ${this.refusals.map(({ file, reason }) => reason === 'type'
300
+ ? i18next.t('text.file refused for type', { name: file.name, defaultValue: `${file.name}: not a type taken here` })
301
+ : reason === 'size'
302
+ ? i18next.t('text.file refused for size', {
303
+ name: file.name,
304
+ limit: megabytes(maxFileSize),
305
+ defaultValue: `${file.name}: over ${megabytes(maxFileSize)}`
306
+ })
307
+ : i18next.t('text.file refused for count', {
308
+ name: file.name,
309
+ limit: maxFiles,
310
+ defaultValue: `${file.name}: ${maxFiles} files at most`
311
+ })).join(' · ')}
312
+ </div>`;
313
+ }
142
314
  updated(changes) {
143
315
  if (changes.has('value') && !(this.value instanceof Array)) {
144
316
  this.value = !this.value ? [] : [this.value];
145
317
  }
318
+ if (changes.has('value')) {
319
+ this.syncPreviews();
320
+ }
321
+ if (changes.has('dropTarget')) {
322
+ this.setDropArea();
323
+ }
324
+ }
325
+ connectedCallback() {
326
+ super.connectedCallback();
327
+ if (this.hasUpdated)
328
+ this.setDropArea();
146
329
  }
147
330
  firstUpdated() {
148
- FileDropHelper.set(this);
149
- this.addEventListener('file-drop', (e) => {
150
- if (this.disabled) {
151
- return;
331
+ this.setDropArea();
332
+ }
333
+ disconnectedCallback() {
334
+ super.disconnectedCallback();
335
+ this.undoDrop?.();
336
+ this.dropArea?.removeEventListener('file-drop', this.onFileDrop);
337
+ this.undoDrop = this.dropArea = undefined;
338
+ this.previews.forEach(url => URL.revokeObjectURL(url));
339
+ this.previews.clear();
340
+ }
341
+ setDropArea() {
342
+ const area = this.dropTarget || this;
343
+ if (area === this.dropArea)
344
+ return;
345
+ this.undoDrop?.();
346
+ this.dropArea?.removeEventListener('file-drop', this.onFileDrop);
347
+ this.dropArea = area;
348
+ this.undoDrop = FileDropHelper.set(area);
349
+ area.addEventListener('file-drop', this.onFileDrop);
350
+ }
351
+ syncPreviews() {
352
+ const files = this.value || [];
353
+ this.previews.forEach((url, file) => {
354
+ if (!files.includes(file)) {
355
+ URL.revokeObjectURL(url);
356
+ this.previews.delete(file);
357
+ }
358
+ });
359
+ if (!this.quiet)
360
+ return;
361
+ files.forEach(file => {
362
+ if (!this.previews.has(file) && file.type?.startsWith('image/')) {
363
+ this.previews.set(file, URL.createObjectURL(file));
152
364
  }
153
- const detail = e.detail;
154
- this.value = this.multiple ? detail : detail[0] ? [detail[0]] : [];
155
- this._notifyChange();
156
365
  });
157
366
  }
158
367
  get files() {
159
368
  return this.value;
160
369
  }
370
+ /** Opens the file dialog — for an owner whose own button starts the pick. */
371
+ pick() {
372
+ if (this.disabled || this.readonly || this.uploading)
373
+ return;
374
+ this.fileInput?.click();
375
+ }
161
376
  reset() {
162
- this.fileInput.value = '';
377
+ if (this.fileInput)
378
+ this.fileInput.value = '';
163
379
  this.value = [];
380
+ this.refusals = [];
164
381
  this._notifyChange();
165
382
  }
166
- _onChangeValue(e) {
167
- const fileInput = e.currentTarget;
383
+ removeFile(file) {
384
+ const files = this.value || [];
385
+ this.value = files.filter(one => one !== file);
386
+ this.refusals = [];
387
+ this._notifyChange();
388
+ }
389
+ async take(picked) {
168
390
  const files = this.value || [];
169
- this.value = this.attachFileList
170
- ? [...files, ...Array.from(fileInput.files)]
171
- : [...Array.from(fileInput.files)];
172
- fileInput.files = null;
391
+ const keep = this.attachFileList;
392
+ const limits = this.local ? narrowLimits(this.limits) : narrowLimits(this.limits, await fileUploadLimits());
393
+ const { taken, refused } = sortPickedFiles(picked, {
394
+ accept: this.accept,
395
+ limits,
396
+ already: keep ? files.length : 0
397
+ });
398
+ this.refusals = refused;
399
+ this.refusalLimits = limits;
400
+ if (!taken.length)
401
+ return;
402
+ this.value = keep ? [...files, ...taken] : taken;
173
403
  this._notifyChange();
174
404
  }
405
+ _onChangeValue(e) {
406
+ const fileInput = e.currentTarget;
407
+ const picked = Array.from(fileInput.files);
408
+ /* The same file picked again must still raise change. */
409
+ fileInput.value = '';
410
+ this.take(picked);
411
+ }
175
412
  _notifyChange() {
176
413
  this.dispatchEvent(new CustomEvent('change', {
177
414
  bubbles: true,
@@ -201,9 +438,33 @@ __decorate([
201
438
  __decorate([
202
439
  property({ type: Boolean, reflect: true, attribute: 'attach-filelist' })
203
440
  ], OxInputFile.prototype, "attachFileList", void 0);
441
+ __decorate([
442
+ property({ type: Boolean, reflect: true })
443
+ ], OxInputFile.prototype, "quiet", void 0);
444
+ __decorate([
445
+ property({ attribute: false })
446
+ ], OxInputFile.prototype, "dropTarget", void 0);
447
+ __decorate([
448
+ property({ type: Boolean, reflect: true, attribute: 'outside-trigger' })
449
+ ], OxInputFile.prototype, "outsideTrigger", void 0);
450
+ __decorate([
451
+ property({ attribute: false })
452
+ ], OxInputFile.prototype, "limits", void 0);
453
+ __decorate([
454
+ property({ type: Boolean, reflect: true })
455
+ ], OxInputFile.prototype, "local", void 0);
456
+ __decorate([
457
+ property({ type: Number })
458
+ ], OxInputFile.prototype, "progress", void 0);
204
459
  __decorate([
205
460
  query('#input-file')
206
461
  ], OxInputFile.prototype, "fileInput", void 0);
462
+ __decorate([
463
+ state()
464
+ ], OxInputFile.prototype, "refusals", void 0);
465
+ __decorate([
466
+ state()
467
+ ], OxInputFile.prototype, "refusalLimits", void 0);
207
468
  OxInputFile = __decorate([
208
469
  customElement('ox-input-file')
209
470
  ], OxInputFile);