adminforth 3.35.1 → 4.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.
Files changed (94) hide show
  1. package/dist/modules/codeInjector.d.ts.map +1 -1
  2. package/dist/modules/codeInjector.js +20 -3
  3. package/dist/modules/codeInjector.js.map +1 -1
  4. package/dist/modules/styleGenerator.d.ts +13 -0
  5. package/dist/modules/styleGenerator.d.ts.map +1 -1
  6. package/dist/modules/styleGenerator.js +274 -1
  7. package/dist/modules/styleGenerator.js.map +1 -1
  8. package/dist/modules/styles.d.ts +24 -0
  9. package/dist/modules/styles.d.ts.map +1 -1
  10. package/dist/modules/styles.js +452 -427
  11. package/dist/modules/styles.js.map +1 -1
  12. package/dist/modules/utils.d.ts +1 -13
  13. package/dist/modules/utils.d.ts.map +1 -1
  14. package/dist/modules/utils.js +2 -275
  15. package/dist/modules/utils.js.map +1 -1
  16. package/dist/spa/index.html +2 -2
  17. package/dist/spa/package-lock.json +1373 -1136
  18. package/dist/spa/package.json +18 -15
  19. package/dist/spa/pnpm-lock.yaml +1067 -648
  20. package/dist/spa/src/App.vue +84 -92
  21. package/dist/spa/src/afcl/AreaChart.vue +8 -24
  22. package/dist/spa/src/afcl/BarChart.vue +8 -23
  23. package/dist/spa/src/afcl/Button.vue +8 -2
  24. package/dist/spa/src/afcl/ButtonGroup.vue +9 -9
  25. package/dist/spa/src/afcl/Card.vue +40 -19
  26. package/dist/spa/src/afcl/Checkbox.vue +6 -6
  27. package/dist/spa/src/afcl/CountryFlag.vue +17 -11
  28. package/dist/spa/src/afcl/DatePicker.vue +7 -7
  29. package/dist/spa/src/afcl/Dialog.vue +8 -9
  30. package/dist/spa/src/afcl/DropdownMenu.vue +210 -0
  31. package/dist/spa/src/afcl/DropdownMenuAccordion.vue +56 -0
  32. package/dist/spa/src/afcl/DropdownMenuItem.vue +86 -0
  33. package/dist/spa/src/afcl/Dropzone.vue +10 -26
  34. package/dist/spa/src/afcl/Input.vue +4 -4
  35. package/dist/spa/src/afcl/Link.vue +1 -1
  36. package/dist/spa/src/afcl/LinkButton.vue +15 -1
  37. package/dist/spa/src/afcl/MixedChart.vue +4 -18
  38. package/dist/spa/src/afcl/Modal.vue +9 -9
  39. package/dist/spa/src/afcl/PieChart.vue +8 -29
  40. package/dist/spa/src/afcl/ProgressBar.vue +2 -2
  41. package/dist/spa/src/afcl/Select.vue +20 -20
  42. package/dist/spa/src/afcl/Skeleton.vue +1 -1
  43. package/dist/spa/src/afcl/Spinner.vue +1 -1
  44. package/dist/spa/src/afcl/Table.vue +19 -26
  45. package/dist/spa/src/afcl/Textarea.vue +1 -1
  46. package/dist/spa/src/afcl/Toggle.vue +4 -4
  47. package/dist/spa/src/afcl/Tooltip.vue +1 -1
  48. package/dist/spa/src/afcl/TreeMapChart.vue +5 -20
  49. package/dist/spa/src/afcl/VerticalTabs.vue +4 -4
  50. package/dist/spa/src/afcl/buttonStyles.ts +73 -33
  51. package/dist/spa/src/afcl/chartUtils.ts +19 -0
  52. package/dist/spa/src/afcl/classMerge.ts +11 -0
  53. package/dist/spa/src/afcl/dropdownMenuContext.ts +10 -0
  54. package/dist/spa/src/afcl/fileSize.ts +12 -0
  55. package/dist/spa/src/afcl/index.ts +3 -0
  56. package/dist/spa/src/components/AcceptModal.vue +17 -19
  57. package/dist/spa/src/components/Breadcrumbs.vue +11 -13
  58. package/dist/spa/src/components/BreadcrumbsWithButtons.vue +2 -2
  59. package/dist/spa/src/components/CallActionWrapper.vue +3 -0
  60. package/dist/spa/src/components/ColumnValueInput.vue +5 -5
  61. package/dist/spa/src/components/ColumnValueInputWrapper.vue +7 -6
  62. package/dist/spa/src/components/CustomDateRangePicker.vue +8 -8
  63. package/dist/spa/src/components/CustomRangePicker.vue +2 -2
  64. package/dist/spa/src/components/ErrorMessage.vue +3 -4
  65. package/dist/spa/src/components/Filters.vue +13 -13
  66. package/dist/spa/src/components/GroupsTable.vue +12 -12
  67. package/dist/spa/src/components/ListActionsThreeDots.vue +89 -207
  68. package/dist/spa/src/components/MenuLink.vue +23 -20
  69. package/dist/spa/src/components/RangePicker.vue +9 -10
  70. package/dist/spa/src/components/ResourceListTable.vue +68 -65
  71. package/dist/spa/src/components/ShowTable.vue +8 -8
  72. package/dist/spa/src/components/Sidebar.vue +31 -38
  73. package/dist/spa/src/components/ThreeDotsMenu.vue +88 -136
  74. package/dist/spa/src/components/Toast.vue +16 -23
  75. package/dist/spa/src/components/UserMenuSettingsButton.vue +15 -36
  76. package/dist/spa/src/components/ValueRenderer.vue +13 -8
  77. package/dist/spa/src/components/icons/IconCalendar.vue +3 -3
  78. package/dist/spa/src/components/icons/IconTime.vue +3 -3
  79. package/dist/spa/src/index.scss +6 -2
  80. package/dist/spa/src/main.ts +2 -0
  81. package/dist/spa/src/renderers/CompactField.vue +3 -3
  82. package/dist/spa/src/renderers/CompactUUID.vue +3 -3
  83. package/dist/spa/src/renderers/SensitiveBlurCell.vue +9 -9
  84. package/dist/spa/src/utils/threeDotsMenu.ts +7 -0
  85. package/dist/spa/src/utils/userMenu.ts +5 -0
  86. package/dist/spa/src/utils/utils.ts +12 -39
  87. package/dist/spa/src/views/CreateEditSkeleton.vue +3 -3
  88. package/dist/spa/src/views/CreateView.vue +5 -6
  89. package/dist/spa/src/views/EditView.vue +5 -6
  90. package/dist/spa/src/views/ListView.vue +114 -115
  91. package/dist/spa/src/views/LoginView.vue +4 -4
  92. package/dist/spa/src/views/ShowView.vue +17 -18
  93. package/dist/spa/vite.config.ts +23 -16
  94. package/package.json +1 -2
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div>
3
- <div class="grid w-40 gap-4 mb-2">
3
+ <div class="grid w-40 gap-2 mb-2">
4
4
  <div>
