@autoafleveren/ui 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autoafleveren/ui",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/*",
@@ -86,7 +86,9 @@
86
86
  "@vuepic/vue-datepicker": "^11.0.2",
87
87
  "@vueuse/components": "^13.5.0",
88
88
  "@vueuse/core": "^13.5.0",
89
+ "@vueuse/integrations": "^13.7.0",
89
90
  "date-fns": "^4.1.0",
91
+ "focus-trap": "^7",
90
92
  "js-cookie": "^3.0.5",
91
93
  "laravel-echo": "^2.1.7",
92
94
  "mini-svg-data-uri": "^1.4.4",
@@ -1,9 +1,8 @@
1
1
  <script lang="ts" setup>
2
- import { computed, onMounted, ref, toValue, useTemplateRef } from 'vue';
3
- import { onClickOutside } from '@vueuse/core';
2
+ import { computed, nextTick, onMounted, ref, toValue, useTemplateRef } from 'vue';
4
3
  import { WarningIcon } from '~icons';
5
4
  import { AppLoader } from '~components';
6
- import { useActionBar, useContextMenu } from '~composables';
5
+ import { useActionBar, useContextMenu, useFocusTrap } from '~composables';
7
6
  import AppActionBarSubMenu from './AppActionBarSubMenu.vue';
8
7
  import { domClassesPerMenu, domClassesPerType } from '.';
9
8
 
@@ -16,11 +15,16 @@
16
15
  confirmed: false,
17
16
  });
18
17
 
19
- const emit = defineEmits<{ (event: 'close'): void }>();
18
+ const emit = defineEmits<{
19
+ (event: 'close'): void;
20
+ (event: 'confirm'): void;
21
+ (event: 'confirmed'): void;
22
+ }>();
20
23
 
21
24
  const actionBar = useActionBar();
22
25
  const contextMenu = useContextMenu();
23
- const actionBarElement = useTemplateRef<HTMLDivElement>('actionBarItem');
26
+ const submenuElement = useTemplateRef<HTMLDivElement>('submenuElement');
27
+ const { activate, deactivate, onEscape, onBack } = useFocusTrap(submenuElement);
24
28
  const confirm = ref(false);
25
29
  const loading = ref(false);
26
30
  const modelValueSubMenu = ref<number[]>([]);
@@ -61,6 +65,17 @@
61
65
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
62
66
  if ((props.action.confirm || props.action.component) && !confirm.value) {
63
67
  confirm.value = true;
68
+
69
+ if (props.action.component) {
70
+ await nextTick();
71
+
72
+ emit('confirm');
73
+
74
+ await nextTick();
75
+
76
+ setTimeout(activate, 300);
77
+ }
78
+
64
79
  return;
65
80
  }
66
81
 
@@ -81,27 +96,37 @@
81
96
  }
82
97
 
83
98
  function subMenuClose(): void {
99
+ deactivate();
100
+
84
101
  confirm.value = false;
85
102
 
103
+ emit('confirmed');
104
+ }
105
+
106
+ function close(): void {
107
+ subMenuClose();
108
+
86
109
  emit('close');
87
110
  }
88
111
 
89
- onClickOutside(actionBarElement, () => {
90
- confirm.value = false;
91
- });
112
+ onEscape(close);
113
+ onBack(subMenuClose);
92
114
  </script>
93
115
 
94
116
  <template>
95
117
  <div
96
118
  v-if="itemHidden !== true"
97
- ref="actionBarItem"
98
- :class="[...domClassesPerType[type], ...domClassesPerMenu[props.context ? 'contextMenu' : 'actionBar']]"
99
- class="relative cursor-pointer rounded-md transition-colors duration-200"
119
+ :class="[
120
+ ...domClassesPerType[type],
121
+ ...domClassesPerMenu[props.context ? 'contextMenu' : 'actionBar'],
122
+ props.action.component ? 'has-submenu' : '',
123
+ ]"
124
+ class="relative cursor-pointer rounded-md transition-colors duration-200"
100
125
  >
101
126
  <div
102
127
  v-tippy="context ? undefined : action.name"
103
128
  :class="{ 'px-2.5 py-4': !context }"
104
- class="flex size-full items-center space-x-2 whitespace-nowrap"
129
+ class="app-action-bar-item flex size-full items-center space-x-2 whitespace-nowrap"
105
130
  data-test-action-bar-icon
106
131
  @click="onClickAction"
107
132
  >
@@ -115,6 +140,7 @@
115
140
 
