@aleph-alpha/chat-kit 4.0.0 → 5.0.1
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-DJN-nZ2-.js → CkHistory.vue_vue_type_script_setup_true_lang-CtQUc3m3.js} +435 -378
- package/dist/chat-kit.css +28 -6
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +14 -13
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
- package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts +8 -7
- package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts.map +1 -1
- package/dist/components/base/CkAgentMessage/types.d.ts +16 -5
- package/dist/components/base/CkAgentMessage/types.d.ts.map +1 -1
- package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts +16 -0
- package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts.map +1 -0
- package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts +21 -0
- package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts.map +1 -0
- package/dist/components/base/CkAgentMessageHeader/index.d.ts +3 -0
- package/dist/components/base/CkAgentMessageHeader/index.d.ts.map +1 -0
- package/dist/components/base/CkAgentMessageHeader/types.d.ts +22 -0
- package/dist/components/base/CkAgentMessageHeader/types.d.ts.map +1 -0
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts +6 -0
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -1
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -1
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +10 -6
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -1
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts +15 -4
- package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +2 -1
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts.map +1 -1
- package/dist/components/base/CkFileItemsList/types.d.ts +17 -4
- package/dist/components/base/CkFileItemsList/types.d.ts.map +1 -1
- package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts +19 -0
- package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts.map +1 -0
- package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts +22 -0
- package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts.map +1 -0
- package/dist/components/base/CkHeaderActivityButton/index.d.ts +3 -0
- package/dist/components/base/CkHeaderActivityButton/index.d.ts.map +1 -0
- package/dist/components/base/CkHeaderActivityButton/types.d.ts +36 -0
- package/dist/components/base/CkHeaderActivityButton/types.d.ts.map +1 -0
- package/dist/components/base/CkPromptInput/CkPromptInput.stories.d.ts +6 -6
- package/dist/components/base/CkPromptInput/CkPromptInput.vue.d.ts.map +1 -1
- package/dist/components/base/CkPromptInput/types.d.ts +4 -4
- package/dist/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue.d.ts.map +1 -1
- package/dist/components/base/CkPromptInputDisclaimer/types.d.ts +1 -1
- package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
- package/dist/components/base/CkPromptSendButton/types.d.ts +1 -1
- package/dist/components/base/CkPromptStopButton/types.d.ts +1 -1
- package/dist/components/base/index.d.ts +4 -0
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +13 -1
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/types.d.ts +1 -1
- package/dist/components/composed/index.d.ts +0 -1
- package/dist/components/composed/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +26 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -26
- package/package.json +1 -1
- package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +12 -17
- package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +124 -113
- package/src/components/base/CkAgentMessage/CkAgentMessage.vue +18 -16
- package/src/components/base/CkAgentMessage/types.ts +16 -5
- package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.spec.ts +64 -0
- package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.ts +90 -0
- package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue +47 -0
- package/src/components/base/CkAgentMessageHeader/index.ts +2 -0
- package/src/components/base/CkAgentMessageHeader/types.ts +21 -0
- package/src/components/base/CkCitation/CkCitation.stories.ts +12 -0
- package/src/components/base/CkCitation/CkCitation.vue +34 -9
- package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +9 -0
- package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +11 -7
- package/src/components/base/CkCitationCard/CkCitationCard.vue +5 -3
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +1 -1
- package/src/components/base/CkFileItemsList/CkFileItemsList.spec.ts +88 -13
- package/src/components/base/CkFileItemsList/CkFileItemsList.stories.ts +61 -13
- package/src/components/base/CkFileItemsList/CkFileItemsList.vue +153 -110
- package/src/components/base/CkFileItemsList/types.ts +17 -4
- package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.spec.ts +50 -0
- package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.ts +102 -0
- package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue +39 -0
- package/src/components/base/CkHeaderActivityButton/index.ts +2 -0
- package/src/components/base/CkHeaderActivityButton/types.ts +36 -0
- package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +88 -0
- package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +6 -6
- package/src/components/base/CkPromptInput/CkPromptInput.vue +63 -12
- package/src/components/base/CkPromptInput/types.ts +4 -4
- package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +1 -1
- package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +1 -1
- package/src/components/base/CkPromptInputDisclaimer/types.ts +1 -1
- package/src/components/base/CkPromptSendButton/types.ts +1 -1
- package/src/components/base/CkPromptStopButton/types.ts +1 -1
- package/src/components/base/index.ts +7 -0
- package/src/components/composed/CkConversation/CkConversation.spec.ts +81 -6
- package/src/components/composed/CkConversation/CkConversation.vue +78 -35
- package/src/components/composed/CkConversation/types.ts +1 -1
- package/src/components/composed/index.ts +0 -1
- package/src/components/index.ts +6 -1
- package/src/index.ts +5 -1
- package/dist/components/composed/CkInput/CkInput.stories.d.ts +0 -43
- package/dist/components/composed/CkInput/CkInput.stories.d.ts.map +0 -1
- package/dist/components/composed/CkInput/CkInput.vue.d.ts +0 -50
- package/dist/components/composed/CkInput/CkInput.vue.d.ts.map +0 -1
- package/dist/components/composed/CkInput/index.d.ts +0 -3
- package/dist/components/composed/CkInput/index.d.ts.map +0 -1
- package/dist/components/composed/CkInput/types.d.ts +0 -55
- package/dist/components/composed/CkInput/types.d.ts.map +0 -1
- package/src/components/composed/CkInput/CkInput.spec.ts +0 -191
- package/src/components/composed/CkInput/CkInput.stories.ts +0 -434
- package/src/components/composed/CkInput/CkInput.vue +0 -106
- package/src/components/composed/CkInput/index.ts +0 -2
- package/src/components/composed/CkInput/types.ts +0 -55
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-width header row for an assistant (agent) message. Owns the agent icon,
|
|
3
|
+
* a header text and an optional activity button, and is intended to be passed
|
|
4
|
+
* into the `header` slot of `CkAgentMessage`.
|
|
5
|
+
*
|
|
6
|
+
* It has two visual states, driven purely by whether the `activity` slot is
|
|
7
|
+
* provided:
|
|
8
|
+
* - **with `activity`** — vertical padding and a bottom border separate the
|
|
9
|
+
* header from the message body;
|
|
10
|
+
* - **without `activity`** — a compact icon + text row with no padding or
|
|
11
|
+
* border.
|
|
12
|
+
*
|
|
13
|
+
* Slots: `icon` (leading agent avatar/icon), default (header text, ellipsised
|
|
14
|
+
* on overflow), `activity` (typically a `CkHeaderActivityButton`).
|
|
15
|
+
*
|
|
16
|
+
* @category Messages
|
|
17
|
+
* @useCases agent message header, assistant turn header, chain-of-thought summary row, activity indicator row
|
|
18
|
+
* @keywords agent, assistant, message, header, icon, activity, conversation
|
|
19
|
+
* @related CkAgentMessage, CkHeaderActivityButton, CkConversation
|
|
20
|
+
*/
|
|
21
|
+
export type CkAgentMessageHeaderProps = Record<string, never>;
|
|
@@ -100,6 +100,18 @@ export const WithoutLink: Story = {
|
|
|
100
100
|
args: { url: undefined },
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A very long file name truncates with an ellipsis inside the hover card while
|
|
105
|
+
* the external-link action stays pinned to the right of the truncated name; the
|
|
106
|
+
* full name is exposed on hover via the `title` attribute.
|
|
107
|
+
*/
|
|
108
|
+
export const LongFileName: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
fileName:
|
|
111
|
+
'Annual-Strategy-And-Operational-Review-And-Lessons-Learned-Digital-Transformation-Phase-1-FY2026-Final-APPROVED-v3.pdf',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
103
115
|
/**
|
|
104
116
|
* Non-paginated source — the card omits the page footer.
|
|
105
117
|
*/
|
|
@@ -43,9 +43,11 @@ const triggerLabel = computed(
|
|
|
43
43
|
const CLOSE_DELAY_MS = 80;
|
|
44
44
|
|
|
45
45
|
const open = ref(false);
|
|
46
|
-
//
|
|
47
|
-
// the keyboard
|
|
48
|
-
|
|
46
|
+
// Whether the current open session was initiated by the pointer (hover) rather
|
|
47
|
+
// than the keyboard. Persists for the whole session — including the close — so
|
|
48
|
+
// we can fully suppress the popover's focus management for hover while keeping
|
|
49
|
+
// it for keyboard users.
|
|
50
|
+
const openedByPointer = ref(false);
|
|
49
51
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
50
52
|
|
|
51
53
|
function show(): void {
|
|
@@ -65,20 +67,41 @@ function scheduleHide(): void {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function onPointerEnter(): void {
|
|
68
|
-
|
|
70
|
+
// Only mark the session pointer-driven on a closed → open transition. A stray
|
|
71
|
+
// mouseenter while the card is already open (e.g. it was opened via keyboard,
|
|
72
|
+
// then the pointer drifts over it) must not flip the flag, or onCloseAutoFocus
|
|
73
|
+
// would wrongly skip restoring focus and leave focus lost on close.
|
|
74
|
+
if (!open.value) openedByPointer.value = true;
|
|
69
75
|
show();
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
// Don't reset `openedByPointer` here — the close fires one tick later
|
|
79
|
+
// (CLOSE_DELAY_MS), and `onCloseAutoFocus` still needs to know the session was
|
|
80
|
+
// hover-driven so it can skip restoring focus to the marker.
|
|
72
81
|
function onPointerLeave(): void {
|
|
73
|
-
isPointerInteraction.value = false;
|
|
74
82
|
scheduleHide();
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
85
|
+
// Enter/Space activate the marker (opening the card) via keyboard; mark those
|
|
86
|
+
// sessions keyboard-driven so focus management is restored (into the card on
|
|
87
|
+
// open, back to the marker on close/Escape). Other keys (Tab, arrows) must not
|
|
88
|
+
// flip the pointer/keyboard distinction.
|
|
89
|
+
function onTriggerKeydown(event: KeyboardEvent): void {
|
|
90
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
91
|
+
openedByPointer.value = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// For hover sessions, suppress the popover's focus management entirely: don't
|
|
96
|
+
// pull focus into the card on open (it would steal focus / scroll), and don't
|
|
97
|
+
// yank focus back to the marker on close. That close-time refocus is what
|
|
98
|
+
// painted a stray focus-visible ring on the marker you'd just hovered away from.
|
|
80
99
|
function onOpenAutoFocus(event: Event): void {
|
|
81
|
-
if (
|
|
100
|
+
if (openedByPointer.value) event.preventDefault();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function onCloseAutoFocus(event: Event): void {
|
|
104
|
+
if (openedByPointer.value) event.preventDefault();
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
// Close once focus leaves the card entirely (keyboard users tabbing out). Moving
|
|
@@ -108,6 +131,7 @@ onBeforeUnmount(() => {
|
|
|
108
131
|
? 'bg-background-accent-soft text-black'
|
|
109
132
|
: 'bg-background-accent-yellow text-background-status-warning',
|
|
110
133
|
]"
|
|
134
|
+
@keydown="onTriggerKeydown"
|
|
111
135
|
@mouseenter="onPointerEnter"
|
|
112
136
|
@mouseleave="onPointerLeave"
|
|
113
137
|
>
|
|
@@ -119,6 +143,7 @@ onBeforeUnmount(() => {
|
|
|
119
143
|
:side-offset="8"
|
|
120
144
|
class="w-auto border-0 bg-transparent p-0 shadow-none"
|
|
121
145
|
@open-auto-focus="onOpenAutoFocus"
|
|
146
|
+
@close-auto-focus="onCloseAutoFocus"
|
|
122
147
|
@mouseenter="onPointerEnter"
|
|
123
148
|
@mouseleave="onPointerLeave"
|
|
124
149
|
@focusin="show"
|
|
@@ -60,6 +60,15 @@ describe('CkCitationCard', () => {
|
|
|
60
60
|
expect(screen.getByText(snippet).className).toContain('break-words');
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('clamps a long snippet to a bounded number of lines so card height does not grow with excerpt length', () => {
|
|
64
|
+
const snippet = Array(40)
|
|
65
|
+
.fill('A long sentence of citation excerpt text.')
|
|
66
|
+
.join(' ');
|
|
67
|
+
render(CkCitationCard, { props: { ...base, snippet } });
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText(snippet).className).toContain('line-clamp-6');
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
it('uses the injected citationOpenSource label via provideChatKitLabels', () => {
|
|
64
73
|
const Wrapper = defineComponent({
|
|
65
74
|
setup() {
|
|
@@ -92,10 +92,11 @@ export const MinimalSnippetOnly: Story = {
|
|
|
92
92
|
};
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* A long file name truncates with an ellipsis
|
|
96
|
-
*
|
|
97
|
-
* on hover via the `title` attribute. The
|
|
98
|
-
* length, including a single unbroken token
|
|
95
|
+
* A long file name truncates with an ellipsis while the external-link action
|
|
96
|
+
* stays pinned to the right of the (truncated) name and the card keeps its
|
|
97
|
+
* width; the full name stays available on hover via the `title` attribute. The
|
|
98
|
+
* `truncate` behaviour holds for any length, including a single unbroken token
|
|
99
|
+
* with no separators.
|
|
99
100
|
*/
|
|
100
101
|
export const LongFileName: Story = {
|
|
101
102
|
args: {
|
|
@@ -105,12 +106,15 @@ export const LongFileName: Story = {
|
|
|
105
106
|
};
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
* A
|
|
109
|
-
*
|
|
109
|
+
* A whole-document-length excerpt is clamped to a bounded number of lines
|
|
110
|
+
* (`line-clamp-6`) with a trailing ellipsis, so the card stays a compact preview
|
|
111
|
+
* and its height does not grow with the excerpt length — no matter how large a
|
|
112
|
+
* chunk `file_search` returns (it can be as large as the entire source file). The
|
|
113
|
+
* full text remains reachable via the external-link action.
|
|
110
114
|
*/
|
|
111
115
|
export const LongSnippet: Story = {
|
|
112
116
|
args: {
|
|
113
|
-
snippet: `${LOREM} ${LOREM}`,
|
|
117
|
+
snippet: `${LOREM} ${LOREM} ${LOREM} ${LOREM} ${LOREM}`,
|
|
114
118
|
},
|
|
115
119
|
};
|
|
116
120
|
|
|
@@ -42,10 +42,10 @@ function handleOpen(): void {
|
|
|
42
42
|
<div
|
|
43
43
|
class="ck-citation-card flex w-[22.5rem] max-w-[90vw] flex-col gap-2 rounded-md bg-background-surface-floating px-6 pb-6 pt-4 shadow-md"
|
|
44
44
|
>
|
|
45
|
-
<div class="flex items-center gap-
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
46
|
<UiFileType :filename="fileName" class="shrink-0" />
|
|
47
47
|
<p
|
|
48
|
-
class="min-w-0
|
|
48
|
+
class="min-w-0 truncate text-sm font-medium text-content-on-surface-primary"
|
|
49
49
|
:title="fileName"
|
|
50
50
|
>
|
|
51
51
|
{{ fileName }}
|
|
@@ -62,7 +62,9 @@ function handleOpen(): void {
|
|
|
62
62
|
</UiButton>
|
|
63
63
|
</div>
|
|
64
64
|
|
|
65
|
-
<p
|
|
65
|
+
<p
|
|
66
|
+
class="line-clamp-6 break-words text-sm leading-5 text-content-on-surface-secondary"
|
|
67
|
+
>
|
|
66
68
|
{{ snippet }}
|
|
67
69
|
</p>
|
|
68
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render, screen, within } from '@testing-library/vue';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import CkFileItemsList from './CkFileItemsList.vue';
|
|
5
5
|
import type { CkFileEntry } from './types';
|
|
6
6
|
|
|
@@ -11,8 +11,34 @@ const files: CkFileEntry[] = [
|
|
|
11
11
|
{ id: 'd', filename: 'archive.zip' },
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
+
// The inline capacity is derived purely from the measured container width: how
|
|
15
|
+
// many items fit at `minItemWidth` each. JSDOM has no layout, so we stub
|
|
16
|
+
// `clientWidth` (read in `onMounted`). The default 700px fits 3 inline items at
|
|
17
|
+
// the default 200px `minItemWidth`, reproducing the typical "3 inline, rest
|
|
18
|
+
// collapsed" behaviour for these specs.
|
|
19
|
+
//
|
|
20
|
+
// Note: width is measured in `onMounted`, which updates reactively *after* the
|
|
21
|
+
// initial render. Assertions that depend on the *narrowed* (width-collapsed)
|
|
22
|
+
// state must therefore await the re-render (e.g. via `findByRole`) rather than
|
|
23
|
+
// querying synchronously.
|
|
24
|
+
let clientWidthSpy: ReturnType<typeof vi.spyOn>;
|
|
25
|
+
|
|
26
|
+
function setContainerWidth(px: number) {
|
|
27
|
+
clientWidthSpy.mockReturnValue(px);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
clientWidthSpy = vi
|
|
32
|
+
.spyOn(Element.prototype, 'clientWidth', 'get')
|
|
33
|
+
.mockReturnValue(700);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
clientWidthSpy.mockRestore();
|
|
38
|
+
});
|
|
39
|
+
|
|
14
40
|
describe('CkFileItemsList', () => {
|
|
15
|
-
it('renders all files inline when
|
|
41
|
+
it('renders all files inline when they fit the available width', () => {
|
|
16
42
|
const { container } = render(CkFileItemsList, {
|
|
17
43
|
props: { files: files.slice(0, 3) },
|
|
18
44
|
});
|
|
@@ -25,34 +51,82 @@ describe('CkFileItemsList', () => {
|
|
|
25
51
|
).toBeNull();
|
|
26
52
|
});
|
|
27
53
|
|
|
28
|
-
it('collapses into a popover trigger when
|
|
54
|
+
it('collapses into a popover trigger when the files no longer fit', async () => {
|
|
29
55
|
render(CkFileItemsList, { props: { files } });
|
|
30
56
|
|
|
31
|
-
const trigger = screen.
|
|
57
|
+
const trigger = await screen.findByRole('button', { name: /4 files/i });
|
|
32
58
|
expect(trigger).toBeInTheDocument();
|
|
33
59
|
expect(trigger).toHaveTextContent('4 files');
|
|
34
60
|
});
|
|
35
61
|
|
|
36
|
-
it('
|
|
62
|
+
it('keeps every file inline on a wide wrapper (no upper cap)', () => {
|
|
63
|
+
setContainerWidth(2000);
|
|
64
|
+
const many: CkFileEntry[] = [
|
|
65
|
+
...files,
|
|
66
|
+
{ id: 'e', filename: 'slides.key' },
|
|
67
|
+
{ id: 'f', filename: 'budget.xlsx' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const { container } = render(CkFileItemsList, { props: { files: many } });
|
|
71
|
+
|
|
72
|
+
const list = container.querySelector('[data-slot="file-items-list"]');
|
|
73
|
+
expect(list?.querySelectorAll('li')).toHaveLength(6);
|
|
74
|
+
expect(
|
|
75
|
+
container.querySelector('[data-slot="file-items-list-trigger"]'),
|
|
76
|
+
).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reduces the inline count as the wrapper narrows', async () => {
|
|
80
|
+
// 360px fits only 1 item at the default 200px minItemWidth, so 3 files no
|
|
81
|
+
// longer fit and collapse. The narrowing is applied after the onMounted
|
|
82
|
+
// measurement, so await the collapsed trigger rather than querying
|
|
83
|
+
// synchronously.
|
|
84
|
+
setContainerWidth(360);
|
|
85
|
+
|
|
86
|
+
const { container } = render(CkFileItemsList, {
|
|
87
|
+
props: { files: files.slice(0, 3) },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
await screen.findByRole('button', { name: /3 files/i }),
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
expect(container.querySelector('[data-slot="file-items-list"]')).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('constrains inline item width between minItemWidth and maxItemWidth', () => {
|
|
97
|
+
const { container } = render(CkFileItemsList, {
|
|
98
|
+
props: { files: files.slice(0, 2), minItemWidth: 140, maxItemWidth: 200 },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const item = container.querySelector<HTMLElement>(
|
|
102
|
+
'[data-slot="file-items-list"] li',
|
|
103
|
+
);
|
|
104
|
+
expect(item?.style.minWidth).toBe('140px');
|
|
105
|
+
expect(item?.style.maxWidth).toBe('200px');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('uses an explicit triggerLabel when provided', async () => {
|
|
37
109
|
render(CkFileItemsList, {
|
|
38
110
|
props: { files, triggerLabel: '+1 more attachment' },
|
|
39
111
|
});
|
|
40
112
|
|
|
41
|
-
expect(screen.
|
|
113
|
+
expect(await screen.findByRole('button')).toHaveTextContent(
|
|
114
|
+
'+1 more attachment',
|
|
115
|
+
);
|
|
42
116
|
});
|
|
43
117
|
|
|
44
|
-
it('keeps a single-line trigger (no count subtitle) when showStatus is off', () => {
|
|
118
|
+
it('keeps a single-line trigger (no count subtitle) when showStatus is off', async () => {
|
|
45
119
|
render(CkFileItemsList, {
|
|
46
120
|
props: { files, triggerLabel: 'Uploaded documents' },
|
|
47
121
|
});
|
|
48
122
|
|
|
49
|
-
const trigger = screen.
|
|
123
|
+
const trigger = await screen.findByRole('button');
|
|
50
124
|
expect(trigger).toHaveTextContent('Uploaded documents');
|
|
51
125
|
expect(trigger).not.toHaveTextContent('4 files');
|
|
52
126
|
});
|
|
53
127
|
|
|
54
128
|
describe('showStatus summary on the collapsed trigger', () => {
|
|
55
|
-
it('summarises the number of failed uploads in an error line', () => {
|
|
129
|
+
it('summarises the number of failed uploads in an error line', async () => {
|
|
56
130
|
const withFailures: CkFileEntry[] = [
|
|
57
131
|
{ id: 'a', filename: 'a.pdf', error: 'Upload failed' },
|
|
58
132
|
{ id: 'b', filename: 'b.md', error: 'Upload failed' },
|
|
@@ -68,13 +142,13 @@ describe('CkFileItemsList', () => {
|
|
|
68
142
|
},
|
|
69
143
|
});
|
|
70
144
|
|
|
71
|
-
const trigger = screen.
|
|
145
|
+
const trigger = await screen.findByRole('button', {
|
|
72
146
|
name: /Uploading documents, 2 uploads failed/i,
|
|
73
147
|
});
|
|
74
148
|
expect(trigger).toHaveTextContent('2 uploads failed');
|
|
75
149
|
});
|
|
76
150
|
|
|
77
|
-
it('renders an aggregate progress bar while files are uploading', () => {
|
|
151
|
+
it('renders an aggregate progress bar while files are uploading', async () => {
|
|
78
152
|
const uploading: CkFileEntry[] = [
|
|
79
153
|
{ id: 'a', filename: 'a.pdf', progress: 20 },
|
|
80
154
|
{ id: 'b', filename: 'b.md', progress: 60 },
|
|
@@ -90,10 +164,11 @@ describe('CkFileItemsList', () => {
|
|
|
90
164
|
},
|
|
91
165
|
});
|
|
92
166
|
|
|
167
|
+
await screen.findByRole('button');
|
|
93
168
|
expect(container.querySelector('[data-slot="progress"]')).toBeTruthy();
|
|
94
169
|
});
|
|
95
170
|
|
|
96
|
-
it('shows the file count once all uploads succeed', () => {
|
|
171
|
+
it('shows the file count once all uploads succeed', async () => {
|
|
97
172
|
render(CkFileItemsList, {
|
|
98
173
|
props: {
|
|
99
174
|
files,
|
|
@@ -102,7 +177,7 @@ describe('CkFileItemsList', () => {
|
|
|
102
177
|
},
|
|
103
178
|
});
|
|
104
179
|
|
|
105
|
-
const trigger = screen.
|
|
180
|
+
const trigger = await screen.findByRole('button');
|
|
106
181
|
expect(trigger).toHaveTextContent('Uploaded documents');
|
|
107
182
|
expect(trigger).toHaveTextContent('4 files');
|
|
108
183
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
-
import { ref } from 'vue';
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
3
|
import CkFileItemsList from './CkFileItemsList.vue';
|
|
4
4
|
import type { CkFileEntry } from './types';
|
|
5
5
|
|
|
@@ -12,12 +12,13 @@ const meta: Meta<typeof CkFileItemsList> = {
|
|
|
12
12
|
docs: {
|
|
13
13
|
description: {
|
|
14
14
|
component:
|
|
15
|
-
'Compact horizontal row of file attachments. Renders
|
|
15
|
+
'Compact horizontal row of file attachments. Renders as many `UiFileItem`s inline as fit the available width — each kept between `minItemWidth` and `maxItemWidth` wide — and collapses the full list into a single popover trigger once they no longer fit.',
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
argTypes: {
|
|
20
|
-
|
|
20
|
+
minItemWidth: { control: { type: 'number', min: 80, max: 320, step: 8 } },
|
|
21
|
+
maxItemWidth: { control: { type: 'number', min: 120, max: 480, step: 8 } },
|
|
21
22
|
size: { control: { type: 'inline-radio' }, options: ['default', 'small'] },
|
|
22
23
|
isRemovable: { control: 'boolean' },
|
|
23
24
|
disabled: { control: 'boolean' },
|
|
@@ -97,15 +98,15 @@ function handleRemove(id: CkFileEntry['id']) {
|
|
|
97
98
|
</template>`;
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
*
|
|
101
|
-
* row of `UiFileItem`s.
|
|
101
|
+
* As many files as fit the available width are rendered inline as a horizontal
|
|
102
|
+
* row of `UiFileItem`s (each kept between `minItemWidth` and `maxItemWidth`).
|
|
102
103
|
*/
|
|
103
104
|
export const Inline: Story = {
|
|
104
105
|
render: () => ({
|
|
105
106
|
components: { CkFileItemsList },
|
|
106
107
|
setup: () => ({ files: baseFiles }),
|
|
107
108
|
template: `
|
|
108
|
-
<div class="bg-background-surface-primary p-4 w-[
|
|
109
|
+
<div class="bg-background-surface-primary p-4 w-[44rem]">
|
|
109
110
|
<CkFileItemsList :files="files" />
|
|
110
111
|
</div>
|
|
111
112
|
`,
|
|
@@ -114,15 +115,15 @@ export const Inline: Story = {
|
|
|
114
115
|
};
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
|
-
* When the
|
|
118
|
-
* popover trigger. Click it to reveal the full vertical list of files.
|
|
118
|
+
* When the files no longer fit the available width, the component collapses to
|
|
119
|
+
* a single popover trigger. Click it to reveal the full vertical list of files.
|
|
119
120
|
*/
|
|
120
121
|
export const Collapsed: Story = {
|
|
121
122
|
render: () => ({
|
|
122
123
|
components: { CkFileItemsList },
|
|
123
124
|
setup: () => ({ files: overflowFiles }),
|
|
124
125
|
template: `
|
|
125
|
-
<div class="bg-background-surface-primary p-4 w-[
|
|
126
|
+
<div class="bg-background-surface-primary p-4 w-[44rem]">
|
|
126
127
|
<CkFileItemsList :files="files" />
|
|
127
128
|
</div>
|
|
128
129
|
`,
|
|
@@ -193,7 +194,7 @@ export const Removable: Story = {
|
|
|
193
194
|
return { files, handleRemove };
|
|
194
195
|
},
|
|
195
196
|
template: `
|
|
196
|
-
<div class="bg-background-surface-primary p-4 w-[
|
|
197
|
+
<div class="bg-background-surface-primary p-4 w-[44rem]">
|
|
197
198
|
<CkFileItemsList :files="files" is-removable @remove="handleRemove" />
|
|
198
199
|
</div>
|
|
199
200
|
`,
|
|
@@ -232,7 +233,7 @@ export const CustomItem: Story = {
|
|
|
232
233
|
components: { CkFileItemsList },
|
|
233
234
|
setup: () => ({ files: baseFiles }),
|
|
234
235
|
template: `
|
|
235
|
-
<div class="bg-background-surface-primary p-4 w-[
|
|
236
|
+
<div class="bg-background-surface-primary p-4 w-[44rem]">
|
|
236
237
|
<CkFileItemsList :files="files">
|
|
237
238
|
<template #item="{ file }">
|
|
238
239
|
<a
|
|
@@ -283,7 +284,7 @@ export const Clickable: Story = {
|
|
|
283
284
|
return { files: overflowFiles, openFile, lastClicked };
|
|
284
285
|
},
|
|
285
286
|
template: `
|
|
286
|
-
<div class="bg-background-surface-primary p-4 w-[
|
|
287
|
+
<div class="bg-background-surface-primary p-4 w-[44rem]">
|
|
287
288
|
<CkFileItemsList
|
|
288
289
|
:files="files"
|
|
289
290
|
list-label="Attached files"
|
|
@@ -335,7 +336,7 @@ export const Disabled: Story = {
|
|
|
335
336
|
] as CkFileEntry[],
|
|
336
337
|
}),
|
|
337
338
|
template: `
|
|
338
|
-
<div class="flex flex-col gap-6 bg-background-surface-primary p-4 w-[
|
|
339
|
+
<div class="flex flex-col gap-6 bg-background-surface-primary p-4 w-[44rem]">
|
|
339
340
|
<div>
|
|
340
341
|
<p class="label-12-medium text-content-on-surface-muted mb-2">
|
|
341
342
|
Whole list disabled
|
|
@@ -353,3 +354,50 @@ export const Disabled: Story = {
|
|
|
353
354
|
}),
|
|
354
355
|
parameters: { docs: { source: { code: disabledTemplateSource } } },
|
|
355
356
|
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The inline count adapts to the available width. There is no hard cap on the
|
|
360
|
+
* number of inline items — as many fit are kept, each between `minItemWidth`
|
|
361
|
+
* and `maxItemWidth` wide. As the wrapper narrows, fewer items stay inline
|
|
362
|
+
* until the whole list collapses into the popover trigger.
|
|
363
|
+
*
|
|
364
|
+
* Drag the bottom-right corner of the box to resize it and watch the list
|
|
365
|
+
* react. (The other stories use a fixed-width wrapper, so resizing the browser
|
|
366
|
+
* window alone won't change their layout.)
|
|
367
|
+
*/
|
|
368
|
+
export const Responsive: Story = {
|
|
369
|
+
args: {
|
|
370
|
+
minItemWidth: 200,
|
|
371
|
+
maxItemWidth: 256,
|
|
372
|
+
fileCount: 3,
|
|
373
|
+
} as never,
|
|
374
|
+
argTypes: {
|
|
375
|
+
fileCount: { control: { type: 'number', min: 1, max: 5, step: 1 } },
|
|
376
|
+
} as never,
|
|
377
|
+
render: (args) => ({
|
|
378
|
+
components: { CkFileItemsList },
|
|
379
|
+
setup() {
|
|
380
|
+
const argsWithCount = args as typeof args & { fileCount?: number };
|
|
381
|
+
const files = computed(() =>
|
|
382
|
+
overflowFiles.slice(
|
|
383
|
+
0,
|
|
384
|
+
Math.min(5, Math.max(1, argsWithCount.fileCount ?? 5)),
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
const listArgs = computed(() => {
|
|
388
|
+
const rest = { ...argsWithCount };
|
|
389
|
+
delete rest.fileCount;
|
|
390
|
+
return rest;
|
|
391
|
+
});
|
|
392
|
+
return { args: listArgs, files };
|
|
393
|
+
},
|
|
394
|
+
template: `
|
|
395
|
+
<div
|
|
396
|
+
class="resize-x overflow-auto bg-background-surface-primary p-4"
|
|
397
|
+
style="resize: horizontal; overflow: auto; width: 44rem; min-width: 12rem; max-width: 100%;"
|
|
398
|
+
>
|
|
399
|
+
<CkFileItemsList v-bind="args" :files="files" />
|
|
400
|
+
</div>
|
|
401
|
+
`,
|
|
402
|
+
}),
|
|
403
|
+
};
|