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 | +} |