@aleph-alpha/chat-kit 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{CkInput.vue_vue_type_script_setup_true_lang-DPZedAQS.js → CkInput.vue_vue_type_script_setup_true_lang-DYTizPsB.js} +361 -72
- package/dist/chat-kit.css +40 -5
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue.d.ts +4 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.d.ts +17 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +31 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/index.d.ts +3 -0
- package/dist/components/base/CkDropZoneOverlay/index.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/types.d.ts +88 -0
- package/dist/components/base/CkDropZoneOverlay/types.d.ts.map +1 -0
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts +7 -0
- package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -0
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/types.d.ts +9 -0
- package/dist/components/base/CkFileItemsList/types.d.ts.map +1 -1
- package/dist/components/base/index.d.ts +2 -0
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +19 -18
- package/dist/composables/fileUpload/index.d.ts.map +1 -1
- package/dist/composables/fileUpload/types.d.ts +1 -1
- package/dist/composables/fileUpload/types.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/{index-BPXg1Yf6.js → index-THzZwfMU.js} +45 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -20
- package/package.json +1 -1
- package/src/components/base/CkDropZoneOverlay/CkDropZoneIllustration.vue +125 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.spec.ts +104 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.stories.ts +130 -0
- package/src/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue +272 -0
- package/src/components/base/CkDropZoneOverlay/index.ts +2 -0
- package/src/components/base/CkDropZoneOverlay/types.ts +93 -0
- package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue +22 -1
- package/src/components/base/CkFileItemsList/CkFileItemsList.spec.ts +67 -0
- package/src/components/base/CkFileItemsList/CkFileItemsList.stories.ts +48 -0
- package/src/components/base/CkFileItemsList/CkFileItemsList.vue +78 -4
- package/src/components/base/CkFileItemsList/types.ts +9 -0
- package/src/components/base/index.ts +7 -0
- package/src/components/index.ts +5 -0
- package/src/composables/fileUpload/index.spec.ts +18 -7
- package/src/composables/fileUpload/index.ts +39 -19
- package/src/composables/fileUpload/types.ts +1 -1
- package/src/composables/fileUpload/useFileUpload.spec.ts +26 -0
- package/src/composables/fileUpload/useFileUpload.ts +12 -3
- package/src/composables/fileUpload/useFileUploadWrapper.ts +1 -1
- package/src/index.ts +5 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
import CkDropZoneIllustration from './CkDropZoneIllustration.vue';
|
|
5
|
+
import type {
|
|
6
|
+
CkDropZoneOverlayEmits,
|
|
7
|
+
CkDropZoneOverlayProps,
|
|
8
|
+
CkDropZoneRejection,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
defineOptions({
|
|
12
|
+
name: 'CkDropZoneOverlay',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<CkDropZoneOverlayProps>(), {
|
|
16
|
+
accept: '',
|
|
17
|
+
maxFileSizeBytes: Number.POSITIVE_INFINITY,
|
|
18
|
+
maxFiles: Number.POSITIVE_INFINITY,
|
|
19
|
+
disabled: false,
|
|
20
|
+
heading: undefined,
|
|
21
|
+
hint: 'Add txt, docx, pdf, md, or csv',
|
|
22
|
+
ariaLabel: 'Drop files anywhere to attach',
|
|
23
|
+
showErrors: true,
|
|
24
|
+
errorTimeoutMs: 4000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<CkDropZoneOverlayEmits>();
|
|
28
|
+
|
|
29
|
+
const visible = ref(false);
|
|
30
|
+
/**
|
|
31
|
+
* `dragenter`/`dragleave` fire when crossing *any* element boundary, not just
|
|
32
|
+
* the page boundary. We count enters/leaves so the overlay stays open while
|
|
33
|
+
* the cursor moves over child elements and only closes when the drag truly
|
|
34
|
+
* leaves the window. See https://stackoverflow.com/a/21002544.
|
|
35
|
+
*/
|
|
36
|
+
const dragCounter = ref(0);
|
|
37
|
+
const errors = ref<string[]>([]);
|
|
38
|
+
let errorTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
|
|
40
|
+
// --- accept parsing --------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface ParsedAccept {
|
|
43
|
+
extensions: string[];
|
|
44
|
+
mimes: string[];
|
|
45
|
+
/** Wildcard MIME prefixes like `image/` (from `image/*`). */
|
|
46
|
+
wildcards: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parsedAccept = computed<ParsedAccept>(() => {
|
|
50
|
+
const extensions: string[] = [];
|
|
51
|
+
const mimes: string[] = [];
|
|
52
|
+
const wildcards: string[] = [];
|
|
53
|
+
for (const raw of props.accept.split(',')) {
|
|
54
|
+
const token = raw.trim().toLowerCase();
|
|
55
|
+
if (!token) continue;
|
|
56
|
+
if (token.startsWith('.')) {
|
|
57
|
+
extensions.push(token);
|
|
58
|
+
} else if (token.endsWith('/*')) {
|
|
59
|
+
wildcards.push(token.slice(0, -1));
|
|
60
|
+
} else {
|
|
61
|
+
mimes.push(token);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { extensions, mimes, wildcards };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function fileMatchesAccept(file: File): boolean {
|
|
68
|
+
const { extensions, mimes, wildcards } = parsedAccept.value;
|
|
69
|
+
// Empty accept = allow everything.
|
|
70
|
+
if (!extensions.length && !mimes.length && !wildcards.length) return true;
|
|
71
|
+
const name = file.name.toLowerCase();
|
|
72
|
+
const type = file.type.toLowerCase();
|
|
73
|
+
if (extensions.some((e) => name.endsWith(e))) return true;
|
|
74
|
+
if (type && mimes.includes(type)) return true;
|
|
75
|
+
if (type && wildcards.some((w) => type.startsWith(w))) return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatBytes(bytes: number): string {
|
|
80
|
+
const mb = bytes / (1024 * 1024);
|
|
81
|
+
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
|
82
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isSingle = computed(() => props.maxFiles === 1);
|
|
86
|
+
|
|
87
|
+
const resolvedHeading = computed(
|
|
88
|
+
() =>
|
|
89
|
+
props.heading ??
|
|
90
|
+
(isSingle.value
|
|
91
|
+
? 'Drop a file here to add to chat'
|
|
92
|
+
: 'Drop files here to add to chat'),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// --- drag detection --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function isFileDrag(e: DragEvent): boolean {
|
|
98
|
+
const types = e.dataTransfer?.types;
|
|
99
|
+
if (!types) return false;
|
|
100
|
+
// 'Files' is the standard token; Firefox also exposes 'application/x-moz-file'.
|
|
101
|
+
for (const t of types) {
|
|
102
|
+
if (t === 'Files' || t.toLowerCase().includes('file')) return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onWindowDragEnter(e: DragEvent) {
|
|
108
|
+
if (props.disabled || !isFileDrag(e)) return;
|
|
109
|
+
dragCounter.value += 1;
|
|
110
|
+
visible.value = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onWindowDragOver(e: DragEvent) {
|
|
114
|
+
if (props.disabled || !visible.value || !isFileDrag(e)) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onWindowDragLeave() {
|
|
119
|
+
if (!visible.value) return;
|
|
120
|
+
dragCounter.value = Math.max(0, dragCounter.value - 1);
|
|
121
|
+
if (dragCounter.value === 0) hide();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onWindowDrop(e: DragEvent) {
|
|
125
|
+
if (props.disabled || !visible.value || !isFileDrag(e)) return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
hide();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hide() {
|
|
131
|
+
dragCounter.value = 0;
|
|
132
|
+
visible.value = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- overlay drop handler --------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function onOverlayDrop(e: DragEvent) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const dropped = Array.from(e.dataTransfer?.files ?? []);
|
|
140
|
+
hide();
|
|
141
|
+
if (!dropped.length) return;
|
|
142
|
+
|
|
143
|
+
const accepted: File[] = [];
|
|
144
|
+
const rejected: CkDropZoneRejection[] = [];
|
|
145
|
+
|
|
146
|
+
for (const file of dropped) {
|
|
147
|
+
if (!fileMatchesAccept(file)) {
|
|
148
|
+
rejected.push({
|
|
149
|
+
file,
|
|
150
|
+
reason: 'unsupported-type',
|
|
151
|
+
message: `"${file.name}" — unsupported file type`,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (file.size > props.maxFileSizeBytes) {
|
|
156
|
+
rejected.push({
|
|
157
|
+
file,
|
|
158
|
+
reason: 'too-large',
|
|
159
|
+
message: `"${file.name}" — exceeds ${formatBytes(props.maxFileSizeBytes)} limit`,
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
accepted.push(file);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Enforce the per-drop cap: anything beyond `maxFiles` is dropped with a
|
|
167
|
+
// single consolidated rejection so the user understands why.
|
|
168
|
+
let toEmit = accepted;
|
|
169
|
+
if (accepted.length > props.maxFiles) {
|
|
170
|
+
toEmit = accepted.slice(0, props.maxFiles);
|
|
171
|
+
rejected.push({
|
|
172
|
+
reason: 'too-many',
|
|
173
|
+
message: isSingle.value
|
|
174
|
+
? 'Only one file at a time — kept the first.'
|
|
175
|
+
: `Only up to ${props.maxFiles} files at a time — kept the first ${props.maxFiles}.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (rejected.length) {
|
|
180
|
+
emit('rejected', rejected);
|
|
181
|
+
if (props.showErrors) pushErrors(rejected.map((r) => r.message));
|
|
182
|
+
}
|
|
183
|
+
if (toEmit.length) emit('files', toEmit);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pushErrors(messages: string[]) {
|
|
187
|
+
errors.value = [...errors.value, ...messages];
|
|
188
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
189
|
+
errorTimer = setTimeout(() => {
|
|
190
|
+
errors.value = [];
|
|
191
|
+
}, props.errorTimeoutMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- lifecycle -------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
onMounted(() => {
|
|
197
|
+
window.addEventListener('dragenter', onWindowDragEnter);
|
|
198
|
+
window.addEventListener('dragover', onWindowDragOver);
|
|
199
|
+
window.addEventListener('dragleave', onWindowDragLeave);
|
|
200
|
+
window.addEventListener('drop', onWindowDrop);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
onBeforeUnmount(() => {
|
|
204
|
+
window.removeEventListener('dragenter', onWindowDragEnter);
|
|
205
|
+
window.removeEventListener('dragover', onWindowDragOver);
|
|
206
|
+
window.removeEventListener('dragleave', onWindowDragLeave);
|
|
207
|
+
window.removeEventListener('drop', onWindowDrop);
|
|
208
|
+
if (errorTimer) clearTimeout(errorTimer);
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<template>
|
|
213
|
+
<Teleport to="body">
|
|
214
|
+
<Transition name="ck-drop-overlay">
|
|
215
|
+
<!-- The overlay IS the drop target; `role="dialog"` describes its
|
|
216
|
+
transient appearance for AT users, but the lint rule still flags
|
|
217
|
+
the drop handler as a "static element interaction". -->
|
|
218
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
|
|
219
|
+
<div
|
|
220
|
+
v-if="visible"
|
|
221
|
+
data-slot="drop-zone-overlay"
|
|
222
|
+
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background-surface-secondary/80 backdrop-blur-sm p-6"
|
|
223
|
+
role="dialog"
|
|
224
|
+
aria-modal="false"
|
|
225
|
+
:aria-label="ariaLabel"
|
|
226
|
+
@dragover.prevent
|
|
227
|
+
@drop="onOverlayDrop"
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
class="pointer-events-none flex h-full w-full flex-col items-center justify-center gap-4 text-content-on-surface-primary"
|
|
231
|
+
>
|
|
232
|
+
<!-- Replace the whole overlay surface (illustration + copy). -->
|
|
233
|
+
<slot>
|
|
234
|
+
<CkDropZoneIllustration class="h-36 w-52" />
|
|
235
|
+
<div class="flex flex-col items-center gap-1 text-center">
|
|
236
|
+
<p class="body-16-bold">{{ resolvedHeading }}</p>
|
|
237
|
+
<p v-if="hint" class="body-16-regular">{{ hint }}</p>
|
|
238
|
+
</div>
|
|
239
|
+
</slot>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</Transition>
|
|
243
|
+
|
|
244
|
+
<Transition name="ck-drop-overlay">
|
|
245
|
+
<ul
|
|
246
|
+
v-if="showErrors && errors.length"
|
|
247
|
+
data-slot="drop-zone-errors"
|
|
248
|
+
class="pointer-events-none fixed bottom-6 left-1/2 z-50 max-w-[36rem] -translate-x-1/2 flex list-inside list-disc flex-col gap-1 rounded-md border border-border-surface-default bg-background-surface-default p-3 text-sm text-content-on-surface-secondary"
|
|
249
|
+
role="alert"
|
|
250
|
+
>
|
|
251
|
+
<li v-for="(msg, i) in errors" :key="i">{{ msg }}</li>
|
|
252
|
+
</ul>
|
|
253
|
+
</Transition>
|
|
254
|
+
</Teleport>
|
|
255
|
+
</template>
|
|
256
|
+
|
|
257
|
+
<style scoped>
|
|
258
|
+
.ck-drop-overlay-enter-active,
|
|
259
|
+
.ck-drop-overlay-leave-active {
|
|
260
|
+
transition: opacity 150ms ease;
|
|
261
|
+
}
|
|
262
|
+
.ck-drop-overlay-enter-from,
|
|
263
|
+
.ck-drop-overlay-leave-to {
|
|
264
|
+
opacity: 0;
|
|
265
|
+
}
|
|
266
|
+
@media (prefers-reduced-motion: reduce) {
|
|
267
|
+
.ck-drop-overlay-enter-active,
|
|
268
|
+
.ck-drop-overlay-leave-active {
|
|
269
|
+
transition: none;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
</style>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Why a dropped file was rejected. */
|
|
2
|
+
export type CkDropZoneRejectionReason =
|
|
3
|
+
| 'unsupported-type'
|
|
4
|
+
| 'too-large'
|
|
5
|
+
| 'too-many';
|
|
6
|
+
|
|
7
|
+
export interface CkDropZoneRejection {
|
|
8
|
+
/** The offending file, when the rejection concerns a specific file. */
|
|
9
|
+
file?: File;
|
|
10
|
+
/** Machine-readable reason, for consumers building their own messaging. */
|
|
11
|
+
reason: CkDropZoneRejectionReason;
|
|
12
|
+
/** Default human-readable (English) message used by the built-in toast. */
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Full-window drag-and-drop overlay. Listens for file drags anywhere on the
|
|
18
|
+
* page, reveals a teleported overlay, validates dropped files against `accept`,
|
|
19
|
+
* `maxFileSizeBytes` and `maxFiles`, and emits the accepted files.
|
|
20
|
+
*
|
|
21
|
+
* Purely presentational and self-contained: it owns its own drag state and has
|
|
22
|
+
* no knowledge of the host app. All limits and copy are configurable, and
|
|
23
|
+
* rejection handling can be delegated to the consumer via the `rejected` event.
|
|
24
|
+
*
|
|
25
|
+
* @category Form Inputs
|
|
26
|
+
* @useCases page-level file attachment, drag-and-drop upload, chat composer
|
|
27
|
+
* @keywords drag and drop, drop zone, file upload, overlay, attachments
|
|
28
|
+
* @related CkFileItemsList, CkPromptInput
|
|
29
|
+
*/
|
|
30
|
+
export interface CkDropZoneOverlayProps {
|
|
31
|
+
/**
|
|
32
|
+
* HTML `accept` string used to validate dropped files (extensions, MIME
|
|
33
|
+
* types and `type/*` wildcards). Empty string accepts everything.
|
|
34
|
+
* @default ''
|
|
35
|
+
*/
|
|
36
|
+
accept?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Reject files larger than this many bytes.
|
|
39
|
+
* @default Number.POSITIVE_INFINITY
|
|
40
|
+
*/
|
|
41
|
+
maxFileSizeBytes?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Maximum number of files accepted per drop. Excess files are dropped and
|
|
44
|
+
* reported as a single rejection.
|
|
45
|
+
* @default Number.POSITIVE_INFINITY
|
|
46
|
+
*/
|
|
47
|
+
maxFiles?: number;
|
|
48
|
+
/**
|
|
49
|
+
* When true, the overlay never appears and window drag events are ignored,
|
|
50
|
+
* so the page behaves normally (e.g. while a response is streaming or the
|
|
51
|
+
* attachment limit is already reached).
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Heading text. Defaults to a sensible singular/plural string derived from
|
|
57
|
+
* `maxFiles` (e.g. "Drop a file here to add to chat" vs "Drop files here to
|
|
58
|
+
* add to chat").
|
|
59
|
+
*/
|
|
60
|
+
heading?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Secondary hint shown under the heading (e.g. supported formats / limits).
|
|
63
|
+
* Pass an empty string to hide it.
|
|
64
|
+
* @default 'Add txt, docx, pdf, md, or csv'
|
|
65
|
+
*/
|
|
66
|
+
hint?: string;
|
|
67
|
+
/**
|
|
68
|
+
* `aria-label` for the overlay dialog.
|
|
69
|
+
* @default 'Drop files anywhere to attach'
|
|
70
|
+
*/
|
|
71
|
+
ariaLabel?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Render the built-in error toast for rejected files. Set to `false` to take
|
|
74
|
+
* over messaging via the `rejected` event.
|
|
75
|
+
* @default true
|
|
76
|
+
*/
|
|
77
|
+
showErrors?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* How long the built-in error toast stays visible, in milliseconds.
|
|
80
|
+
* @default 4000
|
|
81
|
+
*/
|
|
82
|
+
errorTimeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CkDropZoneOverlayEmits {
|
|
86
|
+
/** Files that passed validation, capped to `maxFiles`. */
|
|
87
|
+
(event: 'files', files: File[]): void;
|
|
88
|
+
/**
|
|
89
|
+
* Rejected files / drops. Emitted alongside the built-in toast so consumers
|
|
90
|
+
* can integrate their own notification system (and disable `showErrors`).
|
|
91
|
+
*/
|
|
92
|
+
(event: 'rejected', rejections: CkDropZoneRejection[]): void;
|
|
93
|
+
}
|
|
@@ -215,7 +215,7 @@ function handleSubmit(): void {
|
|
|
215
215
|
v-for="reason in reasons"
|
|
216
216
|
:key="reason.code"
|
|
217
217
|
:value="reason.code"
|
|
218
|
-
class="ck-feedback-dialog__chip"
|
|
218
|
+
class="ck-feedback-dialog__chip shadow-none"
|
|
219
219
|
>
|
|
220
220
|
{{ reason.label }}
|
|
221
221
|
</UiToggleGroupItem>
|
|
@@ -229,6 +229,7 @@ function handleSubmit(): void {
|
|
|
229
229
|
:id="freeTextId"
|
|
230
230
|
v-model="freeText"
|
|
231
231
|
name="ck-feedback-dialog-free-text"
|
|
232
|
+
class="ck-feedback-dialog__freetext"
|
|
232
233
|
:placeholder="freeTextPlaceholder"
|
|
233
234
|
:disabled="disabled || submitting"
|
|
234
235
|
:maxlength="maxFreeTextLength"
|
|
@@ -279,6 +280,26 @@ function handleSubmit(): void {
|
|
|
279
280
|
line-height: 1rem;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
/* Selected chip: soft-active treatment (per Figma). Token vars keep it
|
|
284
|
+
* theme-aware; scoped here because the data-[state=on]:bg-* utility isn't
|
|
285
|
+
* emitted by the consumer's UnoCSS build. The :hover selector holds the
|
|
286
|
+
* styling when hovering a selected chip (the outline variant ships a
|
|
287
|
+
* data-[state=on]:hover override of equal specificity otherwise). */
|
|
288
|
+
.ck-feedback-dialog__chip[data-state='on'],
|
|
289
|
+
.ck-feedback-dialog__chip[data-state='on']:hover {
|
|
290
|
+
border-color: oklch(var(--accent-default));
|
|
291
|
+
background-color: oklch(var(--background-accent-soft));
|
|
292
|
+
color: oklch(var(--content-on-accent-soft));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/*
|
|
296
|
+
* The free-text field stays vertically resizable, but cap its height so
|
|
297
|
+
* dragging the resize handle can't push it past the dialog bounds.
|
|
298
|
+
*/
|
|
299
|
+
.ck-feedback-dialog__freetext {
|
|
300
|
+
max-height: 11rem;
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
.ck-feedback-dialog__response :deep(p),
|
|
283
304
|
.ck-feedback-dialog__response :deep(li) {
|
|
284
305
|
line-height: 1.5rem;
|
|
@@ -41,6 +41,73 @@ describe('CkFileItemsList', () => {
|
|
|
41
41
|
expect(screen.getByRole('button')).toHaveTextContent('+1 more attachment');
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it('keeps a single-line trigger (no count subtitle) when showStatus is off', () => {
|
|
45
|
+
render(CkFileItemsList, {
|
|
46
|
+
props: { files, triggerLabel: 'Uploaded documents' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const trigger = screen.getByRole('button');
|
|
50
|
+
expect(trigger).toHaveTextContent('Uploaded documents');
|
|
51
|
+
expect(trigger).not.toHaveTextContent('4 files');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('showStatus summary on the collapsed trigger', () => {
|
|
55
|
+
it('summarises the number of failed uploads in an error line', () => {
|
|
56
|
+
const withFailures: CkFileEntry[] = [
|
|
57
|
+
{ id: 'a', filename: 'a.pdf', error: 'Upload failed' },
|
|
58
|
+
{ id: 'b', filename: 'b.md', error: 'Upload failed' },
|
|
59
|
+
{ id: 'c', filename: 'c.csv' },
|
|
60
|
+
{ id: 'd', filename: 'd.docx' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
render(CkFileItemsList, {
|
|
64
|
+
props: {
|
|
65
|
+
files: withFailures,
|
|
66
|
+
showStatus: true,
|
|
67
|
+
triggerLabel: 'Uploading documents',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const trigger = screen.getByRole('button', {
|
|
72
|
+
name: /Uploading documents, 2 uploads failed/i,
|
|
73
|
+
});
|
|
74
|
+
expect(trigger).toHaveTextContent('2 uploads failed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders an aggregate progress bar while files are uploading', () => {
|
|
78
|
+
const uploading: CkFileEntry[] = [
|
|
79
|
+
{ id: 'a', filename: 'a.pdf', progress: 20 },
|
|
80
|
+
{ id: 'b', filename: 'b.md', progress: 60 },
|
|
81
|
+
{ id: 'c', filename: 'c.csv', progress: 40 },
|
|
82
|
+
{ id: 'd', filename: 'd.docx', progress: 80 },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const { container } = render(CkFileItemsList, {
|
|
86
|
+
props: {
|
|
87
|
+
files: uploading,
|
|
88
|
+
showStatus: true,
|
|
89
|
+
triggerLabel: 'Uploading documents',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(container.querySelector('[data-slot="progress"]')).toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows the file count once all uploads succeed', () => {
|
|
97
|
+
render(CkFileItemsList, {
|
|
98
|
+
props: {
|
|
99
|
+
files,
|
|
100
|
+
showStatus: true,
|
|
101
|
+
triggerLabel: 'Uploaded documents',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const trigger = screen.getByRole('button');
|
|
106
|
+
expect(trigger).toHaveTextContent('Uploaded documents');
|
|
107
|
+
expect(trigger).toHaveTextContent('4 files');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
44
111
|
it('emits remove with the file id from inline items', async () => {
|
|
45
112
|
const user = userEvent.setup();
|
|
46
113
|
const { emitted } = render(CkFileItemsList, {
|
|
@@ -130,6 +130,54 @@ export const Collapsed: Story = {
|
|
|
130
130
|
parameters: { docs: { source: { code: collapsedTemplateSource } } },
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
+
const uploadingFiles: CkFileEntry[] = [
|
|
134
|
+
{ id: 1, filename: 'project-proposal.pdf', typeLabel: 'PDF', progress: 35 },
|
|
135
|
+
{ id: 2, filename: 'meeting-notes.md', typeLabel: 'MD', progress: 70 },
|
|
136
|
+
{ id: 3, filename: 'q3-revenue.csv', typeLabel: 'CSV', progress: 50 },
|
|
137
|
+
{ id: 4, filename: 'design-spec.docx', typeLabel: 'DOCX', progress: 20 },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const failedFiles: CkFileEntry[] = [
|
|
141
|
+
{ id: 1, filename: 'project-proposal.pdf', error: 'Upload failed' },
|
|
142
|
+
{ id: 2, filename: 'meeting-notes.md', error: 'Upload failed' },
|
|
143
|
+
{ id: 3, filename: 'q3-revenue.csv', typeLabel: 'CSV', sizeLabel: '320 KB' },
|
|
144
|
+
{
|
|
145
|
+
id: 4,
|
|
146
|
+
filename: 'design-spec.docx',
|
|
147
|
+
typeLabel: 'DOCX',
|
|
148
|
+
sizeLabel: '880 KB',
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* With `show-status`, the collapsed trigger becomes a two-line summary derived
|
|
154
|
+
* from each entry's `progress`/`error`: an aggregate progress bar while
|
|
155
|
+
* uploading, an "N uploads failed" line (error tone) once any entry fails, and
|
|
156
|
+
* otherwise an "N files" count. Open the popover to see the per-file rows.
|
|
157
|
+
*/
|
|
158
|
+
export const UploadStatus: Story = {
|
|
159
|
+
render: () => ({
|
|
160
|
+
components: { CkFileItemsList },
|
|
161
|
+
setup: () => ({ uploadingFiles, failedFiles, doneFiles: overflowFiles }),
|
|
162
|
+
template: `
|
|
163
|
+
<div class="flex flex-col gap-6 bg-background-surface-primary p-4 w-[28rem]">
|
|
164
|
+
<div>
|
|
165
|
+
<p class="label-12-medium text-content-on-surface-muted mb-2">Uploading</p>
|
|
166
|
+
<CkFileItemsList :files="uploadingFiles" show-status trigger-label="Uploading documents" />
|
|
167
|
+
</div>
|
|
168
|
+
<div>
|
|
169
|
+
<p class="label-12-medium text-content-on-surface-muted mb-2">Some uploads failed</p>
|
|
170
|
+
<CkFileItemsList :files="failedFiles" show-status is-removable trigger-label="Uploading documents" />
|
|
171
|
+
</div>
|
|
172
|
+
<div>
|
|
173
|
+
<p class="label-12-medium text-content-on-surface-muted mb-2">All uploaded</p>
|
|
174
|
+
<CkFileItemsList :files="doneFiles" show-status trigger-label="Uploaded documents" />
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
`,
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
|
|
133
181
|
/**
|
|
134
182
|
* Each row exposes a remove control when `isRemovable` is `true`. The component
|
|
135
183
|
* emits `remove` with the corresponding file `id`.
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
UiPopover,
|
|
6
6
|
UiPopoverContent,
|
|
7
7
|
UiPopoverTrigger,
|
|
8
|
+
UiProgress,
|
|
8
9
|
type UiFileItemSize,
|
|
9
10
|
} from '@aleph-alpha/ui-library';
|
|
10
11
|
import { computed, ref } from 'vue';
|
|
@@ -25,6 +26,7 @@ const props = withDefaults(defineProps<CkFileItemsListProps>(), {
|
|
|
25
26
|
isRemovable: false,
|
|
26
27
|
removeLabel: 'Remove file',
|
|
27
28
|
disabled: false,
|
|
29
|
+
showStatus: false,
|
|
28
30
|
triggerLabel: undefined,
|
|
29
31
|
triggerAriaLabel: undefined,
|
|
30
32
|
listLabel: undefined,
|
|
@@ -57,10 +59,49 @@ const defaultTriggerLabel = computed(() => `${props.files.length} files`);
|
|
|
57
59
|
const resolvedTriggerLabel = computed(
|
|
58
60
|
() => props.triggerLabel ?? defaultTriggerLabel.value,
|
|
59
61
|
);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
// --- upload status summary (only used when `showStatus` is set) ------------
|
|
64
|
+
|
|
65
|
+
function hasError(file: CkFileEntry): boolean {
|
|
66
|
+
return file.error != null && file.error !== '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const failedFiles = computed(() => props.files.filter(hasError));
|
|
70
|
+
const uploadingFiles = computed(() =>
|
|
71
|
+
props.files.filter(
|
|
72
|
+
(f) => !hasError(f) && f.progress != null && f.progress < 100,
|
|
73
|
+
),
|
|
63
74
|
);
|
|
75
|
+
const hasFailures = computed(() => failedFiles.value.length > 0);
|
|
76
|
+
const uploadInProgress = computed(() => uploadingFiles.value.length > 0);
|
|
77
|
+
|
|
78
|
+
const aggregateProgress = computed(() => {
|
|
79
|
+
const list = uploadingFiles.value;
|
|
80
|
+
if (!list.length) return 0;
|
|
81
|
+
const total = list.reduce((sum, f) => sum + (f.progress ?? 0), 0);
|
|
82
|
+
return Math.round(total / list.length);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const failedStatusLabel = computed(() => {
|
|
86
|
+
const n = failedFiles.value.length;
|
|
87
|
+
return `${n} ${n === 1 ? 'upload' : 'uploads'} failed`;
|
|
88
|
+
});
|
|
89
|
+
const countStatusLabel = computed(() => `${props.files.length} files`);
|
|
90
|
+
|
|
91
|
+
/** Status portion appended to the trigger's accessible name. */
|
|
92
|
+
const triggerStatusText = computed(() => {
|
|
93
|
+
if (!props.showStatus) return '';
|
|
94
|
+
if (uploadInProgress.value) return `uploading ${aggregateProgress.value}%`;
|
|
95
|
+
if (hasFailures.value) return failedStatusLabel.value;
|
|
96
|
+
if (props.triggerLabel) return countStatusLabel.value;
|
|
97
|
+
return '';
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const resolvedTriggerAriaLabel = computed(() => {
|
|
101
|
+
if (props.triggerAriaLabel) return props.triggerAriaLabel;
|
|
102
|
+
const base = props.triggerLabel ?? defaultTriggerLabel.value;
|
|
103
|
+
return triggerStatusText.value ? `${base}, ${triggerStatusText.value}` : base;
|
|
104
|
+
});
|
|
64
105
|
|
|
65
106
|
function handleRemove(file: CkFileEntry) {
|
|
66
107
|
if (isRowDisabled(file)) return;
|
|
@@ -116,13 +157,46 @@ function handleRowClick(file: CkFileEntry, event: MouseEvent) {
|
|
|
116
157
|
:class="size === 'small' ? 'p-2' : 'p-3'"
|
|
117
158
|
:aria-label="resolvedTriggerAriaLabel"
|
|
118
159
|
>
|
|
119
|
-
<UiFileType
|
|
160
|
+
<UiFileType
|
|
161
|
+
v-if="showStatus && hasFailures && !uploadInProgress"
|
|
162
|
+
:error="true"
|
|
163
|
+
/>
|
|
164
|
+
<UiFileType v-else icon="paperclip" tone="default" />
|
|
165
|
+
|
|
120
166
|
<span
|
|
167
|
+
v-if="!showStatus"
|
|
121
168
|
class="min-w-0 flex-1 truncate text-content-on-surface-primary"
|
|
122
169
|
:class="size === 'small' ? 'label-12-medium' : 'body-14-medium'"
|
|
123
170
|
>
|
|
124
171
|
{{ resolvedTriggerLabel }}
|
|
125
172
|
</span>
|
|
173
|
+
<div v-else class="flex min-w-0 flex-1 flex-col items-start gap-0.5">
|
|
174
|
+
<span
|
|
175
|
+
class="w-full truncate text-content-on-surface-primary"
|
|
176
|
+
:class="size === 'small' ? 'label-12-medium' : 'body-14-medium'"
|
|
177
|
+
>
|
|
178
|
+
{{ resolvedTriggerLabel }}
|
|
179
|
+
</span>
|
|
180
|
+
<UiProgress
|
|
181
|
+
v-if="uploadInProgress"
|
|
182
|
+
:model-value="aggregateProgress"
|
|
183
|
+
class="h-1 w-full"
|
|
184
|
+
:aria-label="`${resolvedTriggerLabel} upload progress`"
|
|
185
|
+
/>
|
|
186
|
+
<span
|
|
187
|
+
v-else-if="hasFailures"
|
|
188
|
+
class="label-12-medium w-full truncate text-content-on-status-error-inverse"
|
|
189
|
+
>
|
|
190
|
+
{{ failedStatusLabel }}
|
|
191
|
+
</span>
|
|
192
|
+
<span
|
|
193
|
+
v-else-if="triggerLabel"
|
|
194
|
+
class="label-12-medium w-full truncate text-content-on-surface-muted"
|
|
195
|
+
>
|
|
196
|
+
{{ countStatusLabel }}
|
|
197
|
+
</span>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
126
200
|
<UiIcon
|
|
127
201
|
name="chevron-down"
|
|
128
202
|
:size="16"
|
|
@@ -46,6 +46,15 @@ export interface CkFileItemsListProps {
|
|
|
46
46
|
removeLabel?: string;
|
|
47
47
|
/** Disable every row + the collapsed trigger. @default false */
|
|
48
48
|
disabled?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Render a two-line collapsed trigger that summarises upload state derived
|
|
51
|
+
* from each entry's `progress`/`error`: an aggregate progress bar while any
|
|
52
|
+
* entry is uploading, an "N uploads failed" line (error tone) once any entry
|
|
53
|
+
* fails, otherwise an "N files" count (shown only when an explicit
|
|
54
|
+
* `triggerLabel` is set). Off by default so static lists keep the original
|
|
55
|
+
* single-line trigger. @default false
|
|
56
|
+
*/
|
|
57
|
+
showStatus?: boolean;
|
|
49
58
|
/** Visible label on the collapsed trigger. Defaults to `"{n} files"`. */
|
|
50
59
|
triggerLabel?: string;
|
|
51
60
|
/** A11y label for the trigger. Falls back to `triggerLabel`, then count. */
|
|
@@ -3,6 +3,13 @@ export { CkAgentMessage } from './CkAgentMessage';
|
|
|
3
3
|
export { CkConversationLayout } from './CkConversationLayout';
|
|
4
4
|
export type { CkConversationLayoutProps } from './CkConversationLayout';
|
|
5
5
|
export { CkCopyMessageActionButton } from './CkCopyMessageActionButton';
|
|
6
|
+
export { CkDropZoneOverlay } from './CkDropZoneOverlay';
|
|
7
|
+
export type {
|
|
8
|
+
CkDropZoneOverlayProps,
|
|
9
|
+
CkDropZoneOverlayEmits,
|
|
10
|
+
CkDropZoneRejection,
|
|
11
|
+
CkDropZoneRejectionReason,
|
|
12
|
+
} from './CkDropZoneOverlay';
|
|
6
13
|
export { CkFeedbackButton } from './CkFeedbackButton';
|
|
7
14
|
export type {
|
|
8
15
|
CkFeedbackButtonProps,
|