@veritree/ui 0.19.2-16 → 0.19.2-18

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.
Files changed (28) hide show
  1. package/mixins/floating-ui-content.js +102 -0
  2. package/mixins/floating-ui-item.js +219 -0
  3. package/mixins/floating-ui.js +74 -0
  4. package/package.json +6 -2
  5. package/src/components/Dialog/VTDialog.vue +3 -0
  6. package/src/components/Dialog/VTDialogOverlay.vue +1 -1
  7. package/src/components/DropdownMenu/VTDropdownMenu.vue +24 -26
  8. package/src/components/DropdownMenu/VTDropdownMenuContent.vue +27 -70
  9. package/src/components/DropdownMenu/VTDropdownMenuDivider.vue +5 -2
  10. package/src/components/DropdownMenu/VTDropdownMenuItem.vue +8 -128
  11. package/src/components/DropdownMenu/VTDropdownMenuLabel.vue +3 -3
  12. package/src/components/DropdownMenu/VTDropdownMenuTrigger.vue +99 -114
  13. package/src/components/Form/VTFormFeedback.vue +33 -22
  14. package/src/components/Form/VTFormLabel.vue +3 -1
  15. package/src/components/Form/VTFormRow.vue +5 -0
  16. package/src/components/Listbox/VTListbox.vue +22 -41
  17. package/src/components/Listbox/VTListboxContent.vue +22 -114
  18. package/src/components/Listbox/VTListboxItem.vue +10 -183
  19. package/src/components/Listbox/VTListboxLabel.vue +0 -10
  20. package/src/components/Listbox/VTListboxList.vue +22 -31
  21. package/src/components/Listbox/VTListboxSearch.vue +28 -29
  22. package/src/components/Listbox/VTListboxTrigger.vue +69 -86
  23. package/src/components/Popover/VTPopover.vue +21 -27
  24. package/src/components/Popover/VTPopoverContent.vue +23 -58
  25. package/src/components/Popover/VTPopoverDivider.vue +4 -11
  26. package/src/components/Popover/VTPopoverItem.vue +13 -10
  27. package/src/components/Popover/VTPopoverTrigger.vue +120 -20
  28. package/src/components/Utils/FloatingUi.vue +58 -0
@@ -1,34 +1,20 @@
1
1
  <template>
2
2
  <button
3
+ :id="id"
4
+ :class="classComputed"
5
+ :disabled="disabled"
3
6
  :aria-expanded="expanded"
4
7
  :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
8
  type="button"
14
- @click.stop.prevent="onClick"
15
- @keydown.down.prevent="onKeyArrowDown"
16
- @keydown.up.prevent="onKeyArrowUp"
9
+ @click.prevent="onClick"
10
+ @keydown.down.prevent="onKeyDownOrUp"
11
+ @keydown.up.prevent="onKeyDownOrUp"
17
12
  @keydown.esc.stop="onKeyEsc"
18
13
  >
19
- <span
20
- :class="{
21
- 'Listbox-button__text': headless,
22
- 'text-left': !headless,
23
- }"
24
- >
14
+ <span :class="[headless ? 'listbox-button__text' : 'truncate text-left']">
25
15
  <slot></slot>
26
16
  </span>
27
- <span
28
- :class="{
29
- 'Listbox-button__icon': headless,
30
- }"
31
- >
17
+ <span :class="[headless ? 'listbox-button__icon' : 'shrink-0']">
32
18
  <IconChevronDown
33
19
  class="transition-transform"
34
20
  :class="{ 'rotate-180': expanded }"
@@ -38,6 +24,7 @@
38
24
  </template>
39
25
 
40
26
  <script>
27
+ import { formControlMixin } from '../../../mixins/form-control';
41
28
  import { IconChevronDown } from '@veritree/icons';
42
29
 