5
5
  <label v-if="label" for="start-time" class="block mb-2 text-sm font-medium text-lightDatePickerButtonText dark:text-darkDatePickerButtonText">{{ label }}</label>
6
6
 
@@ -10,7 +10,7 @@
10
10
  </div>
11
11
 
12
12
  <input ref="datepickerStartEl" type="text"
13
- class="af-pick-date-button bg-lightDatePickerButtonBackground border border-lightDatePickerButtonBorder text-lightDatePickerButtonText text-sm rounded-default block w-full p-2.5 dark:bg-darkDatePickerButtonBackground dark:border-darkDatePickerButtonBorder dark:placeholder-darkInputPlaceholderText dark:text-darkDatePickerButtonText focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
13
+ class="af-pick-date-button bg-lightDatePickerButtonBackground border border-lightDatePickerButtonBorder text-lightDatePickerButtonText text-sm rounded-default block w-full px-2.5 py-1.5 leading-5 shadow-customLight focus:outline-none focus:ring-[3px] focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder dark:bg-darkDatePickerButtonBackground dark:border-darkDatePickerButtonBorder dark:placeholder-darkInputPlaceholderText dark:text-darkDatePickerButtonText focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
14
14
  :placeholder="$t('Select date')" :disabled="readonly" />
15
15
 
16
16
  </div>
@@ -18,24 +18,24 @@
18
18
  </div>
19
19
 
20
20
  <div>
21
- <div class="grid w-40 gap-4 mb-2" :class="{hidden: !showTimeInputs}">
21
+ <div class="grid w-40 gap-2 mb-2" :class="{hidden: !showTimeInputs}">
22
22
  <div>
23
23
  <div class="relative">
24
24
  <div class="absolute inset-y-0 end-0 top-0 flex items-center pe-3.5 pointer-events-none">
25
25
  <IconTime class="w-4 h-4 text-lightDatePickerIcon dark:text-darkDatePickerIcon bg-lightDatePickerButtonBackground dark:bg-darkDatePickerButtonBackground"/>
26
26
  </div>
27
27
  <input v-model="startTime" type="time" id="start-time" step="1"
28
- class="af-pick-time-button bg-lightDatePickerButtonBackground border border-lightDatePickerButtonBorder text-lightDatePickerButtonText text-sm rounded-default block w-full p-2.5 dark:bg-darkInputBackground dark:border-darkInputBorder dark:placeholder-darkInputPlaceholderText dark:text-darkDatePickerButtonText focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
28
+ class="af-pick-time-button bg-lightDatePickerButtonBackground border border-lightDatePickerButtonBorder text-lightDatePickerButtonText text-sm rounded-default block w-full px-2.5 py-1.5 leading-5 shadow-customLight focus:outline-none focus:ring-[3px] focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder dark:bg-darkInputBackground dark:border-darkInputBorder dark:placeholder-darkInputPlaceholderText dark:text-darkDatePickerButtonText focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder"
29
29
  value="00:00" :disabled="readonly" required/>
