@veritree/ui 0.8.0 → 0.11.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 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
@@ -6,6 +6,7 @@ const components = [
6
6
  'src/Dialog',
7
7
  'src/DropdownMenu',
8
8
  'src/Form',
9
+ 'src/Listbox',
9
10
  'src/Image',
10
11
  'src/Tabs',
11
12
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veritree/ui",
3
- "version": "0.8.0",
3
+ "version": "0.11.0",
4
4
  "description": "veritree ui library",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -85,6 +85,7 @@ export default {
85
85
 
86
86
  hide() {
87
87
  this.visible = false;
88
+ this.$emit('hidden');
88
89
  this.api().unregisterItems();
89
90
  },
90
91
  },
@@ -1,150 +1,73 @@
1
1
  <template>
2
- <div class="Listbox"><slot></slot></div>
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
- // Get children components
13
- const getListbox = () => this.listbox;
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 unregisterOption = (id) => {
34
- _unregister(this.listboxOptions, id);
19
+ const registerTrigger = (trigger) => {
20
+ if (!trigger) return;
21
+ this.trigger = trigger;
35
22
  };
36
23
 
37
- // Register helper functions
38
- const _register = (arr, item) => {
39
- arr.push(item);
24
+ const registerContent = (content) => {
25
+ if (!content) return;
26
+ this.content = content;
40
27
  };
41
28
 
42
- const _unregister = (arr, id) => {
43
- const index = _getIndex(arr, id);
44
- arr.splice(index, 1);
29
+ const registerSearch = (search) => {
30
+ if (!search) return;
31
+ this.search = search;
45
32
  };
46
33
 
47
- // Handle focus and unfocus
48
- const getFocusedIndex = () => {
49
- let index = 0;
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 unfocusOptions = () => {
90
- this.listboxOptions.forEach((option) => {
91
- if (option.focused) option.unfocus();
92
- });
39
+ const registerItem = (item) => {
40
+ if (!item) return;
41
+ this.items.push(item);
93
42
  };
94
43
 
95
- const _focusOption = (index) => {
96
- unfocusOptions();
97
- this.listboxOptions[index].focus();
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 _getFocusedOption = () => {
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
- getListbox,
134
- getListboxButton,
135
- registerListboxButton,
136
- registerListbox,
137
- registerOption,
138
- unregisterOption,
139
- focusOptionByFilter,
140
- focusFirstOption,
141
- focusLastOption,
142
- focusPreviousOption,
143
- focusNextOption,
144
- unfocusOptions,
145
- selectOption,
146
- getFocusedIndex,
147
- getListboxValue,
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
- listboxButton: null,
164
- listboxOptions: [],
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>