@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.
@@ -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>
@@ -1,81 +0,0 @@
1
- <template>
2
- <button
3
- :aria-expanded="expanded"
4
- aria-haspopup="listbox"
5
- class="Listbox-button"
6
- :data-theme="theme"
7
- type="button"
8
- @click.prevent="onClick"
9
- @keydown="onKeyDown"
10
- >
11
- <span class="Listbox-button__text"><slot></slot></span>
12
- <span class="Listbox-button__icon">
13
- <IconChevronUp v-if="expanded" />
14
- <IconChevronDown v-else />
15
- </span>
16
- </button>
17
- </template>
18
-
19
- <script>
20
- import { IconChevronDown, IconChevronUp } from '@veritree/icons';
21
- import { keys } from '../utils/keyboard';
22
-
23
- export default {
24
- name: 'VTListboxButton',
25
-
26
- components: { IconChevronDown, IconChevronUp },
27
-
28
- inject: ['api'],
29
-
30
- props: {
31
- theme: {
32
- type: String,
33
- default: null,
34
- validator(value) {
35
- return ['dark'].includes(value);
36
- },
37
- },
38
- },
39
-
40
- data() {
41
- return {
42
- expanded: false,
43
- };
44
- },
45
-
46
- mounted() {
47
- this.api().registerListboxButton(this);
48
- },
49
-
50
- methods: {
51
- focus() {
52
- this.$el.focus();
53
- this.toggleExpanded();
54
- },
55
-
56
- toggleExpanded() {
57
- this.expanded = !this.expanded;
58
- },
59
-
60
- onClick() {
61
- const listbox = this.api().getListbox();
62
- this.expanded ? listbox.hide() : listbox.show();
63
-
64
- this.toggleExpanded();
65
- },
66
-
67
- onKeyDown(event) {
68
- const key = event.key;
69
- const listbox = this.api().getListbox();
70
-
71
- switch (key) {
72
- case keys.down:
73
- event.preventDefault();
74
- listbox.show();
75
- this.toggleExpanded();
76
- break;
77
- }
78
- },
79
- },
80
- };
81
- </script>
@@ -1,126 +0,0 @@
1
- <template>
2
- <li
3
- :id="id"
4
- class="Listbox-option"
5
- role="option"
6
- @mousedown.prevent="onMouseDown"
7
- @mousemove="onMousemove"
8
- @mouseout="onMouseleave"
9
- >
10
- <slot></slot>
11
- </li>
12
- </template>
13
-
14
- <script>
15
- import { scrollElementIntoView } from '../utils/components';
16
- import { genId } from '../utils/ids';
17
- import { areObjsEqual, isObj } from '../utils/objects';
18
-
19
- export default {
20
- name: 'VTListboxOption',
21
-
22
- inject: ['api'],
23
-
24
- props: {
25
- value: {
26
- type: [String, Number, Object],
27
- required: true,
28
- },
29
- selected: {
30
- type: Boolean,
31
- default: false,
32
- },
33
- },
34
-
35
- data() {
36
- return {
37
- id: `listbox-option-${genId()}`,
38
- focused: false,
39
- isMousemove: false,
40
- parent: null,
41
- };
42
- },
43
-
44
- watch: {
45
- focused(newValue) {
46
- if (!newValue) return;
47
-
48
- if (!this.parent) this.parent = this.api().getListbox();
49
- if (!this.isMousemove) scrollElementIntoView(this.$el, this.parent.$el);
50
- this.parent.updateActiveDescendant(this.id);
51
- },
52
- },
53
-
54
- mounted() {
55
- this.api().registerOption(this);
56
-
57
- if (this.selected) {
58
- this.select();
59
- this.focus();
60
- }
61
-
62
- // todo: make sure it works with other values than objects
63
- const listboxOptionSelected = this.api().getListboxValue();
64
-
65
- if (!listboxOptionSelected) {
66
- return;
67
- }
68
-
69
- if (isObj(this.value)) {
70
- if (areObjsEqual(this.value, listboxOptionSelected)) {
71
- this.select();
72
- this.focus();
73
- }
74
-
75
- return;
76
- }
77
-
78
- if (this.value === listboxOptionSelected) {
79
- this.select();
80
- this.focus();
81
- }
82
- },
83
-
84
- beforeDestroy() {
85
- this.api().unregisterOption(this.id);
86
- },
87
-
88
- methods: {
89
- focus() {
90
- this.focused = true;
91
- this.$el.classList.add('is-focused');
92
- },
93
-
94
- unfocus() {
95
- this.focused = false;
96
- this.$el.classList.remove('is-focused');
97
- },
98
-
99
- select() {
100
- this.$el.setAttribute('aria-selected', true);
101
- },
102
-
103
- unselect() {
104
- this.$el.setAttribute('aria-selected', false);
105
- },
106
-
107
- onMouseDown(event) {
108
- if (event.buttons === 1) this.api().selectOption(this);
109
- },
110
-
111
- // Mousemove instead of mouseover to support keyboard navigation.
112
- // The problem with mouseover is that when scrolling (scrollIntoView),
113
- // mouseover event gets triggered.
114
- onMousemove() {
115
- this.isMousemove = true;
116
- this.api().unfocusOptions();
117
- this.focus();
118
- },
119
-
120
- onMouseleave() {
121
- this.isMousemove = false;
122
- this.unfocus();
123
- },
124
- },
125
- };
126
- </script>
@@ -1,119 +0,0 @@
1
- <template>
2
- <ul
3
- v-if="visible"
4
- aria-orientation="vertical"
5
- role="listbox"
6
- class="Listbox-options"
7
- tabindex="-1"
8
- @keydown="onKeyDown"
9
- @blur="onBlur"
10
- >
11
- <slot></slot>
12
- </ul>
13
- </template>
14
-
15
- <script>
16
- import { keys } from '../utils/keyboard';
17
- let timer = null;
18
-
19
- export default {
20
- name: 'VTListboxOptions',
21
-
22
- inject: ['api'],
23
-
24
- data() {
25
- return {
26
- visible: false,
27
- filter: '',
28
- };
29
- },
30
-
31
- watch: {
32
- visible(newValue) {
33
- if (newValue) {
34
- this.$nextTick(() => {
35
- this.$el.focus();
36
- this.handleOptionFocus();
37
- });
38
- } else {
39
- const listboxButton = this.api().getListboxButton();
40
-
41
- this.$nextTick(() => {
42
- listboxButton.focus();
43
- });
44
- }
45
- },
46
- },
47
-
48
- mounted() {
49
- this.api().registerListbox(this);
50
- },
51
-
52
- methods: {
53
- show() {
54
- this.visible = true;
55
- },
56
-
57
- hide() {
58
- this.visible = false;
59
- },
60
-
61
- handleOptionFocus() {
62
- const selectedIndex = this.api().getFocusedIndex();
63
- if (!selectedIndex) this.api().focusFirstOption();
64
- },
65
-
66
- updateActiveDescendant(id) {
67
- this.$el.setAttribute('aria-activedescendant', id);
68
- },
69
-
70
- onKeyDown(event) {
71
- const key = event.key;
72
- const code = event.code;
73
-
74
- if (code.includes('Key')) {
75
- this.filter += code.replace('Key', '');
76
-
77
- clearTimeout(timer);
78
-
79
- timer = setTimeout(() => {
80
- this.api().focusOptionByFilter(this.filter);
81
- this.filter = '';
82
- }, 100);
83
- }
84
-
85
- switch (key) {
86
- case keys.up:
87
- event.preventDefault();
88
- this.api().focusPreviousOption();
89
- break;
90
- case keys.down:
91
- event.preventDefault();
92
- this.api().focusNextOption();
93
- break;
94
- case keys.home:
95
- event.preventDefault();
96
- this.api().focusFirstOption();
97
- break;
98
- case keys.end:
99
- event.preventDefault();
100
- this.api().focusLastOption();
101
- break;
102
- case keys.enter:
103
- event.preventDefault();
104
- this.api().selectOption();
105
- break;
106
- case keys.escape:
107
- this.hide();
108
- break;
109
- default:
110
- break;
111
- }
112
- },
113
-
114
- onBlur() {
115
- this.hide();
116
- },
117
- },
118
- };
119
- </script>