30
30
  </div>
31
31
  </div>
32
32
  </div>
33
33
 
34
34
  <button type="button"
35
- class="text-lightDatePickerExpandText dark:text-darkDatePickerExpandText text-base font-medium hover:underline p-0 inline-flex items-center mb-2"
35
+ class="text-lightDatePickerExpandText dark:text-darkDatePickerExpandText text-[13px] font-medium hover:underline p-0 inline-flex items-center gap-0.5 mb-2"
36
36
  :class="{hidden: column.type !== 'datetime'}"
37
37
  @click="toggleTimeInputs">{{ showTimeInputs ? $t('Hide time') : $t('Show time') }}
38
- <svg class="w-8 h-8 ms-0.5" :class="{'rotate-180': showTimeInputs}" aria-hidden="true"
38
+ <svg class="w-4 h-4" :class="{'rotate-180': showTimeInputs}" aria-hidden="true"
39
39
  xmlns="http://www.w3.org/2000/svg" width="24" height="24"
40
40
  fill="none" viewBox="0 0 24 24">
41
41
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -51,7 +51,7 @@ import dayjs from 'dayjs';
51
51
  import utc from 'dayjs/plugin/utc';
52
52
 
53
53
  import {useCoreStore} from '@/stores/core';
54
- import {FLEXIBLE_DATEPICKER_FORMAT} from '@/utils';
54
+ import {FLEXIBLE_DATEPICKER_FORMAT} from '@/utils/dateUtils';
55
55
 
56
56
  import IconCalendar from "@/components/icons/IconCalendar.vue";
57
57
  import IconTime from "@/components/icons/IconTime.vue";
@@ -18,37 +18,35 @@
18
18
  <!-- Modal header -->
19
19
  <div
20
20
  v-if="header"
21
- class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-darkDialogBreakLine border-lightDialogBreakLine"
21
+ class="flex items-center justify-between px-5 py-4 border-b rounded-t-xl dark:border-darkDialogBreakLine border-lightDialogBreakLine"
22
22
  >
23
- <h3 class="text-xl font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">
23
+ <h3 class="text-base font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">
24
24
  {{ header }}
25
25
  </h3>
26
26
  <button
27
27
  v-if="headerCloseButton"
28
28
  type="button"
29
- class="text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
29
+ class="text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-default text-sm w-7 h-7 ms-auto inline-flex justify-center items-center focus:outline-none focus-visible:ring-[3px] focus-visible:ring-lightFocusRing dark:focus-visible:ring-darkFocusRing dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
30
30
  @click="tryToCancelModal"
31
31
  >
32
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
33
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
34
- </svg>
32
+ <IconX class="w-4 h-4" aria-hidden="true" />
35
33
  <span class="sr-only">{{ t('Close Modal') }}</span>
36
34
  </button>
37
35
  </div>
38
36
  <!-- Modal body -->
39
- <div class="p-4 md:p-5 text-lightDialogBodyText dark:text-darkDialogBodyText">
37
+ <div class="px-5 py-4 text-sm text-lightDialogBodyText dark:text-darkDialogBodyText">
40
38
  <slot></slot>
41
39
  </div>
42
40
  <!-- Modal footer -->
43
41
  <div
44
42
  v-if="buttons.length"
45
- class="flex items-center p-4 md:p-5 border-t border-lightDialogBreakLine rounded-b dark:border-darkDialogBreakLine"
43
+ class="flex items-center justify-end flex-wrap gap-y-2 px-5 py-3 border-t border-lightDialogBreakLine rounded-b-xl bg-lightFormHeading dark:bg-darkFormHeading dark:border-darkDialogBreakLine"
46
44
  >
47
45
  <Button
48
46
  v-for="(button, buttonIndex) in buttons"
49
47
  :key="buttonIndex"
50
48
  v-bind="button.options"
51
- :class="{ 'ms-3': buttonIndex > 0 }"
49
+ :class="{ 'ms-2': buttonIndex > 0 }"
52
50
  @click="button.onclick(dialog)"
53
51
  :mode="button.options?.mode || 'primary'"
54
52
  >
@@ -60,6 +58,7 @@
60
58
 
61
59
  <script setup lang="ts">
62
60
  import Button from "./Button.vue";
61
+ import { IconX } from '@iconify-prerendered/vue-lucide';
63
62
  import { ref, computed, type Ref } from 'vue';
64
63
  import { Modal } from '@/afcl'
