@pequity/squirrel 10.0.3 → 10.1.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 (59) hide show
  1. package/README.md +20 -1
  2. package/dist/cjs/chunks/p-action-bar.js +17 -14
  3. package/dist/cjs/chunks/p-date-picker.js +3 -1
  4. package/dist/cjs/chunks/p-dropdown-select.js +27 -26
  5. package/dist/cjs/chunks/p-inline-date-picker.js +3 -1
  6. package/dist/cjs/chunks/p-pagination-info.js +2 -2
  7. package/dist/cjs/chunks/p-pagination.js +13 -11
  8. package/dist/cjs/chunks/p-tabs-pills.js +8 -8
  9. package/dist/cjs/index.js +101 -48
  10. package/dist/cjs/p-drawer.js +4 -4
  11. package/dist/cjs/p-input-search.js +5 -4
  12. package/dist/cjs/p-modal.js +3 -3
  13. package/dist/es/chunks/p-action-bar.js +18 -15
  14. package/dist/es/chunks/p-date-picker.js +3 -1
  15. package/dist/es/chunks/p-dropdown-select.js +27 -26
  16. package/dist/es/chunks/p-inline-date-picker.js +3 -1
  17. package/dist/es/chunks/p-pagination-info.js +2 -2
  18. package/dist/es/chunks/p-pagination.js +13 -11
  19. package/dist/es/chunks/p-tabs-pills.js +8 -8
  20. package/dist/es/index.js +102 -49
  21. package/dist/es/p-drawer.js +4 -4
  22. package/dist/es/p-input-search.js +5 -4
  23. package/dist/es/p-modal.js +3 -3
  24. package/dist/squirrel/index.d.ts +1 -0
  25. package/dist/squirrel/plugin/index.d.ts +11 -0
  26. package/dist/squirrel.css +40 -40
  27. package/package.json +28 -25
  28. package/squirrel/components/p-action-bar/p-action-bar.vue +4 -1
  29. package/squirrel/components/p-btn/p-btn.spec.js +0 -1
  30. package/squirrel/components/p-checkbox/p-checkbox.stories.js +2 -2
  31. package/squirrel/components/p-date-picker/p-date-picker.vue +3 -2
  32. package/squirrel/components/p-drawer/p-drawer.spec.js +364 -0
  33. package/squirrel/components/p-drawer/p-drawer.vue +8 -2
  34. package/squirrel/components/p-dropdown/p-dropdown.spec.js +252 -55
  35. package/squirrel/components/p-dropdown-select/p-dropdown-select.vue +16 -12
  36. package/squirrel/components/p-file-upload/p-file-upload.spec.js +0 -1
  37. package/squirrel/components/p-file-upload/p-file-upload.vue +26 -9
  38. package/squirrel/components/p-inline-date-picker/p-inline-date-picker.vue +3 -1
  39. package/squirrel/components/p-input-search/p-input-search.vue +2 -2
  40. package/squirrel/components/p-modal/p-modal.vue +1 -1
  41. package/squirrel/components/p-pagination/p-pagination.vue +3 -3
  42. package/squirrel/components/p-pagination-info/p-pagination-info.vue +2 -2
  43. package/squirrel/components/p-progress-bar/{p-progess-bar.spec.js → p-progress-bar.spec.js} +7 -5
  44. package/squirrel/components/p-select-btn/p-select-btn.spec.js +104 -0
  45. package/squirrel/components/p-select-list/p-select-list.vue +7 -5
  46. package/squirrel/components/p-select-pill/p-select-pill.spec.js +114 -0
  47. package/squirrel/components/p-table/usePTableColResize.spec.js +123 -11
  48. package/squirrel/components/p-table/usePTableHeaderWrap.spec.js +1 -1
  49. package/squirrel/components/p-table/usePTableRowVirtualizer.spec.js +207 -0
  50. package/squirrel/components/p-table-header-cell/p-table-header-cell.stories.js +3 -0
  51. package/squirrel/components/p-table-sort/p-table-sort.vue +4 -4
  52. package/squirrel/components/p-tabs-pills/p-tabs-pills.vue +1 -1
  53. package/squirrel/index.spec.js +5 -0
  54. package/squirrel/index.ts +1 -0
  55. package/squirrel/locales/en-US.json +47 -0
  56. package/squirrel/locales/fr-CA.json +47 -0
  57. package/squirrel/plugin/index.spec.ts +140 -0
  58. package/squirrel/plugin/index.ts +54 -0
  59. package/squirrel/utils/listKeyboardNavigation.spec.js +58 -0
