@veritree/ui 0.19.2-20 → 0.19.2-21

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.
@@ -10,22 +10,17 @@ export const floatingUiContentMixin = {
10
10
  type: Boolean,
11
11
  default: false,
12
12
  },
13
- floatingUiClass: {
14
- type: [String, Function],
15
- default: null,
16
- },
17
13
  },
18
14
 
19
15
  data() {
20
16
  return {
21
17
  el: null,
22
- isMousemove: false,
23
18
  visible: false,
24
19
  };
25
20
  },
26
21
 
27
22
  mounted() {
28
- document.addEventListener('click', (e) => this.onDocumentClick(e));
23
+ document.addEventListener('click', this.onDocumentClick);
29
24
  },
30
25
 
31
26
  destroyed() {
@@ -79,22 +74,6 @@ export const floatingUiContentMixin = {
79
74
  }
80
75
  },
81
76
 
82
- // Mousemove instead of mouseover to support keyboard navigation.
83
- // The problem with mouseover is that when scrolling (scrollIntoView),
84
- // mouseover event gets triggered.
85
- setMousemove() {
86
- this.isMousemove = true;
87
- },
88
-
89
- unsetMousemove() {
90
- this.isMousemove = false;
91
- },
92
-
93
- getMousemove() {
94
- return this.isMousemove;
95
- },
96
-
97
- //
98
77
  setActiveDescedant(id) {
99
78
  this.activeDescedant = id;
100
79
  },
@@ -19,10 +19,6 @@ export const floatingUiItemMixin = {
19
19
  return this.apiInjected().items;
20
20
  },
21
21
 
22
- el() {
23
- return this.$el;
24
- },
25
-
26
22
  componentContent() {
27
23
  return this.apiInjected().componentContent;
28
24
  },
@@ -36,7 +32,7 @@ export const floatingUiItemMixin = {
36
32
  // default styles
37
33
  this.headless
38
34
  ? `${this.componentName}`
39
- : 'relative z-10 flex items-center gap-2 px-3 py-2 text-inherit no-underline',
35
+ : 'relative z-10 flex items-center gap-2 px-3 py-2 text-inherit no-underline cursor-pointer',
40
36
  // disabled state styles
41
37
  this.headless
42
38
  ? this.disabled
@@ -51,7 +47,7 @@ export const floatingUiItemMixin = {
51
47
  ? `${this.componentName}--selected`
52
48
  : null
53
49
  : this.selected
54
- ? 'bg-secondary-200/10'
50
+ ? 'bg-gray-200'
55
51
  : null,
56
52
  ];
57
53
  },
@@ -62,20 +58,24 @@ export const floatingUiItemMixin = {
62
58
  index: null,
63
59
  selected: false,
64
60
  tabIndex: 0,
61
+ eventType: null,
65
62
  };
66
63
  },
67
64
 