65
64
  import { useI18n } from "vue-i18n";
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <div ref="rootRef" v-bind="restAttrs" :class="rootClasses">
3
+ <div class="afcl-dropdown-menu-trigger" @click="onTriggerClick">
4
+ <slot name="trigger" :open="isOpen" />
5
+ </div>
6
+ <Teleport to="body" :disabled="!teleportToBody">
7
+ <div
8
+ v-if="keepMounted || isOpen"
9
+ v-show="isOpen"
10
+ ref="contentRef"
11
+ role="menu"
12
+ :class="contentClasses"
13
+ :style="fixedStyle"
14
+ >
15
+ <slot :close="close" />
16
+ </div>
17
+ </Teleport>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { computed, inject, nextTick, onBeforeUnmount, provide, ref, useAttrs, watch } from 'vue';
23
+ import { type ClassNameValue } from 'tailwind-merge';
24
+ import { mergeClasses } from './classMerge';
25
+ import { DROPDOWN_MENU_KEY, type DropdownMenuPlacement } from './dropdownMenuContext';
26
+
27
+ defineOptions({ inheritAttrs: false });
28
+
29
+ const props = withDefaults(defineProps<{
30
+ placement?: DropdownMenuPlacement,
31
+ closeOnSelect?: boolean,
32
+ keepMounted?: boolean,
33
+ teleportToBody?: boolean,
34
+ contentClass?: ClassNameValue,
35
+ }>(), {
36
+ placement: 'bottom-start',
37
+ closeOnSelect: true,
38
+ keepMounted: false,
39
+ teleportToBody: false,
40
+ });
41
+
42
+ const isOpen = defineModel<boolean>('open', { default: false });
43
+
44
+ const attrs = useAttrs();
45
+ const restAttrs = computed(() => {
46
+ const { class: _class, ...rest } = attrs;
47
+ return rest;
48
+ });
49
+
50
+ const VIEWPORT_MARGIN = 8;
51
+ const GAP = 6;
52
+
53
+ const PLACEMENT_CLASSES: Record<DropdownMenuPlacement, string> = {
54
+ 'bottom-start': 'top-full left-0 mt-1.5',
55
+ 'bottom-end': 'top-full right-0 mt-1.5',
56
+ 'left-start': 'top-0 right-full mr-2',
57
+ 'right-start': 'top-0 left-full ml-2',
58
+ };
59
+
60
+ const OPPOSITE_PLACEMENT: Record<DropdownMenuPlacement, DropdownMenuPlacement> = {
61
+ 'bottom-start': 'bottom-end',
62
+ 'bottom-end': 'bottom-start',
63
+ 'left-start': 'right-start',
64
+ 'right-start': 'left-start',
65
+ };
66
+
67
+ const parentMenu = inject(DROPDOWN_MENU_KEY, null);
68
+
69
+ const rootRef = ref<HTMLElement | null>(null);
70
+ const contentRef = ref<HTMLElement | null>(null);
71
+ const flipped = ref(false);
72
+ const measuring = ref(false);
73
+ const fixedStyle = ref<Record<string, string>>();
74
+
75
+ const currentPlacement = computed(() => flipped.value ? OPPOSITE_PLACEMENT[props.placement] : props.placement);
76
+
77
+ const rootClasses = computed(() => mergeClasses('afcl-dropdown-menu relative inline-block', attrs.class as ClassNameValue));
78
+
79
+ const contentClasses = computed(() => mergeClasses(
80
+ 'afcl-dropdown-menu-content z-50 w-max min-w-[11rem] max-w-xs p-1 rounded-lg text-sm text-left',
81
+ 'bg-lightDropdownOptionsBackground text-lightDropdownOptionsText shadow-popover',
82
+ 'dark:bg-darkDropdownOptionsBackground dark:text-darkDropdownOptionsText dark:shadow-darkPopover',
83
+ props.teleportToBody ? 'fixed overflow-y-auto' : ['absolute', PLACEMENT_CLASSES[currentPlacement.value]],
84
+ measuring.value && 'invisible',
85
+ props.contentClass,
86
+ ));
87
+
88
+ provide(DROPDOWN_MENU_KEY, {
89
+ itemSelected: () => {
90
+ if (props.closeOnSelect) {
91
+ close();
92
+ }
93
+ },
94
+ });
95
+
96
+ function open() {
97
+ isOpen.value = true;
98
+ }
99
+
100
+ function close() {
101
+ isOpen.value = false;
102
+ }
103
+
104
+ function toggle() {
105
+ isOpen.value = !isOpen.value;
106
+ }
107
+
108
+ function onTriggerClick(e: MouseEvent) {
109
+ // a submenu trigger lives inside an item of the parent menu, opening it must not select that item
110
+ if (parentMenu) {
111
+ e.stopPropagation();
112
+ }
113
+ toggle();
114
+ }
115
+
116
+ function flipIfOutsideViewport() {
117
+ const content = contentRef.value!.getBoundingClientRect();
118
+ flipped.value = content.left < VIEWPORT_MARGIN || content.right > window.innerWidth - VIEWPORT_MARGIN;
119
+ }
120
+
121
+ // fixed positioning for teleported content, so menus inside scroll containers (tables) are not clipped
122
+ function placeFixed() {
123
+ const trigger = rootRef.value!.getBoundingClientRect();
124
+ const content = contentRef.value!.getBoundingClientRect();
125
+ const sideways = props.placement === 'left-start' || props.placement === 'right-start';
126
+
127
+ let left: number;
128
+ let top: number;
129
+ let maxHeight = window.innerHeight - 2 * VIEWPORT_MARGIN;
130
+ if (sideways) {
131
+ left = props.placement === 'left-start' ? trigger.left - GAP - content.width : trigger.right + GAP;
132
+ top = Math.min(trigger.top, window.innerHeight - VIEWPORT_MARGIN - content.height);
133
+ } else {
134
+ left = props.placement === 'bottom-start' ? trigger.left : trigger.right - content.width;
135
+ const spaceBelow = window.innerHeight - trigger.bottom - GAP - VIEWPORT_MARGIN;
136
+ const spaceAbove = trigger.top - GAP - VIEWPORT_MARGIN;
137
+ const below = content.height <= spaceBelow || spaceBelow >= spaceAbove;
138
+ maxHeight = below ? spaceBelow : spaceAbove;
139
+ top = below ? trigger.bottom + GAP : trigger.top - GAP - Math.min(content.height, maxHeight);
140
+ }
141
+ const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - VIEWPORT_MARGIN - content.width);
142
+
143
+ fixedStyle.value = {
144
+ top: `${Math.round(Math.max(VIEWPORT_MARGIN, top))}px`,
145
+ left: `${Math.round(Math.min(Math.max(left, VIEWPORT_MARGIN), maxLeft))}px`,
146
+ maxHeight: `${Math.round(maxHeight)}px`,
147
+ };
148
+ }
149
+
150
+ function onDocumentMousedown(e: MouseEvent) {
151
+ const target = e.target as Node;
152
+ if (rootRef.value?.contains(target) || contentRef.value?.contains(target)) {
153
+ return;
154
+ }
155
+ close();
156
+ }
157
+
158
+ function onKeydown(e: KeyboardEvent) {
159
+ if (e.key === 'Escape') {
160
+ close();
161
+ }
162
+ }
163
+
164
+ function onViewportChange(e: Event) {
165
+ // scrolling inside the menu itself must not close it
166
+ if (e.target instanceof Node && contentRef.value?.contains(e.target)) {
167
+ return;
168
+ }
169
+ close();
170
+ }
171
+
172
+ function addListeners() {
173
+ document.addEventListener('mousedown', onDocumentMousedown);
174
+ document.addEventListener('keydown', onKeydown);
175
+ if (props.teleportToBody) {
176
+ window.addEventListener('scroll', onViewportChange, true);
177
+ window.addEventListener('resize', onViewportChange);
178
+ }
179
+ }
180
+
181
+ function removeListeners() {
182
+ document.removeEventListener('mousedown', onDocumentMousedown);
183
+ document.removeEventListener('keydown', onKeydown);
184
+ window.removeEventListener('scroll', onViewportChange, true);
185
+ window.removeEventListener('resize', onViewportChange);
186
+ }
187
+
188
+ watch(isOpen, async (opened) => {
189
+ if (!opened) {
190
+ removeListeners();
191
+ return;
192
+ }
193
+ addListeners();
194
+ measuring.value = true;
195
+ flipped.value = false;
196
+ await nextTick();
197
+ if (props.teleportToBody) {
198
+ placeFixed();
199
+ // icons and web fonts can change the content width right after the first paint
200
+ requestAnimationFrame(() => isOpen.value && placeFixed());
201
+ } else {
202
+ flipIfOutsideViewport();
203
+ }
204
+ measuring.value = false;
205
+ });
206
+
207
+ onBeforeUnmount(removeListeners);
208
+
209
+ defineExpose({ open, close, toggle });
210
+ </script>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <div v-bind="restAttrs" :class="rootClasses">
3
+ <DropdownMenuItem
4
+ keepMenuOpen
5
+ :aria-expanded="isOpen"
6
+ :class="['afcl-dropdown-menu-accordion-trigger', triggerClass]"
7
+ @click="isOpen = !isOpen"
8
+ >
9
+ <template v-if="$slots.icon" #icon>
10
+ <slot name="icon" />
11
+ </template>
12
+ {{ label }}
13
+ <template #suffix>
14
+ <IconChevronDown
15
+ class="w-4 h-4 text-lightInputIconColor dark:text-darkInputIconColor transition-transform duration-150"
16
+ :class="isOpen && 'rotate-180'"
17
+ />
18
+ </template>
19
+ </DropdownMenuItem>
20
+ <!-- collapsed items stay in layout with zero height, so the menu already fits its widest item and does not jump on expand -->
21
+ <div
22
+ role="group"
23
+ class="afcl-dropdown-menu-accordion-items [&>.afcl-dropdown-menu-item]:ps-9"
24
+ :class="!isOpen && 'h-0 overflow-hidden invisible'"
25
+ >
26
+ <slot />
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import { computed, useAttrs } from 'vue';
33
+ import { type ClassNameValue } from 'tailwind-merge';
34
+ import { IconChevronDown } from '@iconify-prerendered/vue-lucide';
35
+ import { mergeClasses } from './classMerge';
36
+ import DropdownMenuItem from './DropdownMenuItem.vue';
37
+
38
+ defineOptions({ inheritAttrs: false });
39
+
40
+ withDefaults(defineProps<{
41
+ label: string,
42
+ triggerClass?: ClassNameValue,
43
+ }>(), {
44
+ triggerClass: '',
45
+ });
46
+
47
+ const isOpen = defineModel<boolean>('open', { default: false });
48
+
49
+ const attrs = useAttrs();
50
+ const restAttrs = computed(() => {
51
+ const { class: _class, ...rest } = attrs;
52
+ return rest;
53
+ });
54
+
55
+ const rootClasses = computed(() => mergeClasses('afcl-dropdown-menu-accordion', attrs.class as ClassNameValue));
56
+ </script>
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <component
3
+ :is="to && !disabled ? RouterLink : 'div'"
4
+ v-bind="restAttrs"
5
+ :to="disabled ? undefined : to"
6
+ role="menuitem"
7
+ :tabindex="disabled ? -1 : 0"
8
+ :aria-disabled="disabled || undefined"
9
+ :class="classes"
10
+ @click="select"
11
+ @keydown.enter.self.prevent="activate"
12
+ @keydown.space.self.prevent="activate"
13
+ >
14
+ <span
15
+ v-if="$slots.icon"
16
+ class="afcl-dropdown-menu-item-icon flex shrink-0 items-center [&>svg]:w-4 [&>svg]:h-4"
17
+ :class="!danger && 'text-lightInputIconColor dark:text-darkInputIconColor'"
18
+ >
19
+ <slot name="icon" />
20
+ </span>
21
+ <span class="min-w-0 flex-1">
22
+ <slot />
23
+ </span>
24
+ <span v-if="$slots.suffix" class="afcl-dropdown-menu-item-suffix ms-auto flex shrink-0 items-center gap-1 text-xs text-lightDropdownButtonsPlaceholderText dark:text-darkDropdownButtonsPlaceholderText">
25
+ <slot name="suffix" />
26
+ </span>
27
+ </component>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { computed, inject, useAttrs } from 'vue';
32
+ import { RouterLink, type RouteLocationRaw } from 'vue-router';
33
+ import { type ClassNameValue } from 'tailwind-merge';
34
+ import { mergeClasses } from './classMerge';
35
+ import { DROPDOWN_MENU_KEY } from './dropdownMenuContext';
36
+
37
+ defineOptions({ inheritAttrs: false });
38
+
39
+ const props = defineProps<{
40
+ disabled?: boolean,
41
+ danger?: boolean,
42
+ to?: RouteLocationRaw,
43
+ // a click does not close the menu, e.g. on a row which expands nested items
44
+ keepMenuOpen?: boolean,
45
+ }>();
46
+
47
+ const emit = defineEmits<{
48
+ (e: 'click', event: MouseEvent): void,
49
+ }>();
50
+
51
+ const menu = inject(DROPDOWN_MENU_KEY, null);
52
+
53
+ const attrs = useAttrs();
54
+ const restAttrs = computed(() => {
55
+ const { class: _class, ...rest } = attrs;
56
+ return rest;
57
+ });
58
+
59
+ const classes = computed(() => mergeClasses(
60
+ 'afcl-dropdown-menu-item flex w-full items-center gap-2 px-3 py-2 rounded-md text-sm cursor-pointer select-none outline-none transition-colors',
61
+ // an expanded row (submenu or accordion trigger) stays highlighted
62
+ 'aria-expanded:bg-lightDropdownOptionsHoverBackground dark:aria-expanded:bg-darkDropdownOptionsHoverBackground',
63
+ !props.disabled && [
64
+ 'hover:bg-lightDropdownOptionsHoverBackground focus-visible:bg-lightDropdownOptionsHoverBackground',
65
+ 'dark:hover:bg-darkDropdownOptionsHoverBackground dark:focus-visible:bg-darkDropdownOptionsHoverBackground',
66
+ ],
67
+ props.danger && 'text-lightInputErrorColor dark:text-darkInputErrorColor',
68
+ props.disabled && 'opacity-50 cursor-not-allowed',
69
+ attrs.class as ClassNameValue,
70
+ ));
71
+
72
+ // keyboard activation goes through a real click, so RouterLink navigation runs as well
73
+ function activate(e: KeyboardEvent) {
74
+ (e.currentTarget as HTMLElement).click();
75
+ }
76
+
77
+ function select(e: MouseEvent) {
78
+ if (props.disabled) {
79
+ return;
80
+ }
81
+ emit('click', e);
82
+ if (!props.keepMenuOpen) {
83
+ menu?.itemSelected();
84
+ }
85
+ }
86
+ </script>
@@ -5,7 +5,7 @@
5
5
  @dragleave.prevent="dragging = false"
