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,10 +158,25 @@ | ||
158 | <!--</script>--> | 158 | <!--</script>--> |
159 | 159 | ||
160 | <template> | 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 | <Button type="ghost" @click="handleTabsAdd" size="small" slot="extra">增加</Button> | 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 | </template> | 180 | </template> |
166 | <script> | 181 | <script> |
167 | export default { | 182 | export default { |
src/components/tabs/tabs.vue
@@ -2,7 +2,13 @@ | @@ -2,7 +2,13 @@ | ||
2 | <div :class="classes"> | 2 | <div :class="classes"> |
3 | <div :class="[prefixCls + '-bar']"> | 3 | <div :class="[prefixCls + '-bar']"> |
4 | <div :class="[prefixCls + '-nav-right']" v-if="showSlot"><slot name="extra"></slot></div> | 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 | <div ref="navWrap" :class="[prefixCls + '-nav-wrap', scrollable ? prefixCls + '-nav-scrollable' : '']"> | 12 | <div ref="navWrap" :class="[prefixCls + '-nav-wrap', scrollable ? prefixCls + '-nav-scrollable' : '']"> |
7 | <span :class="[prefixCls + '-nav-prev', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollPrev"><Icon type="chevron-left"></Icon></span> | 13 | <span :class="[prefixCls + '-nav-prev', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollPrev"><Icon type="chevron-left"></Icon></span> |
8 | <span :class="[prefixCls + '-nav-next', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollNext"><Icon type="chevron-right"></Icon></span> | 14 | <span :class="[prefixCls + '-nav-next', scrollable ? '' : prefixCls + '-nav-scroll-disabled']" @click="scrollNext"><Icon type="chevron-right"></Icon></span> |
@@ -20,7 +26,7 @@ | @@ -20,7 +26,7 @@ | ||
20 | </div> | 26 | </div> |
21 | </div> | 27 | </div> |
22 | </div> | 28 | </div> |
23 | - <div :class="contentClasses" :style="contentStyle"><slot></slot></div> | 29 | + <div :class="contentClasses" :style="contentStyle" ref="panes"><slot></slot></div> |
24 | </div> | 30 | </div> |
25 | </template> | 31 | </template> |
26 | <script> | 32 | <script> |
@@ -31,6 +37,28 @@ | @@ -31,6 +37,28 @@ | ||
31 | import elementResizeDetectorMaker from 'element-resize-detector'; | 37 | import elementResizeDetectorMaker from 'element-resize-detector'; |
32 | 38 | ||
33 | const prefixCls = 'ivu-tabs'; | 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 | export default { | 63 | export default { |
36 | name: 'Tabs', | 64 | name: 'Tabs', |
@@ -68,11 +96,13 @@ | @@ -68,11 +96,13 @@ | ||
68 | barWidth: 0, | 96 | barWidth: 0, |
69 | barOffset: 0, | 97 | barOffset: 0, |
70 | activeKey: this.value, | 98 | activeKey: this.value, |
99 | + focusedKey: this.value, | ||
71 | showSlot: false, | 100 | showSlot: false, |
72 | navStyle: { | 101 | navStyle: { |
73 | transform: '' | 102 | transform: '' |
74 | }, | 103 | }, |
75 | - scrollable: false | 104 | + scrollable: false, |
105 | + transitioning: false, | ||
76 | }; | 106 | }; |
77 | }, | 107 | }, |
78 | computed: { | 108 | computed: { |
@@ -183,17 +213,46 @@ | @@ -183,17 +213,46 @@ | ||
183 | `${prefixCls}-tab`, | 213 | `${prefixCls}-tab`, |
184 | { | 214 | { |
185 | [`${prefixCls}-tab-disabled`]: item.disabled, | 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 | handleChange (index) { | 221 | handleChange (index) { |
222 | + if (this.transitioning) return; | ||
223 | + | ||
224 | + this.transitioning = true; | ||
225 | + setTimeout(() => this.transitioning = false, transitionTime); | ||
226 | + | ||
191 | const nav = this.navList[index]; | 227 | const nav = this.navList[index]; |
192 | if (nav.disabled) return; | 228 | if (nav.disabled) return; |
193 | this.activeKey = nav.name; | 229 | this.activeKey = nav.name; |
194 | this.$emit('input', nav.name); | 230 | this.$emit('input', nav.name); |
195 | this.$emit('on-click', nav.name); | 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 | handleRemove (index) { | 256 | handleRemove (index) { |
198 | const tabs = this.getTabs(); | 257 | const tabs = this.getTabs(); |
199 | const tab = tabs[index]; | 258 | const tab = tabs[index]; |
@@ -325,8 +384,10 @@ | @@ -325,8 +384,10 @@ | ||
325 | watch: { | 384 | watch: { |
326 | value (val) { | 385 | value (val) { |
327 | this.activeKey = val; | 386 | this.activeKey = val; |
387 | + this.focusedKey = val; | ||
328 | }, | 388 | }, |
329 | - activeKey () { | 389 | + activeKey (val) { |
390 | + this.focusedKey = val; | ||
330 | this.updateBar(); | 391 | this.updateBar(); |
331 | this.updateStatus(); | 392 | this.updateStatus(); |
332 | this.broadcast('Table', 'on-visible-change', true); | 393 | this.broadcast('Table', 'on-visible-change', true); |
@@ -351,6 +412,8 @@ | @@ -351,6 +412,8 @@ | ||
351 | 412 | ||
352 | this.mutationObserver.observe(hiddenParentNode, { attributes: true, childList: true, characterData: true, attributeFilter: ['style'] }); | 413 | this.mutationObserver.observe(hiddenParentNode, { attributes: true, childList: true, characterData: true, attributeFilter: ['style'] }); |
353 | } | 414 | } |
415 | + | ||
416 | + this.handleTabKeyboardSelect(); | ||
354 | }, | 417 | }, |
355 | beforeDestroy() { | 418 | beforeDestroy() { |
356 | this.observer.removeListener(this.$refs.navWrap, this.handleResize); | 419 | this.observer.removeListener(this.$refs.navWrap, this.handleResize); |
src/styles/components/tabs.less
@@ -39,6 +39,13 @@ | @@ -39,6 +39,13 @@ | ||
39 | .clearfix; | 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 | &-nav-container-scrolling { | 49 | &-nav-container-scrolling { |
43 | padding-left: 32px; | 50 | padding-left: 32px; |
44 | padding-right: 32px; | 51 | padding-right: 32px; |
@@ -158,6 +165,7 @@ | @@ -158,6 +165,7 @@ | ||
158 | width: 100%; | 165 | width: 100%; |
159 | transition: opacity .3s; | 166 | transition: opacity .3s; |
160 | opacity: 1; | 167 | opacity: 1; |
168 | + outline: none; | ||
161 | } | 169 | } |
162 | 170 | ||
163 | .@{tabs-prefix-cls}-tabpane-inactive { | 171 | .@{tabs-prefix-cls}-tabpane-inactive { |
@@ -228,4 +236,4 @@ | @@ -228,4 +236,4 @@ | ||
228 | display: none; | 236 | display: none; |
229 | } | 237 | } |
230 | } | 238 | } |
231 | -} | ||
232 | \ No newline at end of file | 239 | \ No newline at end of file |
240 | +} |