Commit acbd8b1792ddb134c7037fe98ceaa0716da2efb8
Committed by
GitHub
Merge pull request #3554 from SergioCrisostomo/tabs-keyboard
Tabs keyboard navigation
Showing
3 changed files
with
95 additions
and
9 deletions
Show diff stats
examples/routers/tabs.vue
... | ... | @@ -158,10 +158,25 @@ |
158 | 158 | <!--</script>--> |
159 | 159 | |
160 | 160 | <template> |
161 | - <Tabs type="card"> | |
162 | - <TabPane v-for="tab in tabs" :key="tab" :label="'标签' + tab">标签{{ tab }}</TabPane> | |
161 | + <div> | |
162 | + <i-input></i-input> | |
163 | 163 | <Button type="ghost" @click="handleTabsAdd" size="small" slot="extra">增加</Button> |
164 | - </Tabs> | |
164 | + | |
165 | + <hr style="margin: 10px 0;"> | |
166 | + <Tabs type="card"> | |
167 | + <TabPane v-for="tab in tabs" :key="tab" :label="'Tab' + tab"> | |
168 | + <div> | |
169 | + <h3>Some text...</h3> | |
170 | + <i-button>Some focusable content...{{ tab }}</i-button> | |
171 | + </div> | |
172 | + </TabPane> | |
173 | + </Tabs> | |
174 | + <Tabs type="card"> | |
175 | + <TabPane label="标签一">标签一的内容</TabPane> | |
176 | + <TabPane label="标签二" disabled>标签二的内容</TabPane> | |
177 | + <TabPane label="标签三">标签三的内容</TabPane> | |
178 | + </Tabs> | |
179 | + </div> | |
165 | 180 | </template> |
166 | 181 | <script> |
167 | 182 | export default { | ... | ... |
src/components/tabs/tabs.vue
... | ... | @@ -2,7 +2,13 @@ |
2 | 2 | <div :class="classes"> |
3 | 3 | <div :class="[prefixCls + '-bar']"> |
4 | 4 | <div :class="[prefixCls + '-nav-right']" v-if="showSlot"><slot name="extra"></slot></div> |
5 | - <div :class="[prefixCls + '-nav-container']"> | |
5 | + <div | |
6 | + :class="[prefixCls + '-nav-container']" | |
7 | + tabindex="0" | |
8 | + ref="navContainer" | |
9 | + @keydown="handleTabKeyNavigation" | |
10 | + @keydown.space.prevent="handleTabKeyboardSelect" | |
11 | + > | |
6 | 12 | <div ref="navWrap" :class="[prefixCls + '-nav-wrap', scrollable ? prefixCls + '-nav-scrollable' : '']"> |
7 | 13 | <span :class="[prefixCls + '-nav-prev', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollPrev"><Icon type="chevron-left"></Icon></span> |
8 | 14 | <span :class="[prefixCls + '-nav-next', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollNext"><Icon type="chevron-right"></Icon></span> |
... | ... | @@ -20,7 +26,7 @@ |
20 | 26 | </div> |
21 | 27 | </div> |
22 | 28 | </div> |
23 | - <div :class="contentClasses" :style="contentStyle"><slot></slot></div> | |
29 | + <div :class="contentClasses" :style="contentStyle" ref="panes"><slot></slot></div> | |
24 | 30 | </div> |
25 | 31 | </template> |
26 | 32 | <script> |
... | ... | @@ -31,6 +37,28 @@ |
31 | 37 | import elementResizeDetectorMaker from 'element-resize-detector'; |
32 | 38 | |
33 | 39 | const prefixCls = 'ivu-tabs'; |
40 | + const transitionTime = 300; // from CSS | |
41 | + | |
42 | + const getNextTab = (list, activeKey, direction, countDisabledAlso) => { | |
43 | + const currentIndex = list.findIndex(tab => tab.name === activeKey); | |
44 | + const nextIndex = (currentIndex + direction + list.length) % list.length; | |
45 | + const nextTab = list[nextIndex]; | |
46 | + if (nextTab.disabled) return getNextTab(list, nextTab.name, direction, countDisabledAlso); | |
47 | + else return nextTab; | |
48 | + }; | |
49 | + | |
50 | + const focusFirst = (element, root) => { | |
51 | + try {element.focus();} | |
52 | + catch(err) {} // eslint-disable-line no-empty | |
53 | + | |
54 | + if (document.activeElement == element && element !== root) return true; | |
55 | + | |
56 | + const candidates = element.children; | |
57 | + for (let candidate of candidates) { | |
58 | + if (focusFirst(candidate, root)) return true; | |
59 | + } | |
60 | + return false; | |
61 | + }; | |
34 | 62 | |
35 | 63 | export default { |
36 | 64 | name: 'Tabs', |
... | ... | @@ -68,11 +96,13 @@ |
68 | 96 | barWidth: 0, |
69 | 97 | barOffset: 0, |
70 | 98 | activeKey: this.value, |
99 | + focusedKey: this.value, | |
71 | 100 | showSlot: false, |
72 | 101 | navStyle: { |
73 | 102 | transform: '' |
74 | 103 | }, |
75 | - scrollable: false | |
104 | + scrollable: false, | |
105 | + transitioning: false, | |
76 | 106 | }; |
77 | 107 | }, |
78 | 108 | computed: { |
... | ... | @@ -183,17 +213,46 @@ |
183 | 213 | `${prefixCls}-tab`, |
184 | 214 | { |
185 | 215 | [`${prefixCls}-tab-disabled`]: item.disabled, |
186 | - [`${prefixCls}-tab-active`]: item.name === this.activeKey | |
216 | + [`${prefixCls}-tab-active`]: item.name === this.activeKey, | |
217 | + [`${prefixCls}-tab-focused`]: item.name === this.focusedKey, | |
187 | 218 | } |
188 | 219 | ]; |
189 | 220 | }, |
190 | 221 | handleChange (index) { |
222 | + if (this.transitioning) return; | |
223 | + | |
224 | + this.transitioning = true; | |
225 | + setTimeout(() => this.transitioning = false, transitionTime); | |
226 | + | |
191 | 227 | const nav = this.navList[index]; |
192 | 228 | if (nav.disabled) return; |
193 | 229 | this.activeKey = nav.name; |
194 | 230 | this.$emit('input', nav.name); |
195 | 231 | this.$emit('on-click', nav.name); |
196 | 232 | }, |
233 | + handleTabKeyNavigation(e){ | |
234 | + if (e.keyCode !== 37 && e.keyCode !== 39) return; | |
235 | + const direction = e.keyCode === 39 ? 1 : -1; | |
236 | + const nextTab = getNextTab(this.navList, this.focusedKey, direction); | |
237 | + this.focusedKey = nextTab.name; | |
238 | + }, | |
239 | + handleTabKeyboardSelect(){ | |
240 | + this.activeKey = this.focusedKey || 0; | |
241 | + const nextIndex = Math.max(this.navList.findIndex(tab => tab.name === this.focusedKey), 0); | |
242 | + | |
243 | + [...this.$refs.panes.children].forEach((el, i) => { | |
244 | + if (nextIndex === i) { | |
245 | + [...el.children].forEach(child => child.style.display = 'block'); | |
246 | + setTimeout(() => { | |
247 | + focusFirst(el, el); | |
248 | + }, transitionTime); | |
249 | + } else { | |
250 | + setTimeout(() => { | |
251 | + [...el.children].forEach(child => child.style.display = 'none'); | |
252 | + }, transitionTime); | |
253 | + } | |
254 | + }); | |
255 | + }, | |
197 | 256 | handleRemove (index) { |
198 | 257 | const tabs = this.getTabs(); |
199 | 258 | const tab = tabs[index]; |
... | ... | @@ -325,8 +384,10 @@ |
325 | 384 | watch: { |
326 | 385 | value (val) { |
327 | 386 | this.activeKey = val; |
387 | + this.focusedKey = val; | |
328 | 388 | }, |
329 | - activeKey () { | |
389 | + activeKey (val) { | |
390 | + this.focusedKey = val; | |
330 | 391 | this.updateBar(); |
331 | 392 | this.updateStatus(); |
332 | 393 | this.broadcast('Table', 'on-visible-change', true); |
... | ... | @@ -351,6 +412,8 @@ |
351 | 412 | |
352 | 413 | this.mutationObserver.observe(hiddenParentNode, { attributes: true, childList: true, characterData: true, attributeFilter: ['style'] }); |
353 | 414 | } |
415 | + | |
416 | + this.handleTabKeyboardSelect(); | |
354 | 417 | }, |
355 | 418 | beforeDestroy() { |
356 | 419 | this.observer.removeListener(this.$refs.navWrap, this.handleResize); | ... | ... |
src/styles/components/tabs.less
... | ... | @@ -39,6 +39,13 @@ |
39 | 39 | .clearfix; |
40 | 40 | } |
41 | 41 | |
42 | + &-nav-container:focus { | |
43 | + outline: none; | |
44 | + .@{tabs-prefix-cls}-tab-focused { | |
45 | + border-color: @link-hover-color !important; | |
46 | + } | |
47 | + } | |
48 | + | |
42 | 49 | &-nav-container-scrolling { |
43 | 50 | padding-left: 32px; |
44 | 51 | padding-right: 32px; |
... | ... | @@ -158,6 +165,7 @@ |
158 | 165 | width: 100%; |
159 | 166 | transition: opacity .3s; |
160 | 167 | opacity: 1; |
168 | + outline: none; | |
161 | 169 | } |
162 | 170 | |
163 | 171 | .@{tabs-prefix-cls}-tabpane-inactive { |
... | ... | @@ -228,4 +236,4 @@ |
228 | 236 | display: none; |
229 | 237 | } |
230 | 238 | } |
231 | -} | |
232 | 239 | \ No newline at end of file |
240 | +} | ... | ... |