6
6
  @drop.prevent="dragging = false; doEmit(($event.dataTransfer as DataTransfer).files)"
7
7
  >
8
- <label :id="id" class="flex flex-col items-center justify-center w-full border-2 border-dashed rounded-default cursor-pointer
8
+ <label :id="id" class="flex flex-col items-center justify-center w-full border border-dashed rounded-lg cursor-pointer transition-colors
9
9
  hover:bg-lightDropzoneBackgroundHover hover:border-lightDropzoneBorderHover dark:hover:border-darkDropzoneBorderHover dark:hover:bg-darkDropzoneBackgroundHover"
10
10
  :class="{
11
11
  'border-lightDropzoneBorderDragging dark:border-darkDropzoneBorderDragging': dragging,
@@ -26,23 +26,9 @@
26
26
  />
27
27
 
28
28
  <div class="flex flex-col items-center justify-center pt-5 pb-6">
29
- <svg
30
- v-if="!selectedFiles.length"
31
- class="w-8 h-8 mb-4 text-lightDropzoneIcon dark:text-darkDropzoneIcon"
32
- xmlns="http://www.w3.org/2000/svg"
33
- fill="none"
34
- viewBox="0 0 20 16"
35
- >
36
- <path
37
- stroke="currentColor"
38
- stroke-linecap="round"
39
- stroke-linejoin="round"
40
- stroke-width="2"
41
- d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5
42
- 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0
43
- 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
44
- />
45
- </svg>
29
+ <span v-if="!selectedFiles.length" class="flex items-center justify-center w-9 h-9 mb-3 rounded-lg border border-lightDropzoneBorder bg-lightDropzoneBackgroundHover text-lightDropzoneIcon dark:border-darkDropzoneBorder dark:bg-darkDropzoneBackgroundHover dark:text-darkDropzoneIcon">
30
+ <IconCloudUpload class="w-5 h-5" />
31
+ </span>
46
32
 
