@vue-interface/btn-dropdown 4.0.2 → 4.0.4

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/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import BtnDropdown from './src/BtnDropdown.vue';
2
+
3
+ export {
4
+ BtnDropdown
5
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vue-interface/btn-dropdown",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "description": "A Vue button dropdown component.",
5
5
  "type": "module",
6
6
  "main": "./dist/btn-dropdown.umd.js",
@@ -10,6 +10,7 @@
10
10
  ".": {
11
11
  "source": "./index.ts",
12
12
  "types": "./dist/index.d.ts",
13
+ "style": "./index.css",
13
14
  "import": "./dist/btn-dropdown.js",
14
15
  "require": "./dist/btn-dropdown.umd.js"
15
16
  }
@@ -36,7 +37,9 @@
36
37
  "homepage": "https://vue-interface.github.io/packages/btn-dropdown",
37
38
  "readme": "README.md",
38
39
  "files": [
40
+ "src",
39
41
  "dist",
42
+ "index.ts",
40
43
  "index.css",
41
44
  "README.md",
42
45
  "LICENSE"
@@ -45,8 +48,8 @@
45
48
  "@floating-ui/dom": "^1.7.1",
46
49
  "@floating-ui/vue": "^1.1.6",
47
50
  "vue": "^3.3.4",
48
- "@vue-interface/btn": "5.0.2",
49
- "@vue-interface/dropdown-menu": "3.0.2"
51
+ "@vue-interface/btn": "5.0.3",
52
+ "@vue-interface/dropdown-menu": "3.0.4"
50
53
  },
51
54
  "scripts": {
52
55
  "dev": "vite",
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import BtnDropdownSingle from './BtnDropdownSingle.vue';
3
+ import BtnDropdownSplit from './BtnDropdownSplit.vue';
4
+ import { BtnDropdownEvents, BtnDropdownProps } from './useDropdownHandler';
5
+
6
+ const props = withDefaults(defineProps<{
7
+ split?: boolean
8
+ } & BtnDropdownProps>(), {
9
+ split: false,
10
+ caret: true
11
+ });
12
+
13
+ const emit = defineEmits<BtnDropdownEvents>();
14
+ </script>
15
+
16
+ <template>
17
+ <Component
18
+ :is="!split ? BtnDropdownSingle : BtnDropdownSplit"
19
+ v-bind="props"
20
+ @click="(...args) => emit('click', ...args)"
21
+ @click-toggle="(...args) => emit('clickToggle', ...args)"
22
+ @show="(...args) => emit('show', ...args)"
23
+ @hide="(...args) => emit('hide', ...args)">
24
+ <template #button="slot">
25
+ <slot
26
+ name="button"
27
+ v-bind="slot" />
28
+ </template>
29
+ <template #toggle="slot">
30
+ <slot
31
+ name="toggle"
32
+ v-bind="slot" />
33
+ </template>
34
+ <template #split="slot">
35
+ <slot
36
+ name="split"
37
+ v-bind="slot" />
38
+ </template>
39
+ <slot />
40
+ </Component>
41
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { DropdownMenu } from '@vue-interface/dropdown-menu';
3
+ import { BtnDropdownEvents, BtnDropdownProps, useDropdownHandler } from './useDropdownHandler';
4
+
5
+ const props = withDefaults(defineProps<BtnDropdownProps>(), {
6
+ caret: true,
7
+ variant: 'btn-primary'
8
+ });
9
+
10
+ const emit = defineEmits<BtnDropdownEvents>();
11
+
12
+ const {
13
+ target,
14
+ menu,
15
+ buttonClasses,
16
+ classes,
17
+ expanded,
18
+ floatingStyles,
19
+ onBlur,
20
+ onClickToggle,
21
+ onClickItem
22
+ } = useDropdownHandler(props, emit);
23
+ </script>
24
+
25
+ <template>
26
+ <div
27
+ class="btn-group"
28
+ :class="classes">
29
+ <slot
30
+ name="button"
31
+ v-bind="{ target: (el: HTMLElement) => target = el, expanded, onBlur, onClickToggle }">
32
+ <button
33
+ ref="target"
34
+ type="button"
35
+ :class="{...buttonClasses, ['dropdown-toggle']: true}"
36
+ aria-haspopup="true"
37
+ :aria-expanded="expanded"
38
+ @blur="onBlur"
39
+ @click="onClickToggle">
40
+ {{ label }}
41
+ </button>
42
+ </slot>
43
+ <DropdownMenu
44
+ ref="menu"
45
+ :class="{
46
+ 'show': expanded
47
+ }"
48
+ :style="floatingStyles"
49
+ @blur="onBlur"
50
+ @click="onClickItem"
51
+ @mousedown.prevent>
52
+ <slot />
53
+ </DropdownMenu>
54
+ </div>
55
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import { DropdownMenu } from '@vue-interface/dropdown-menu';
3
+ import { BtnDropdownEvents, BtnDropdownProps, useDropdownHandler } from './useDropdownHandler';
4
+
5
+ const props = withDefaults(defineProps<BtnDropdownProps>(), {
6
+ caret: true,
7
+ variant: 'btn-primary'
8
+ });
9
+
10
+ const emit = defineEmits<BtnDropdownEvents>();
11
+
12
+ const {
13
+ target,
14
+ menu,
15
+ buttonClasses,
16
+ classes,
17
+ expanded,
18
+ floatingStyles,
19
+ onBlur,
20
+ onClick,
21
+ onClickToggle,
22
+ onClickItem
23
+ } = useDropdownHandler(props, emit);
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ class="btn-group btn-dropdown-split"
29
+ :class="classes">
30
+ <slot
31
+ v-if="!dropleft"
32
+ name="button"
33
+ v-bind="{ expanded, onBlur, onClickToggle }">
34
+ <button
35
+ type="button"
36
+ :class="buttonClasses"
37
+ aria-haspopup="true"
38
+ :aria-expanded="expanded"
39
+ @blur="onBlur"
40
+ @click="onClick">
41
+ {{ label }}
42
+ </button>
43
+ </slot>
44
+ <div class="btn-group">
45
+ <slot
46
+ name="toggle"
47
+ v-bind="{ target: (el: HTMLElement) => target = el, expanded, onBlur, onClickToggle }">
48
+ <button
49
+ ref="target"
50
+ type="button"
51
+ aria-haspopup="true"
52
+ :aria-expanded="expanded"
53
+ :class="{...buttonClasses, 'dropdown-toggle': true, 'dropdown-toggle-split': split}"
54
+ @blur="onBlur"
55
+ @click="onClickToggle" />
56
+ </slot>
57
+ <DropdownMenu
58
+ ref="menu"
59
+ :class="{
60
+ 'show': expanded
61
+ }"
62
+ :style="floatingStyles"
63
+ @blur="onBlur"
64
+ @click="onClickItem"
65
+ @mousedown.prevent>
66
+ <slot />
67
+ </DropdownMenu>
68
+ </div>
69
+ <slot
70
+ v-if="dropleft"
71
+ name="button"
72
+ v-bind="{ expanded, onBlur, onClickToggle }">
73
+ <button
74
+ type="button"
75
+ :class="buttonClasses"
76
+ aria-haspopup="true"
77
+ :aria-expanded="expanded"
78
+ @blur="onBlur"
79
+ @click="onClick">
80
+ {{ label }}
81
+ </button>
82
+ </slot>
83
+ </div>
84
+ </template>
@@ -0,0 +1,213 @@
1
+ import { flip, offset, Placement, type Alignment, type Middleware, type OffsetOptions, type Side } from '@floating-ui/dom';
2
+ import { useFloating } from '@floating-ui/vue';
3
+ import { DropdownMenu } from '@vue-interface/dropdown-menu';
4
+ import { computed, ref, watchEffect, type EmitFn, type HTMLAttributes } from 'vue';
5
+
6
+ type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>);
7
+
8
+ export type BtnGroupSizes = 'btn-group-xs'
9
+ | 'btn-group-sm'
10
+ | 'btn-group-md'
11
+ | 'btn-group-lg'
12
+ | 'btn-group-xl'
13
+ | 'btn-group-2xl'
14
+ | 'btn-group-3xl'
15
+ | 'btn-group-4xl';
16
+
17
+ export type BtnDropdownProps = {
18
+ align?: Alignment;
19
+ block?: boolean;
20
+ buttonClass?: HTMLAttributes['class'],
21
+ caret?: boolean;
22
+ dropup?: boolean;
23
+ dropright?: boolean;
24
+ dropleft?: boolean;
25
+ label?: string;
26
+ offset?: OffsetOptions,
27
+ middleware?: Middleware[],
28
+ side?: Side;
29
+ size?: LiteralUnion<BtnGroupSizes>;
30
+ split?: boolean;
31
+ variant?: string;
32
+ }
33
+
34
+ export type BtnDropdownEvents = {
35
+ click: [event: MouseEvent];
36
+ clickToggle: [event: MouseEvent];
37
+ show: [],
38
+ hide: []
39
+ }
40
+
41
+ export function useDropdownHandler(props: BtnDropdownProps, emit: EmitFn<BtnDropdownEvents>) {
42
+ const target = ref<HTMLElement>();
43
+ const menu = ref<InstanceType<typeof DropdownMenu>>();
44
+ const expanded = ref(false);
45
+
46
+
47
+ const alignment = computed<Alignment>(() => props.align ?? 'start');
48
+
49
+ const side = computed<Side>(() => {
50
+ if(props.dropup) {
51
+ return 'top';
52
+ }
53
+
54
+ if(props.dropleft) {
55
+ return 'left';
56
+ }
57
+
58
+ if(props.dropright) {
59
+ return 'right';
60
+ }
61
+
62
+ return 'bottom';
63
+ });
64
+
65
+ const placement = computed<Placement>(() => `${side.value}-${alignment.value}`);
66
+
67
+ const classes = computed(() => ({
68
+ 'dropdown': props.dropup && props.dropright && props.dropleft,
69
+ 'dropup': props.dropup,
70
+ 'dropright': props.dropright,
71
+ 'dropleft': props.dropleft,
72
+ 'expanded': expanded.value,
73
+ [props.size ?? '']: !!props.size,
74
+ }));
75
+
76
+ const buttonClasses = computed(() => {
77
+ const classes = {
78
+ btn: true,
79
+ [props.variant ?? '']: !!props.variant,
80
+ 'btn-block': !!props.block,
81
+ 'no-caret': !props.caret
82
+ };
83
+
84
+ if(typeof props.buttonClass === 'string') {
85
+ classes[props.buttonClass] = true;
86
+ }
87
+ else if(Array.isArray(props.buttonClass)) {
88
+ for(const value of props.buttonClass) {
89
+ classes[value] = true;
90
+ }
91
+ }
92
+ else if(props.buttonClass) {
93
+ Object.assign(classes, props.buttonClass);
94
+ }
95
+
96
+ return classes;
97
+ });
98
+
99
+ const { floatingStyles, update } = useFloating(target, menu, {
100
+ placement: placement,
101
+ middleware: props.middleware ?? [
102
+ offset(props.offset ?? 5),
103
+ flip()
104
+ ]
105
+ });
106
+
107
+ function show() {
108
+ expanded.value = true;
109
+
110
+ if(!target.value || !menu.value) {
111
+ return;
112
+ }
113
+
114
+ update();
115
+
116
+ emit('show');
117
+ }
118
+
119
+ function hide() {
120
+ expanded.value = false;
121
+
122
+ target.value?.blur();
123
+
124
+ emit('hide');
125
+ }
126
+
127
+ function toggle() {
128
+ if(!expanded.value) {
129
+ show();
130
+ }
131
+ else {
132
+ hide();
133
+ }
134
+ }
135
+
136
+ function isFocusable(element: HTMLElement) {
137
+ const nodes = Array.from(menu.value?.$el.querySelectorAll('label, input, select, textarea') ?? []);
138
+
139
+ for(const i in nodes) {
140
+ if(element === nodes[i]) {
141
+ return true;
142
+ }
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ function onBlur(e: FocusEvent) {
149
+ if(!(e.relatedTarget instanceof HTMLElement)) {
150
+ hide();
151
+
152
+ return;
153
+ }
154
+
155
+ if(menu.value && !menu.value?.$el.contains(e.relatedTarget) || !target.value?.contains(e.relatedTarget)) {
156
+ hide();
157
+ }
158
+ }
159
+
160
+ function onClick(e: MouseEvent) {
161
+ emit('click', e);
162
+ }
163
+
164
+ function onClickToggle(e: MouseEvent) {
165
+ e.target?.dispatchEvent(new Event('focus', e));
166
+
167
+ emit('clickToggle', e);
168
+
169
+ if(!e.defaultPrevented) {
170
+ toggle();
171
+ }
172
+ }
173
+
174
+ function onClickItem(e: PointerEvent) {
175
+ if(!(e.target instanceof HTMLElement)) {
176
+ hide();
177
+
178
+ return;
179
+ }
180
+
181
+ if(!isFocusable(e.target) && !e.defaultPrevented) {
182
+ hide();
183
+ }
184
+ }
185
+
186
+ watchEffect(() => {
187
+ if(expanded.value) {
188
+ window.addEventListener('resize', update);
189
+ }
190
+ else {
191
+ window.removeEventListener('resize', update);
192
+ }
193
+ });
194
+
195
+ return {
196
+ target,
197
+ menu,
198
+ alignment,
199
+ expanded,
200
+ floatingStyles,
201
+ placement,
202
+ side,
203
+ classes,
204
+ buttonClasses,
205
+ show,
206
+ hide,
207
+ toggle,
208
+ onBlur,
209
+ onClick,
210
+ onClickToggle,
211
+ onClickItem
212
+ };
213
+ }