@origostudio/origo-design 0.0.52 → 0.0.53

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 (81) hide show
  1. package/CHANGELOG.md +3 -6
  2. package/dist/packages/angular-renderer/CHANGELOG.md +10 -0
  3. package/dist/packages/angular-renderer/package.json +4 -3
  4. package/dist/packages/angular-renderer/src/components/primitives/date-range-picker/date-range-picker.component.d.ts +1 -1
  5. package/dist/packages/angular-renderer/src/components/primitives/date-range-picker/date-range-picker.component.js.map +1 -1
  6. package/dist/packages/angular-renderer/src/components/primitives/divider/divider.component.js +1 -1
  7. package/dist/packages/angular-renderer/src/components/primitives/divider/divider.component.js.map +1 -1
  8. package/dist/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.d.ts +70 -0
  9. package/dist/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.js +263 -0
  10. package/dist/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.js.map +1 -0
  11. package/dist/packages/angular-renderer/src/components/primitives/file-input/file-input.component.d.ts +64 -0
  12. package/dist/packages/angular-renderer/src/components/primitives/file-input/file-input.component.js +192 -0
  13. package/dist/packages/angular-renderer/src/components/primitives/file-input/file-input.component.js.map +1 -0
  14. package/dist/packages/angular-renderer/src/components/primitives/grid/grid.component.js +2 -2
  15. package/dist/packages/angular-renderer/src/components/primitives/grid/grid.component.js.map +1 -1
  16. package/dist/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.d.ts +44 -0
  17. package/dist/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.js +143 -0
  18. package/dist/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.js.map +1 -0
  19. package/dist/packages/angular-renderer/src/components/primitives/stack/stack.component.js +3 -3
  20. package/dist/packages/angular-renderer/src/components/primitives/stack/stack.component.js.map +1 -1
  21. package/dist/packages/angular-renderer/src/index.d.ts +3 -0
  22. package/dist/packages/angular-renderer/src/index.js +3 -0
  23. package/dist/packages/angular-renderer/src/index.js.map +1 -1
  24. package/dist/packages/angular-renderer/src/lib/primitives.provider.js +6 -0
  25. package/dist/packages/angular-renderer/src/lib/primitives.provider.js.map +1 -1
  26. package/dist/packages/cli/CHANGELOG.md +4 -0
  27. package/dist/packages/cli/package.json +2 -2
  28. package/dist/packages/cli/package.json.bak +2 -2
  29. package/dist/packages/core/CHANGELOG.md +4 -0
  30. package/dist/packages/core/package.json +1 -1
  31. package/dist/packages/core/package.json.bak +1 -1
  32. package/dist/packages/design-tokens/CHANGELOG.md +4 -0
  33. package/dist/packages/design-tokens/package.json +1 -1
  34. package/dist/packages/playground/browser/{chunk-DPJ2_PnQ.js → chunk-BD3aW3fo.js} +1 -1
  35. package/dist/packages/playground/browser/{chunk-ClEEX1ib.js → chunk-BGrPMh-Q.js} +1 -1
  36. package/dist/packages/playground/browser/{chunk-IRlAEjxV.js → chunk-Bb34YjCX.js} +1 -1
  37. package/dist/packages/playground/browser/{chunk-BDJ6g_eZ.js → chunk-CYa5xYdO.js} +1 -1
  38. package/dist/packages/playground/browser/{chunk-kBISgbOR.js → chunk-CZYE6Vo5.js} +1 -1
  39. package/dist/packages/playground/browser/{chunk-BRY4TA8M.js → chunk-CoyK3eaz.js} +1 -1
  40. package/dist/packages/playground/browser/{chunk-D8i35nMZ.js → chunk-Cxq4lcyl.js} +1 -1
  41. package/dist/packages/playground/browser/{chunk-kdIujLCE.js → chunk-D-gQXFhO.js} +1 -1
  42. package/dist/packages/playground/browser/{chunk-lkXWJECn.js → chunk-DFUtHoSa.js} +1 -1
  43. package/dist/packages/playground/browser/{chunk-BdpxuY7o.js → chunk-DGFBCrz0.js} +1 -1
  44. package/dist/packages/playground/browser/{chunk-C18SRlXx.js → chunk-DjQs2rZT.js} +1 -1
  45. package/dist/packages/playground/browser/{chunk-FPNisYyW.js → chunk-DoDHfFRC.js} +1 -1
  46. package/dist/packages/playground/browser/{chunk-MmjtX4PA.js → chunk-K_6Vunnz.js} +1 -1
  47. package/dist/packages/playground/browser/{chunk-DpG_PyU_.js → chunk-OWSRr_rS.js} +1 -1
  48. package/dist/packages/playground/browser/{chunk-BKu6N-zK.js → chunk-kcBcpdDQ.js} +1 -1
  49. package/dist/packages/playground/browser/index.html +1 -1
  50. package/dist/packages/playground/browser/main-ZTUYGW6H.js +748 -0
  51. package/package.json +1 -1
  52. package/packages/angular-renderer/CHANGELOG.md +10 -0
  53. package/packages/angular-renderer/package.json +4 -3
  54. package/packages/angular-renderer/src/components/primitives/date-range-picker/date-range-picker.component.ts +1 -1
  55. package/packages/angular-renderer/src/components/primitives/divider/divider.component.ts +2 -2
  56. package/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.html +24 -0
  57. package/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.scss +32 -0
  58. package/packages/angular-renderer/src/components/primitives/dropzone/dropzone.component.ts +293 -0
  59. package/packages/angular-renderer/src/components/primitives/file-input/file-input.component.html +13 -0
  60. package/packages/angular-renderer/src/components/primitives/file-input/file-input.component.scss +32 -0
  61. package/packages/angular-renderer/src/components/primitives/file-input/file-input.component.ts +216 -0
  62. package/packages/angular-renderer/src/components/primitives/grid/grid.component.ts +6 -5
  63. package/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.html +23 -0
  64. package/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.scss +33 -0
  65. package/packages/angular-renderer/src/components/primitives/rich-text-editor/rich-text-editor.component.ts +165 -0
  66. package/packages/angular-renderer/src/components/primitives/stack/stack.component.ts +6 -6
  67. package/packages/angular-renderer/src/index.ts +3 -0
  68. package/packages/angular-renderer/src/lib/primitives.provider.ts +6 -0
  69. package/packages/cli/CHANGELOG.md +4 -0
  70. package/packages/cli/package.json +2 -2
  71. package/packages/core/CHANGELOG.md +4 -0
  72. package/packages/core/package.json +1 -1
  73. package/packages/design-tokens/CHANGELOG.md +4 -0
  74. package/packages/design-tokens/package.json +1 -1
  75. package/packages/devtools/CHANGELOG.md +4 -0
  76. package/packages/devtools/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  77. package/packages/devtools/package.json +3 -3
  78. package/packages/playground/CHANGELOG.md +4 -0
  79. package/packages/playground/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  80. package/packages/playground/package.json +3 -3
  81. package/dist/packages/playground/browser/main-CD73IVQX.js +0 -744
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@origostudio/origo-design",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,13 @@
1
+ ## 0.0.53 (2026-10-09)
2
+
3
+ ### 🚀 Features
4
+
5
+ - implement file-input, dropzone, and rich-text-editor angular primitives ([69cc864](https://github.com/OrigoStudio/origo-design/commit/69cc864))
6
+
7
+ ### ❤️ Thank You
8
+
9
+ - Hardik Patel @hardikpatelse
10
+
1
11
  ## 0.0.52 (2026-10-09)
2
12
 
3
13
  ### 🚀 Features
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@origostudio/angular-renderer",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "dependencies": {
9
- "@origostudio/core": "0.0.52",
10
- "@origostudio/design-tokens": "0.0.52",
9
+ "@origostudio/core": "0.0.53",
10
+ "@origostudio/design-tokens": "0.0.53",
11
11
  "tslib": "^2.3.0",
12
12
  "@analogjs/vite-plugin-angular": "^2.7.1",
13
13
  "zone.js": "^0.16.3",
@@ -16,6 +16,7 @@
16
16
  "peerDependencies": {
17
17
  "@angular/common": ">=18.0.0",
18
18
  "@angular/core": ">=18.0.0",
19
+ "@angular/forms": ">=18.0.0",
19
20
  "@angular/platform-browser": ">=18.0.0"
20
21
  },
21
22
  "repository": {
@@ -61,7 +61,7 @@ export class DateRangePickerComponent implements OrigoAdapter<DateRangePickerPro
61
61
  contract = input.required<InteractionContract<DateRangePickerProps>>();
62
62
 
63
63
  // DateRangePicker doesn't use a single value model in the same way, but keeping it for adapter compat if needed
64
- value = model<any>(null);
64
+ value = model<unknown>(null);
65
65
 
66
66
  startDate = signal<string | null>(null);
67
67
  endDate = signal<string | null>(null);
@@ -37,7 +37,7 @@ const contractSchema: Record<
37
37
  };
38
38
 
39
39
  const finalSchema = { ...contractSchema };
40
- delete (finalSchema as any).thickness;
40
+ delete (finalSchema as Record<string, unknown>)['thickness'];
41
41
 
42
42
  @Component({
43
43
  selector: 'origo-divider',
@@ -64,7 +64,7 @@ export class DividerComponent implements OrigoAdapter<DividerProps> {
64
64
  protected props = computed(() => {
65
65
  const p = coerceContractProps<DividerProps>(this.contract().props, finalSchema);
66
66
  const original = this.contract().props || {};
67
- if ('thickness' in original) p.thickness = original.thickness as any;
67
+ if ('thickness' in original) p.thickness = original.thickness as string | number;
68
68
  return p;
69
69
  });
70
70
 
@@ -0,0 +1,24 @@
1
+ <div
2
+ class="origo-dropzone__wrapper"
3
+ [class.origo-dropzone--dragging]="isDragging()"
4
+ [class.origo-dropzone--disabled]="disabled"
5
+ (dragover)="onDragOver($event)"
6
+ (dragleave)="onDragLeave($event)"
7
+ (drop)="onDrop($event)"
8
+ (click)="inputEl.click()"
9
+ >
10
+ <input
11
+ #inputEl
12
+ type="file"
13
+ class="origo-dropzone__input"
14
+ [multiple]="computedMultiple()"
15
+ [accept]="computedAccept()"
16
+ [disabled]="disabled"
17
+ (change)="onFileChange($event)"
18
+ (focus)="onFocus($event)"
19
+ (blur)="onBlur($event)"
20
+ />
21
+ <div class="origo-dropzone__content">
22
+ <ng-container #vc></ng-container>
23
+ </div>
24
+ </div>
@@ -0,0 +1,32 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ .origo-dropzone__wrapper {
6
+ position: relative;
7
+ border: 2px dashed var(--origo-color-border-default);
8
+ border-radius: var(--origo-shape-border-radius-medium);
9
+ padding-block: var(--origo-spacing-large);
10
+ padding-inline: var(--origo-spacing-large);
11
+ text-align: center;
12
+ transition:
13
+ background-color var(--origo-motion-duration-short, 150ms)
14
+ var(--origo-motion-easing-standard, ease),
15
+ border-color var(--origo-motion-duration-short, 150ms) var(--origo-motion-easing-standard, ease);
16
+ background-color: var(--origo-color-surface-default);
17
+ cursor: pointer;
18
+ }
19
+
20
+ .origo-dropzone__wrapper.origo-dropzone--dragging {
21
+ background-color: var(--origo-color-surface-hover);
22
+ border-color: var(--origo-color-border-focus);
23
+ }
24
+
25
+ .origo-dropzone__input {
26
+ width: 0.1px;
27
+ height: 0.1px;
28
+ opacity: 0;
29
+ overflow: hidden;
30
+ position: absolute;
31
+ z-index: -1;
32
+ }
@@ -0,0 +1,293 @@
1
+ import {
2
+ Component,
3
+ input,
4
+ ChangeDetectionStrategy,
5
+ ViewEncapsulation,
6
+ forwardRef,
7
+ viewChild,
8
+ ViewContainerRef,
9
+ output,
10
+ computed,
11
+ signal,
12
+ inject,
13
+ ElementRef,
14
+ } from '@angular/core';
15
+ import { InteractionContract } from '@origostudio/core';
16
+ import { OrigoAdapter, coerceContractProps } from '../../../adapters/web/adapter';
17
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
18
+ import { CommonModule } from '@angular/common';
19
+ import { HttpClient, HttpEventType } from '@angular/common/http';
20
+
21
+ export interface DropzoneProps {
22
+ permissions?: Record<string, string>;
23
+ rules?: Record<string, unknown>;
24
+ metadata?: Record<string, unknown>;
25
+ multiple?: boolean;
26
+ accept?: string;
27
+ maxFileSize?: number;
28
+ uploadUrl?: string;
29
+ 'aria-label'?: string;
30
+ 'aria-describedby'?: string;
31
+ }
32
+
33
+ @Component({
34
+ selector: 'origo-dropzone',
35
+ standalone: true,
36
+ imports: [CommonModule],
37
+ templateUrl: './dropzone.component.html',
38
+ styleUrls: ['./dropzone.component.scss'],
39
+ changeDetection: ChangeDetectionStrategy.OnPush,
40
+ encapsulation: ViewEncapsulation.ShadowDom,
41
+ providers: [
42
+ {
43
+ provide: NG_VALUE_ACCESSOR,
44
+ useExisting: forwardRef(() => DropzoneComponent),
45
+ multi: true,
46
+ },
47
+ ],
48
+ host: {
49
+ '[class.origo-dropzone]': 'true',
50
+ '[attr.data-testid]': 'contract().id',
51
+ '[attr.aria-label]': 'computedAriaLabel()',
52
+ '[attr.aria-describedby]': 'computedAriaDescribedBy()',
53
+ },
54
+ })
55
+ export class DropzoneComponent implements OrigoAdapter<DropzoneProps>, ControlValueAccessor {
56
+ static readonly contractSchema = {
57
+ permissions: 'object',
58
+ rules: 'object',
59
+ metadata: 'object',
60
+ multiple: 'boolean',
61
+ accept: 'string',
62
+ maxFileSize: 'number',
63
+ uploadUrl: 'string',
64
+ 'aria-label': 'string',
65
+ 'aria-describedby': 'string',
66
+ } as const;
67
+ static readonly strictContract = false;
68
+
69
+ contract = input.required<InteractionContract<DropzoneProps>>();
70
+ vc = viewChild.required('vc', { read: ViewContainerRef });
71
+ inputRef = viewChild<ElementRef<HTMLInputElement>>('inputEl');
72
+
73
+ fileDrop = output<File[]>();
74
+ fileSelect = output<File[]>();
75
+ fileRemove = output<File>();
76
+ fileError = output<string>();
77
+ clear = output<void>();
78
+ focus = output<FocusEvent>();
79
+ blur = output<FocusEvent>();
80
+
81
+ uploadStart = output<void>();
82
+ uploadProgress = output<number>();
83
+ uploadSuccess = output<unknown>();
84
+ uploadError = output<unknown>();
85
+
86
+ private http = inject(HttpClient, { optional: true });
87
+
88
+ protected props = computed(() => {
89
+ return coerceContractProps<DropzoneProps>(
90
+ this.contract().props,
91
+ DropzoneComponent.contractSchema
92
+ );
93
+ });
94
+
95
+ computedMultiple = computed(() => !!this.props()?.multiple);
96
+ computedAccept = computed(() => this.props()?.accept ?? '*/*');
97
+ computedMaxFileSize = computed(() => this.props()?.maxFileSize);
98
+ computedUploadUrl = computed(() => this.props()?.uploadUrl);
99
+ computedAriaLabel = computed(() => this.props()?.['aria-label'] || null);
100
+ computedAriaDescribedBy = computed(() => this.props()?.['aria-describedby'] || null);
101
+
102
+ disabled = false;
103
+ files = signal<File[]>([]);
104
+ isDragging = signal<boolean>(false);
105
+
106
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
107
+ private onChange: (value: File[]) => void = () => {};
108
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
109
+ private onTouched: () => void = () => {};
110
+
111
+ writeValue(obj: unknown): void {
112
+ if (!obj) {
113
+ this.files.set([]);
114
+ const inputEl = this.inputRef()?.nativeElement;
115
+ if (inputEl) inputEl.value = '';
116
+ } else if (Array.isArray(obj)) {
117
+ this.files.set(obj);
118
+ } else {
119
+ this.files.set([obj as File]);
120
+ }
121
+ }
122
+
123
+ registerOnChange(fn: (value: File[]) => void): void {
124
+ this.onChange = fn;
125
+ }
126
+
127
+ registerOnTouched(fn: () => void): void {
128
+ this.onTouched = fn;
129
+ }
130
+
131
+ setDisabledState?(isDisabled: boolean): void {
132
+ this.disabled = isDisabled;
133
+ }
134
+
135
+ onDragOver(event: DragEvent) {
136
+ if (this.disabled) return;
137
+ event.preventDefault();
138
+ this.isDragging.set(true);
139
+ }
140
+
141
+ onDragLeave(event: DragEvent) {
142
+ if (this.disabled) return;
143
+ event.preventDefault();
144
+
145
+ // Ignore dragleave if it's just moving into a child element
146
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
147
+ if (
148
+ event.clientX > rect.left &&
149
+ event.clientX < rect.right &&
150
+ event.clientY > rect.top &&
151
+ event.clientY < rect.bottom
152
+ ) {
153
+ return;
154
+ }
155
+ this.isDragging.set(false);
156
+ }
157
+
158
+ onDrop(event: DragEvent) {
159
+ if (this.disabled) return;
160
+ event.preventDefault();
161
+ this.isDragging.set(false);
162
+ this.onTouched();
163
+
164
+ if (event.dataTransfer?.files) {
165
+ const droppedFiles = Array.from(event.dataTransfer.files);
166
+ if (!droppedFiles.length) return;
167
+ this.processFiles(droppedFiles, 'drop');
168
+ }
169
+ }
170
+
171
+ onFileChange(event: Event) {
172
+ const inputEl = event.target as HTMLInputElement;
173
+ if (inputEl.files) {
174
+ const selectedFiles = Array.from(inputEl.files);
175
+ if (!selectedFiles.length) return;
176
+ this.processFiles(selectedFiles, 'select');
177
+ inputEl.value = '';
178
+ }
179
+ }
180
+
181
+ private processFiles(incomingFiles: File[], source: 'drop' | 'select') {
182
+ const accept = this.computedAccept();
183
+ let validFiles = incomingFiles;
184
+
185
+ if (accept !== '*/*') {
186
+ const acceptedTypes = accept.split(',').map((a: string) => a.trim().toLowerCase());
187
+ validFiles = incomingFiles.filter(file => {
188
+ const fileType = file.type.toLowerCase();
189
+ const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
190
+ return acceptedTypes.some((type: string) => {
191
+ if (type.startsWith('.')) return fileExt === type;
192
+ if (type.endsWith('/*')) return fileType.startsWith(type.substring(0, type.length - 1));
193
+ return fileType === type;
194
+ });
195
+ });
196
+ if (validFiles.length < incomingFiles.length) {
197
+ this.fileError.emit(
198
+ `Some files were rejected because they do not match the accepted types: ${accept}`
199
+ );
200
+ }
201
+ }
202
+
203
+ if (!validFiles.length) return;
204
+
205
+ const maxFileSize = this.computedMaxFileSize();
206
+ if (typeof maxFileSize === 'number' && maxFileSize >= 0) {
207
+ const invalidFiles = validFiles.filter(file => file.size > maxFileSize);
208
+ if (invalidFiles.length > 0) {
209
+ this.fileError.emit(`File size exceeds maximum allowed size of ${maxFileSize} bytes`);
210
+ validFiles = validFiles.filter(file => file.size <= maxFileSize);
211
+ }
212
+ }
213
+
214
+ if (!validFiles.length) return;
215
+
216
+ const currentFiles = this.computedMultiple()
217
+ ? [...this.files(), ...validFiles]
218
+ : [...validFiles];
219
+ this.files.set(currentFiles);
220
+ this.onChange(currentFiles);
221
+
222
+ if (source === 'drop') {
223
+ this.fileDrop.emit(validFiles);
224
+ } else {
225
+ this.fileSelect.emit(validFiles);
226
+ }
227
+
228
+ const uploadUrl = this.computedUploadUrl();
229
+ if (uploadUrl && validFiles.length > 0) {
230
+ this.uploadFiles(validFiles, uploadUrl);
231
+ }
232
+ }
233
+
234
+ onFocus(event: FocusEvent) {
235
+ this.focus.emit(event);
236
+ }
237
+
238
+ onBlur(event: FocusEvent) {
239
+ this.onTouched();
240
+ this.blur.emit(event);
241
+ }
242
+
243
+ public removeFile(file: File) {
244
+ const updated = this.files().filter(f => f !== file);
245
+ this.files.set(updated);
246
+ this.onChange(updated);
247
+ this.fileRemove.emit(file);
248
+ }
249
+
250
+ public clearFiles() {
251
+ this.files.set([]);
252
+ this.onChange([]);
253
+ this.clear.emit();
254
+ const inputEl = this.inputRef()?.nativeElement;
255
+ if (inputEl) inputEl.value = '';
256
+ }
257
+
258
+ private uploadFiles(filesToUpload: File[], url: string) {
259
+ this.uploadStart.emit();
260
+
261
+ if (!this.http) {
262
+ this.uploadError.emit('HttpClient is not provided.');
263
+ return;
264
+ }
265
+
266
+ const formData = new FormData();
267
+ filesToUpload.forEach(file => formData.append('file', file));
268
+
269
+ this.http
270
+ .post(url, formData, {
271
+ reportProgress: true,
272
+ observe: 'events',
273
+ responseType: 'json',
274
+ })
275
+ .subscribe({
276
+ next: event => {
277
+ if (event.type === HttpEventType.UploadProgress) {
278
+ if (event.total && event.total > 0) {
279
+ const percentComplete = (event.loaded / event.total) * 100;
280
+ this.uploadProgress.emit(percentComplete);
281
+ } else {
282
+ this.uploadProgress.emit(100);
283
+ }
284
+ } else if (event.type === HttpEventType.Response) {
285
+ this.uploadSuccess.emit(event.body);
286
+ }
287
+ },
288
+ error: err => {
289
+ this.uploadError.emit(err.message || 'Upload failed');
290
+ },
291
+ });
292
+ }
293
+ }
@@ -0,0 +1,13 @@
1
+ <div class="origo-file-input__wrapper">
2
+ <input
3
+ #inputEl
4
+ type="file"
5
+ [multiple]="computedMultiple()"
6
+ [accept]="computedAccept()"
7
+ [disabled]="disabled"
8
+ (change)="onFileChange($event)"
9
+ (focus)="onFocus($event)"
10
+ (blur)="onBlur($event)"
11
+ />
12
+ <ng-container #vc></ng-container>
13
+ </div>
@@ -0,0 +1,32 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ .origo-file-input__wrapper {
6
+ position: relative;
7
+ border: 1px solid var(--origo-color-border-default);
8
+ border-radius: var(--origo-shape-border-radius-medium);
9
+ padding-block: var(--origo-spacing-small);
10
+ padding-inline: var(--origo-spacing-small);
11
+ background-color: var(--origo-color-surface-default);
12
+ transition:
13
+ border-color 0.15s ease-in-out,
14
+ box-shadow 0.15s ease-in-out;
15
+ }
16
+
17
+ .origo-file-input__wrapper:focus-within {
18
+ border-color: var(--origo-color-border-focus);
19
+ box-shadow: 0 0 0 2px var(--origo-color-surface-hover);
20
+ }
21
+
22
+ input[type='file'] {
23
+ width: 100%;
24
+ font-family: inherit;
25
+ font-size: var(--origo-typography-body-size);
26
+ color: var(--origo-color-text-default);
27
+ }
28
+
29
+ input[type='file']:disabled {
30
+ opacity: 0.5;
31
+ cursor: not-allowed;
32
+ }
@@ -0,0 +1,216 @@
1
+ import {
2
+ Component,
3
+ input,
4
+ ChangeDetectionStrategy,
5
+ ViewEncapsulation,
6
+ forwardRef,
7
+ viewChild,
8
+ ViewContainerRef,
9
+ output,
10
+ computed,
11
+ inject,
12
+ ElementRef,
13
+ } from '@angular/core';
14
+ import { InteractionContract } from '@origostudio/core';
15
+ import { OrigoAdapter, coerceContractProps } from '../../../adapters/web/adapter';
16
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
17
+ import { HttpClient, HttpEventType } from '@angular/common/http';
18
+
19
+ export interface FileInputProps {
20
+ permissions?: Record<string, string>;
21
+ rules?: Record<string, unknown>;
22
+ metadata?: Record<string, unknown>;
23
+ multiple?: boolean;
24
+ accept?: string;
25
+ maxFileSize?: number;
26
+ uploadUrl?: string;
27
+ 'aria-label'?: string;
28
+ 'aria-describedby'?: string;
29
+ }
30
+
31
+ @Component({
32
+ selector: 'origo-file-input',
33
+ standalone: true,
34
+ templateUrl: './file-input.component.html',
35
+ styleUrls: ['./file-input.component.scss'],
36
+ changeDetection: ChangeDetectionStrategy.OnPush,
37
+ encapsulation: ViewEncapsulation.ShadowDom,
38
+ providers: [
39
+ {
40
+ provide: NG_VALUE_ACCESSOR,
41
+ useExisting: forwardRef(() => FileInputComponent),
42
+ multi: true,
43
+ },
44
+ ],
45
+ host: {
46
+ '[class.origo-file-input]': 'true',
47
+ '[attr.data-testid]': 'contract().id',
48
+ '[attr.aria-label]': 'computedAriaLabel()',
49
+ '[attr.aria-describedby]': 'computedAriaDescribedBy()',
50
+ },
51
+ })
52
+ export class FileInputComponent implements OrigoAdapter<FileInputProps>, ControlValueAccessor {
53
+ static readonly contractSchema = {
54
+ permissions: 'object',
55
+ rules: 'object',
56
+ metadata: 'object',
57
+ multiple: 'boolean',
58
+ accept: 'string',
59
+ maxFileSize: 'number',
60
+ uploadUrl: 'string',
61
+ 'aria-label': 'string',
62
+ 'aria-describedby': 'string',
63
+ } as const;
64
+ static readonly strictContract = false;
65
+
66
+ contract = input.required<InteractionContract<FileInputProps>>();
67
+ vc = viewChild.required('vc', { read: ViewContainerRef });
68
+ inputRef = viewChild<ElementRef<HTMLInputElement>>('inputEl');
69
+
70
+ fileSelect = output<File[]>();
71
+ fileRemove = output<File>();
72
+ fileError = output<string>();
73
+ clear = output<void>();
74
+ focus = output<FocusEvent>();
75
+ blur = output<FocusEvent>();
76
+ uploadStart = output<void>();
77
+ uploadProgress = output<number>();
78
+ uploadSuccess = output<unknown>();
79
+ uploadError = output<unknown>();
80
+
81
+ private http = inject(HttpClient, { optional: true });
82
+
83
+ protected props = computed(() => {
84
+ return coerceContractProps<FileInputProps>(
85
+ this.contract().props,
86
+ FileInputComponent.contractSchema
87
+ );
88
+ });
89
+
90
+ computedMultiple = computed(() => !!this.props()?.multiple);
91
+ computedAccept = computed(() => this.props()?.accept ?? '*/*');
92
+ computedMaxFileSize = computed(() => this.props()?.maxFileSize);
93
+ computedUploadUrl = computed(() => this.props()?.uploadUrl);
94
+ computedAriaLabel = computed(() => this.props()?.['aria-label'] || null);
95
+ computedAriaDescribedBy = computed(() => this.props()?.['aria-describedby'] || null);
96
+
97
+ disabled = false;
98
+ files: File[] = [];
99
+
100
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
101
+ private onChange: (value: File[]) => void = () => {};
102
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
103
+ private onTouched: () => void = () => {};
104
+
105
+ writeValue(obj: unknown): void {
106
+ if (!obj) {
107
+ this.files = [];
108
+ const inputEl = this.inputRef()?.nativeElement;
109
+ if (inputEl) inputEl.value = '';
110
+ } else if (Array.isArray(obj)) {
111
+ this.files = obj;
112
+ } else {
113
+ this.files = [obj as File];
114
+ }
115
+ }
116
+
117
+ registerOnChange(fn: (value: File[]) => void): void {
118
+ this.onChange = fn;
119
+ }
120
+
121
+ registerOnTouched(fn: () => void): void {
122
+ this.onTouched = fn;
123
+ }
124
+
125
+ setDisabledState?(isDisabled: boolean): void {
126
+ this.disabled = isDisabled;
127
+ }
128
+
129
+ onFileChange(event: Event) {
130
+ const inputEl = event.target as HTMLInputElement;
131
+ if (inputEl.files) {
132
+ const selectedFiles = Array.from(inputEl.files);
133
+ if (!selectedFiles.length) return;
134
+
135
+ const maxFileSize = this.computedMaxFileSize();
136
+ if (typeof maxFileSize === 'number' && maxFileSize >= 0) {
137
+ const invalidFiles = selectedFiles.filter(file => file.size > maxFileSize);
138
+ if (invalidFiles.length > 0) {
139
+ this.fileError.emit(`File size exceeds maximum allowed size of ${maxFileSize} bytes`);
140
+ inputEl.value = '';
141
+ return;
142
+ }
143
+ }
144
+
145
+ this.files = this.computedMultiple() ? [...this.files, ...selectedFiles] : [...selectedFiles];
146
+ this.onChange(this.files);
147
+ this.fileSelect.emit(selectedFiles);
148
+
149
+ const uploadUrl = this.computedUploadUrl();
150
+ if (uploadUrl && selectedFiles.length > 0) {
151
+ this.uploadFiles(selectedFiles, uploadUrl);
152
+ }
153
+
154
+ inputEl.value = '';
155
+ }
156
+ }
157
+
158
+ onFocus(event: FocusEvent) {
159
+ this.focus.emit(event);
160
+ }
161
+
162
+ onBlur(event: FocusEvent) {
163
+ this.onTouched();
164
+ this.blur.emit(event);
165
+ }
166
+
167
+ public removeFile(file: File) {
168
+ this.files = this.files.filter(f => f !== file);
169
+ this.onChange(this.files);
170
+ this.fileRemove.emit(file);
171
+ }
172
+
173
+ public clearFiles() {
174
+ this.files = [];
175
+ this.onChange(this.files);
176
+ this.clear.emit();
177
+ const inputEl = this.inputRef()?.nativeElement;
178
+ if (inputEl) inputEl.value = '';
179
+ }
180
+
181
+ private uploadFiles(files: File[], url: string) {
182
+ this.uploadStart.emit();
183
+
184
+ if (!this.http) {
185
+ this.uploadError.emit('HttpClient is not provided.');
186
+ return;
187
+ }
188
+
189
+ const formData = new FormData();
190
+ files.forEach(file => formData.append('file', file));
191
+
192
+ this.http
193
+ .post(url, formData, {
194
+ reportProgress: true,
195
+ observe: 'events',
196
+ responseType: 'json',
197
+ })
198
+ .subscribe({
199
+ next: event => {
200
+ if (event.type === HttpEventType.UploadProgress) {
201
+ if (event.total && event.total > 0) {
202
+ const percentComplete = (event.loaded / event.total) * 100;
203
+ this.uploadProgress.emit(percentComplete);
204
+ } else {
205
+ this.uploadProgress.emit(100);
206
+ }
207
+ } else if (event.type === HttpEventType.Response) {
208
+ this.uploadSuccess.emit(event.body);
209
+ }
210
+ },
211
+ error: err => {
212
+ this.uploadError.emit(err.message || 'Upload failed');
213
+ },
214
+ });
215
+ }
216
+ }