@veritree/ui 0.6.1 → 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.
@@ -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>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <div
3
+ :id="id"
4
+ class="relative"
5
+ :aria-haspopup="content ? 'true' : null"
6
+ :aria-controls="content ? content.id : null"
7
+ >
8
+ <slot></slot>
9
+ </div>
10
+ </template>
11
+
12
+ <script>
13
+ import { genId } from '~/utils/ids';
14
+
15
+ export default {
16
+ name: 'VTPopover',
17
+
18
+ provide() {
19
+ return {
20
+ api: () => {
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;
27
+ };
28
+
29
+ const registerContent = (content) => {
30
+ if (!content) return;
31
+ this.content = content;
32
+ };
33
+
34
+ return {
35
+ id,
36
+ isDark,
37
+ isHeadless,
38
+ isRight,
39
+ button,
40
+ content,
41
+ registerButton,
42
+ registerContent,
43
+ };
44
+ },
45
+ };
46
+ },
47
+
48
+ props: {
49
+ headless: {
50
+ type: Boolean,
51
+ default: false,
52
+ },
53
+ dark: {
54
+ type: Boolean,
55
+ default: false,
56
+ },
57
+ right: {
58
+ type: Boolean,
59
+ default: false,
60
+ },
61
+ },
62
+
63
+ data() {
64
+ return {
65
+ id: `popover-${genId()}`,
66
+ button: null,
67
+ content: null,
68
+ };
69
+ },
70
+ };
71
+ </script>