47
33
  <div
48
34
  v-else
@@ -53,7 +39,7 @@
53
39
  class="text-sm text-lightDropzoneIcon dark:text-darkDropzoneIcon bg-lightDropzoneBackgroundHover dark:bg-darkDropzoneBackgroundHover rounded-default
54
40
  flex items-center gap-1 px-2 py-1 group"
55
41
  >
56
- <IconFileSolid class="w-4 h-4 flex-shrink-0" />
42
+ <IconFile class="w-4 h-4 flex-shrink-0" />
57
43
  <span
58
44
  class="truncate max-w-[200px]"
59
45
  :title="file.name"
@@ -64,13 +50,11 @@
64
50
  <button
65
51
  type="button"
66
52
  @click.prevent.stop="removeFile(index)"
67
- class="text-lightDropzoneIcon dark:text-darkDropzoneIcon hover:text-red-600 dark:hover:text-red-400
53
+ class="text-lightDropzoneIcon dark:text-darkDropzoneIcon hover:text-lightInputErrorColor dark:hover:text-darkInputErrorColor
68
54
  opacity-70 hover:opacity-100 transition-all"
69
55
  :title="$t('Remove file')"
70
56
  >
71
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
73
- </svg>
57
+ <IconX class="w-3.5 h-3.5" />
74
58
  </button>