@@ -0,0 +1,207 @@
1
+ import { usePTableRowVirtualizer } from '@squirrel/components/p-table/usePTableRowVirtualizer';
2
+ import { computed, createApp, nextTick, ref } from 'vue';
3
+
4
+ // Mock @tanstack/vue-virtual
5
+ const mockVirtualizer = {
6
+ getVirtualItems: vi.fn(),
7
+ getTotalSize: vi.fn(),
8
+ measureElement: vi.fn(),
9
+ };
10
+
11
+ vi.mock('@tanstack/vue-virtual', () => ({
12
+ useVirtualizer: vi.fn(() => ref(mockVirtualizer)),
13
+ }));
14
+
15
+ const withSetup = (composable) => {
16
+ let result;
17
+
18
+ const app = createApp({
19
+ setup() {
20
+ result = composable();
21
+ return () => {};
22
+ },
23
+ });
24
+
25
+ app.mount(document.createElement('div'));
26
+
27
+ return result;
28
+ };
29
+
30
+ describe('usePTableRowVirtualizer', () => {
31
+ const mockOptions = computed(() => ({
32
+ count: 100,
33
+ getScrollElement: () => document.createElement('div'),
34
+ getItemKey: (index) => `item-${index}`,
35
+ estimateSize: () => 50,
36
+ overscan: 5,
37
+ }));
38
+
39
+ beforeEach(() => {
40
+ mockVirtualizer.getVirtualItems.mockReturnValue([]);
41
+ mockVirtualizer.getTotalSize.mockReturnValue(0);
42
+ });
43
+
44
+ it.each([
45
+ ['null', null],
46
+ ['undefined', undefined],
47
+ ['false', false],
48
+ ])('returns default values when options.value is %s', (description, value) => {
49
+ const options = computed(() => value);
50
+ const result = withSetup(() => usePTableRowVirtualizer(options));
51
+
52
+ expect(result.virtualizer).toBe(null);
53
+ expect(result.virtualRows.value).toEqual([{ key: 0, index: 0 }]);
54
+ expect(result.paddingTop.value).toBe(0);
55
+ expect(result.paddingBottom.value).toBe(0);
56
+ expect(result.measureElement()).toBeUndefined();
57
+ });
58
+
59
+ it('creates and returns virtualizer instance', () => {
60
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
61
+
62
+ expect(result.virtualizer).toBeTruthy();
63
+ expect(result.virtualizer.value).toStrictEqual(mockVirtualizer);
64
+ });
65
+
66
+ it.each([
67
+ [
68
+ 'with virtual items',
69
+ [
70
+ { key: 'item-0', index: 0, start: 0, end: 50 },
71
+ { key: 'item-1', index: 1, start: 50, end: 100 },
72
+ ],
73
+ ],
74
+ ['when empty', []],
75
+ ])('returns virtual items from virtualizer %s', (description, mockItems) => {
76
+ mockVirtualizer.getVirtualItems.mockReturnValue(mockItems);
77
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
78
+
79
+ expect(result.virtualRows.value).toBe(mockItems);
80
+ expect(mockVirtualizer.getVirtualItems).toHaveBeenCalled();
81
+ });
82
+
83
+ it.each([
84
+ [
85
+ 'returns first virtual row start when virtual rows exist',
86
+ [
87
+ { key: 'item-5', index: 5, start: 250, end: 300 },
88
+ { key: 'item-6', index: 6, start: 300, end: 350 },
89
+ ],
90
+ 250,
91
+ ],
92
+ ['returns 0 when no virtual rows exist', [], 0],
93
+ ])('paddingTop: %s', (description, mockItems, expectedPadding) => {
94
+ mockVirtualizer.getVirtualItems.mockReturnValue(mockItems);
95
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
96
+
97
+ expect(result.paddingTop.value).toBe(expectedPadding);
98
+ });
99
+
100
+ it.each([
101
+ [
102
+ 'calculates correctly with multiple virtual rows',
103
+ [
104
+ { key: 'item-0', index: 0, start: 0, end: 50 },
105
+ { key: 'item-1', index: 1, start: 50, end: 100 },
106
+ { key: 'item-2', index: 2, start: 100, end: 150 },
107
+ ],
108
+ 500,
109
+ 350,
110
+ ],
111
+ ['returns 0 when no virtual rows exist', [], 500, 0],
112
+ ['handles single virtual row correctly', [{ key: 'item-0', index: 0, start: 0, end: 50 }], 200, 150],
113
+ ])('paddingBottom: %s', (description, mockItems, totalSize, expectedPadding) => {
114
+ mockVirtualizer.getVirtualItems.mockReturnValue(mockItems);
115
+ mockVirtualizer.getTotalSize.mockReturnValue(totalSize);
116
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
117
+
118
+ expect(result.paddingBottom.value).toBe(expectedPadding);
119
+ });
120
+
121
+ it.each([
122
+ ['null', null],
123
+ ['undefined', undefined],
124
+ ])('measureElement returns undefined when element is %s', (description, element) => {
125
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
126
+
127
+ expect(result.measureElement(element)).toBeUndefined();
128
+ expect(mockVirtualizer.measureElement).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('measureElement works with HTML element directly', async () => {
132
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
133
+ const htmlElement = document.createElement('div');
134
+
135
+ result.measureElement(htmlElement);
136
+
137
+ await nextTick();
138
+ expect(mockVirtualizer.measureElement).toHaveBeenCalledWith(htmlElement);
139
+ });
140
+
141
+ it('measureElement extracts $el from Vue component instance', async () => {
142
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
143
+ const mockEl = document.createElement('div');
144
+ const componentInstance = { $el: mockEl };
145
+
146
+ result.measureElement(componentInstance);
147
+
148
+ await nextTick();
149
+ expect(mockVirtualizer.measureElement).toHaveBeenCalledWith(mockEl);
150
+ });
151
+
152
+ it.each([
153
+ ['string', { $el: 'not-an-element' }],
154
+ ['null', { $el: null }],
155
+ ['text node', { $el: document.createTextNode('text') }],
156
+ ])('measureElement does not measure when extracted element is %s', async (description, componentInstance) => {
157
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
158
+
159
+ result.measureElement(componentInstance);
160
+
161
+ await nextTick();
162
+ expect(mockVirtualizer.measureElement).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('measureElement always returns undefined', () => {
166
+ const result = withSetup(() => usePTableRowVirtualizer(mockOptions));
167
+ const htmlElement = document.createElement('div');
168
+
169
+ const returnValue = result.measureElement(htmlElement);
170
+
171
+ expect(returnValue).toBeUndefined();
172
+ });
173
+
174
+ it('computes virtual rows from current virtualizer state', () => {
175
+ const items1 = [{ key: 'item-0', index: 0, start: 0, end: 50 }];
176
+ const items2 = [{ key: 'item-1', index: 1, start: 50, end: 100 }];
177
+
178
+ // Test with first set of items
179
+ mockVirtualizer.getVirtualItems.mockReturnValue(items1);
180
+ const result1 = withSetup(() => usePTableRowVirtualizer(mockOptions));
181
+ expect(result1.virtualRows.value).toStrictEqual(items1);
182
+
183
+ // Test with second set of items (new composable instance)
184
+ mockVirtualizer.getVirtualItems.mockReturnValue(items2);
185
+ const result2 = withSetup(() => usePTableRowVirtualizer(mockOptions));
186
+ expect(result2.virtualRows.value).toStrictEqual(items2);
187
+ });
188
+
189
+ it('computes padding values from current virtualizer state', () => {
190
+ const items1 = [{ key: 'item-0', index: 0, start: 0, end: 50 }];
191
+ const items2 = [{ key: 'item-5', index: 5, start: 250, end: 300 }];
192
+
193
+ mockVirtualizer.getTotalSize.mockReturnValue(500);
194
+
195
+ // Test with first set of items
196
+ mockVirtualizer.getVirtualItems.mockReturnValue(items1);
197
+ const result1 = withSetup(() => usePTableRowVirtualizer(mockOptions));
198
+ expect(result1.paddingTop.value).toBe(0);
199
+ expect(result1.paddingBottom.value).toBe(450); // 500 - 50
200
+
201
+ // Test with second set of items (new composable instance)
202
+ mockVirtualizer.getVirtualItems.mockReturnValue(items2);
203
+ const result2 = withSetup(() => usePTableRowVirtualizer(mockOptions));
204
+ expect(result2.paddingTop.value).toBe(250);
205
+ expect(result2.paddingBottom.value).toBe(200); // 500 - 300
206
+ });
207
+ });
@@ -83,6 +83,9 @@ export const WithIconSlot = {
83
83
  export const WithBackground = {
84
84
  render: (args) => ({
85
85
  components: { PTableHeaderCell },
86
+ setup() {
87
+ return { args };
88
+ },
86
89
  template: `
87
90
  <div class="w-52 px-2 py-2 bg-p-blue-10">
88
91
  <PTableHeaderCell v-bind="args" />
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div>
3
3
  <div class="flex items-center justify-between">
4
- <div class="px-4 text-xs font-semibold text-p-gray-40">SORT</div>
4
+ <div class="px-4 text-xs font-semibold text-p-gray-40">{{ $t('squirrel.table_sort_sort') }}</div>
5
5
  <div
6
6
  :class="[
7
7
  'px-4 text-xs font-semibold text-primary',
@@ -11,7 +11,7 @@
11
11
  ]"
12
12
  @click="$emit('update:modelValue', SORTING_TYPES.NO_SORTING)"
13
13
  >
14
- Clear
14
+ {{ $t('squirrel.table_sort_clear') }}
15
15
  </div>
16
16
  </div>
17
17
  <div class="mt-2">
@@ -23,7 +23,7 @@
23
23
  class="text-sm font-semibold text-p-purple-60"
24
24
  :class="{ 'text-primary': modelValue === SORTING_TYPES.ASC }"
25
25
  >
26
- Sort ascending
26
+ {{ $t('squirrel.table_sort_sort_ascending') }}
27
27
  </div>
28
28
  <div>
29
29
  <img
@@ -41,7 +41,7 @@
41
41
  class="text-sm font-semibold text-p-purple-60"
42
42
  :class="{ 'text-primary': modelValue === SORTING_TYPES.DESC }"
43
43
  >
44
- Sort descending
44
+ {{ $t('squirrel.table_sort_sort_descending') }}
45
45
  </div>
46
46
  <div>
47
47
  <img
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <nav
3
3
  class="flex h-6 w-fit flex-row space-x-1 overflow-x-auto rounded bg-p-gray-10 p-1 text-sm font-medium text-p-gray-50"
4
- aria-label="Tabs Pills"
4
+ :aria-label="$t('squirrel.tabs_pills_aria_label')"
5
5
  role="tablist"
6
6
  aria-orientation="horizontal"
7
7
  >
@@ -18,4 +18,9 @@ describe('index.ts module exports', () => {
18
18
  const utils = await import('@squirrel/utils');
19
19
  expect(utils).toBeDefined();
20
20
  });
21
+
22
+ it('should export from @squirrel/plugin', async () => {
23
+ const plugin = await import('@squirrel/plugin');
24
+ expect(plugin).toBeDefined();
25
+ });
21
26
  });
package/squirrel/index.ts CHANGED
@@ -2,6 +2,7 @@ import '@squirrel/assets/squirrel.css';
2
2
 
3
3
  export * from '@squirrel/components';
4
4
  export * from '@squirrel/composables';
5
+ export * from '@squirrel/plugin';
5
6
  export * from '@squirrel/tailwind/config';
6
7
  export * from '@squirrel/utils';
7
8
  export * from 'tailwind-variants';
@@ -0,0 +1,47 @@
1
+ {
2
+ "squirrel": {
3
+ "close": "Close",
4
+ "action_bar_clear_all": "Clear All",
5
+ "select_list_items": "{count} item | {count} items",
6
+ "select_list_select_all": "Select all",
7
+ "select_list_select_all_filtered": "Select all filtered",
8
+ "select_list_clear_all": "Clear all",
9
+ "select_list_no_items_found": "No items found",
10
+ "dropdown_select_aria_label": "Dropdown select",
11
+ "dropdown_select_remove_item": "Remove item",
12
+ "dropdown_select_clear_selection": "Clear selection",
13
+ "dropdown_select_all_options_selected": "All options selected",
14
+ "dropdown_select_options": "option | options",
15
+ "dropdown_select_selected": "selected",
16
+ "dropdown_select_items": "@:squirrel.select_list_items",
17
+ "dropdown_select_select_all": "@:squirrel.select_list_select_all",
18
+ "dropdown_select_select_all_filtered": "@:squirrel.select_list_select_all_filtered",
19
+ "dropdown_select_clear_all": "@:squirrel.select_list_clear_all",
20
+ "dropdown_select_add": "Add",
21
+ "dropdown_select_no_items_found_type_to_add": "No items found. Type to add",
22
+ "dropdown_select_no_items_found": "@:squirrel.select_list_no_items_found",
23
+ "file_upload_dropzone": "dropzone",
24
+ "file_upload_drag_or_select": "Drag or {select}",
25
+ "file_upload_drop": "Drop {fileWord}",
26
+ "file_upload_max": "Max {count}",
27
+ "file_upload_one": "One",
28
+ "file_upload_files": "file | files",
29
+ "file_upload_select": "select {fileWord}",
30
+ "file_upload_with_size_less_than": "with size less than {maxSize} | with size less than {maxSize} each",
31
+ "file_upload_max_files_exceeded": "You can only upload a maximum of {count} {fileWord}.",
32
+ "file_upload_files_not_allowed": "{extension} files are not allowed.",
33
+ "file_upload_file_size_exceeded": "File size of {fileName} exceeds {maxSize}.",
34
+ "input_search_press_enter_to_search": "Press enter to search",
35
+ "input_search_clear_search_input": "Clear search input",
36
+ "pagination_go_to_previous_page": "go to the previous page",
37
+ "pagination_go_to_page": "go to page {page}",
38
+ "pagination_go_to_next_page": "go to the next page",
39
+ "pagination_info_showing_results": "Showing {from} to {to} of {count} results",
40
+ "pagination_info_no_results_found": "No results found",
41
+ "table_sort_sort": "SORT",
42
+ "table_sort_clear": "Clear",
43
+ "table_sort_sort_ascending": "Sort ascending",
44
+ "table_sort_sort_descending": "Sort descending",
45
+ "tabs_pills_aria_label": "Tabs Pills"
46
+ }
47
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "squirrel": {
3
+ "close": "Fermer",
4
+ "action_bar_clear_all": "Effacer tout",
5
+ "select_list_items": "{count} élément | {count} éléments",
6
+ "select_list_select_all": "Tout sélectionner",
7
+ "select_list_select_all_filtered": "Sélectionner tout (filtré)",
8
+ "select_list_clear_all": "Effacer tout",
9
+ "select_list_no_items_found": "Aucun élément trouvé",
10
+ "dropdown_select_aria_label": "Liste déroulante",
11
+ "dropdown_select_remove_item": "Supprimer l'élément",
12
+ "dropdown_select_clear_selection": "Effacer la sélection",
13
+ "dropdown_select_all_options_selected": "Toutes les options sélectionnées",
14
+ "dropdown_select_options": "option | options",
15
+ "dropdown_select_selected": "sélectionné",
16
+ "dropdown_select_items": "@:squirrel.select_list_items",
17
+ "dropdown_select_select_all": "@:squirrel.select_list_select_all",
18
+ "dropdown_select_select_all_filtered": "@:squirrel.select_list_select_all_filtered",
19
+ "dropdown_select_clear_all": "@:squirrel.select_list_clear_all",
20
+ "dropdown_select_add": "Ajouter",
21
+ "dropdown_select_no_items_found_type_to_add": "Aucun élément trouvé. Tapez pour ajouter",
22
+ "dropdown_select_no_items_found": "@:squirrel.select_list_no_items_found",
23
+ "file_upload_dropzone": "zone de dépôt",
24
+ "file_upload_drag_or_select": "Glisser ou {select}",
25
+ "file_upload_drop": "Déposer {fileWord}",
26
+ "file_upload_max": "Max {count}",
27
+ "file_upload_one": "Un",
28
+ "file_upload_files": "fichier | fichiers",
29
+ "file_upload_select": "sélectionner {fileWord}",
30
+ "file_upload_with_size_less_than": "avec une taille inférieure à {maxSize} | avec une taille inférieure à {maxSize} chacun",
31
+ "file_upload_max_files_exceeded": "Vous ne pouvez télécharger qu'un maximum de {count} {fileWord}.",
32
+ "file_upload_files_not_allowed": "Les fichiers {extension} ne sont pas autorisés.",
33
+ "file_upload_file_size_exceeded": "La taille du fichier {fileName} dépasse {maxSize}.",
34
+ "input_search_press_enter_to_search": "Appuyez sur Entrée pour rechercher",
35
+ "input_search_clear_search_input": "Effacer la saisie de recherche",
36
+ "pagination_go_to_previous_page": "aller à la page précédente",
37
+ "pagination_go_to_page": "aller à la page {page}",
38
+ "pagination_go_to_next_page": "aller à la page suivante",
39
+ "pagination_info_showing_results": "Affichage de {from} à {to} sur {count} résultats",
40
+ "pagination_info_no_results_found": "Aucun résultat trouvé",
41
+ "table_sort_sort": "TRIER",
42
+ "table_sort_clear": "Effacer",
43
+ "table_sort_sort_ascending": "Trier par ordre croissant",
44
+ "table_sort_sort_descending": "Trier par ordre décroissant",
45
+ "tabs_pills_aria_label": "Onglets pilules"
46
+ }
47
+ }
@@ -0,0 +1,140 @@
1
+ import enUS from '@squirrel/locales/en-US.json';
2
+ import frCA from '@squirrel/locales/fr-CA.json';
3
+ import { SquirrelPlugin } from '@squirrel/plugin/index';
4
+ import { type App, createApp, nextTick, ref } from 'vue';
5
+
6
+ type MockI18nMessages = {
7
+ [locale: string]: Record<string, unknown>;
8
+ };
9
+
10
+ // Mock i18n instance that matches the expected interface
11
+ const createMockI18n = (initialLocale = 'en-US', initialMessages: MockI18nMessages = {}) => {
12
+ const locale = ref(initialLocale);
13
+ const messages: MockI18nMessages = { ...initialMessages };
14
+ const availableLocales = Object.keys(messages);
15
+
16
+ return {
17
+ global: {
18
+ locale,
19
+ mergeLocaleMessage: vi.fn((localeKey: string, newMessages: Record<string, unknown>) => {
20
+ if (!messages[localeKey]) {
21
+ messages[localeKey] = {};
22
+ availableLocales.push(localeKey);
23
+ }
24
+ Object.assign(messages[localeKey], newMessages);
25
+ }),
26
+ getLocaleMessage: vi.fn((localeKey: string) => {
27
+ return messages[localeKey] || {};
28
+ }),
29
+ availableLocales,
30
+ },
31
+ };
32
+ };
33
+
34
+ describe('SquirrelPlugin', () => {
35
+ let app: App;
36
+ let mockI18n: ReturnType<typeof createMockI18n>;
37
+
38
+ beforeEach(() => {
39
+ app = createApp({});
40
+ mockI18n = createMockI18n();
41
+ });
42
+
43
+ it('should install without errors', () => {
44
+ expect(() => {
45
+ app.use(SquirrelPlugin, mockI18n);
46
+ }).not.toThrow();
47
+ });
48
+
49
+ it('should merge en-US messages when locale is en-US', async () => {
50
+ mockI18n = createMockI18n('en-US', { 'en-US': {} });
51
+ app.use(SquirrelPlugin, mockI18n);
52
+
53
+ await nextTick();
54
+
55
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('en-US', enUS);
56
+ });
57
+
58
+ it('should merge fr-CA messages when locale is fr-CA', async () => {
59
+ mockI18n = createMockI18n('fr-CA', { 'fr-CA': {} });
60
+ app.use(SquirrelPlugin, mockI18n);
61
+
62
+ await nextTick();
63
+
64
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('fr-CA', frCA);
65
+ });
66
+
67
+ it('should not merge messages for unsupported locales', async () => {
68
+ mockI18n = createMockI18n('es-ES', { 'es-ES': {} });
69
+ app.use(SquirrelPlugin, mockI18n);
70
+
71
+ await nextTick();
72
+
73
+ expect(mockI18n.global.mergeLocaleMessage).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('should not merge messages when squirrel key already exists', async () => {
77
+ mockI18n = createMockI18n('en-US', {
78
+ 'en-US': { squirrel: { someKey: 'someValue' } },
79
+ });
80
+ app.use(SquirrelPlugin, mockI18n);
81
+
82
+ await nextTick();
83
+
84
+ expect(mockI18n.global.mergeLocaleMessage).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('should react to locale changes', async () => {
88
+ // Only have en-US available initially, so watcher doesn't get unwatched
89
+ mockI18n = createMockI18n('en-US', { 'en-US': {}, 'fr-CA': {} });
90
+ mockI18n.global.availableLocales = ['en-US']; // Only one locale available
91
+ app.use(SquirrelPlugin, mockI18n);
92
+
93
+ await nextTick();
94
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('en-US', enUS);
95
+
96
+ // Clear the mock to test the next call
97
+ mockI18n.global.mergeLocaleMessage.mockClear();
98
+
99
+ // Add fr-CA to available locales and change to it
100
+ mockI18n.global.availableLocales.push('fr-CA');
101
+ mockI18n.global.locale.value = 'fr-CA';
102
+ await nextTick();
103
+
104
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('fr-CA', frCA);
105
+ });
106
+
107
+ it('should stop watching when all Squirrel locales are available', async () => {
108
+ // Create mock with both locales available initially
109
+ mockI18n = createMockI18n('en-US', { 'en-US': {}, 'fr-CA': {} });
110
+ mockI18n.global.availableLocales = ['en-US', 'fr-CA'];
111
+
112
+ app.use(SquirrelPlugin, mockI18n);
113
+
114
+ await nextTick();
115
+
116
+ // Should merge the current locale
117
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('en-US', enUS);
118
+
119
+ // Clear the mock
120
+ mockI18n.global.mergeLocaleMessage.mockClear();
121
+
122
+ // Change locale after unwatching should not trigger more merges
123
+ mockI18n.global.locale.value = 'fr-CA';
124
+ await nextTick();
125
+
126
+ // Should not merge again since watcher was stopped
127
+ expect(mockI18n.global.mergeLocaleMessage).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('should handle empty existing messages', async () => {
131
+ mockI18n = createMockI18n('en-US', { 'en-US': {} });
132
+ mockI18n.global.getLocaleMessage = vi.fn().mockReturnValue({});
133
+
134
+ app.use(SquirrelPlugin, mockI18n);
135
+
136
+ await nextTick();
137
+
138
+ expect(mockI18n.global.mergeLocaleMessage).toHaveBeenCalledWith('en-US', enUS);
139
+ });
140
+ });
@@ -0,0 +1,54 @@
1
+ import enUS from '@squirrel/locales/en-US.json';
2
+ import frCA from '@squirrel/locales/fr-CA.json';
3
+ import { type App, nextTick, type Plugin, watchEffect } from 'vue';
4
+
5
+ type I18nInstance = {
6
+ global: {
7
+ locale: { value: string };
8
+ mergeLocaleMessage(locale: string, messages: Record<string, unknown>): void;
9
+ getLocaleMessage(locale: string): Record<string, unknown>;
10
+ availableLocales: string[];
11
+ };
12
+ };
13
+
14
+ const squirrelMessages = {
15
+ 'en-US': enUS,
16
+ 'fr-CA': frCA,
17
+ } as const;
18
+
19
+ type SquirrelLocale = keyof typeof squirrelMessages;
20
+
21
+ const isSquirrelLocale = (locale: string): locale is SquirrelLocale => {
22
+ return locale in squirrelMessages;
23
+ };
24
+
25
+ /**
26
+ * Squirrel Vue Plugin for i18n integration.
27
+ *
28
+ * This plugin merges Squirrel's translations with the consumer's existing i18n instance.
29
+ * This ensures there's only one i18n instance and no component/directive conflicts.
30
+ *
31
+ * @param app - Vue application instance
32
+ * @param i18n - The consumer's i18n instance with mergeLocaleMessage support
33
+ */
34
+ export const SquirrelPlugin: Plugin = {
35
+ install(app: App, i18n: I18nInstance) {
36
+ const unwatch = watchEffect(() => {
37
+ const currentLocale = i18n.global.locale.value;
38
+
39
+ if (isSquirrelLocale(currentLocale)) {
40
+ const existingMessages = i18n.global.getLocaleMessage(currentLocale);
41
+
42
+ if (!('squirrel' in existingMessages)) {
43
+ i18n.global.mergeLocaleMessage(currentLocale, squirrelMessages[currentLocale]);
44
+ }
45
+
46
+ if (Object.keys(squirrelMessages).every((locale) => i18n.global.availableLocales.includes(locale))) {
47
+ nextTick(() => {
48
+ unwatch();
49
+ });
50
+ }
51
+ }
52
+ });
53
+ },
54
+ };
@@ -206,4 +206,62 @@ describe('listKeyboardNavigation', () => {
206
206
  navigationSvc2.destroy();
207
207
  consoleWarnSpy.mockRestore();
208
208
  });
209
+
210
+ it('handles arrow navigation when no items exist', () => {
211
+ // Create empty container
212
+ document.body.innerHTML = '<div class="empty-dropdown-menu"></div>';
213
+
214
+ const emptyContainer = document.querySelector('.empty-dropdown-menu');
215
+ const navigationSvc = setupListKeyboardNavigation({
216
+ itemContainer: emptyContainer,
217
+ });
218
+
219
+ const event = new window.KeyboardEvent('keydown', {
220
+ key: 'ArrowDown',
221
+ keyCode: 40,
222
+ bubbles: true,
223
+ target: emptyContainer,
224
+ });
225
+
226
+ // Should not throw error when no items exist (covers line 86)
227
+ expect(() => navigationSvc.listKeydown(event)).not.toThrow();
228
+
229
+ navigationSvc.destroy();
230
+ });
231
+
232
+ it('scrolls elements into view when they are outside container bounds', () => {
233
+ const scrollIntoViewMock = vi.fn();
234
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: scrollIntoViewMock, writable: true });
235
+
236
+ const navigationSvc = createTestSvc();
237
+ const container = document.querySelector('.dropdown-menu');
238
+ const items = navigationSvc.getItems();
239
+
240
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 200 });
241
+
242
+ // Element above bounds
243
+ vi.spyOn(items[0], 'getBoundingClientRect').mockReturnValue({ top: 50, bottom: 80 });
244
+ navigationSvc.listKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
245
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'auto', block: 'nearest', inline: 'start' });
246
+
247
+ // Element below bounds
248
+ vi.spyOn(items[1], 'getBoundingClientRect').mockReturnValue({ top: 220, bottom: 250 });
249
+ navigationSvc.listKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
250
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'auto', block: 'nearest', inline: 'end' });
251
+
252
+ navigationSvc.destroy();
253
+ });
254
+
255
+ it('handles adjustScroll when container is invalid', async () => {
256
+ const domUtils = await vi.importActual('@squirrel/utils/dom');
257
+ const isElementSpy = vi.spyOn(domUtils, 'isElement').mockReturnValue(false);
258
+
259
+ const navigationSvc = createTestSvc();
260
+
261
+ // Navigation should handle invalid container gracefully (covers line 133)
262
+ expect(() => navigationSvc.listKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }))).not.toThrow();
263
+
264
+ isElementSpy.mockRestore();
265
+ navigationSvc.destroy();
266
+ });
209
267
  });