@veritree/ui 0.8.0 → 0.9.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/index.js +14 -9
- package/nuxt.js +1 -0
- package/package.json +1 -1
- package/src/Listbox/VTListbox.vue +79 -122
- package/src/Listbox/VTListboxContent.vue +151 -0
- package/src/Listbox/VTListboxItem.vue +226 -0
- package/src/Listbox/VTListboxLabel.vue +38 -0
- package/src/Listbox/VTListboxList.vue +63 -0
- package/src/Listbox/VTListboxSearch.vue +138 -0
- package/src/Listbox/VTListboxTrigger.vue +158 -0
- package/src/Listbox/VTListboxButton.vue +0 -81
- package/src/Listbox/VTListboxOption.vue +0 -126
- package/src/Listbox/VTListboxOptions.vue +0 -119
package/index.js
CHANGED
|
@@ -19,6 +19,13 @@ import VTPopoverItem from "./src/Popover/VTPopoverItem.vue";
|
|
|
19
19
|
import VTPopoverTrigger from "./src/Popover/VTPopoverTrigger.vue";
|
|
20
20
|
import VTFormFeedback from "./src/Form/VTFormFeedback.vue";
|
|
21
21
|
import VTFormGroup from "./src/Form/VTFormGroup.vue";
|
|
22
|
+
import VTListbox from "./src/Listbox/VTListbox.vue";
|
|
23
|
+
import VTListboxContent from "./src/Listbox/VTListboxContent.vue";
|
|
24
|
+
import VTListboxItem from "./src/Listbox/VTListboxItem.vue";
|
|
25
|
+
import VTListboxLabel from "./src/Listbox/VTListboxLabel.vue";
|
|
26
|
+
import VTListboxList from "./src/Listbox/VTListboxList.vue";
|
|
27
|
+
import VTListboxSearch from "./src/Listbox/VTListboxSearch.vue";
|
|
28
|
+
import VTListboxTrigger from "./src/Listbox/VTListboxTrigger.vue";
|
|
22
29
|
|
|
23
30
|
import VTAlert from "./src/Alerts/VTAlert.vue";
|
|
24
31
|
|
|
@@ -34,11 +41,6 @@ import VTInputUpload from "./src/Input/VTInputUpload.vue";
|
|
|
34
41
|
|
|
35
42
|
import VTTextarea from "./src/Textarea/VTTextarea.vue";
|
|
36
43
|
|
|
37
|
-
import VTListbox from "./src/Listbox/VTListbox.vue";
|
|
38
|
-
import VTListboxButton from "./src/Listbox/VTListboxButton.vue";
|
|
39
|
-
import VTListboxOption from "./src/Listbox/VTListboxOption.vue";
|
|
40
|
-
import VTListboxOptions from "./src/Listbox/VTListboxOptions.vue";
|
|
41
|
-
|
|
42
44
|
import VTModal from "./src/Modal/VTModal.vue";
|
|
43
45
|
|
|
44
46
|
import VTAccordion from "./src/Accordion/VTAccordion.vue";
|
|
@@ -94,6 +96,13 @@ export {
|
|
|
94
96
|
VTPopoverTrigger,
|
|
95
97
|
VTFormFeedback,
|
|
96
98
|
VTFormGroup,
|
|
99
|
+
VTListbox,
|
|
100
|
+
VTListboxContent,
|
|
101
|
+
VTListboxItem,
|
|
102
|
+
VTListboxLabel,
|
|
103
|
+
VTListboxList,
|
|
104
|
+
VTListboxSearch,
|
|
105
|
+
VTListboxTrigger,
|
|
97
106
|
VTButton,
|
|
98
107
|
// VTButtonSave,
|
|
99
108
|
VTInput,
|
|
@@ -101,10 +110,6 @@ export {
|
|
|
101
110
|
VTInputFile,
|
|
102
111
|
VTInputUpload,
|
|
103
112
|
VTTextarea,
|
|
104
|
-
VTListbox,
|
|
105
|
-
VTListboxButton,
|
|
106
|
-
VTListboxOption,
|
|
107
|
-
VTListboxOptions,
|
|
108
113
|
VTModal,
|
|
109
114
|
VTAccordion,
|
|
110
115
|
VTAccordionButton,
|
package/nuxt.js
CHANGED
package/package.json
CHANGED
|
@@ -1,150 +1,73 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="Listbox"
|
|
2
|
+
<div :class="{ Listbox: headless, relative: !headless, 'z-20': active }">
|
|
3
|
+
<slot></slot>
|
|
4
|
+
</div>
|
|
3
5
|
</template>
|
|
4
6
|
|
|
5
7
|
<script>
|
|
8
|
+
import { genId } from '~/utils/ids';
|
|
9
|
+
|
|
6
10
|
export default {
|
|
7
11
|
name: 'VTListbox',
|
|
8
12
|
|
|
9
13
|
provide() {
|
|
10
14
|
return {
|
|
11
15
|
api: () => {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const getListboxValue = () => this.value;
|
|
15
|
-
const getListboxButton = () => this.listboxButton;
|
|
16
|
-
|
|
17
|
-
// Handle registering and unregistering
|
|
18
|
-
const registerListboxButton = (button) => {
|
|
19
|
-
if (!button) return;
|
|
20
|
-
this.listboxButton = button;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const registerListbox = (listbox) => {
|
|
24
|
-
if (!listbox) return;
|
|
25
|
-
this.listbox = listbox;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const registerOption = (option) => {
|
|
29
|
-
if (!option) return;
|
|
30
|
-
_register(this.listboxOptions, option);
|
|
31
|
-
};
|
|
16
|
+
const { dark: isDark, right: isRight } = this;
|
|
17
|
+
const { id, listbox, trigger, content, search, list, items } = this;
|
|
32
18
|
|
|
33
|
-
const
|
|
34
|
-
|
|
19
|
+
const registerTrigger = (trigger) => {
|
|
20
|
+
if (!trigger) return;
|
|
21
|
+
this.trigger = trigger;
|
|
35
22
|
};
|
|
36
23
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
24
|
+
const registerContent = (content) => {
|
|
25
|
+
if (!content) return;
|
|
26
|
+
this.content = content;
|
|
40
27
|
};
|
|
41
28
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
const registerSearch = (search) => {
|
|
30
|
+
if (!search) return;
|
|
31
|
+
this.search = search;
|
|
45
32
|
};
|
|
46
33
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.listboxOptions.forEach((option, i) => {
|
|
52
|
-
if (option.focused) index = i;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
return index;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const focusOptionByFilter = (filter) => {
|
|
59
|
-
const optionIndex = this.listboxOptions.findIndex((option) => {
|
|
60
|
-
const text = option.$el.innerText;
|
|
61
|
-
return text.toLowerCase().indexOf(filter.toLowerCase()) === 0;
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
if (optionIndex >= 0) _focusOption(optionIndex);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const focusFirstOption = () => {
|
|
68
|
-
_focusOption(0);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const focusLastOption = () => {
|
|
72
|
-
_focusOption(this.listboxOptions.length - 1);
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const focusPreviousOption = () => {
|
|
76
|
-
const focusedIndex = getFocusedIndex();
|
|
77
|
-
const previousIndex = focusedIndex - 1;
|
|
78
|
-
|
|
79
|
-
if (previousIndex >= 0) _focusOption(previousIndex);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const focusNextOption = () => {
|
|
83
|
-
const focusedIndex = getFocusedIndex();
|
|
84
|
-
const nextIndex = focusedIndex + 1;
|
|
85
|
-
|
|
86
|
-
if (nextIndex < this.listboxOptions.length) _focusOption(nextIndex);
|
|
34
|
+
const registerList = (list) => {
|
|
35
|
+
if (!list) return;
|
|
36
|
+
this.list = list;
|
|
87
37
|
};
|
|
88
38
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
39
|
+
const registerItem = (item) => {
|
|
40
|
+
if (!item) return;
|
|
41
|
+
this.items.push(item);
|
|
93
42
|
};
|
|
94
43
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
this.
|
|
44
|
+
const unregisterItem = (id) => {
|
|
45
|
+
const index = this.items.findIndex((item) => item.id === id);
|
|
46
|
+
this.items.splice(index, 1);
|
|
98
47
|
};
|
|
99
48
|
|
|
100
|
-
const
|
|
101
|
-
return this.listboxOptions.filter((option) => option.focused)[0];
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
// handle selecting and unselecting
|
|
105
|
-
const selectOption = () => {
|
|
106
|
-
const focusedOption = _getFocusedOption();
|
|
107
|
-
|
|
108
|
-
// do nothing if option is already selected
|
|
109
|
-
if (focusedOption.selected) return;
|
|
110
|
-
|
|
111
|
-
// unselect all options
|
|
112
|
-
this.listboxOptions.forEach((option) => option.unselect());
|
|
113
|
-
|
|
114
|
-
// select focused option
|
|
115
|
-
if (focusedOption) {
|
|
116
|
-
focusedOption.select();
|
|
117
|
-
_onSelectedOption(focusedOption.value);
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const _onSelectedOption = (value) => {
|
|
122
|
-
this.listbox.hide();
|
|
49
|
+
const emit = (value) => {
|
|
123
50
|
this.$emit('input', value);
|
|
124
|
-
this.$emit('change');
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// Index helper functions
|
|
128
|
-
const _getIndex = (arr, id) => {
|
|
129
|
-
return arr.findIndex((item) => item.id === id);
|
|
51
|
+
this.$emit('change', value);
|
|
130
52
|
};
|
|
131
53
|
|
|
132
54
|
return {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
55
|
+
id,
|
|
56
|
+
isDark,
|
|
57
|
+
isRight,
|
|
58
|
+
listbox,
|
|
59
|
+
trigger,
|
|
60
|
+
search,
|
|
61
|
+
content,
|
|
62
|
+
list,
|
|
63
|
+
items,
|
|
64
|
+
registerTrigger,
|
|
65
|
+
registerContent,
|
|
66
|
+
registerSearch,
|
|
67
|
+
registerList,
|
|
68
|
+
registerItem,
|
|
69
|
+
unregisterItem,
|
|
70
|
+
emit,
|
|
148
71
|
};
|
|
149
72
|
},
|
|
150
73
|
};
|
|
@@ -155,14 +78,48 @@ export default {
|
|
|
155
78
|
type: [String, Number, Object],
|
|
156
79
|
default: null,
|
|
157
80
|
},
|
|
81
|
+
headless: {
|
|
82
|
+
type: Boolean,
|
|
83
|
+
default: false,
|
|
84
|
+
},
|
|
85
|
+
dark: {
|
|
86
|
+
type: Boolean,
|
|
87
|
+
default: false,
|
|
88
|
+
},
|
|
89
|
+
right: {
|
|
90
|
+
type: Boolean,
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
158
93
|
},
|
|
159
94
|
|
|
160
95
|
data() {
|
|
161
96
|
return {
|
|
97
|
+
id: `listbox-${genId()}`,
|
|
162
98
|
listbox: null,
|
|
163
|
-
|
|
164
|
-
|
|
99
|
+
trigger: null,
|
|
100
|
+
content: null,
|
|
101
|
+
search: null,
|
|
102
|
+
list: null,
|
|
103
|
+
items: [],
|
|
104
|
+
active: false,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
mounted() {
|
|
109
|
+
this.listbox = {
|
|
110
|
+
setActive: this.setActive,
|
|
111
|
+
clearActive: this.clearActive,
|
|
165
112
|
};
|
|
166
113
|
},
|
|
114
|
+
|
|
115
|
+
methods: {
|
|
116
|
+
setActive() {
|
|
117
|
+
this.active = true;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
clearActive() {
|
|
121
|
+
this.active = null;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
167
124
|
};
|
|
168
125
|
</script>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<transition
|
|
3
|
+
enter-active-class="duration-200 ease-out"
|
|
4
|
+
enter-class="translate-y-[15px] opacity-0"
|
|
5
|
+
enter-to-class="translate-y-0 opacity-100"
|
|
6
|
+
leave-active-class="duration-200 ease-in"
|
|
7
|
+
leave-class="translate-y-0 opacity-100"
|
|
8
|
+
leave-to-class="translate-y-[15px] opacity-0"
|
|
9
|
+
@after-leave="hide"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
v-if="visible"
|
|
13
|
+
:id="id"
|
|
14
|
+
:aria-activedescendant="activeDescedant"
|
|
15
|
+
:class="{
|
|
16
|
+
MenuList: headless,
|
|
17
|
+
'absolute z-10 grid w-full min-w-[222px] overflow-hidden rounded-md py-2 px-3':
|
|
18
|
+
!headless,
|
|
19
|
+
'border-gray-100 bg-white shadow-300': !dark && !headless,
|
|
20
|
+
'bg-forest-default border border-solid border-gray-700 shadow-gray-700':
|
|
21
|
+
dark && !headless,
|
|
22
|
+
'left-0': !right && !headless,
|
|
23
|
+
'right-0': right && !headless,
|
|
24
|
+
'top-full mt-3': isTop && !headless,
|
|
25
|
+
'bottom-full mb-3': isBottom && !headless,
|
|
26
|
+
}"
|
|
27
|
+
role="listbox"
|
|
28
|
+
>
|
|
29
|
+
<slot></slot>
|
|
30
|
+
</div>
|
|
31
|
+
</transition>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script>
|
|
35
|
+
import { genId } from '~/utils/ids';
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
name: 'VTListboxContent',
|
|
39
|
+
|
|
40
|
+
inject: ['api'],
|
|
41
|
+
|
|
42
|
+
props: {
|
|
43
|
+
headless: {
|
|
44
|
+
type: Boolean,
|
|
45
|
+
default: false,
|
|
46
|
+
},
|
|
47
|
+
bottom: {
|
|
48
|
+
type: Boolean,
|
|
49
|
+
default: false,
|
|
50
|
+
},
|
|
51
|
+
top: {
|
|
52
|
+
type: Boolean,
|
|
53
|
+
default: true,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
data() {
|
|
58
|
+
return {
|
|
59
|
+
id: `listboxcontent-${genId()}`,
|
|
60
|
+
activeDescedant: null,
|
|
61
|
+
visible: false,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
computed: {
|
|
66
|
+
dark() {
|
|
67
|
+
return this.api().isDark;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
right() {
|
|
71
|
+
return this.api().isRight;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
listbox() {
|
|
75
|
+
return this.api().listbox;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
trigger() {
|
|
79
|
+
return this.api().trigger;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
search() {
|
|
83
|
+
return this.api().search;
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// directions
|
|
87
|
+
isTop() {
|
|
88
|
+
return this.top && !this.bottom;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
isBottom() {
|
|
92
|
+
return this.bottom;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
mounted() {
|
|
97
|
+
const content = {
|
|
98
|
+
id: this.id,
|
|
99
|
+
show: this.show,
|
|
100
|
+
hide: this.hide,
|
|
101
|
+
setActiveDescedant: this.setActiveDescedant,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.api().registerContent(content);
|
|
105
|
+
|
|
106
|
+
// TODO: Create a directive or mixin for this
|
|
107
|
+
document.addEventListener('click', (e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
if (this.visible && !this.$el.contains(e.target)) this.trigger.onClick();
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
destroyed() {
|
|
114
|
+
// TODO: Create a directive or mixin for this
|
|
115
|
+
document.removeEventListener('click', this.trigger.onClick());
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
methods: {
|
|
119
|
+
show() {
|
|
120
|
+
if (this.visible) return;
|
|
121
|
+
|
|
122
|
+
this.visible = true;
|
|
123
|
+
|
|
124
|
+
this.$nextTick(() => {
|
|
125
|
+
this.listbox.setActive();
|
|
126
|
+
|
|
127
|
+
if (this.search) this.search.el.focus();
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
hide() {
|
|
132
|
+
if (!this.visible) return;
|
|
133
|
+
|
|
134
|
+
this.visible = false;
|
|
135
|
+
|
|
136
|
+
this.$nextTick(() => {
|
|
137
|
+
this.trigger.focus();
|
|
138
|
+
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
this.listbox.clearActive();
|
|
141
|
+
this.trigger.contract();
|
|
142
|
+
}, 100);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
setActiveDescedant(id) {
|
|
147
|
+
this.activeDescedant = id;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
</script>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<li
|
|
3
|
+
:id="id"
|
|
4
|
+
:class="{
|
|
5
|
+
ListboxItem: headless,
|
|
6
|
+
'relative z-10 flex items-center gap-2 px-3 py-2 no-underline': !headless,
|
|
7
|
+
'hover:bg-secondary-200/10': !dark && !headless,
|
|
8
|
+
'text-white': dark && !headless,
|
|
9
|
+
'pointer-events-none opacity-75': disabled && !headless,
|
|
10
|
+
'bg-secondary-200/10': selected && !headless,
|
|
11
|
+
}"
|
|
12
|
+
:aria-disabled="disabled"
|
|
13
|
+
:aria-selected="String(selected)"
|
|
14
|
+
:tabindex="tabIndex"
|
|
15
|
+
role="option"
|
|
16
|
+
@click.stop="onClick"
|
|
17
|
+
@keydown.down.prevent="focusPreviousItem"
|
|
18
|
+
@keydown.up.prevent="focusNextItem"
|
|
19
|
+
@keydown.home.prevent="focusFirstItem"
|
|
20
|
+
@keydown.end.prevent="focusLastItem"
|
|
21
|
+
@keydown.esc.stop="onKeyEsc"
|
|
22
|
+
@keydown.enter.prevent="onClick"
|
|
23
|
+
@mousemove="onMousemove"
|
|
24
|
+
@mouseout="onMouseleave"
|
|
25
|
+
@keydown.tab.prevent
|
|
26
|
+
>
|
|
27
|
+
<span class="truncate"><slot></slot></span>
|
|
28
|
+
</li>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script>
|
|
32
|
+
import { scrollElementIntoView } from '~/utils/components';
|
|
33
|
+
import { genId } from '~/utils/ids';
|
|
34
|
+
|
|
35
|
+
export default {
|
|
36
|
+
name: 'VTListboxItem',
|
|
37
|
+
|
|
38
|
+
inject: ['api'],
|
|
39
|
+
|
|
40
|
+
props: {
|
|
41
|
+
headless: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: false,
|
|
44
|
+
},
|
|
45
|
+
disabled: {
|
|
46
|
+
type: Boolean,
|
|
47
|
+
default: false,
|
|
48
|
+
},
|
|
49
|
+
value: {
|
|
50
|
+
type: [String, Number, Object, Array],
|
|
51
|
+
required: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
data() {
|
|
56
|
+
return {
|
|
57
|
+
id: `listboxitem-${genId()}`,
|
|
58
|
+
index: null,
|
|
59
|
+
selected: false,
|
|
60
|
+
tabIndex: 0,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
computed: {
|
|
65
|
+
dark() {
|
|
66
|
+
return this.api().isDark;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
items() {
|
|
70
|
+
return this.api().items;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
el() {
|
|
74
|
+
return this.$el;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
trigger() {
|
|
78
|
+
return this.api().trigger;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
content() {
|
|
82
|
+
return this.api().content;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
list() {
|
|
86
|
+
return this.api().list;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
search() {
|
|
90
|
+
return this.api().search;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
watch: {
|
|
95
|
+
selected(newValue) {
|
|
96
|
+
if (!newValue || !this.list) return;
|
|
97
|
+
|
|
98
|
+
if (this.content) {
|
|
99
|
+
this.content.setActiveDescedant(this.id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isMousemove = this.list.getMousemove();
|
|
103
|
+
|
|
104
|
+
if (!isMousemove) {
|
|
105
|
+
scrollElementIntoView(this.el, this.list.el);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
mounted() {
|
|
111
|
+
const item = {
|
|
112
|
+
id: this.id,
|
|
113
|
+
focus: this.focus,
|
|
114
|
+
select: this.select,
|
|
115
|
+
unselect: this.unselect,
|
|
116
|
+
onClick: this.onClick,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.api().registerItem(item);
|
|
120
|
+
|
|
121
|
+
this.index = this.items.length - 1;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
beforeDestroy() {
|
|
125
|
+
this.api().unregisterItem(this.id);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
methods: {
|
|
129
|
+
select() {
|
|
130
|
+
this.selected = true;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
unselect() {
|
|
134
|
+
this.selected = false;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
focus() {
|
|
138
|
+
if (!this.el) return;
|
|
139
|
+
|
|
140
|
+
this.tabIndex = -1;
|
|
141
|
+
this.selected = true;
|
|
142
|
+
this.el.focus();
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
focusFirstItem() {
|
|
146
|
+
this.setFocusToItem(0);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
focusLastItem() {
|
|
150
|
+
this.setFocusToItem(this.items.length - 1);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Focus the previous item in the menu.
|
|
155
|
+
* If is the first item, jump to the last item.
|
|
156
|
+
*/
|
|
157
|
+
focusPreviousItem() {
|
|
158
|
+
const isLast = this.index === this.items.length - 1;
|
|
159
|
+
const goToIndex = isLast ? 0 : this.index + 1;
|
|
160
|
+
|
|
161
|
+
this.setFocusToItem(goToIndex);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Focus the next item in the menu.
|
|
166
|
+
* If is the last item, jump to the first item.
|
|
167
|
+
*/
|
|
168
|
+
focusNextItem() {
|
|
169
|
+
const isFirst = this.index === 0;
|
|
170
|
+
const goToIndex = isFirst ? this.items.length - 1 : this.index - 1;
|
|
171
|
+
|
|
172
|
+
this.setFocusToItem(goToIndex);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Focus item by remove its tabindex and calling
|
|
177
|
+
* focus to the element.
|
|
178
|
+
*
|
|
179
|
+
* @param {Number, String} goToIndex
|
|
180
|
+
*/
|
|
181
|
+
setFocusToItem(goToIndex) {
|
|
182
|
+
this.tabIndex = 0;
|
|
183
|
+
this.selected = false;
|
|
184
|
+
|
|
185
|
+
const isMousemove = this.list.getMousemove();
|
|
186
|
+
|
|
187
|
+
if (isMousemove) {
|
|
188
|
+
this.list.unsetMousemove();
|
|
189
|
+
this.items.forEach((item) => item.unselect());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.items[goToIndex].focus();
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hides content/menu and focus on trigger
|
|
197
|
+
*/
|
|
198
|
+
leaveMenu() {
|
|
199
|
+
if (this.content) this.content.hide();
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
onKeyEsc() {
|
|
203
|
+
this.leaveMenu();
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
onClick() {
|
|
207
|
+
if (this.disabled) return;
|
|
208
|
+
|
|
209
|
+
this.api().emit(this.value);
|
|
210
|
+
this.$nextTick(() => this.leaveMenu());
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
onMousemove() {
|
|
214
|
+
this.items.forEach((item) => item.unselect());
|
|
215
|
+
|
|
216
|
+
this.select();
|
|
217
|
+
this.list.setMousemove();
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
onMouseleave() {
|
|
221
|
+
this.unselect();
|
|
222
|
+
this.list.unsetMousemove();
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="as"
|
|
4
|
+
:class="{
|
|
5
|
+
ListboxLabel: headless,
|
|
6
|
+
'mb-2 block text-xs font-normal uppercase': !headless,
|
|
7
|
+
'text-inherit': !dark && !headless,
|
|
8
|
+
'text-white': dark && !headless,
|
|
9
|
+
}"
|
|
10
|
+
>
|
|
11
|
+
<slot></slot>
|
|
12
|
+
</component>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
export default {
|
|
17
|
+
name: 'VTListboxLabel',
|
|
18
|
+
|
|
19
|
+
inject: ['api'],
|
|
20
|
+
|
|
21
|
+
props: {
|
|
22
|
+
as: {
|
|
23
|
+
type: String,
|
|
24
|
+
default: 'span',
|
|
25
|
+
},
|
|
26
|
+
headless: {
|
|
27
|
+
type: Boolean,
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
computed: {
|
|
33
|
+
dark() {
|
|
34
|
+
return this.api().isDark;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
</script>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ul
|
|
3
|
+
:id="id"
|
|
4
|
+
:class="{
|
|
5
|
+
ListboxList: headless,
|
|
6
|
+
'-mx-3 max-h-[160px] w-auto overflow-y-auto': !headless,
|
|
7
|
+
}"
|
|
8
|
+
>
|
|
9
|
+
<slot></slot>
|
|
10
|
+
</ul>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { genId } from '~/utils/ids';
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
name: 'VTListboxList',
|
|
18
|
+
|
|
19
|
+
inject: ['api'],
|
|
20
|
+
|
|
21
|
+
props: {
|
|
22
|
+
headless: {
|
|
23
|
+
type: Boolean,
|
|
24
|
+
default: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
data() {
|
|
29
|
+
return {
|
|
30
|
+
id: `listboxlist-${genId()}`,
|
|
31
|
+
isMousemove: false,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
mounted() {
|
|
36
|
+
const list = {
|
|
37
|
+
el: this.$el,
|
|
38
|
+
getMousemove: this.getMousemove,
|
|
39
|
+
setMousemove: this.setMousemove,
|
|
40
|
+
unsetMousemove: this.unsetMousemove,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this.api().registerList(list);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
methods: {
|
|
47
|
+
// Mousemove instead of mouseover to support keyboard navigation.
|
|
48
|
+
// The problem with mouseover is that when scrolling (scrollIntoView),
|
|
49
|
+
// mouseover event gets triggered.
|
|
50
|
+
setMousemove() {
|
|
51
|
+
this.isMousemove = true;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
unsetMousemove() {
|
|
55
|
+
this.isMousemove = false;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
getMousemove() {
|
|
59
|
+
return this.isMousemove;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
</script>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<input
|
|
3
|
+
v-model="search"
|
|
4
|
+
:class="{ ListboxList: headless, 'form-control mb-1': !headless }"
|
|
5
|
+
type="text"
|
|
6
|
+
@input="onChange"
|
|
7
|
+
@keydown.down.prevent="focusNextItem"
|
|
8
|
+
@keydown.up.prevent="focusPreviousItem"
|
|
9
|
+
@keydown.home.prevent="focusFirstItem"
|
|
10
|
+
@keydown.end.prevent="focusLastItem"
|
|
11
|
+
@keydown.enter.prevent="onKeyEnter"
|
|
12
|
+
@keydown.esc.stop="hide"
|
|
13
|
+
@keydown.tab.prevent="hide"
|
|
14
|
+
/>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
export default {
|
|
19
|
+
name: 'VTListboxSearch',
|
|
20
|
+
|
|
21
|
+
inject: ['api'],
|
|
22
|
+
|
|
23
|
+
props: {
|
|
24
|
+
headless: {
|
|
25
|
+
type: Boolean,
|
|
26
|
+
default: false,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
data() {
|
|
31
|
+
return {
|
|
32
|
+
search: '',
|
|
33
|
+
index: -1,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
computed: {
|
|
38
|
+
content() {
|
|
39
|
+
return this.api().content;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
trigger() {
|
|
43
|
+
return this.api().trigger;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
list() {
|
|
47
|
+
return this.api().list;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
items() {
|
|
51
|
+
return this.api().items;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
item() {
|
|
55
|
+
return this.items[this.index];
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
mounted() {
|
|
60
|
+
const search = {
|
|
61
|
+
el: this.$el,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this.api().registerSearch(search);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
beforeDestroy() {
|
|
68
|
+
this.search = '';
|
|
69
|
+
this.$emit('change', this.search.trim());
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
methods: {
|
|
73
|
+
focusNextItem() {
|
|
74
|
+
if (this.index !== -1) {
|
|
75
|
+
this.unselectItem();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.index++;
|
|
79
|
+
|
|
80
|
+
if (this.index > this.items.length - 1) {
|
|
81
|
+
this.index = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.item) this.item.select();
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
focusPreviousItem() {
|
|
88
|
+
this.unselectItem();
|
|
89
|
+
|
|
90
|
+
this.index--;
|
|
91
|
+
|
|
92
|
+
if (this.index < 0) {
|
|
93
|
+
this.index = this.items.length - 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.item.select();
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
focusFirstItem() {
|
|
100
|
+
this.unselectItem();
|
|
101
|
+
this.index = 0;
|
|
102
|
+
this.item.select();
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
focusLastItem() {
|
|
106
|
+
this.unselectItem();
|
|
107
|
+
this.index = this.items.length - 1;
|
|
108
|
+
this.item.select();
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
unselectItem() {
|
|
112
|
+
const isMousemove = this.list.getMousemove();
|
|
113
|
+
|
|
114
|
+
if (isMousemove) {
|
|
115
|
+
this.list.unsetMousemove();
|
|
116
|
+
this.items.forEach((item) => item.unselect());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.item) this.item.unselect();
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
onChange() {
|
|
123
|
+
this.index = 0;
|
|
124
|
+
if (this.item) this.item.select();
|
|
125
|
+
|
|
126
|
+
this.$emit('change', this.search.trim());
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
onKeyEnter() {
|
|
130
|
+
if (this.item) this.item.onClick();
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
hide() {
|
|
134
|
+
if (this.content) this.content.hide();
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
</script>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<button
|
|
3
|
+
:aria-expanded="expanded"
|
|
4
|
+
:aria-haspopup="hasPopup"
|
|
5
|
+
:class="{
|
|
6
|
+
'Listbox-button': headless,
|
|
7
|
+
'flex w-full justify-between rounded-md border border-solid py-2 px-3':
|
|
8
|
+
!headless,
|
|
9
|
+
'border-gray-300 text-gray-500': !dark && !headless,
|
|
10
|
+
'border-white/70 text-white focus-visible:ring-2 focus-visible:ring-white':
|
|
11
|
+
dark && !headless,
|
|
12
|
+
}"
|
|
13
|
+
type="button"
|
|
14
|
+
@click.stop.prevent="onClick"
|
|
15
|
+
@keydown.down.prevent="onKeyArrowDown"
|
|
16
|
+
@keydown.up.prevent="onKeyArrowUp"
|
|
17
|
+
@keydown.esc.stop="onKeyEsc"
|
|
18
|
+
>
|
|
19
|
+
<span class="Listbox-button__text"><slot></slot></span>
|
|
20
|
+
<span class="Listbox-button__icon">
|
|
21
|
+
<IconChevronDown
|
|
22
|
+
class="transition-transform"
|
|
23
|
+
:class="{ 'rotate-180': expanded }"
|
|
24
|
+
/>
|
|
25
|
+
</span>
|
|
26
|
+
</button>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script>
|
|
30
|
+
import { IconChevronDown } from '@veritree/icons';
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
name: 'VTListboxTrigger',
|
|
34
|
+
|
|
35
|
+
components: { IconChevronDown },
|
|
36
|
+
|
|
37
|
+
inject: ['api'],
|
|
38
|
+
|
|
39
|
+
props: {
|
|
40
|
+
headless: {
|
|
41
|
+
type: Boolean,
|
|
42
|
+
default: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
data() {
|
|
47
|
+
return {
|
|
48
|
+
expanded: false,
|
|
49
|
+
hasPopup: false,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
computed: {
|
|
54
|
+
dark() {
|
|
55
|
+
return this.api().isDark;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
content() {
|
|
59
|
+
return this.api().content;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
items() {
|
|
63
|
+
return this.api().items;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
firstMenuItem() {
|
|
67
|
+
return this.items[0];
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
lastMenuItem() {
|
|
71
|
+
return this.items[this.items.length - 1];
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
mounted() {
|
|
76
|
+
const trigger = {
|
|
77
|
+
el: this.$el,
|
|
78
|
+
focus: this.focus,
|
|
79
|
+
onClick: this.onClick,
|
|
80
|
+
contract: this.contract,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
this.api().registerTrigger(trigger);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
methods: {
|
|
87
|
+
/**
|
|
88
|
+
* Shows content/menu if not already visible
|
|
89
|
+
*/
|
|
90
|
+
showContent() {
|
|
91
|
+
this.expanded = true;
|
|
92
|
+
this.content.show();
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
focus() {
|
|
96
|
+
this.$el.focus();
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
contract() {
|
|
100
|
+
if (!this.expanded) return;
|
|
101
|
+
this.expanded = false;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* On click, do the following:
|
|
106
|
+
*
|
|
107
|
+
* 1. Toggle aria expanded attribute/state
|
|
108
|
+
* 2. Open the menu if it's closed
|
|
109
|
+
* 3. Close the menu if it's open
|
|
110
|
+
*/
|
|
111
|
+
onClick() {
|
|
112
|
+
if (!this.content) return;
|
|
113
|
+
this.expanded ? this.content.hide() : this.showContent();
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* On key arrow down, do the following:
|
|
118
|
+
*
|
|
119
|
+
* 1. if the menu is not expanded, expand it and focus the first menu item
|
|
120
|
+
* 2. if the menu is expanded, focus the first menu item
|
|
121
|
+
*/
|
|
122
|
+
onKeyArrowDown() {
|
|
123
|
+
if (!this.content) return;
|
|
124
|
+
|
|
125
|
+
this.showContent();
|
|
126
|
+
|
|
127
|
+
this.$nextTick(() => {
|
|
128
|
+
this.firstMenuItem.focus();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* On key arrow up, do the following:
|
|
134
|
+
*
|
|
135
|
+
* 1. if the menu is not expanded, expand it and focus the last menu item
|
|
136
|
+
* 2. if the menu is expanded, focus the last menu item
|
|
137
|
+
*/
|
|
138
|
+
onKeyArrowUp() {
|
|
139
|
+
if (!this.content) return;
|
|
140
|
+
|
|
141
|
+
this.showContent();
|
|
142
|
+
|
|
143
|
+
this.$nextTick(() => {
|
|
144
|
+
this.lastMenuItem.focus();
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
onKeyEsc() {
|
|
149
|
+
if (!this.content) return;
|
|
150
|
+
|
|
151
|
+
if (this.expanded) {
|
|
152
|
+
this.toggleExpanded();
|
|
153
|
+
this.content.hide();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
</script>
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<button
|
|
3
|
-
:aria-expanded="expanded"
|
|
4
|
-
aria-haspopup="listbox"
|
|
5
|
-
class="Listbox-button"
|
|
6
|
-
:data-theme="theme"
|
|
7
|
-
type="button"
|
|
8
|
-
@click.prevent="onClick"
|
|
9
|
-
@keydown="onKeyDown"
|
|
10
|
-
>
|
|
11
|
-
<span class="Listbox-button__text"><slot></slot></span>
|
|
12
|
-
<span class="Listbox-button__icon">
|
|
13
|
-
<IconChevronUp v-if="expanded" />
|
|
14
|
-
<IconChevronDown v-else />
|
|
15
|
-
</span>
|
|
16
|
-
</button>
|
|
17
|
-
</template>
|
|
18
|
-
|
|
19
|
-
<script>
|
|
20
|
-
import { IconChevronDown, IconChevronUp } from '@veritree/icons';
|
|
21
|
-
import { keys } from '../utils/keyboard';
|
|
22
|
-
|
|
23
|
-
export default {
|
|
24
|
-
name: 'VTListboxButton',
|
|
25
|
-
|
|
26
|
-
components: { IconChevronDown, IconChevronUp },
|
|
27
|
-
|
|
28
|
-
inject: ['api'],
|
|
29
|
-
|
|
30
|
-
props: {
|
|
31
|
-
theme: {
|
|
32
|
-
type: String,
|
|
33
|
-
default: null,
|
|
34
|
-
validator(value) {
|
|
35
|
-
return ['dark'].includes(value);
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
data() {
|
|
41
|
-
return {
|
|
42
|
-
expanded: false,
|
|
43
|
-
};
|
|
44
|
-
},
|
|
45
|
-
|
|
46
|
-
mounted() {
|
|
47
|
-
this.api().registerListboxButton(this);
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
methods: {
|
|
51
|
-
focus() {
|
|
52
|
-
this.$el.focus();
|
|
53
|
-
this.toggleExpanded();
|
|
54
|
-
},
|
|
55
|
-
|
|
56
|
-
toggleExpanded() {
|
|
57
|
-
this.expanded = !this.expanded;
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
onClick() {
|
|
61
|
-
const listbox = this.api().getListbox();
|
|
62
|
-
this.expanded ? listbox.hide() : listbox.show();
|
|
63
|
-
|
|
64
|
-
this.toggleExpanded();
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
onKeyDown(event) {
|
|
68
|
-
const key = event.key;
|
|
69
|
-
const listbox = this.api().getListbox();
|
|
70
|
-
|
|
71
|
-
switch (key) {
|
|
72
|
-
case keys.down:
|
|
73
|
-
event.preventDefault();
|
|
74
|
-
listbox.show();
|
|
75
|
-
this.toggleExpanded();
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
</script>
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<li
|
|
3
|
-
:id="id"
|
|
4
|
-
class="Listbox-option"
|
|
5
|
-
role="option"
|
|
6
|
-
@mousedown.prevent="onMouseDown"
|
|
7
|
-
@mousemove="onMousemove"
|
|
8
|
-
@mouseout="onMouseleave"
|
|
9
|
-
>
|
|
10
|
-
<slot></slot>
|
|
11
|
-
</li>
|
|
12
|
-
</template>
|
|
13
|
-
|
|
14
|
-
<script>
|
|
15
|
-
import { scrollElementIntoView } from '../utils/components';
|
|
16
|
-
import { genId } from '../utils/ids';
|
|
17
|
-
import { areObjsEqual, isObj } from '../utils/objects';
|
|
18
|
-
|
|
19
|
-
export default {
|
|
20
|
-
name: 'VTListboxOption',
|
|
21
|
-
|
|
22
|
-
inject: ['api'],
|
|
23
|
-
|
|
24
|
-
props: {
|
|
25
|
-
value: {
|
|
26
|
-
type: [String, Number, Object],
|
|
27
|
-
required: true,
|
|
28
|
-
},
|
|
29
|
-
selected: {
|
|
30
|
-
type: Boolean,
|
|
31
|
-
default: false,
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
data() {
|
|
36
|
-
return {
|
|
37
|
-
id: `listbox-option-${genId()}`,
|
|
38
|
-
focused: false,
|
|
39
|
-
isMousemove: false,
|
|
40
|
-
parent: null,
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
watch: {
|
|
45
|
-
focused(newValue) {
|
|
46
|
-
if (!newValue) return;
|
|
47
|
-
|
|
48
|
-
if (!this.parent) this.parent = this.api().getListbox();
|
|
49
|
-
if (!this.isMousemove) scrollElementIntoView(this.$el, this.parent.$el);
|
|
50
|
-
this.parent.updateActiveDescendant(this.id);
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
mounted() {
|
|
55
|
-
this.api().registerOption(this);
|
|
56
|
-
|
|
57
|
-
if (this.selected) {
|
|
58
|
-
this.select();
|
|
59
|
-
this.focus();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// todo: make sure it works with other values than objects
|
|
63
|
-
const listboxOptionSelected = this.api().getListboxValue();
|
|
64
|
-
|
|
65
|
-
if (!listboxOptionSelected) {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (isObj(this.value)) {
|
|
70
|
-
if (areObjsEqual(this.value, listboxOptionSelected)) {
|
|
71
|
-
this.select();
|
|
72
|
-
this.focus();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (this.value === listboxOptionSelected) {
|
|
79
|
-
this.select();
|
|
80
|
-
this.focus();
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
beforeDestroy() {
|
|
85
|
-
this.api().unregisterOption(this.id);
|
|
86
|
-
},
|
|
87
|
-
|
|
88
|
-
methods: {
|
|
89
|
-
focus() {
|
|
90
|
-
this.focused = true;
|
|
91
|
-
this.$el.classList.add('is-focused');
|
|
92
|
-
},
|
|
93
|
-
|
|
94
|
-
unfocus() {
|
|
95
|
-
this.focused = false;
|
|
96
|
-
this.$el.classList.remove('is-focused');
|
|
97
|
-
},
|
|
98
|
-
|
|
99
|
-
select() {
|
|
100
|
-
this.$el.setAttribute('aria-selected', true);
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
unselect() {
|
|
104
|
-
this.$el.setAttribute('aria-selected', false);
|
|
105
|
-
},
|
|
106
|
-
|
|
107
|
-
onMouseDown(event) {
|
|
108
|
-
if (event.buttons === 1) this.api().selectOption(this);
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
// Mousemove instead of mouseover to support keyboard navigation.
|
|
112
|
-
// The problem with mouseover is that when scrolling (scrollIntoView),
|
|
113
|
-
// mouseover event gets triggered.
|
|
114
|
-
onMousemove() {
|
|
115
|
-
this.isMousemove = true;
|
|
116
|
-
this.api().unfocusOptions();
|
|
117
|
-
this.focus();
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
onMouseleave() {
|
|
121
|
-
this.isMousemove = false;
|
|
122
|
-
this.unfocus();
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
</script>
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<ul
|
|
3
|
-
v-if="visible"
|
|
4
|
-
aria-orientation="vertical"
|
|
5
|
-
role="listbox"
|
|
6
|
-
class="Listbox-options"
|
|
7
|
-
tabindex="-1"
|
|
8
|
-
@keydown="onKeyDown"
|
|
9
|
-
@blur="onBlur"
|
|
10
|
-
>
|
|
11
|
-
<slot></slot>
|
|
12
|
-
</ul>
|
|
13
|
-
</template>
|
|
14
|
-
|
|
15
|
-
<script>
|
|
16
|
-
import { keys } from '../utils/keyboard';
|
|
17
|
-
let timer = null;
|
|
18
|
-
|
|
19
|
-
export default {
|
|
20
|
-
name: 'VTListboxOptions',
|
|
21
|
-
|
|
22
|
-
inject: ['api'],
|
|
23
|
-
|
|
24
|
-
data() {
|
|
25
|
-
return {
|
|
26
|
-
visible: false,
|
|
27
|
-
filter: '',
|
|
28
|
-
};
|
|
29
|
-
},
|
|
30
|
-
|
|
31
|
-
watch: {
|
|
32
|
-
visible(newValue) {
|
|
33
|
-
if (newValue) {
|
|
34
|
-
this.$nextTick(() => {
|
|
35
|
-
this.$el.focus();
|
|
36
|
-
this.handleOptionFocus();
|
|
37
|
-
});
|
|
38
|
-
} else {
|
|
39
|
-
const listboxButton = this.api().getListboxButton();
|
|
40
|
-
|
|
41
|
-
this.$nextTick(() => {
|
|
42
|
-
listboxButton.focus();
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
|
|
48
|
-
mounted() {
|
|
49
|
-
this.api().registerListbox(this);
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
methods: {
|
|
53
|
-
show() {
|
|
54
|
-
this.visible = true;
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
hide() {
|
|
58
|
-
this.visible = false;
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
handleOptionFocus() {
|
|
62
|
-
const selectedIndex = this.api().getFocusedIndex();
|
|
63
|
-
if (!selectedIndex) this.api().focusFirstOption();
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
updateActiveDescendant(id) {
|
|
67
|
-
this.$el.setAttribute('aria-activedescendant', id);
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
onKeyDown(event) {
|
|
71
|
-
const key = event.key;
|
|
72
|
-
const code = event.code;
|
|
73
|
-
|
|
74
|
-
if (code.includes('Key')) {
|
|
75
|
-
this.filter += code.replace('Key', '');
|
|
76
|
-
|
|
77
|
-
clearTimeout(timer);
|
|
78
|
-
|
|
79
|
-
timer = setTimeout(() => {
|
|
80
|
-
this.api().focusOptionByFilter(this.filter);
|
|
81
|
-
this.filter = '';
|
|
82
|
-
}, 100);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
switch (key) {
|
|
86
|
-
case keys.up:
|
|
87
|
-
event.preventDefault();
|
|
88
|
-
this.api().focusPreviousOption();
|
|
89
|
-
break;
|
|
90
|
-
case keys.down:
|
|
91
|
-
event.preventDefault();
|
|
92
|
-
this.api().focusNextOption();
|
|
93
|
-
break;
|
|
94
|
-
case keys.home:
|
|
95
|
-
event.preventDefault();
|
|
96
|
-
this.api().focusFirstOption();
|
|
97
|
-
break;
|
|
98
|
-
case keys.end:
|
|
99
|
-
event.preventDefault();
|
|
100
|
-
this.api().focusLastOption();
|
|
101
|
-
break;
|
|
102
|
-
case keys.enter:
|
|
103
|
-
event.preventDefault();
|
|
104
|
-
this.api().selectOption();
|
|
105
|
-
break;
|
|
106
|
-
case keys.escape:
|
|
107
|
-
this.hide();
|
|
108
|
-
break;
|
|
109
|
-
default:
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
|
|
114
|
-
onBlur() {
|
|
115
|
-
this.hide();
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
</script>
|