116
141
  <AppActionBarSubMenu
117
142
  v-if="props.action.component && confirm"
143
+ ref="submenuElement"
118
144
  v-model="modelValueSubMenu"
119
145
  v-bind="props"
120
146
  @close="subMenuClose"
@@ -125,10 +125,10 @@ describe('the AppActionBarItem component', () => {
125
125
 
126
126
  expect(subMenu.exists()).toBe(true);
127
127
 
128
- expect(wrapper.emitted('close')).toBeUndefined();
128
+ expect(wrapper.emitted('confirmed')).toBeUndefined();
129
129
 
130
130
  subMenu.vm.$emit('close');
131
131
 
132
- expect(wrapper.emitted('close')).toBeDefined();
132
+ expect(wrapper.emitted('confirmed')).toBeDefined();
133
133
  });
134
134
  });
@@ -8,7 +8,7 @@ export const domClassesPerType: Record<Type, string[]> = {
8
8
  };
9
9
 
10
10
  export const domClassesPerMenu: Record<'contextMenu' | 'actionBar', string[]> = {
11
- contextMenu: ['hover:bg-zinc-600', 'px-2', 'py-1'],
11
+ contextMenu: ['focus:bg-zinc-600', 'px-2', 'py-1'],
12
12
  actionBar: ['hover:bg-black-100/20'],
13
13
  };
14
14
 
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" setup>
2
- import { computed, ref } from 'vue';
3
- import { onClickOutside, onKeyStroke } from '@vueuse/core';
4
- import { useActionBar, useClientPoint, useContextMenu } from '~composables';
2
+ import { computed, nextTick, onUnmounted, ref } from 'vue';
3
+ import { onClickOutside } from '@vueuse/core';
4
+ import { useActionBar, useClientPoint, useContextMenu, useFocusTrap } from '~composables';
5
5
  import AppActionBarItem from '~components/AppActionBar/AppActionBarItem.vue';
6
6
  import ShortcutItem from './ShortcutItem.vue';
7
7
 
@@ -22,9 +22,12 @@
22
22
  const contextMenu = useContextMenu();
23
23
  const contextMenuElement = ref<HTMLDivElement>();
24
24
  const clientComputedPosition = useClientPoint(contextMenuElement);
25
+ const { activate, deactivate, onEscape } = useFocusTrap(contextMenuElement);
25
26
 
26
27
  const actionsWithFallback = computed((): Action[] => props.actions ?? actionbar.actions.value);
27
28
 
29
+ onUnmounted(deactivate);
30
+
28
31
  async function open(): Promise<void> {
29
32
  isOpen.value = true;
30
33
 
@@ -33,11 +36,17 @@
33
36
  document.querySelector('#app')?.classList.add('context-menu-open');
34
37
 
35
38
  clientComputedPosition.setPosition(props.event.x, props.event.y);
39
+
40
+ await nextTick();
41
+
42
+ activate();
36
43
  }
37
44
 
