@aleph-alpha/chat-kit 4.0.0 → 5.0.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.
Files changed (110) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-DJN-nZ2-.js → CkHistory.vue_vue_type_script_setup_true_lang-Bp9hXdH6.js} +434 -377
  2. package/dist/chat-kit.css +28 -6
  3. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +14 -13
  4. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  5. package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts +8 -7
  6. package/dist/components/base/CkAgentMessage/CkAgentMessage.vue.d.ts.map +1 -1
  7. package/dist/components/base/CkAgentMessage/types.d.ts +16 -5
  8. package/dist/components/base/CkAgentMessage/types.d.ts.map +1 -1
  9. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts +16 -0
  10. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.d.ts.map +1 -0
  11. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts +21 -0
  12. package/dist/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue.d.ts.map +1 -0
  13. package/dist/components/base/CkAgentMessageHeader/index.d.ts +3 -0
  14. package/dist/components/base/CkAgentMessageHeader/index.d.ts.map +1 -0
  15. package/dist/components/base/CkAgentMessageHeader/types.d.ts +22 -0
  16. package/dist/components/base/CkAgentMessageHeader/types.d.ts.map +1 -0
  17. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +6 -0
  18. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -1
  19. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -1
  20. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +5 -4
  21. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -1
  22. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -1
  23. package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts +15 -4
  24. package/dist/components/base/CkFileItemsList/CkFileItemsList.stories.d.ts.map +1 -1
  25. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +2 -1
  26. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts.map +1 -1
  27. package/dist/components/base/CkFileItemsList/types.d.ts +17 -4
  28. package/dist/components/base/CkFileItemsList/types.d.ts.map +1 -1
  29. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts +19 -0
  30. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.d.ts.map +1 -0
  31. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts +22 -0
  32. package/dist/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue.d.ts.map +1 -0
  33. package/dist/components/base/CkHeaderActivityButton/index.d.ts +3 -0
  34. package/dist/components/base/CkHeaderActivityButton/index.d.ts.map +1 -0
  35. package/dist/components/base/CkHeaderActivityButton/types.d.ts +36 -0
  36. package/dist/components/base/CkHeaderActivityButton/types.d.ts.map +1 -0
  37. package/dist/components/base/CkPromptInput/CkPromptInput.stories.d.ts +6 -6
  38. package/dist/components/base/CkPromptInput/CkPromptInput.vue.d.ts.map +1 -1
  39. package/dist/components/base/CkPromptInput/types.d.ts +4 -4
  40. package/dist/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue.d.ts.map +1 -1
  41. package/dist/components/base/CkPromptInputDisclaimer/types.d.ts +1 -1
  42. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  43. package/dist/components/base/CkPromptSendButton/types.d.ts +1 -1
  44. package/dist/components/base/CkPromptStopButton/types.d.ts +1 -1
  45. package/dist/components/base/index.d.ts +4 -0
  46. package/dist/components/base/index.d.ts.map +1 -1
  47. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +13 -1
  48. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  49. package/dist/components/composed/CkConversation/types.d.ts +1 -1
  50. package/dist/components/composed/index.d.ts +0 -1
  51. package/dist/components/composed/index.d.ts.map +1 -1
  52. package/dist/components/index.d.ts +3 -3
  53. package/dist/components/index.d.ts.map +1 -1
  54. package/dist/components/index.js +26 -25
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +27 -26
  58. package/package.json +1 -1
  59. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +12 -17
  60. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +124 -113
  61. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +18 -16
  62. package/src/components/base/CkAgentMessage/types.ts +16 -5
  63. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.spec.ts +64 -0
  64. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.stories.ts +90 -0
  65. package/src/components/base/CkAgentMessageHeader/CkAgentMessageHeader.vue +47 -0
  66. package/src/components/base/CkAgentMessageHeader/index.ts +2 -0
  67. package/src/components/base/CkAgentMessageHeader/types.ts +21 -0
  68. package/src/components/base/CkCitation/CkCitation.stories.ts +12 -0
  69. package/src/components/base/CkCitation/CkCitation.vue +34 -9
  70. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +5 -4
  71. package/src/components/base/CkCitationCard/CkCitationCard.vue +2 -2
  72. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +1 -1
  73. package/src/components/base/CkFileItemsList/CkFileItemsList.spec.ts +88 -13
  74. package/src/components/base/CkFileItemsList/CkFileItemsList.stories.ts +61 -13
  75. package/src/components/base/CkFileItemsList/CkFileItemsList.vue +153 -110
  76. package/src/components/base/CkFileItemsList/types.ts +17 -4
  77. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.spec.ts +50 -0
  78. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.stories.ts +102 -0
  79. package/src/components/base/CkHeaderActivityButton/CkHeaderActivityButton.vue +39 -0
  80. package/src/components/base/CkHeaderActivityButton/index.ts +2 -0
  81. package/src/components/base/CkHeaderActivityButton/types.ts +36 -0
  82. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +88 -0
  83. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +6 -6
  84. package/src/components/base/CkPromptInput/CkPromptInput.vue +63 -12
  85. package/src/components/base/CkPromptInput/types.ts +4 -4
  86. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +1 -1
  87. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +1 -1
  88. package/src/components/base/CkPromptInputDisclaimer/types.ts +1 -1
  89. package/src/components/base/CkPromptSendButton/types.ts +1 -1
  90. package/src/components/base/CkPromptStopButton/types.ts +1 -1
  91. package/src/components/base/index.ts +7 -0
  92. package/src/components/composed/CkConversation/CkConversation.spec.ts +81 -6
  93. package/src/components/composed/CkConversation/CkConversation.vue +78 -35
  94. package/src/components/composed/CkConversation/types.ts +1 -1
  95. package/src/components/composed/index.ts +0 -1
  96. package/src/components/index.ts +6 -1
  97. package/src/index.ts +5 -1
  98. package/dist/components/composed/CkInput/CkInput.stories.d.ts +0 -43
  99. package/dist/components/composed/CkInput/CkInput.stories.d.ts.map +0 -1
  100. package/dist/components/composed/CkInput/CkInput.vue.d.ts +0 -50
  101. package/dist/components/composed/CkInput/CkInput.vue.d.ts.map +0 -1
  102. package/dist/components/composed/CkInput/index.d.ts +0 -3
  103. package/dist/components/composed/CkInput/index.d.ts.map +0 -1
  104. package/dist/components/composed/CkInput/types.d.ts +0 -55
  105. package/dist/components/composed/CkInput/types.d.ts.map +0 -1
  106. package/src/components/composed/CkInput/CkInput.spec.ts +0 -191
  107. package/src/components/composed/CkInput/CkInput.stories.ts +0 -434
  108. package/src/components/composed/CkInput/CkInput.vue +0 -106
  109. package/src/components/composed/CkInput/index.ts +0 -2
  110. 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