43
30
  export default {
@@ -45,29 +32,22 @@ export default {
45
32
 
46
33
  components: { IconChevronDown },
47
34
 
48
- inject: ['apiListbox'],
35
+ mixins: [formControlMixin],
49
36
 
50
- props: {
51
- headless: {
52
- type: Boolean,
53
- default: false,
54
- },
55
- },
37
+ inject: ['apiListbox'],
56
38
 
57
39
  data() {
58
40
  return {
41
+ name: 'listbox-button',
59
42
  expanded: false,
60
43
  hasPopup: false,
44
+ id: null,
61
45
  };
62
46
  },
63
47
 
64
48
  computed: {
65
- dark() {
66
- return this.apiListbox().isDark;
67
- },
68
-
69
- content() {
70
- return this.apiListbox().content;
49
+ componentContent() {
50
+ return this.apiListbox().componentContent;
71
51
  },
72
52
 
73
53
  items() {
@@ -84,85 +64,88 @@ export default {
84
64
  },
85
65
 
86
66
  mounted() {
67
+ this.id = `listbox-trigger-${this.apiListbox().id}`;
68
+
87
69
  const trigger = {
88
70
  el: this.$el,
71
+ cancel: this.cancel,
89
72
  focus: this.focus,
90
- onClick: this.onClick,
91
- contract: this.contract,
73
+ id: this.id,
92
74
  };
93
75
 
94
76
  this.apiListbox().registerTrigger(trigger);
95
77
  },
96
78
 
97
79
  methods: {
98
- /**
99
- * Shows content/menu if not already visible
100
- */
101
- showContent() {
80
+ init(e) {
81
+ if (!this.componentContent) {
82
+ return;
83
+ }
84
+
85
+ if (this.expanded) {
86
+ this.cancel();
87
+ return;
88
+ }
89
+
102
90
  this.expanded = true;
103
- this.content.show();
104
- },
105
91
 
106
- focus() {
107
- this.$el.focus();
92
+ // delay stop propagation to close other visible
93
+ // dropdowns and delay click event to control
94
+ // this dropdown visibility
95
+ setTimeout(() => e.stopImmediatePropagation(), 50);
96
+ setTimeout(() => this.showComponentContent(), 100);
108
97
  },
109
98
 
110
- contract() {
111
- if (!this.expanded) return;
99
+ cancel() {
100
+ if (!this.componentContent) {
101
+ return;
102
+ }
112
103
  this.expanded = false;
113
- },
114
104
 
115
- /**
116
- * On click, do the following:
117
- *
118
- * 1. Toggle aria expanded attribute/state
119
- * 2. Open the menu if it's closed
120
- * 3. Close the menu if it's open
121
- */
122
- onClick() {
123
- if (!this.content) return;
124
- this.expanded ? this.content.hide() : this.showContent();
105
+ this.hideComponentContent();
125
106
  },
126
107
 
127
- /**
128
- * On key arrow down, do the following:
129
- *
130
- * 1. if the menu is not expanded, expand it and focus the first menu item
131
- * 2. if the menu is expanded, focus the first menu item
132
- */
133
- onKeyArrowDown() {
134
- if (!this.content) return;
108
+ focus() {
109
+ this.$el.focus();
110
+ },
135
111
 
136
- this.showContent();
112
+ showComponentContent() {
113
+ this.componentContent.show();
114
+ },
137
115
 
138
- this.$nextTick(() => {
139
- this.firstMenuItem.focus();
140
- });
116
+ hideComponentContent() {
117
+ this.componentContent.hide();
141
118
  },
142
119
 
143
- /**
144
- * On key arrow up, do the following:
145
- *
146
- * 1. if the menu is not expanded, expand it and focus the last menu item
147
- * 2. if the menu is expanded, focus the last menu item
148
- */
149
- onKeyArrowUp() {
150
- if (!this.content) return;
120
+ onClick(e) {
121
+ this.init(e);
122
+ },
151
123
 
152
- this.showContent();
124
+ onKeyDownOrUp(e) {
125
+ if (!this.expanded) {
126
+ this.$el.click(e);
127
+ }
153
128
 
129
+ const keyCode = e.code;
130
+ const listItemPosition =
131
+ keyCode === 'ArrowDown'
132
+ ? 'firstMenuItem'
133
+ : keyCode === 'ArrowUp'
134
+ ? 'lastMenuItem'
135
+ : null;
136
+
137
+ // settimeout here is delaying the focusing the element
138
+ // since it is not rendered yet. All items will only
139
+ // be available when the content is fully visible.
154
140
  this.$nextTick(() => {
155
- this.lastMenuItem.focus();
141
+ setTimeout(() => this[listItemPosition].focus(), 100);
156
142
  });
157
143
  },
158
144
 
145
+ // change it to a better name or move the methods inside to another function
159
146
  onKeyEsc() {
160
- if (!this.content) return;
161
-
162
- if (this.expanded) {
163
- this.toggleExpanded();
164
- this.content.hide();
165
- }
147
+ this.cancel();
148
+ this.focus();
166
149
  },
167
150
  },
168
151
  };
@@ -2,43 +2,41 @@
2
2
  <div
3
3
  :id="id"
4
4
  class="relative"
5
- :aria-haspopup="content ? 'true' : null"
6
- :aria-controls="content ? content.id : null"
5
+ :aria-haspopup="componentContent ? 'true' : null"
6
+ :aria-controls="componentContent ? componentContent.id : null"
7
7
  >
8
8
  <slot></slot>
9
9
  </div>
10
10
  </template>
11
11
 
12
12
  <script>
13
+ import { floatingUiMixin } from '../../../mixins/floating-ui';
13
14
  import { genId } from '../../utils/ids';
14
15
 
15
16
  export default {
16
17
  name: 'VTPopover',
17
18
 
19
+ mixins: [floatingUiMixin],
20
+
18
21
  provide() {
19
22
  return {
20
23
  apiPopover: () => {
21
- const { dark: isDark, headless: isHeadless, right: isRight } = this;
22
- const { id, button, content } = this;
23
-
24
- const registerButton = (button) => {
25
- if (!button) return;
26
- this.button = button;
24
+ const registerTrigger = (trigger) => {
25
+ if (!trigger) return;
26
+ this.componentTrigger = trigger;
27
27
  };
28
28
 
29
29
  const registerContent = (content) => {
30
30
  if (!content) return;
31
- this.content = content;
31
+ this.componentContent = content;
32
32
  };
33
33
 
34
34
  return {
35
- id,
36
- isDark,
37
- isHeadless,
38
- isRight,
39
- button,
40
- content,
41
- registerButton,
35
+ id: this.componentId,
36
+ component: this.component,
37
+ componentTrigger: this.componentTrigger,
38
+ componentContent: this.componentContent,
39
+ registerTrigger,
42
40
  registerContent,
43
41
  };
44
42
  },
@@ -50,22 +48,18 @@ export default {
50
48
  type: Boolean,
51
49
  default: false,
52
50
  },
53
- dark: {
54
- type: Boolean,
55
- default: false,
56
- },
57
- right: {
58
- type: Boolean,
59
- default: false,
60
- },
61
51
  },
62
52
 
63
53
  data() {
64
54
  return {
65
- id: `popover-${genId()}`,
66
- button: null,
67
- content: null,
55
+ componentId: genId(),
68
56
  };
69
57
  },
58
+
59
+ computed: {
60
+ id() {
61
+ return `popover-${this.componentId}`;
62
+ },
63
+ },
70
64
  };
71
65
  </script>
@@ -1,82 +1,47 @@
1
1
  <template>
2
- <transition
3
- enter-active-class="duration-200 ease-out"
4
- enter-from-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-from-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="{ 'popover-content': headless }"
7
+ :floating-ui-class="floatingUiClass"
10
8
  >
11
- <div
12
- v-show="visible"
13
- :id="id"
14
- :class="{
15
- PopoverPanel: headless,
16
- 'absolute top-full mt-3 rounded-md shadow-300 ': !headless,
17
- 'bg-white': !dark,
18
- 'border-gray-700 bg-forest-default shadow-gray-700': dark,
19
- 'left-0': !right,
20
- 'right-0': right,
21
- }"
22
- >
23
- <slot></slot>
24
- </div>
25
- </transition>
9
+ <slot></slot>
10
+ </FloatingUi>
26
11
  </template>
27
12
 
28
13
  <script>
29
- import { genId } from '../../utils/ids';
14
+ import { floatingUiContentMixin } from '../../../mixins/floating-ui-content';
30
15
 
31
16
  export default {
32
17
  name: 'VTPopoverContent',
33
18
 
34
- inject: ['apiPopover'],
19
+ mixins: [floatingUiContentMixin],
35
20
 
36
- data() {
37
- return {
38
- id: `popover-panel-${genId()}`,
39
- visible: false,
40
- };
41
- },
21
+ inject: ['apiPopover'],
42
22
 
43
23
  computed: {
44
- dark() {
45
- return this.apiPopover().isDark;
24
+ id() {
25
+ return `popover-content-${this.apiPopover().id}`;
46
26
  },
47
27
 
48
- headless() {
49
- return this.apiPopover().isHeadless;
28
+ component() {
29
+ return this.apiPopover().component;
50
30
  },
51
31
 
52
- right() {
53
- return this.apiPopover().isRight;
32
+ componentTrigger() {
33
+ return this.apiPopover().componentTrigger;
54
34
  },
55
35
  },
56
36
 
57
37
  mounted() {
58
- this.apiPopover().registerContent(this);
59
-
60
- // T-75 Create a directive or mixin for this
61
- document.addEventListener('click', (e) => {
62
- e.stopPropagation();
63
- if (this.visible && !this.$el.contains(e.target)) this.hide();
64
- });
65
- },
66
-
67
- unmounted() {
68
- // T-76 Create a directive or mixin for this
69
- document.removeEventListener('click', this.hide());
70
- },
71
-
72
- methods: {
73
- show() {
74
- this.visible = true;
75
- },
38
+ const content = {
39
+ id: this.id,
40
+ show: this.show,
41
+ hide: this.hide,
42
+ };
76
43
 
77
- hide() {
78
- this.visible = false;
79
- },
44
+ this.apiPopover().registerContent(content);
80
45
  },
81
46
  };
82
47
  </script>
@@ -3,8 +3,6 @@
3
3
  :class="{
4
4
  PopoverDivider: headless,
5
5
  'h-[1px]': !headless,
6
- 'bg-white': !dark,
7
- 'bg-fd-500': dark,
8
6
  }"
9
7
  ></div>
10
8
  </template>
@@ -13,15 +11,10 @@
13
11
  export default {
14
12
  name: 'VTPopoverDivider',
15
13
 
16
- inject: ['apiPopover'],
17
-
18
- computed: {
19
- dark() {
20
- return this.apiPopover().isDark;
21
- },
22
-
23
- headless() {
24
- return this.apiPopover().isHeadless;
14
+ props: {
15
+ headless: {
16
+ type: Boolean,
17
+ default: false,
25
18
  },
26
19
  },
27
20
  };
@@ -1,13 +1,12 @@
1
1
  <template>
2
2
  <component
3
3
  :is="as"
4
- :class="{
5
- PopoverItem: headless,
6
- '-mx-3 flex min-w-max items-center gap-2 px-3 py-2 no-underline':
7
- !headless,
8
- 'text-fd-500': !dark,
9
- 'text-white hover:bg-fd-450': dark,
10
- }"
4
+ :class="[
5
+ // default styles
6
+ headless
7
+ ? 'popover-item'
8
+ : 'hover:bg-secondary-200/10 relative z-10 -mx-3 flex items-center gap-2 px-3 py-2 text-inherit no-underline',
9
+ ]"
11
10
  @click="onClick"
12
11
  >
13
12
  <slot></slot>
@@ -21,14 +20,18 @@ export default {
21
20
  inject: ['apiPopover'],
22
21
 
23
22
  props: {
24
- to: {
25
- type: [String, Object],
26
- default: null,
23
+ headless: {
24
+ type: Boolean,
25
+ default: false,
27
26
  },
28
27
  href: {
29
28
  type: String,
30
29
  default: null,
31
30
  },
31
+ to: {
32
+ type: [String, Object],
33
+ default: null,
34
+ },
32
35
  },
33
36
 
34
37
  computed: {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div @keydown.esc.prevent="onEsc">
2
+ <div :id="id" @keydown.esc.prevent="onKeyEsc">
3
3
  <slot></slot>
4
4
  </div>
5
5
  </template>
@@ -10,46 +10,146 @@ export default {
10
10
 
11
11
  inject: ['apiPopover'],
12
12
 
13
+ data() {
14
+ return {
15
+ expanded: false,
16
+ hasPopup: false,
17
+ controls: null,
18
+ trigger: null,
19
+ };
20
+ },
21
+
13
22
  computed: {
14
- content() {
15
- return this.apiPopover().content;
23
+ id() {
24
+ return `popover-trigger-${this.apiPopover().id}`;
25
+ },
26
+
27
+ componentContent() {
28
+ return this.apiPopover().componentContent;
16
29
  },
17
30
  },
18
31
 
19
32
  mounted() {
20
- this.apiPopover().registerButton(this);
33
+ const trigger = {
34
+ id: this.id,
35
+ el: this.$el,
36
+ cancel: this.cancel,
37
+ focus: this.focus,
38
+ };
39
+
40
+ this.apiPopover().registerTrigger(trigger);
41
+
21
42
  this.setTrigger();
22
43
  this.addTriggerEvents();
23
44
  },
24
45
 
46
+ destroyed() {
47
+ this.trigger.removeEventListener('click', this.onClick);
48
+ },
49
+
25
50
  methods: {
26
51
  setTrigger() {
27
52
  this.trigger = this.$el.querySelector(':first-child');
28
53
  },
29
54
 
55
+ /**
56
+ * Add event listener to slot element
57
+ *
58
+ * The click event has to be added to the slot child element
59
+ * since we are not setting the onclick on the component
60
+ * itself.
61
+ *
62
+ * Slot must have only one child element. It avoids
63
+ * errors related to adding the event listener.
64
+ */
30
65
  addTriggerEvents() {
31
66
  this.trigger.addEventListener('click', (e) => {
32
- if (this.expanded) {
33
- this.onClick();
34
- return;
35
- }
36
-
37
- // delay stop propagation to close other visible
38
- // dropdowns and delay click event to control
39
- // this dropdown visibility
40
- setTimeout(() => e.stopImmediatePropagation(), 50);
41
- setTimeout(() => this.onClick(), 100);
67
+ this.onClick(e);
42
68
  });
43
69
  },
44
70
 
45
- onClick() {
46
- if (this.content.visible) this.content.hide();
47
- else this.content.show();
71
+ init(e) {
72
+ if (!this.componentContent) {
73
+ return;
74
+ }
75
+
76
+ if (this.expanded) {
77
+ this.cancel();
78
+ return;
79
+ }
80
+
81
+ this.expanded = true;
82
+
83
+ // delay stop propagation to close other visible
84
+ // dropdowns and delay click event to control
85
+ // this dropdown visibility
86
+ setTimeout(() => e.stopImmediatePropagation(), 50);
87
+ setTimeout(() => this.showComponentContent(), 100);
88
+ },
89
+
90
+ cancel() {
91
+ if (!this.componentContent) {
92
+ return;
93
+ }
94
+
95
+ this.expanded = false;
96
+
97
+ this.hideComponentContent();
98
+ },
99
+
100
+ focus() {
101
+ if (this.trigger) this.trigger.focus();
102
+ },
103
+
104
+ showComponentContent() {
105
+ this.componentContent.show();
106
+ },
107
+
108
+ hideComponentContent() {
109
+ this.componentContent.hide();
110
+ },
111
+
112
+ toggleAriaHasPopup() {
113
+ if (this.expanded) {
114
+ this.hasPopup = this.componentContent !== null;
115
+ this.controls = this.hasPopup ? this.componentContent.id : null;
116
+
117
+ return;
118
+ }
119
+
120
+ this.hasPopup = null;
121
+ this.controls = null;
122
+ },
123
+
124
+ onClick(e) {
125
+ this.init(e);
126
+ },
127
+
128
+ onKeyDownOrUp(e) {
129
+ if (!this.expanded) {
130
+ this.$el.click(e);
131
+ }
132
+
133
+ const keyCode = e.code;
134
+ const listItemPosition =
135
+ keyCode === 'ArrowDown'
136
+ ? 'firstMenuItem'
137
+ : keyCode === 'ArrowUp'
138
+ ? 'lastMenuItem'
139
+ : null;
140
+
141
+ // settimeout here is delaying the focusing the element
142
+ // since it is not rendered yet. All items will only
143
+ // be available when the content is fully visible.
144
+ this.$nextTick(() => {
145
+ setTimeout(() => this[listItemPosition].focus(), 100);
146
+ });
48
147
  },
49
148
 
50
- onEsc() {
51
- if (!this.content) return;
52
- this.content.hide();
149
+ // change it to a better name or move the methods inside to another function
150
+ onKeyEsc() {
151
+ this.cancel();
152
+ this.focus();
53
153
  },
54
154
  },
55
155
  };