68
65
  watch: {
69
66
  selected(newValue) {
70
- if (!newValue || !this.componentContent) return;
67
+ if (!newValue || !this.componentContent) {
68
+ return;
69
+ }
71
70
 
72
71
  this.componentContent.setActiveDescedant(this.id);
73
72
 
74
- const isMousemove = this.componentContent.getMousemove();
75
-
76
- if (!isMousemove) {
77
- this.el.scrollIntoView({ block: 'nearest' });
73
+ // do not scroll into view if it's a mouse event
74
+ if (this.eventType && this.eventType.includes('mouse')) {
75
+ return;
78
76
  }
77
+
78
+ this.$el.scrollIntoView({ block: 'nearest' });
79
79
  },
80
80
  },
81
81
 
@@ -95,8 +95,10 @@ export const floatingUiItemMixin = {
95
95
 
96
96
  const item = {
97
97
  id: this.id,
98
+ text: '',
98
99
  select: this.select,
99
100
  unselect: this.unselect,
101
+ isSelected: this.isSelected,
100
102
  focus: this.focus,
101
103
  onClick: this.onClick,
102
104
  };
@@ -104,17 +106,30 @@ export const floatingUiItemMixin = {
104
106
  this.apiInjected().registerItem(item);
105
107
  },
106
108
 
109
+ /**
110
+ * mousemove and mouseleave events will be attached to
111
+ * the element here instead of using vue @mousemove
112
+ * and @mouseleave in the element since they did
113
+ * not worker properly
114
+ */
107
115
  addMouseEventListeners() {
108
- this.el.addEventListener('mousemove', this.onMousemove);
109
- this.el.addEventListener('mouseleave', this.onMouseleave);
116
+ this.$el.addEventListener('mousemove', this.onMousemove);
117
+ this.$el.addEventListener('mouseleave', this.onMouseleave);
110
118
  },
111
119
 
120
+ /**
121
+ * remove the mousemove and mouseleave added for
122
+ * avoiding memory leaks, if @mousemove and
123
+ * @mouseleave were in the element, this
124
+ * wound't be needed
125
+ */
112
126
  removeMouseEventListeners() {
113
- this.el.removeEventListener('mousemove', this.onMousemove);
114
- this.el.removeEventListener('mouseleave', this.onMouseleave);
127
+ this.$el.removeEventListener('mousemove', this.onMousemove);
128
+ this.$el.removeEventListener('mouseleave', this.onMouseleave);
115
129
  },
116
130
 
117
- select() {
131
+ select(eventType) {
132
+ this.eventType = eventType;
118
133
  this.selected = true;
119
134
  },
120
135
 
@@ -122,59 +137,84 @@ export const floatingUiItemMixin = {
122
137
  this.selected = false;
123
138
  },
124
139
 
140
+ isSelected() {
141
+ return this.selected;
142
+ },
143
+
125
144
  focus() {
126
- if (!this.el) return;
145
+ if (!this.$el) return;
127
146
 
128
147
  this.tabIndex = -1;
129
148
  this.selected = true;
130
- this.el.focus();
149
+ this.$el.focus();
131
150
  },
132
151
 
133
152
  focusFirstItem() {
134
- this.setFocusToItem(0);
153
+ const selectedIndex = this.getItemSelectedIndex();
154
+ const newSelectedIndex = 0;
155
+
156
+ this.setFocusToItem(selectedIndex, newSelectedIndex);
135
157
  },
136
158
 
137
159
  focusLastItem() {
138
- this.setFocusToItem(this.items.length - 1);
160
+ const selectedIndex = this.getItemSelectedIndex();
161
+ const newSelectedIndex = this.items.length - 1;
162
+
163
+ this.setFocusToItem(selectedIndex, newSelectedIndex);
139
164
  },
140
165
 
141
166
  /**
142
- * Focus the previous item in the menu.
143
- * If is the first item, jump to the last item.
167
+ * Focus the next item in the menu.
168
+ * If is the last item, jump to the first item.
144
169
  */
145
- focusPreviousItem() {
146
- const isLast = this.index === this.items.length - 1;
147
- const goToIndex = isLast ? 0 : this.index + 1;
170
+ focusNextItem() {
171
+ const selectedIndex = this.getItemSelectedIndex();
172
+ const isLast = selectedIndex === this.items.length - 1;
173
+ const firstItemIndex = 0;
174
+ const nextItemIndex = selectedIndex + 1;
175
+ const newSelectedIndex = isLast ? firstItemIndex : nextItemIndex;
148
176
 
149
- this.setFocusToItem(goToIndex);
177
+ this.setFocusToItem(selectedIndex, newSelectedIndex);
150
178
  },
151
179
 
152
180
  /**
153
- * Focus the next item in the menu.
154
- * If is the last item, jump to the first item.
181
+ * Focus the previous item in the menu.
182
+ * If is the first item, jump to the last item.
155
183
  */
156
- focusNextItem() {
157
- const isFirst = this.index === 0;
158
- const goToIndex = isFirst ? this.items.length - 1 : this.index - 1;
184
+ focusPreviousItem() {
185
+ const selectedIndex = this.getItemSelectedIndex();
186
+ const isFirst = selectedIndex === 0;
187
+ const lastItemIndex = this.items.length - 1;
188
+ const previousItemIndex = selectedIndex - 1;
189
+ const newSelectedIndex = isFirst ? lastItemIndex : previousItemIndex;
159
190
 
160
- this.setFocusToItem(goToIndex);
191
+ this.setFocusToItem(selectedIndex, newSelectedIndex);
161
192
  },
162
193
 
163
194
  /**
164
- * Focus item by remove its tabindex and calling
195
+ * Focus/select item by removing its tabindex and calling
165
196
  * focus to the element.
166
197
  *
167
- * @param {Number, String} goToIndex
198
+ * @param {Number, String} selectedIndex
199
+ * @param {Number, String} newSelectedIndex
168
200
  */
169
- setFocusToItem(goToIndex) {
201
+ setFocusToItem(selectedIndex, newSelectedIndex) {
170
202
  this.tabIndex = 0;
171
- this.selected = false;
172
203
 
173
- // set all selected to false
174
- this.items.forEach((item) => item.unselect());
204
+ // before focusing, let's unselect selected
205
+ // item that were previously focused
206
+ if (selectedIndex >= 0) {
207
+ this.items[selectedIndex].unselect();
208
+ }
209
+
210
+ // focus new item
211
+ if (this.items[newSelectedIndex]) {
212
+ this.items[newSelectedIndex].focus();
213
+ }
214
+ },
175
215
 
176
- // focus item
177
- this.items[goToIndex].focus();
216
+ getItemSelectedIndex() {
217
+ return this.items.findIndex((item) => item.isSelected());
178
218
  },
179
219
 
180
220
  leaveMenu() {
@@ -200,20 +240,27 @@ export const floatingUiItemMixin = {
200
240
  this.leaveMenu();
201
241
  },
202
242
 
203
- onMousemove() {
243
+ onMousemove(event) {
204
244
  if (this.selected) {
205
245
  return;
206
246
  }
207
247
 
208
- this.items.forEach((item) => item.unselect());
248
+ // unselect all items before selecting new one
249
+ for (const item of this.items) {
250
+ item.unselect();
251
+ }
209
252
 
210
- this.select();
211
- this.componentContent.setMousemove();
253
+ /**
254
+ * Select item passing the event type to decide if
255
+ * scrolling will be disabled or not. It is
256
+ * expected that on mouse move event, the scroll
257
+ * doesn't happen automatically.
258
+ */
259
+ this.select(event.type);
212
260
  },
213
261
 
214
262
  onMouseleave() {
215
263
  this.unselect();
216
- this.componentContent.unsetMousemove();
217
264
  },
218
265
  },
219
- };
266
+ };
@@ -1,13 +1,6 @@
1
1
  import { computePosition, flip, shift, offset, size } from '@floating-ui/dom';
2
2
 
3
3
  export const floatingUiMixin = {
4
- props: {
5
- placement: {
6
- type: String,
7
- default: 'bottom-start',
8
- },
9
- },
10
-
11
4
  data() {
12
5
  return {
13
6
  component: null,
@@ -42,18 +35,18 @@ export const floatingUiMixin = {
42
35
  positionContentToTrigger() {
43
36
  const trigger = document.getElementById(this.componentTrigger.id);
44
37
  const content = document.getElementById(this.componentContent.id);
38
+ const minWidthLimit = Number(this.floatingUiMinWidth);
45
39
 
46
40
  computePosition(trigger, content, {
47
41
  placement: this.placement,
48
42
  middleware: [
49
- offset(6),
43
+ offset(5),
50
44
  flip(),
51
45
  shift({ padding: 5 }),
52
46
  size({
53
47
  apply({ rects }) {
54
- // the min width to floating uis should be 200
55
- // since less than that can look not as nice
56
- const minWidthLimit = 200;
48
+ if (!minWidthLimit) return;
49
+
57
50
  const width = rects.reference.width;
58
51
  const minWidth = width < minWidthLimit ? minWidthLimit : width;
59
52
 
@@ -71,4 +64,4 @@ export const floatingUiMixin = {
71
64
  });
72
65
  },
73
66
  },
74
- };
67
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veritree/ui",
3
- "version": "0.19.2-20",
3
+ "version": "0.19.2-21",
4
4
  "description": "veritree ui library",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,26 +16,44 @@ export default {
16
16
  provide() {
17
17
  return {
18
18
  apiListbox: () => {
19
+ const $mutable = {};
20
+
21
+ // Used to get and update the value computed
22
+ // that will then emit the value up to the
23
+ // parent component
24
+ Object.defineProperty($mutable, 'valueComputed', {
25
+ enumerable: true,
26
+ get: () => this.valueComputed,
27
+ set: (value) => (this.valueComputed = value),
28
+ });
29
+
30
+ /**
31
+ * This function registers a trigger by setting its value to the componentTrigger property of the current object.
32
+ * @param {VueComponent} trigger - The trigger to be registered.
33
+ */
19
34
  const registerTrigger = (trigger) => {
20
35
  if (!trigger) return;
21
36
  this.componentTrigger = trigger;
22
37
  };
23
38
 
39
+ /**
40
+ * Registers content to be used in the children components by setting its value to the componentContent property of the current object.
41
+ * @param {any} content - The content to be registered.
42
+ */
24
43
  const registerContent = (content) => {
25
44
  if (!content) return;
26
45
  this.componentContent = content;
27
46
  };
28
47
 
48
+ /**
49
+ * Registers search to be used in the children components by setting its value to the componentSearch property of the current object.
50
+ * @param {any} search - The search to be registered.
51
+ */
29
52
  const registerSearch = (search) => {
30
53
  if (!search) return;
31
- this.search = search;
54
+ this.componentSearch = search;
32
55
  };
33
56
 
34
- // const registerList = (list) => {
35
- // if (!list) return;
36
- // this.list = list;
37
- // };
38
-
39
57
  const registerItem = (item) => {
40
58
  if (!item) return;
41
59
  this.items.push(item);
@@ -46,9 +64,18 @@ export default {
46
64
  this.items.splice(index, 1);
47
65
  };
48
66
 
67
+ const pushSearch = (key) => {
68
+ key === 'Backspace' && this.search.length
69
+ ? (this.search = this.search.slice(0, -1))
70
+ : (this.search += key);
71
+ };
72
+
73
+ const clearSearch = () => {
74
+ this.search = '';
75
+ };
76
+
49
77
  const emit = (value) => {
50
- this.$emit('update:modelValue', value);
51
- this.$emit('change', value);
78
+ this.valueComputed = value;
52
79
  };
53
80
 
54
81
  return {
@@ -56,14 +83,18 @@ export default {
56
83
  component: this.component,
57
84
  componentTrigger: this.componentTrigger,
58
85
  componentContent: this.componentContent,
86
+ componentSearch: this.componentSearch,
59
87
  items: this.items,
88
+ multiple: this.multiple,
60
89
  search: this.search,
90
+ $mutable,
61
91
  registerTrigger,
62
92
  registerContent,
63
93
  registerSearch,
64
- // registerList,
65
94
  registerItem,
66
95
  unregisterItem,
96
+ pushSearch,
97
+ clearSearch,
67
98
  emit,
68
99
  };
69
100
  },
@@ -71,19 +102,52 @@ export default {
71
102
  },
72
103
 
73
104
  props: {
74
- value: {
75
- type: [String, Number, Object],
105
+ /**
106
+ * The value of the component. Can be a string, number, object, or array.
107
+ * @type {string|number|object|array}
108
+ * @default null
109
+ */
110
+ modelValue: {
111
+ type: [String, Number, Object, Array],
76
112
  default: null,
77
113
  },
114
+ /**
115
+ * Determines whether the button will use its default atomic style (tailwind) or its default class
116
+ * @type {boolean}
117
+ * @values
118
+ * - true: The button will have no default style and can be fully customized with a custom class
119
+ * - false: The button will use its default atomic style (tailwind) and can be further customized with additional classes
120
+ * @default null
121
+ */
78
122
  headless: {
79
123
  type: Boolean,
80
124
  default: false,
81
125
  },
82
- dark: {
126
+ /**
127
+ * The placement of the component relative to its trigger element.
128
+ * @type {string}
129
+ * @values 'top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end', 'right', 'right-start', 'right-end'
130
+ * @default 'bottom-start'
131
+ */
132
+ placement: {
133
+ type: String,
134
+ default: 'bottom-start',
135
+ },
136
+ /**
137
+ * Determines whether the component should be aligned to the right of its trigger element.
138
+ * @type {boolean}
139
+ * @default false
140
+ */
141
+ right: {
83
142
  type: Boolean,
84
143
  default: false,
85
144
  },
86
- right: {
145
+ /**
146
+ * Determines whether the component should allow multiple selections.
147
+ * @type {boolean}
148
+ * @default false
149
+ */
150
+ multiple: {
87
151
  type: Boolean,
88
152
  default: false,
89
153
  },
@@ -92,8 +156,25 @@ export default {
92
156
  data() {
93
157
  return {
94
158
  componentId: genId(),
95
- search: null,
159
+ componentSearch: null,
160
+ search: '',
96
161
  items: [],
162
+ itemsChecked: [],
163
+ /**
164
+ * Explaining the need for the floatingUiMinWidth data
165
+ *
166
+ * The floating ui is a result of two items:
167
+ *
168
+ * 1. Trigger: the action button
169
+ * 2. Content: the popper/wrapper that appears after triggering the action button
170
+ *
171
+ * By default, the content will match the triggers width.
172
+ * The problem with this is that the trigger width
173
+ * might be too small causing the content to not fit
174
+ * what is inside it properly. So, to avoid this,
175
+ * a min width is needed.
176
+ */
177
+ floatingUiMinWidth: 200,
97
178
  };
98
179
  },
99
180
 
@@ -101,6 +182,16 @@ export default {
101
182
  id() {
102
183
  return `listbox-${this.componentId}`;
103
184
  },
185
+
186
+ valueComputed: {
187
+ get() {
188
+ return this.modelValue;
189
+ },
190
+ set(newValue) {
191
+ this.$emit('update:modelValue', newValue);
192
+ this.$emit('change', newValue);
193
+ },
194
+ },
104
195
  },
105
196
  };
106
197
  </script>
@@ -1,11 +1,10 @@
1
1
  <template>
2
2
  <FloatingUi
3
- :visible="visible"
4
3
  :id="id"
5
- :aria-activedescendant="activeDescedant"
4
+ :visible="visible"
6
5
  :headless="headless"
7
- :class="{ 'listbox-content': headless }"
8
- :floating-ui-class="floatingUiClass"
6
+ :aria-activedescendant="activeDescedant"
7
+ component="listbox"
9
8
  role="listbox"
10
9
  >
11
10
  <slot></slot>
@@ -47,9 +46,6 @@ export default {
47
46
  id: this.id,
48
47
  show: this.show,
49
48
  hide: this.hide,
50
- getMousemove: this.getMousemove,
51
- setMousemove: this.setMousemove,
52
- unsetMousemove: this.unsetMousemove,
53
49
  setActiveDescedant: this.setActiveDescedant,
54
50
  };
55
51
 
@@ -5,30 +5,47 @@
5
5
  :aria-disabled="disabled"
6
6
  :aria-selected="String(selected)"
7
7
  :tabindex="tabIndex"
8
- role="option"
8
+ :role="'option'"
9
9
  @click.stop="onClick"
10
- @keydown.down.prevent="focusPreviousItem"
11
- @keydown.up.prevent="focusNextItem"
10
+ @keydown.prevent="onKeydown"
11
+ @keydown.down.prevent="focusNextItem"
12
+ @keydown.up.prevent="focusPreviousItem"
12
13
  @keydown.home.prevent="focusFirstItem"
13
14
  @keydown.end.prevent="focusLastItem"
14
15
  @keydown.esc.stop="onKeyEsc"
15
16
  @keydown.enter.prevent="onClick"
16
- @mousemove="onMousemove"
17
- @mouseout="onMouseleave"
18
17
  @keydown.tab.prevent
19
18
  >
19
+ <span v-if="apiListbox().multiple">
20
+ <input
21
+ v-model="apiListbox().$mutable.valueComputed"
22
+ :id="`${id}-checkbox`"
23
+ :value="value"
24
+ type="checkbox"
25
+ @click.stop
26
+ @change="onChange"
27
+ />
28
+ </span>
20
29
  <slot></slot>
30
+ <span v-if="wasSelected" class="ml-auto">
31
+ <IconCheckOutline class="text-secondary-200 h-5 w-5" />
32
+ </span>
21
33
  </li>
22
34
  </template>
23
35
 
24
36
  <script>
37
+ import { IconCheckOutline } from '@veritree/icons';
25
38
  import { floatingUiItemMixin } from '../../../mixins/floating-ui-item';
26
39
 
40
+ let keydownSearchTimeout = null;
41
+
27
42
  export default {
28
43
  name: 'VTListboxItem',
29
44
 
30
45
  mixins: [floatingUiItemMixin],
31
46
 
47
+ components: { IconCheckOutline },
48
+
32
49
  inject: ['apiListbox'],
33
50
 
34
51
  props: {
@@ -40,14 +57,119 @@ export default {
40
57
 
41
58
  data() {
42
59
  return {
60
+ // apiInjected is used in the mixin floating-ui-item
43
61
  apiInjected: this.apiListbox,
44
62
  componentName: 'listbox-item',
45
63
  };
46
64
  },
47
65
 
48
66
  computed: {
67
+ componentContent() {
68
+ return this.apiInjected().componentContent;
69
+ },
70
+
71
+ hasComponentSearch() {
72
+ return this.apiListbox().componentSearch !== null;
73
+ },
74
+
49
75
  search() {
50
- return this.apiInjected().search;
76
+ return this.apiListbox().search;
77
+ },
78
+
79
+ /**
80
+ * The difference between selected (in the mixin) and
81
+ * wasSelected is that selected is more related to
82
+ * the aria-selected while wasSelected is the
83
+ * real selected option for the listbox
84
+ */
85
+ wasSelected() {
86
+ return (
87
+ JSON.stringify(this.value) ===
88
+ JSON.stringify(this.apiListbox().$mutable.valueComputed)
89
+ );
90
+ },
91
+ },
92
+
93
+ mounted() {
94
+ console.log(this.apiListbox().$mutable);
95
+ if (this.wasSelected) {
96
+ /**
97
+ * There are some conflicts between the portal and the interaction
98
+ * with the element. It's unclear why it happens but if no delay,
99
+ * the floating ui content, that is placed in the body by the
100
+ * vue portal plugin, will not appear close to the trigger.
101
+ *
102
+ * Kind hacky but no time do find real solution.
103
+ */
104
+ setTimeout(() => {
105
+ this.hasComponentSearch ? this.select() : this.focus();
106
+ }, 1);
107
+ }
108
+ },
109
+
110
+ methods: {
111
+ /**
112
+ * This event is mainly for the search since other keydown
113
+ * events are handled with vue v-on (@) directive
114
+ *
115
+ * @param {Object} event
116
+ */
117
+ onKeydown(e) {
118
+ const isLetter = e.code.includes('Key');
119
+ const isNumber = e.code.includes('Numpad') || e.code.includes('Digit');
120
+ const isBackspace = e.code === 'Backspace';
121
+ const isSpace = e.code === 'Space';
122
+
123
+ if (isLetter || isNumber || isBackspace || isSpace) {
124
+ this.doSearch(e);
125
+ }
126
+ },
127
+
128
+ /**
129
+ * Searches and focus the options that matches the
130
+ * value typed when the item is selected.
131
+ *
132
+ * @param {Object} event
133
+ */
134
+ doSearch(e) {
135
+ clearTimeout(keydownSearchTimeout);
136
+
137
+ /**
138
+ * Add letter or number to search data in component
139
+ * root, so it is available for all children
140
+ */
141
+ this.apiListbox().pushSearch(e.key);
142
+
143
+ /**
144
+ * Let's get the item selected index at the moment the search
145
+ * starts to later unselect it if necessary
146
+ */
147
+ const itemSelectedIndex = this.getItemSelectedIndex();
148
+
149
+ /**
150
+ * As we have now the search data with value, let's go
151
+ * through all items and check if the search matches
152
+ * with any of the items to get its index value
153
+ */
154
+ const newItemSelectedIndex = this.items.findIndex((item) =>
155
+ item.text.toLowerCase().includes(this.search.toLowerCase())
156
+ );
157
+
158
+ /**
159
+ * Focus the item that matches the search, and if no match
160
+ * we will just remove the selected state from item
161
+ */
162
+ newItemSelectedIndex >= 0
163
+ ? this.setFocusToItem(itemSelectedIndex, newItemSelectedIndex)
164
+ : this.unselect();
165
+
166
+ /**
167
+ * Clear the search data value after some time
168
+ * to allow other searches to be performed
169
+ */
170
+ keydownSearchTimeout = setTimeout(() => {
171
+ this.apiListbox().clearSearch();
172
+ }, 1000);
51
173
  },
52
174
  },
53
175
  };
@@ -1,10 +1,9 @@
1
1
  <template>
2
2
  <component
3
3
  :is="as"
4
- :class="{
5
- ListboxLabel: headless,
6
- 'mb-2 block text-xs font-normal uppercase': !headless,
7
- }"
4
+ :class="[
5
+ headless ? 'listbox-label' : 'mb-2 block text-xs font-normal uppercase',
6
+ ]"
8
7
  >
9
8
  <slot></slot>
10
9
  </component>
@@ -2,7 +2,9 @@
2
2
  <ul
3
3
  :id="id"
4
4
  :class="[
5
- headless ? 'listbox-list' : 'max-h-[160px] w-auto overflow-y-auto -mx-3',
5
+ headless
6
+ ? 'listbox-list'
7
+ : '-mx-3 max-h-[160px] w-auto overflow-y-auto scroll-auto',
6
8
  ]"
7
9
  >
8
10
  <slot></slot>
@@ -27,28 +29,5 @@ export default {
27
29
  return `listbox-list-${this.apiListbox().id}`;
28
30
  },
29
31
  },
30
-
31
- // mounted() {
32
- // const list = {
33
- // el: this.$el,
34
- // };
35
-
36
- // this.apiListbox().registerList(list);
37
- // },
38
-
39
- methods: {
40
- // Mousemove instead of mouseover to support keyboard navigation.
41
- // The problem with mouseover is that when scrolling (scrollIntoView),
42
- // mouseover event gets triggered.
43
- // setMousemove() {
44
- // this.isMousemove = true;
45
- // },
46
- // unsetMousemove() {
47
- // this.isMousemove = false;
48
- // },
49
- // getMousemove() {
50
- // return this.isMousemove;
51
- // },
52
- },
53
32
  };
54
33
  </script>
@@ -1,18 +1,22 @@
1
1
  <template>
2
- <input
3
- :value="modelValue"
4
- type="text"
5
- :class="classComputed"
6
- @input="onInput"
7
- @click.stop
8
- @keydown.down.prevent="focusNextItem"
9
- @keydown.up.prevent="focusPreviousItem"
10
- @keydown.home.prevent="focusFirstItem"
11
- @keydown.end.prevent="focusLastItem"
12
- @keydown.enter.prevent="onKeyEnter"
13
- @keydown.esc.stop="hide"
14
- @keydown.tab.prevent="hide"
15
- />
2
+ <div class="-mx-3 -mt-2">
3
+ <input
4
+ ref="search"
5
+ :value="modelValue"
6
+ v-bind="$attrs"
7
+ type="text"
8
+ class="leading-0 font-inherit w-full max-w-full appearance-none border-b border-solid border-gray-300 py-2 px-3 text-base text-inherit"
9
+ @input="onInput"
10
+ @click.stop
11
+ @keydown.down.prevent="focusNextItem"
12
+ @keydown.up.prevent="focusPreviousItem"
13
+ @keydown.home.prevent="focusFirstItem"
14
+ @keydown.end.prevent="focusLastItem"
15
+ @keydown.enter.prevent="onKeyEnter"
16
+ @keydown.esc.stop="hide"
17
+ @keydown.tab.prevent="hide"
18
+ />
19
+ </div>
16
20
  </template>
17
21
 
18
22
  <script>
@@ -23,12 +27,14 @@ export default {
23
27
 
24
28
  mixins: [formControlMixin],
25
29
 
30
+ inheritAttrs: false,
31
+
26
32
  inject: ['apiListbox'],
27
33
 
28
34
  data() {
29
35
  return {
30
36
  name: 'listbox-search',
31
- index: -1,
37
+ index: null,
32
38
  search: '',
33
39
  };
34
40
  },
@@ -38,10 +44,6 @@ export default {
38
44
  return this.apiListbox().componentTrigger;
39
45
  },
40
46
 
41
- componentContent() {
42
- return this.apiListbox().componentContent;
43
- },
44
-
45
47
  items() {
46
48
  return this.apiListbox().items;
47
49
  },
@@ -52,12 +54,12 @@ export default {
52
54
  },
53
55
 
54
56
  mounted() {
55
- const search = {
57
+ const componentSearch = {
56
58
  el: this.$el,
57
59
  };
58
60
 
59
- this.apiListbox().registerSearch(search);
60
- this.$nextTick(() => setTimeout(() => this.$el.focus(), 150));
61
+ this.apiListbox().registerSearch(componentSearch);
62
+ this.$nextTick(() => setTimeout(() => this.$refs.search.focus(), 150));
61
63
  },
62
64
 
63
65
  beforeUnmount() {
@@ -67,56 +69,61 @@ export default {
67
69
 
68
70
  methods: {
69
71
  focusNextItem() {
70
- if (this.index !== -1) {
71
- this.unselectItem();
72
- }
73
-
74
- this.index++;
72
+ const selectedIndex = this.getItemSelectedIndex();
73
+ const isLast = selectedIndex === this.items.length - 1;
74
+ const firstItemIndex = 0;
75
+ const nextItemIndex = selectedIndex + 1;
76
+ const newSelectedIndex = isLast ? firstItemIndex : nextItemIndex;
75
77
 
76
- if (this.index > this.items.length - 1) {
77
- this.index = 0;
78
- }
79
-
80
- if (this.item) {
81
- this.item.select();
82
- }
78
+ this.selectItem(selectedIndex, newSelectedIndex);
83
79
  },
84
80
 
85
81
  focusPreviousItem() {
86
- this.unselectItem();
87
-
88
- this.index--;
89
-
90
- if (this.index < 0) {
91
- this.index = this.items.length - 1;
92
- }
82
+ const selectedIndex = this.getItemSelectedIndex();
83
+ const isFirst = selectedIndex === 0;
84
+ const lastItemIndex = this.items.length - 1;
85
+ const previousItemIndex = selectedIndex - 1;
86
+ const newSelectedIndex = isFirst ? lastItemIndex : previousItemIndex;
93
87
 
94
- this.item.select();
88
+ this.selectItem(selectedIndex, newSelectedIndex);
95
89
  },
96
90
 
97
91
  focusFirstItem() {
98
- this.unselectItem();
99
- this.index = 0;
100
- this.item.select();
92
+ const selectedIndex = this.getItemSelectedIndex();
93
+ const newSelectedIndex = 0;
94
+
95
+ this.selectItem(selectedIndex, newSelectedIndex);
101
96
  },
102
97
 
103
98
  focusLastItem() {
104
- this.unselectItem();
105
- this.index = this.items.length - 1;
106
- this.item.select();
99
+ const selectedIndex = this.getItemSelectedIndex();
100
+ const newSelectedIndex = this.items.length - 1;
101
+
102
+ this.selectItem(selectedIndex, newSelectedIndex);
107
103
  },
108
104
 
109
- unselectItem() {
110
- const isMousemove = this.componentContent.getMousemove();
105
+ selectItem(selectedIndex, newSelectedIndex) {
106
+ // before selecting, let's unselect selected
107
+ // item that were previously focused
108
+ if (selectedIndex >= 0) {
109
+ this.items[selectedIndex].unselect();
110
+ }
111
111
 
112
- if (isMousemove) {
113
- this.componentContent.unsetMousemove();
114
- this.items.forEach((item) => item.unselect());
112
+ // select new item
113
+ if (this.items[newSelectedIndex]) {
114
+ this.index = newSelectedIndex;
115
+ this.items[newSelectedIndex].select();
115
116
  }
117
+ },
116
118
 
119
+ unselectItem() {
117
120
  if (this.item) this.item.unselect();
118
121
  },
119
122
 
123
+ getItemSelectedIndex() {
124
+ return this.items.findIndex((item) => item.isSelected());
125
+ },
126
+
120
127
  onInput(event) {
121
128
  this.index = 0;
122
129
  if (this.item) this.item.select();
@@ -24,7 +24,10 @@
24
24
  </template>
25
25
 
26
26
  <script>
27
- import { formControlMixin } from '../../../mixins/form-control';
27
+ import {
28
+ formControlMixin,
29
+ formControlStyleMixin,
30
+ } from '../../../mixins/form-control';
28
31
  import { IconChevronDown } from '@veritree/icons';
29
32
 
30
33
  export default {
@@ -32,20 +35,23 @@ export default {
32
35
 
33
36
  components: { IconChevronDown },
34
37
 
35
- mixins: [formControlMixin],
38
+ mixins: [formControlMixin, formControlStyleMixin],
36
39
 
37
40
  inject: ['apiListbox'],
38
41
 
39
42
  data() {
40
43
  return {
41
- name: 'listbox-button',
44
+ name: 'listbox-trigger',
42
45
  expanded: false,
43
46
  hasPopup: false,
44
- id: null,
45
47
  };
46
48
  },
47
49
 
48
50
  computed: {
51
+ id() {
52
+ return `listbox-trigger-${this.apiListbox().id}`;
53
+ },
54
+
49
55
  componentContent() {
50
56
  return this.apiListbox().componentContent;
51
57
  },
@@ -64,8 +70,6 @@ export default {
64
70
  },
65
71
 
66
72
  mounted() {
67
- this.id = `listbox-trigger-${this.apiListbox().id}`;
68
-
69
73
  const trigger = {
70
74
  el: this.$el,
71
75
  cancel: this.cancel,
@@ -100,6 +104,7 @@ export default {
100
104
  if (!this.componentContent) {
101
105
  return;
102
106
  }
107
+
103
108
  this.expanded = false;
104
109
 
105
110
  this.hideComponentContent();
@@ -119,6 +124,7 @@ export default {
119
124
 
120
125
  onClick(e) {
121
126
  this.init(e);
127
+ this.$emit('click');
122
128
  },
123
129
 
124
130
  onKeyDownOrUp(e) {
@@ -2,25 +2,24 @@
2
2
  <Teleport to="body">
3
3
  <transition
4
4
  enter-active-class="duration-200 ease-out"
5
- enter-from-class="translate-y-[15px] opacity-0"
6
- enter-to-class="translate-y-0 opacity-100"
5
+ :enter-from-class="transitionEnterClass"
6
+ :enter-to-class="transitionEnterToClass"
7
7
  leave-active-class="duration-200 ease-in"
8
- leave-from-class="translate-y-0 opacity-100"
9
- leave-to-class="translate-y-[15px] opacity-0"
10
- @after-leave="hide"
8
+ :leave-from-class="transitionLeaveClass"
9
+ :leave-to-class="transitionLeaveToClass"
10
+ @after-leave="hidden"
11
+ @after-enter="shown"
11
12
  >
12
13
  <div
13
14
  v-if="visible"
14
- :id="id"
15
15
  :class="[
16
16
  headless
17
- ? null
18
- : 'shadow-300 absolute z-50 grid overflow-x-hidden rounded-md border-gray-100 bg-white py-2 px-3',
19
- floatingUiClass ? floatingUiClass : null,
17
+ ? `${this.component}-content`
18
+ : `absolute z-50 grid overflow-hidden rounded-md shadow-md ${this.classes} ${this.portalClass}`,
20
19
  ]"
21
20
  v-bind="$attrs"
22
21
  >
23
- <slot></slot>
22
+ <slot />
24
23
  </div>
25
24
  </transition>
26
25
  </Teleport>
@@ -28,30 +27,60 @@
28
27
 
29
28
  <script>
30
29
  export default {
31
- inheritAttrs: false,
32
-
33
30
  props: {
31
+ component: {
32
+ type: String,
33
+ default: null,
34
+ },
34
35
  headless: {
35
36
  type: Boolean,
36
37
  default: false,
37
38
  },
38
- id: {
39
- type: [String, Number],
40
- default: null,
41
- },
42
39
  visible: {
43
40
  type: Boolean,
44
41
  default: false,
45
42
  },
46
- floatingUiClass: {
43
+ portalClass: {
47
44
  type: [String, Function],
48
45
  default: null,
49
46
  },
50
47
  },
51
48
 
49
+ computed: {
50
+ isTooltip() {
51
+ return this.component === 'tooltip';
52
+ },
53
+
54
+ classes() {
55
+ return this.isTooltip
56
+ ? 'bg-gray-800 text-sm text-white py-1 px-2'
57
+ : 'bg-white py-2 px-3';
58
+ },
59
+
60
+ transitionEnterClass() {
61
+ return this.isTooltip ? 'opacity-0' : 'translate-y-[15px] opacity-0';
62
+ },
63
+
64
+ transitionEnterToClass() {
65
+ return this.isTooltip ? 'opacity-100' : 'translate-y-0 opacity-100';
66
+ },
67
+
68
+ transitionLeaveClass() {
69
+ return this.transitionEnterToClass;
70
+ },
71
+
72
+ transitionLeaveToClass() {
73
+ return this.transitionEnterClass;
74
+ },
75
+ },
76
+
52
77
  methods: {
53
- hide() {
54
- this.$emit('hide');
78
+ shown() {
79
+ this.$emit('shown');
80
+ },
81
+
82
+ hidden() {
83
+ this.$emit('hidden');
55
84
  },
56
85
  },
57
86
  };