- // Tracks whether the current open was driven by the pointer (hover) rather than
47
- // the keyboard, so we can suppress the popover's focus management for hover only.
48
- const isPointerInteraction = ref(false);
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
- isPointerInteraction.value = true;
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
- // Suppress auto-focus for hover opens only (so hovering never steals focus or
78
- // scrolls). Keyboard opens keep it, moving focus into the card so the source
79
- // action is reachable.
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 (isPointerInteraction.value) event.preventDefault();
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"
@@ -92,10 +92,11 @@ export const MinimalSnippetOnly: Story = {
92
92
  };
93
93
 
94
94
  /**
95
- * A long file name truncates with an ellipsis (the badge and external-link
96
- * action stay pinned, the card keeps its width); the full name stays available
97
- * on hover via the `title` attribute. The `truncate` behaviour holds for any
98
- * length, including a single unbroken token with no separators.
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: {
@@ -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-3">
45
+ <div class="flex items-center gap-2">
46
46
  <UiFileType :filename="fileName" class="shrink-0" />
47
47
  <p
48
- class="min-w-0 flex-1 truncate text-sm font-medium text-content-on-surface-primary"
48
+ class="min-w-0 truncate text-sm font-medium text-content-on-surface-primary"
49
49
  :title="fileName"
50
50
  >
51
51
  {{ fileName }}
@@ -181,7 +181,7 @@ watch(
181
181
  <template>
182
182
  <div
183
183
  ref="scrollContainerRef"
184
- class="relative flex flex-col gap-4 overflow-y-auto"
184
+ class="relative flex flex-col gap-8 overflow-y-auto"
185
185
  @scroll="handleScroll"
186
186
  >
187
187
  <div
@@ -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 count <= maxInline', () => {
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 count exceeds maxInline', () => {
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.getByRole('button', { name: /4 files/i });
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('uses an explicit triggerLabel when provided', () => {
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.getByRole('button')).toHaveTextContent('+1 more attachment');
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.getByRole('button');
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.getByRole('button', {
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.getByRole('button');
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 up to `maxInline` `UiFileItem`s side-by-side; once that threshold is exceeded, the full list collapses into a single popover trigger.',
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
- maxInline: { control: { type: 'number', min: 1, max: 6, step: 1 } },
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
- * Up to `maxInline` (default `3`) files are rendered inline as a horizontal
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-[36rem]">
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 list overflows `maxInline`, the component collapses to a single
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-[36rem]">
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-[36rem]">
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-[36rem]">
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-[36rem]">
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-[36rem]">
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
+ };