@veritree/ui 0.20.1 → 0.21.1-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.
@@ -0,0 +1,68 @@
1
+ import { computePosition, flip, shift, offset, size } from '@floating-ui/dom';
2
+
3
+ export const floatingUiMixin = {
4
+ props: {
5
+ placement: {
6
+ type: String,
7
+ default: 'bottom-start'
8
+ }
9
+ },
10
+
11
+ data() {
12
+ return {
13
+ component: null,
14
+ componentTrigger: null,
15
+ componentContent: null,
16
+ active: false,
17
+ }
18
+ },
19
+
20
+ watch: {
21
+ active(newVal) {
22
+ if (newVal) this.$nextTick(() => this.positionContentToTrigger());
23
+ },
24
+ },
25
+
26
+ mounted() {
27
+ this.component = {
28
+ setActive: this.setActive,
29
+ clearActive: this.clearActive,
30
+ };
31
+ },
32
+
33
+ methods: {
34
+ setActive() {
35
+ this.active = true;
36
+ },
37
+
38
+ clearActive() {
39
+ this.active = null;
40
+ },
41
+
42
+ positionContentToTrigger() {
43
+ const trigger = document.getElementById(this.componentTrigger.id);
44
+ const content = document.getElementById(this.componentContent.id);
45
+
46
+ computePosition(trigger, content, {
47
+ placement: this.placement,
48
+ middleware: [
49
+ offset(6),
50
+ flip(),
51
+ shift({ padding: 5 }),
52
+ size({
53
+ apply({ rects }) {
54
+ Object.assign(content.style, {
55
+ width: `${rects.reference.width}px`,
56
+ });
57
+ },
58
+ }),
59
+ ],
60
+ }).then(({ x, y }) => {
61
+ Object.assign(content.style, {
62
+ left: `${x}px`,
63
+ top: `${y}px`,
64
+ });
65
+ });
66
+ },
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veritree/ui",
3
- "version": "0.20.1",
3
+ "version": "0.21.1-0",
4
4
  "description": "veritree ui library",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,6 +11,7 @@
11
11
  "access": "public"
12
12
  },
13
13
  "dependencies": {
14
+ "@floating-ui/dom": "^1.0.4",
14
15
  "@linusborg/vue-simple-portal": "^0.1.5",
15
16
  "@veritree/icons": "^0.19.0"
16
17
  },
@@ -5,26 +5,31 @@
5
5
  </template>
6
6
 
7
7
  <script>
8
- import { genId } from "../../utils/ids";
8
+ import { floatingUiMixin } from '../../../mixins/floating-ui';
9
+ import { genId } from '../../utils/ids';
9
10
 
10
11
  export default {
11
- name: "VTDropdownMenu",
12
+ name: 'VTDropdownMenu',
13
+
14
+ mixins: [floatingUiMixin],
12
15
 
13
16
  provide() {
14
17
  return {
15
18
  api: () => {
16
- const { dark: isDark, headless: isHeadless, right: isRight } = this;
17
- const { id, trigger, content, items } = this;
19
+ const { dark: isDark, headless: isHeadless } = this;
18
20
 
19
21
  const registerTrigger = (trigger) => {
20
- this.trigger = trigger;
22
+ if (!trigger) return;
23
+ this.componentTrigger = trigger;
21
24
  };
22
25
 
23
26
  const registerContent = (content) => {
24
- this.content = content;
27
+ if (!content) return;
28
+ this.componentContent = content;
25
29
  };
26
30
 
27
31
  const registerItem = (item) => {
32
+ if (!item) return;
28
33
  this.items.push(item);
29
34
  };
30
35
 
@@ -33,13 +38,13 @@ export default {
33
38
  };
34
39
 
35
40
  return {
36
- id,
41
+ id: this.componentId,
37
42
  isDark,
38
43
  isHeadless,
39
- isRight,
40
- trigger,
41
- content,
42
- items,
44
+ component: this.component,
45
+ componentTrigger: this.componentTrigger,
46
+ componentContent: this.componentContent,
47
+ items: this.items,
43
48
  registerTrigger,
44
49
  registerContent,
45
50
  registerItem,
@@ -66,11 +71,15 @@ export default {
66
71
 
67
72
  data() {
68
73
  return {
69
- id: `menu-${genId()}`,
70
- trigger: null,
71
- content: null,
74
+ componentId: genId(),
72
75
  items: [],
73
76
  };
74
77
  },
78
+
79
+ computed: {
80
+ id() {
81
+ return `dropdown-menu-${this.componentId}`;
82
+ },
83
+ },
75
84
  };
76
85
  </script>
@@ -1,47 +1,37 @@
1
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"
2
+ <FloatingUi
3
+ :visible="visible"
4
+ :id="id"
5
+ :headless="headless"
6
+ :class="{ 'dropdown-menu-content': headless }"
10
7
  >
11
- <div
12
- v-if="visible"
13
- :id="id"
14
- :class="{
15
- MenuList: headless,
16
- 'absolute top-full mt-3 grid min-w-[222px] overflow-hidden rounded border border-solid py-2 px-3 transition-all':
17
- !headless,
18
- 'border-gray-100 bg-white shadow-300': !dark,
19
- 'bg-forest-default border-gray-700 shadow-gray-700': dark,
20
- 'left-0': !right,
21
- 'right-0': right,
22
- }"
23
- >
24
- <slot></slot>
25
- </div>
26
- </transition>
8
+ <slot></slot>
9
+ </FloatingUi>
27
10
  </template>
28
11
 
29
12
  <script>
30
- import { genId } from '../../utils/ids';
13
+ import FloatingUi from '../Utils/FloatingUi.vue';
31
14
 
32
15
  export default {
33
16
  name: 'VTDropdownMenuContent',
34
17
 
18
+ components: {
19
+ FloatingUi,
20
+ },
21
+
35
22
  inject: ['api'],
36
23
 
37
24
  data() {
38
25
  return {
39
- id: `menucontent-${genId()}`,
40
26
  visible: false,
41
27
  };
42
28
  },
43
29
 
44
30
  computed: {
31
+ id() {
32
+ return `dropdown-menu-content-${this.api().id}`;
33
+ },
34
+
45
35
  dark() {
46
36
  return this.api().isDark;
47
37
  },
@@ -50,44 +40,59 @@ export default {
50
40
  return this.api().isHeadless;
51
41
  },
52
42
 
53
- right() {
54
- return this.api().isRight;
43
+ component() {
44
+ return this.api().component;
55
45
  },
56
46
 
57
- trigger() {
58
- return this.api().trigger;
47
+ componentTrigger() {
48
+ return this.api().componentTrigger;
59
49
  },
60
50
  },
61
51
 
62
52
  mounted() {
63
53
  this.api().registerContent(this);
64
54
 
65
- this.$nextTick(() => {
66
- if (this.trigger) this.trigger.toggleHasPopup();
67
- });
68
-
69
- // TODO: Create a directive or mixin for this
55
+ // T-218 Create a directive or mixin for this
70
56
  document.addEventListener('click', (e) => {
57
+ if (!e) {
58
+ return;
59
+ }
60
+
71
61
  e.stopPropagation();
72
- if (this.visible && !this.$el.contains(e.target)) this.trigger.onClick();
62
+
63
+ if (this.visible && !this.$el.contains(e.target)) {
64
+ this.componentTrigger.onClick();
65
+ }
73
66
  });
74
67
  },
75
68
 
76
69
  destroyed() {
77
- // TODO: Create a directive or mixin for this
78
- document.removeEventListener('click', this.trigger.onClick());
70
+ document.removeEventListener('click', this.componentTrigger.onClick);
79
71
  },
80
72
 
81
73
  methods: {
82
74
  show() {
75
+ if (this.visible) return;
76
+
83
77
  this.visible = true;
84
- this.$emit('shown');
78
+
79
+ this.$nextTick(() => {
80
+ this.component.setActive();
81
+ this.$emit('shown');
82
+ });
85
83
  },
86
84
 
87
85
  hide() {
86
+ if (!this.visible) return;
87
+
88
88
  this.visible = false;
89
- this.$emit('hidden');
90
- this.api().unregisterItems();
89
+
90
+ this.$nextTick(() => {
91
+ this.componentTrigger.focus();
92
+ this.componentTrigger.hideExpanded();
93
+ this.component.clearActive();
94
+ this.$emit('hidden');
95
+ });
91
96
  },
92
97
  },
93
98
  };
@@ -5,11 +5,12 @@
5
5
  :to="to"
6
6
  :class="{
7
7
  MenuItem: headless,
8
- '-mx-3 flex min-w-max items-center gap-3 px-3 py-2 text-inherit no-underline':
8
+ '-mx-3 flex min-w-max items-center gap-2 px-3 py-2 text-inherit no-underline':
9
9
  !headless,
10
- 'hover:bg-secondary-200/10': !dark,
11
- 'text-white hover:bg-fd-450 focus:bg-fd-450': dark,
12
- 'pointer-events-none opacity-75': disabled,
10
+ 'hover:bg-secondary-200/10': !dark && !headless,
11
+ 'text-white': dark && !headless,
12
+ 'pointer-events-none opacity-75': disabled && !headless,
13
+ 'bg-secondary-200/10': selected && !headless,
13
14
  }"
14
15
  :tabindex="tabIndex"
15
16
  :aria-disabled="disabled"
@@ -52,13 +53,17 @@ export default {
52
53
 
53
54
  data() {
54
55
  return {
55
- id: `menuitem-${genId()}`,
56
56
  index: null,
57
+ selected: false,
57
58
  tabIndex: 0,
58
59
  };
59
60
  },
60
61
 
61
62
  computed: {
63
+ id() {
64
+ return `dropdown-menu-item-${this.api().id}-${genId()}`;
65
+ },
66
+
62
67
  dark() {
63
68
  return this.api().isDark;
64
69
  },
@@ -79,19 +84,20 @@ export default {
79
84
  return this.$el;
80
85
  },
81
86
 
82
- trigger() {
83
- return this.api().trigger;
87
+ componentTrigger() {
88
+ return this.api().componentTrigger;
84
89
  },
85
90
 
86
- content() {
87
- return this.api().content;
91
+ componentContent() {
92
+ return this.api().componentContent;
88
93
  },
89
94
  },
90
95
 
91
96
  mounted() {
92
97
  const item = {
98
+ select: this.select,
99
+ unselect: this.unselect,
93
100
  focus: this.focus,
94
- el: this.el,
95
101
  };
96
102
 
97
103
  this.api().registerItem(item);
@@ -100,10 +106,19 @@ export default {
100
106
  },
101
107
 
102
108
  methods: {
109
+ select() {
110
+ this.selected = true;
111
+ },
112
+
113
+ unselect() {
114
+ this.selected = false;
115
+ },
116
+
103
117
  focus() {
104
118
  if (!this.el) return;
105
119
 
106
120
  this.tabIndex = -1;
121
+ this.selected = true;
107
122
  this.el.focus();
108
123
  },
109
124
 
@@ -145,6 +160,12 @@ export default {
145
160
  */
146
161
  setFocusToItem(goToIndex) {
147
162
  this.tabIndex = 0;
163
+ this.selected = false;
164
+
165
+ // set all selected to false
166
+ this.items.forEach((item) => item.unselect());
167
+
168
+ // focus item
148
169
  this.items[goToIndex].focus();
149
170
  },
150
171
 
@@ -152,8 +173,8 @@ export default {
152
173
  * Hides content/menu and focus on trigger
153
174
  */
154
175
  leaveMenu() {
155
- if (this.content) this.content.hide();
156
- if (this.trigger) this.trigger.focus();
176
+ if (this.componentContent) this.componentContent.hide();
177
+ if (this.componentTrigger) this.componentTrigger.focus();
157
178
  },
158
179
 
159
180
  onKeyEsc() {
@@ -1,11 +1,12 @@
1
1
  <template>
2
2
  <div
3
+ :id="id"
3
4
  :aria-haspopup="hasPopup"
4
5
  :aria-expanded="expanded"
5
6
  :aria-controls="controls"
6
7
  @keydown.down.prevent="onKeyArrowDown"
7
8
  @keydown.up.prevent="onKeyArrowUp"
8
- @keydown.esc.prevent="onKeyesc"
9
+ @keydown.esc.stop="onKeyesc"
9
10
  >
10
11
  <slot></slot>
11
12
  </div>
@@ -22,21 +23,17 @@ export default {
22
23
  expanded: false,
23
24
  hasPopup: false,
24
25
  controls: null,
26
+ trigger: null,
25
27
  };
26
28
  },
27
29
 
28
30
  computed: {
29
- // gets slot element
30
- slotElm() {
31
- return this.$slots.default[0].elm;
31
+ id() {
32
+ return `dropdown-menu-trigger-${this.api().id}`;
32
33
  },
33
34
 
34
- slotLength() {
35
- return this.$slots.default.length;
36
- },
37
-
38
- content() {
39
- return this.api().content;
35
+ componentContent() {
36
+ return this.api().componentContent;
40
37
  },
41
38
 
42
39
  items() {
@@ -53,15 +50,30 @@ export default {
53
50
  },
54
51
 
55
52
  mounted() {
56
- this.api().registerTrigger(this);
57
- this.addEventListenerToSlotElm();
53
+ const trigger = {
54
+ toggleExpanded: this.toggleExpanded,
55
+ hideExpanded: this.hideExpanded,
56
+ el: this.$el,
57
+ focus: this.focus,
58
+ id: this.id,
59
+ onClick: this.onClick,
60
+ };
61
+
62
+ this.api().registerTrigger(trigger);
63
+
64
+ this.setTrigger();
65
+ this.addTriggerEvents();
58
66
  },
59
67
 
60
68
  destroyed() {
61
- this.slotElm.removeEventListener('click', this.onClick());
69
+ this.trigger.removeEventListener('click', this.onClick);
62
70
  },
63
71
 
64
72
  methods: {
73
+ setTrigger() {
74
+ this.trigger = this.$el.querySelector(':first-child');
75
+ },
76
+
65
77
  /**
66
78
  * Add event listener to slot element
67
79
  *
@@ -72,16 +84,9 @@ export default {
72
84
  * Slot must have only one child element. It avoids
73
85
  * errors related to adding the event listener.
74
86
  */
75
- addEventListenerToSlotElm() {
76
- if (!this.slotLength) return;
77
-
78
- if (this.slotLength > 1) {
79
- throw new Error('VTPopoverButton only accepts one item in its slot');
80
- }
81
-
82
- this.slotElm.addEventListener('click', (e) => {
83
- e.stopImmediatePropagation();
84
- this.onClick();
87
+ addTriggerEvents() {
88
+ this.trigger.addEventListener('click', (e) => {
89
+ this.onClick(e);
85
90
  });
86
91
  },
87
92
 
@@ -89,27 +94,21 @@ export default {
89
94
  * Shows content/menu if not already visible
90
95
  */
91
96
  showContent() {
92
- if (!this.expanded) {
93
- this.toggleExpanded();
94
- this.content.show();
95
- }
97
+ this.expanded = true;
98
+ this.componentContent.show();
96
99
  },
97
100
 
98
101
  /**
99
102
  * Focus slot element if it exists and toggle expanded
100
103
  */
101
104
  focus() {
102
- if (!this.slotElm) return;
103
-
104
- this.slotElm.focus();
105
- this.toggleExpanded();
105
+ if (this.trigger) this.trigger.focus();
106
106
  },
107
107
 
108
- /**
109
- * Toggles aria expanded attribute/state
110
- */
108
+ //
111
109
  toggleExpanded() {
112
- this.expanded = !this.expanded;
110
+ if (!this.expanded) return;
111
+ this.expanded = false;
113
112
  },
114
113
 
115
114
  /**
@@ -123,7 +122,7 @@ export default {
123
122
  * Toggles aria popup/controls attribute/state
124
123
  */
125
124
  toggleHasPopup() {
126
- if (!this.content) return;
125
+ if (!this.componentContent) return;
127
126
 
128
127
  this.hasPopup = !this.hasPopup;
129
128
 
@@ -132,37 +131,41 @@ export default {
132
131
  return;
133
132
  }
134
133
 
135
- this.controls = this.content.id;
134
+ this.controls = this.componentContent.id;
136
135
  },
137
136
 
138
137
  /**
139
- * On click, do the following:
140
- *
141
- * 1. Toggle aria expanded attribute/state
142
- * 2. Open the menu if it's closed
143
- * 3. Close the menu if it's open
138
+ * 1. Set aria expanded attribute/state to false
139
+ * 2. Close the menu
144
140
  */
145
- onClick() {
146
- if (!this.content) return;
147
-
148
- this.toggleExpanded();
141
+ hide() {
142
+ this.hideExpanded();
149
143
 
150
144
  this.$nextTick(() => {
151
- if (this.expanded) this.content.show();
152
- else this.content.hide();
145
+ this.componentContent.hide();
153
146
  });
154
147
  },
155
148
 
156
149
  /**
157
- * 1. Set aria expanded attribute/state to false
158
- * 2. Close the menu
150
+ * On click, do the following:
151
+ *
152
+ * 1. Toggle aria expanded attribute/state
153
+ * 2. Open the menu if it's closed
154
+ * 3. Close the menu if it's open
159
155
  */
160
- hide() {
161
- this.hideExpanded();
156
+ onClick(e) {
157
+ if (!this.componentContent) return;
162
158
 
163
- this.$nextTick(() => {
164
- this.content.hide();
165
- });
159
+ if (this.expanded) {
160
+ this.componentContent.hide();
161
+ return;
162
+ }
163
+
164
+ // delay stop propagation to close other visible
165
+ // dropdowns and delay click event to control
166
+ // this dropdown visibility
167
+ setTimeout(() => e.stopImmediatePropagation(), 50);
168
+ setTimeout(() => this.showContent(), 100);
166
169
  },
167
170
 
168
171
  /**
@@ -172,12 +175,15 @@ export default {
172
175
  * 2. if the menu is expanded, focus the first menu item
173
176
  */
174
177
  onKeyArrowDown() {
175
- if (!this.content) return;
178
+ if (!this.componentContent) return;
176
179
 
177
180
  this.showContent();
178
181
 
182
+ // settimeout here is delaying the focusing the element
183
+ // since it is not rendered yet. All items will only
184
+ // be available when the content is fully visible.
179
185
  this.$nextTick(() => {
180
- this.firstMenuItem.focus();
186
+ setTimeout(() => this.firstMenuItem.focus(), 150);
181
187
  });
182
188
  },
183
189
 
@@ -188,21 +194,23 @@ export default {
188
194
  * 2. if the menu is expanded, focus the last menu item
189
195
  */
190
196
  onKeyArrowUp() {
191
- if (!this.content) return;
197
+ if (!this.componentContent) return;
192
198
 
193
199
  this.showContent();
194
200
 
201
+ // settimeout here is delaying the focusing the element
202
+ // since it is not rendered yet. All items will only
203
+ // be available when the content is fully visible.
195
204
  this.$nextTick(() => {
196
- this.lastMenuItem.focus();
205
+ setTimeout(() => this.lastMenuItem.focus(), 150);
197
206
  });
198
207
  },
199
208
 
200
209
  onKeyesc() {
201
- if (!this.content) return;
210
+ if (!this.componentContent) return;
202
211
 
203
212
  if (this.expanded) {
204
- this.toggleExpanded();
205
- this.content.hide();
213
+ this.componentContent.hide();
206
214
  }
207
215
  },
208
216
  },