@autoafleveren/ui 1.5.1 → 1.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autoafleveren/ui",
3
- "version": "1.5.1",
3
+ "version": "1.5.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/*",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "packageManager": "yarn@4.6.0",
72
72
  "dependencies": {
73
- "@awesome.me/kit-ed6848cc31": "^1.0.7",
73
+ "@awesome.me/kit-ed6848cc31": "^1.0.10",
74
74
  "@floating-ui/vue": "^1.1.7",
75
75
  "@fortawesome/fontawesome-svg-core": "^7.0.0",
76
76
  "@fortawesome/vue-fontawesome": "^3.1.1",
@@ -1,8 +1,9 @@
1
1
  <script lang="ts" setup>
2
2
  import { computed, nextTick, onUnmounted, ref } from 'vue';
3
- import { onClickOutside } from '@vueuse/core';
3
+ import { onClickOutside, useDebounceFn } from '@vueuse/core';
4
4
  import { useActionBar, useClientPoint, useContextMenu, useFocusTrap } from '~composables';
5
5
  import AppActionBarItem from '~components/AppActionBar/AppActionBarItem.vue';
6
+ import AppInput from '~components/AppInput/AppInput.vue';
6
7
  import ShortcutItem from './ShortcutItem.vue';
7
8
 
8
9
  import type { Props, Action } from './index.d';
@@ -19,13 +20,29 @@
19
20
  const isOpen = ref(true);
20
21
  const isLoading = ref(false);
21
22
  const openIndex = ref<number | null>(null);
23
+ const searchActionsQuery = ref<string>('');
22
24
  const actionbar = useActionBar();
23
25
  const contextMenu = useContextMenu();
24
26
  const contextMenuElement = ref<HTMLDivElement>();
25
27
  const clientComputedPosition = useClientPoint(contextMenuElement);
26
28
  const { activate, deactivate, onEscape } = useFocusTrap(contextMenuElement);
29
+ const onSearchActionsQueryInput = useDebounceFn((value: string) => {
30
+ searchActionsQuery.value = value;
31
+ });
27
32
 
28
33
  const actionsWithFallback = computed((): Action[] => props.actions ?? actionbar.actions.value);
34
+ const normalizedSearchQuery = computed((): string => searchActionsQuery.value?.toLowerCase().trim() || '');
35
+ const isSearching = computed((): boolean => normalizedSearchQuery.value?.length > 0);
36
+ const filteredActions = computed((): Action[] => {
37
+ const actions = actionsWithFallback.value;
38
+ const limit = props.maxList ?? actions.length;
39
+
40
+ if (!isSearching.value) return actions.slice(0, limit);
41
+
42
+ return actions
43
+ .filter(action => action.name.toLowerCase().includes(normalizedSearchQuery.value))
44
+ .slice(0, limit);
45
+ });
29
46
 
30
47
  onUnmounted(deactivate);
31
48
 
@@ -84,9 +101,20 @@
84
101
  class="app-context-menu fixed z-50 flex w-64 flex-col rounded-lg bg-secondary p-2 drop-shadow-card empty:hidden"
85
102
  data-test-context-menu
86
103
  >
104
+ <div v-if="enableSearch && actionsWithFallback.length > 0">
105
+ <AppInput
106
+ :model-value="searchActionsQuery"
107
+ :placeholder="searchPlaceholder ?? ''"
108
+ class="mb-2 text-zinc-700"
109
+ type="text"
110
+ data-test-driver-search
111
+ @update:model-value="onSearchActionsQueryInput"
112
+ />
113
+ </div>
114
+
87
115
  <div
88
116
  v-if="contextMenu.shortcuts.value.length > 0"
89
- :class="{ 'mb-2': actionsWithFallback.length > 0 }"
117
+ :class="{ 'mb-2': filteredActions.length > 0 }"
90
118
  class="flex justify-between divide-x divide-zinc-600 rounded-md border border-zinc-600"
91
119
  >
92
120
  <ShortcutItem
@@ -99,7 +127,7 @@
99
127
  </div>
100
128
 
101
129
  <AppActionBarItem
102
- v-for="(action, index) in actionsWithFallback"
130
+ v-for="(action, index) in filteredActions"
103
131
  :key="`action-item-${index}`"
104
132
  :action="action"
105
133
  :context="true"
@@ -116,5 +144,13 @@
116
144
  >
117
145
  <span class="text-base">{{ action.name }}</span>
118
146
  </AppActionBarItem>
147
+
148
+ <p
149
+ v-if="isSearching && filteredActions.length === 0"
150
+ class="px-4 py-2 text-sm text-zinc-500"
151
+ data-test-context-menu-no-results
152
+ >
153
+ {{ noResultsText ?? '' }}
154
+ </p>
119
155
  </div>
120
156
  </template>
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { shallowMount } from '@vue/test-utils';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { flushPromises, shallowMount } from '@vue/test-utils';
3
3
  import { useContextMenu } from '~composables';
4
4
  import { actions as actionsMock, shortcutItem as shortcutItemMock } from '../__mocks__';
5
5
  import AppActionBarItem from '~components/AppActionBar/AppActionBarItem.vue';
@@ -11,19 +11,28 @@ import type { Action } from '~components/AppActionBar/index.d';
11
11
  class MockPointerEvent {}
12
12
  const contextMenu = useContextMenu();
13
13
 
14
- function createWrapper(actions: Action[] = actionsMock) {
14
+ function createWrapper(actions: Action[] = actionsMock, props: Record<string, unknown> = {}) {
15
15
  return shallowMount(AppContextMenu, { props: {
16
16
  actions,
17
17
  event: new MockPointerEvent() as PointerEvent,
18
18
  item: {},
19
+ ...props,
19
20
  } });
20
21
  }
21
22
 
22
23
  describe('the AppContextMenu ShortcutItem component', () => {
24
+ beforeEach(() => {
25
+ vi.useFakeTimers();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.useRealTimers();
30
+ });
31
+
23
32
  it('should display the context menu', () => {
24
33
  const wrapper = createWrapper();
25
-
26
34
  const menu = wrapper.find('[data-test-context-menu]');
35
+
27
36
  expect(menu.exists()).toBe(true);
28
37
  });
29
38
 
@@ -31,8 +40,8 @@ describe('the AppContextMenu ShortcutItem component', () => {
31
40
  expect.assertions(3);
32
41
 
33
42
  const wrapper = createWrapper();
34
-
35
43
  let shortcutItems = wrapper.findAllComponents(ShortcutItem);
44
+
36
45
  expect(shortcutItems).toHaveLength(0);
37
46
 
38
47
  contextMenu.setShortcuts([shortcutItemMock, shortcutItemMock]);
@@ -40,6 +49,7 @@ describe('the AppContextMenu ShortcutItem component', () => {
40
49
  await wrapper.vm.$nextTick();
41
50
 
42
51
  shortcutItems = wrapper.findAllComponents(ShortcutItem);
52
+
43
53
  expect(shortcutItems).toHaveLength(2);
44
54
  expect(shortcutItems[0].props('shortcut')).toStrictEqual(shortcutItemMock);
45
55
  });
@@ -48,8 +58,8 @@ describe('the AppContextMenu ShortcutItem component', () => {
48
58
  expect.assertions(5);
49
59
 
50
60
  const wrapper = createWrapper();
51
-
52
61
  const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
62
+
53
63
  expect(actionBarItems).toHaveLength(2);
54
64
  expect(actionBarItems[0].props('action')).toStrictEqual(actionsMock[0]);
55
65
  expect(actionBarItems[1].props('action')).toStrictEqual(actionsMock[1]);
@@ -61,11 +71,144 @@ describe('the AppContextMenu ShortcutItem component', () => {
61
71
  expect.assertions(1);
62
72
 
63
73
  const wrapper = createWrapper();
64
-
65
74
  const actionBarItem = wrapper.findComponent(AppActionBarItem);
66
75
 
67
76
  actionBarItem.vm.$emit('close');
68
77
 
69
78
  expect(wrapper.emitted()).toHaveProperty('close');
70
79
  });
80
+
81
+ it('should not render the search input when enableSearch is false', () => {
82
+ const wrapper = createWrapper(actionsMock, { enableSearch: false });
83
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
84
+
85
+ expect(searchInput.exists()).toBe(false);
86
+ });
87
+
88
+ it('should not render the search input when enableSearch is not provided', () => {
89
+ const wrapper = createWrapper();
90
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
91
+
92
+ expect(searchInput.exists()).toBe(false);
93
+ });
94
+
95
+ it('should render the search input when enableSearch is true', () => {
96
+ const wrapper = createWrapper(actionsMock, { enableSearch: true });
97
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
98
+
99
+ expect(searchInput.exists()).toBe(true);
100
+ });
101
+
102
+ it('should use the searchPlaceholder prop as the input placeholder', () => {
103
+ const wrapper = createWrapper(actionsMock, {
104
+ enableSearch: true,
105
+ searchPlaceholder: 'Search actions...',
106
+ });
107
+
108
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
109
+
110
+ expect(searchInput.attributes('placeholder')).toBe('Search actions...');
111
+ });
112
+
113
+ it('should default the placeholder to an empty string when searchPlaceholder is not provided', () => {
114
+ const wrapper = createWrapper(actionsMock, { enableSearch: true });
115
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
116
+
117
+ expect(searchInput.attributes('placeholder')).toBe('');
118
+ });
119
+
120
+ it('should display noResultsText when searching yields no results', async () => {
121
+ const wrapper = createWrapper(actionsMock, {
122
+ enableSearch: true,
123
+ noResultsText: 'No results found',
124
+ });
125
+
126
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
127
+
128
+ await searchInput.vm.$emit('update:modelValue', 'nonexistentaction');
129
+
130
+ vi.runAllTimers();
131
+ await flushPromises();
132
+ await wrapper.vm.$nextTick();
133
+
134
+ const noResults = wrapper.find('[data-test-context-menu-no-results]');
135
+
136
+ expect(noResults.exists()).toBe(true);
137
+ expect(noResults.text()).toBe('No results found');
138
+ });
139
+
140
+ it('should default noResultsText to an empty string when not provided', async () => {
141
+ const wrapper = createWrapper(actionsMock, { enableSearch: true });
142
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
143
+
144
+ await searchInput.vm.$emit('update:modelValue', 'test_action_non_existant');
145
+
146
+ vi.runAllTimers();
147
+
148
+ await wrapper.vm.$nextTick();
149
+
150
+ const noResults = wrapper.find('[data-test-context-menu-no-results]');
151
+
152
+ expect(noResults.exists()).toBe(true);
153
+ expect(noResults.text()).toBe('');
154
+ });
155
+
156
+ it('should filter actions based on search query', async () => {
157
+ const wrapper = createWrapper(actionsMock, { enableSearch: true });
158
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
159
+
160
+ await searchInput.vm.$emit('update:modelValue', 'Action #1');
161
+ vi.runAllTimers();
162
+ await wrapper.vm.$nextTick();
163
+
164
+ const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
165
+
166
+ expect(actionBarItems).toHaveLength(1);
167
+ expect(actionBarItems[0].props('action')).toStrictEqual(actionsMock[0]);
168
+ });
169
+
170
+ it('should limit the number of displayed actions when maxList is set', () => {
171
+ const wrapper = createWrapper(actionsMock, { maxList: 1 });
172
+ const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
173
+
174
+ expect(actionBarItems).toHaveLength(1);
175
+ expect(actionBarItems[0].props('action')).toStrictEqual(actionsMock[0]);
176
+ });
177
+
178
+ it('should display all actions when maxList is not set', () => {
179
+ const wrapper = createWrapper(actionsMock);
180
+ const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
181
+
182
+ expect(actionBarItems).toHaveLength(actionsMock.length);
183
+ });
184
+
185
+ it('should display all actions when maxList exceeds the number of actions', () => {
186
+ const wrapper = createWrapper(actionsMock, { maxList: 100 });
187
+ const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
188
+
189
+ expect(actionBarItems).toHaveLength(actionsMock.length);
190
+ });
191
+
192
+ it('should limit filtered search results to maxList', async () => {
193
+ const manyActions: Action[] = [
194
+ { ...actionsMock[0], name: 'Action 1' },
195
+ { ...actionsMock[0], name: 'Action 2' },
196
+ { ...actionsMock[0], name: 'Action 3' },
197
+ { ...actionsMock[0], name: 'Action 4' },
198
+ { ...actionsMock[0], name: 'Action 5' },
199
+ ];
200
+
201
+ const wrapper = createWrapper(manyActions, { enableSearch: true, maxList: 2 });
202
+ const searchInput = wrapper.findComponent({ name: 'AppInput' });
203
+
204
+ await searchInput.vm.$emit('update:modelValue', 'Action');
205
+
206
+ vi.runAllTimers();
207
+
208
+ await wrapper.vm.$nextTick();
209
+
210
+ const actionBarItems = wrapper.findAllComponents(AppActionBarItem);
211
+
212
+ expect(actionBarItems).toHaveLength(2);
213
+ });
71
214
  });
@@ -8,6 +8,10 @@ export interface Props {
8
8
  event: PointerEvent | MouseEvent;
9
9
  actions?: Action[];
10
10
  confirmed?: Action['key'];
11
+ enableSearch?: boolean;
12
+ searchPlaceholder?: string;
13
+ noResultsText?: string;
14
+ maxList?: number;
11
15
  }
12
16
 
13
17
  export interface ContextMenuInstance {
@@ -135,7 +135,7 @@
135
135
  const result = props.onContextMenu?.(item, event, confirmed);
136
136
 
137
137
  if (result !== false) {
138
- contextMenu.open(item, event, confirmed);
138
+ contextMenu.open(item, event, { confirmed });
139
139
  }
140
140
  }
141
141
 
@@ -3,7 +3,7 @@ import { get } from 'radash';
3
3
  import { AppContextMenu } from '~components';
4
4
 
5
5
  import type { App, Ref } from 'vue';
6
- import type { Action, ContextMenuInstance, Shortcut } from '~components/AppContextMenu/index.d';
6
+ import type { ContextMenuInstance, Shortcut, Props } from '~components/AppContextMenu/index.d';
7
7
 
8
8
  const state = {
9
9
  instances: ref<ContextMenuInstance[]>([]),
@@ -18,7 +18,7 @@ function closeContextMenu(contextInstance: ContextMenuInstance): void {
18
18
 
19
19
  interface UseContextMenu<T> {
20
20
  setShortcuts: (shortcuts: Shortcut[]) => void;
21
- open: (item: T, event: PointerEvent | MouseEvent, confirmed?: Action['name']) => Promise<unknown>;
21
+ open: (item: T, event: PointerEvent | MouseEvent, props?: Partial<Props>) => Promise<unknown>;
22
22
  close: () => Promise<void>;
23
23
  closeAll: () => void;
24
24
  shortcuts: Ref<Shortcut[]>;
@@ -35,7 +35,7 @@ export function useContextMenu<T>(): UseContextMenu<T> {
35
35
  state.shortcuts.value = shortcuts;
36
36
  }
37
37
 
38
- async function open(item: T, event: PointerEvent | MouseEvent, confirmed?: Action['name']): Promise<unknown> {
38
+ async function open(item: T, event: PointerEvent | MouseEvent, props?: Partial<Props>): Promise<unknown> {
39
39
  // @ts-expect-error parameter not matched
40
40
  state.instances.value.forEach(closeContextMenu);
41
41
 
@@ -51,10 +51,10 @@ export function useContextMenu<T>(): UseContextMenu<T> {
51
51
  contextInstance = createApp(AppContextMenu, {
52
52
  item,
53
53
 
54
- confirmed,
55
-
56
54
  event,
57
55
 
56
+ ...props,
57
+
58
58
  onClose: (data: unknown) => {
59
59
  resolve(data);
60
60
  },
@@ -73,7 +73,11 @@ export function useEcho(listenerName: string | null = null) {
73
73
 
74
74
  const channelName = channel ?? Object.keys(state.casters)[0];
75
75
 
76
- state.casters[channelName]?.whisper?.(event, data);
76
+ try {
77
+ state.casters[channelName]?.whisper?.(event, data);
78
+ } catch {
79
+ //
80
+ }
77
81
  }
78
82
 
79
83
  function listen<T = unknown>(event: string, callback: (data: T) => void, channel?: string): void {
@@ -68,7 +68,7 @@
68
68
 
69
69
  <main
70
70
  class="relative order-4 flex min-h-[calc(100vh-5rem)] shrink-0 flex-col overflow-y-auto overflow-x-hidden
71
- bg-zinc-100 transition-all duration-300 context-menu-open:overflow-y-hidden justify-self-start
71
+ bg-zinc-100 context-menu-open:overflow-y-hidden scrollbar-gutter-stable justify-self-start
72
72
  md:order-3 md:-mt-0 md:h-screen md:grow md:basis-0 md:overflow-y-auto md:overflow-x-hidden"
73
73
  >
74
74
  <AppActionBar />