75
59
  </div>
76
60
  </template>
@@ -80,7 +64,7 @@
80
64
  v-if="!selectedFiles.length"
81
65
  class="mb-2 text-sm text-lightDropzoneText dark:text-darkDropzoneText"
82
66
  >
83
- <span class="font-semibold">{{ $t('Click to upload') }}</span>
67
+ <span class="font-medium text-lightPrimary dark:text-darkPrimary">{{ $t('Click to upload') }}</span>
84
68
  {{ $t('or drag and drop') }}
85
69
  </p>
86
70
 
@@ -96,9 +80,9 @@
96
80
  </template>
97
81
 
98
82
  <script setup lang="ts">
99
- import { humanifySize } from '@/utils';
83
+ import { humanifySize } from './fileSize';
100
84
  import { ref, type Ref, computed } from 'vue';
101
- import { IconFileSolid } from '@iconify-prerendered/vue-flowbite';
85
+ import { IconFile, IconX, IconCloudUpload } from '@iconify-prerendered/vue-lucide';
102
86
  import { watch } from 'vue';
103
87
  import { useAdminforth } from '@/adminforth';
104
88
 
@@ -3,7 +3,7 @@
3
3
  <div class="afcl-input-wrapper flex z-0 relative" :class="{'opacity-50' : readonly}">
4
4
  <span
5
5
  v-if="$slots.prefix || prefix"
6
- class="inline-flex items-center px-3 text-sm text-lightInputText bg-lightInputBackground border border-s-0 border-lightInputBorder rounded-s-default dark:bg-darkInputBackground dark:text-darkInputText dark:border-darkInputBorder">
6
+ class="inline-flex items-center px-2.5 text-sm text-lightInputIconColor bg-lightSecondaryDarken border border-e-0 border-lightInputBorder rounded-s-default dark:bg-darkSecondaryLighten dark:text-darkInputIconColor dark:border-darkInputBorder">
7
7
  <slot name="prefix">{{ prefix }}</slot>
8
8
  </span>
9
9
 
@@ -15,8 +15,8 @@
15
15
  @input="$emit('update:modelValue', type === 'number' ? numberInputValue(($event.target as HTMLInputElement)?.value) : ($event.target as HTMLInputElement)?.value)"