38
45
  async function close(): Promise<void> {
39
46
  isOpen.value = false;
40
47
 
48
+ deactivate();
49
+
41
50
  emit('close');
42
51
 
43
52
  document.querySelector('#app')?.classList.remove('context-menu-open');
@@ -55,11 +64,7 @@
55
64
 
56
65
  defineExpose({ isOpen, open, close, submit });
57
66
  onClickOutside(contextMenuElement, close);
58
- onKeyStroke(['Escape'], (event: Event) => {
59
- event.preventDefault();
60
-
61
- close();
62
- });
67
+ onEscape(close);
63
68
  </script>
64
69
 
65
70
  <template>
@@ -91,8 +96,12 @@
91
96
  :context="true"
92
97
  :item="item"
93
98
  :confirmed="confirmed && confirmed === action.key"
94
- class="flex h-10 items-center space-x-2 first:rounded-t-lg last:rounded-b-lg"
99
+ :tabindex="`100${index}`"
100
+ class="flex h-10 items-center space-x-2 first:rounded-t-lg last:rounded-b-lg focus:outline-none"
101
+ @confirm="deactivate"
102
+ @confirmed="activate"
95
103
  @close="close"
104
+ @mouseover="($event) => ($event.target as HTMLDivElement | undefined)?.focus()"
96
105
  >
97
106
  <span class="text-base">{{ action.name }}</span>
98
107
  </AppActionBarItem>
@@ -29,7 +29,8 @@
29
29
  <template>
30
30
  <button
31
31
  type="button"
32
- class="w-full p-1.5 text-xxs text-white cursor-pointer hover:bg-zinc-600"
32
+ tabindex="-1"
33
+ class="w-full p-1.5 text-xxs text-white cursor-pointer hover:bg-zinc-600 focus:outline-0"
33
34
  @click="click"
34
35
  >
35
36
  {{ shortcut.copied ? name : shortcut.name }}
@@ -19,6 +19,7 @@
19
19
  <AppDefinitionItem
20
20
  v-for="(item, index) in items"
21
21
  :key="index"
22
+ :tabindex="`1${index}`"
22
23
  :title="item.title"
23
24
  :description="item.description"
24
25
  :component="item.component"
@@ -11,3 +11,4 @@ export * from './useActionBar';
11
11
  export * from './useEcho';
12
12
  export * from './useContextMenu';
13
13
  export * from './useComputedPosition';
14
+ export * from './useFocusTrap';
@@ -0,0 +1,3 @@
1
+ export interface Options {
2
+ childTargetClass?: string;
3
+ }
@@ -0,0 +1,98 @@
1
+ import { ref } from 'vue';
2
+ import { onKeyStroke, useActiveElement } from '@vueuse/core';
3
+ import { useFocusTrap as useFocusTrapVueUse } from '@vueuse/integrations/useFocusTrap';
4
+
5
+ import type { MaybeRefOrGetter } from 'vue';
6
+ import type { Arrayable, MaybeComputedElementRef } from '@vueuse/core';
7
+ import type { Options } from './index.d';
8
+
9
+ export function useFocusTrap(
10
+ target: MaybeRefOrGetter<Arrayable<MaybeRefOrGetter<string> | MaybeComputedElementRef>>,
11
+ options: Options = {},
12
+ ) {
13
+ const isActive = ref<boolean>(false);
14
+ const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrapVueUse(target);
15
+ const activeElement = useActiveElement();
16
+ const onEscapeCallback = ref<() => void>();
17
+ const onBackCallback = ref<() => void>();
18
+
19
+ function onEscape(callback: () => void): void {
20
+ onEscapeCallback.value = callback;
21
+ }
22
+
23
+ function onBack(callback: () => void): void {
24
+ onBackCallback.value = callback;
25
+ }
26
+
27
+ function activate(): void {
28
+ isActive.value = true;
29
+
30
+ try {
31
+ activateTrap();
32
+ } catch (error) {
33
+ console.warn(error);
34
+ }
35
+ }
36
+
37
+ function deactivate(): void {
38
+ isActive.value = false;
39
+
40
+ deactivateTrap();
41
+ }
42
+
43
+ onKeyStroke(['Escape'], (event: KeyboardEvent) => {
44
+ if (!isActive.value) {
45
+ return;
46
+ }
47
+
48
+ event.preventDefault();
49
+
50
+ onEscapeCallback.value?.();
51
+ });
52
+ onKeyStroke(['Enter', 'ArrowRight'], (event: KeyboardEvent) => {
53
+ if (!isActive.value) {
54
+ return;
55
+ }
56
+
57
+ event.preventDefault();
58
+
59
+ if (event.key === 'Enter' || (event.key === 'ArrowRight' && activeElement.value?.classList.contains('has-submenu'))) {
60
+ if (options.childTargetClass) {
61
+ (activeElement.value?.querySelector(options?.childTargetClass ?? '.app-action-bar-item') as HTMLDivElement | undefined)?.click();
62
+
63
+ return;
64
+ }
65
+
66
+ (activeElement.value?.firstElementChild as HTMLDivElement | undefined)?.click();
67
+ }
68
+ });
69
+ onKeyStroke(['ArrowLeft'], () => {
70
+ if (!isActive.value) {
71
+ return;
72
+ }
73
+
74
+ onBackCallback.value?.();
75
+ });
76
+ onKeyStroke(['ArrowDown', 'ArrowUp'], (event: KeyboardEvent) => {
77
+ if (!isActive.value) {
78
+ return;
79
+ }
80
+
81
+ event.preventDefault();
82
+
83
+ if (event.key === 'ArrowUp') {
84
+ (activeElement.value?.previousElementSibling as HTMLDivElement | undefined)?.focus();
85
+ return;
86
+ }
87
+
88
+ (activeElement.value?.nextElementSibling as HTMLDivElement | undefined)?.focus();
89
+ });
90
+
91
+ return {
92
+ isActive,
93
+ activate,
94
+ deactivate,
95
+ onEscape,
96
+ onBack,
97
+ };
98
+ }