16
16
  :value="modelValue"
17
17
  aria-describedby="helper-text-explanation"
18
- class="afcl-input inline-flex bg-lightInputBackground text-lightInputText dark:text-darkInputText border border-lightInputBorder rounded-0 focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary
19
- blue-500 focus:border-blue-500 block w-20 p-2.5 dark:bg-darkInputBackground dark:border-darkInputBorder placeholder-lightInputPlaceholderText dark:placeholder-darkInputPlaceholderText dark:text-darkInputText translate-y-0"
18
+ class="afcl-input inline-flex bg-lightInputBackground text-lightInputText dark:text-darkInputText border border-lightInputBorder rounded-0 shadow-customLight hover:border-lightInputBorderHover focus:outline-none focus:ring-[3px] focus:ring-lightInputFocusRing focus:border-lightInputFocusBorder dark:hover:border-darkInputBorderHover dark:focus:ring-darkInputFocusRing dark:focus:border-darkInputFocusBorder
19
+ block w-20 px-2.5 py-1.5 leading-5 dark:bg-darkInputBackground dark:border-darkInputBorder placeholder-lightInputPlaceholderText dark:placeholder-darkInputPlaceholderText dark:text-darkInputText translate-y-0 transition-colors disabled:cursor-not-allowed"
20
20
  :class="{'rounded-l-default': !$slots.prefix && !prefix, 'rounded-r-default': !$slots.suffix && !suffix, 'w-full': fullWidth, 'text-base': isIos, 'text-sm': !isIos, 'pr-10': $slots.rightIcon }"
21
21
  :disabled="readonly"
22
22
  >
@@ -26,7 +26,7 @@
26
26
  </div>
27
27
  <span
28
28
  v-if="$slots.suffix || suffix"
29
- class="inline-flex items-center px-3 text-sm text-lightInputText bg-lightInputBackground border border-s-0 border-lightInputBorder rounded-e-default dark:bg-darkInputBackground dark:text-darkInputText dark:border-darkInputBorder ">
29
+ class="inline-flex items-center px-2.5 text-sm text-lightInputIconColor bg-lightSecondaryDarken border border-s-0 border-lightInputBorder rounded-e-default dark:bg-darkSecondaryLighten dark:text-darkInputIconColor dark:border-darkInputBorder ">
30
30
  <slot name="suffix">{{ suffix }}</slot>
31
31
  </span>
32
32
 
@@ -33,5 +33,5 @@ const isExternal = computed(() => {
33
33
  return typeof props.to === 'string' && props.to.startsWith('http');
34
34
  });
35
35
 
36
- const linkClasses = "afcl-link text-lightPrimary underline dark:text-darkPrimary hover:no-underline hover:brightness-110 cursor-pointer";
36
+ const linkClasses = "afcl-link font-medium text-lightPrimary underline decoration-lightPrimary/30 underline-offset-[3px] hover:decoration-lightPrimary dark:text-darkPrimary dark:decoration-darkPrimary/40 dark:hover:decoration-darkPrimary cursor-pointer transition-colors";
37
37
  </script>
@@ -1,5 +1,15 @@
1
1
  <template>
2
+ <!-- a link without href leaves the tab order and does not navigate -->
3
+ <a
4
+ v-if="props.disabled"
5
+ v-bind="restAttrs"
6
+ aria-disabled="true"
7
+ :class="buttonClasses"
8
+ >
9
+ <slot></slot>
10
+ </a>
2
11
  <router-link
12
+ v-else
3
13
  v-bind="restAttrs"
4
14
  :to="props.to"
5
15
  :class="buttonClasses"
@@ -11,7 +21,7 @@
11
21
  <script setup lang="ts">
12
22
  import { computed, useAttrs } from 'vue';
13
23
  import { type ClassNameValue } from 'tailwind-merge';
14
- import { getButtonClasses, type ButtonVariant } from './buttonStyles';
24
+ import { getButtonClasses, type ButtonVariant, type ButtonSize } from './buttonStyles';
15
25
  import type { RouteLocationAsRelativeGeneric, RouteLocationAsPathGeneric } from 'vue-router';
16
26
  defineOptions({ inheritAttrs: false });
17
27
 
@@ -27,6 +37,8 @@ const props = withDefaults(defineProps<{
27
37
  disabled?: boolean;
28
38
  active?: boolean;
29
39
  variant?: ButtonVariant;
40
+ size?: ButtonSize;
41
+ iconOnly?: boolean;
30
42
  shadow?: boolean;
31
43
  }>(), {
32
44
  disabled: false,
@@ -38,6 +50,8 @@ const props = withDefaults(defineProps<{
38
50
  const buttonClasses = computed(() => getButtonClasses(
39
51
  {
40
52
  variant: props.variant,
53
+ size: props.size,
54
+ iconOnly: props.iconOnly,
41
55
  shadow: props.shadow,
42
56
  active: props.active,
43
57
  disabled: props.disabled,