Jelajahi Sumber

feat: add a11y to navigation (#1195)

* feat: add a11y to navigation

* feat: add a11y to navigation

* feat: add a11y to navigation

Co-authored-by: pointhalo <[email protected]>
YannLynn 3 tahun lalu
induk
melakukan
7aeeb9ad08

+ 8 - 0
content/navigation/navigation/index-en-US.md

@@ -768,6 +768,14 @@ function NavApp (props = {}) {
 | collapseText   | Title of the collapse button                                                                                                      | (collapsed:boolean) => string\|ReactNode |         | 0.35.0  |
 | collapseText   | Title of the collapse button                                                                                                      | (collapsed:boolean) => string\|ReactNode |         | 0.35.0  |
 | style          | Outermost style                                                                                                                   | object                                    |         |         |
 | style          | Outermost style                                                                                                                   | object                                    |         |         |
 
 
+## Accessibility
+- ### Keyboard and Focus
+- Each clickable item in the Navigation can be focused, use `Tab` and `Shift + Tab` to switch focus between each other, and each link can be activated by the `Enter` key
+- When an item can be opened popup
+  - The way to open the popup layer is hover : when the item is focused, the popup layer opens. Keyboard users can use the down arrow to move the focus to the bullet layer, and the Esc key can return the focus to the item
+  - The way to open the popup layer is click : when the item is focused, click the Enter key to open the popup layer. Keyboard users can use the down arrow to move the focus to the bullet layer, and the Esc key can return the focus to the item
+  -Keyboard interaction does not fully support nested scenes
+
 ## Content Guidelines
 ## Content Guidelines
 
 
 - Navigation bar menu uses sentence case format
 - Navigation bar menu uses sentence case format

+ 8 - 0
content/navigation/navigation/index.md

@@ -775,6 +775,14 @@ function NavApp (props = {}) {
 | style          | 最外层样式                                                                               | CSSProperties                                    |        |  
 | style          | 最外层样式                                                                               | CSSProperties                                    |        |  
 
 
 
 
+## Accessibility
+- ### 键盘和焦点
+- Navigation 内的每个可点击 item 都可以被聚焦,相互之间使用 `Tab` 及 `Shift  + Tab` 切换焦点,并且可以通过 `Enter` 键激活每个链接
+- 当某个 item 可被打开弹层时
+  - 打开弹层方式为 hover :该 item 被聚焦时,弹层打开。键盘用户可以通过下箭头将焦点移动到弹层上,`Esc` 键可以将焦点返回到 item 上
+  - 打开弹层的方式为 click :该 item 被聚焦时,点击 Enter 键,打开弹层。键盘用户可以通过下箭头将焦点移动到弹层上,`Esc` 键可以将焦点返回到 item 上
+  - 键盘交互暂未完整支持嵌套场景
+
 ## 文案规范
 ## 文案规范
 
 
 - 导航栏菜单使用句子大小写格式
 - 导航栏菜单使用句子大小写格式

+ 6 - 0
packages/semi-foundation/navigation/itemFoundation.ts

@@ -1,4 +1,5 @@
 /* argus-disable unPkgSensitiveInfo */
 /* argus-disable unPkgSensitiveInfo */
+import { get } from 'lodash';
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 import isEnterPress from '../utils/isEnterPress';
 import isEnterPress from '../utils/isEnterPress';
 
 
@@ -99,7 +100,12 @@ export default class ItemFoundation<P = Record<string, any>, S = Record<string,
      */
      */
     handleKeyPress(e: any) {
     handleKeyPress(e: any) {
         if (isEnterPress(e)) {
         if (isEnterPress(e)) {
+            const { link, linkOptions } = this.getProps();
+            const target = get(linkOptions, 'target', '_self');
             this.handleClick(e);
             this.handleClick(e);
+            if (typeof link === 'string') {
+                target === '_blank' ? window.open(link) : window.location.href = link;
+            }
         }
         }
     }
     }
 }
 }

+ 5 - 0
packages/semi-foundation/navigation/navigation.scss

@@ -96,6 +96,11 @@ $module: #{$prefix}-navigation;
         &-indent {
         &-indent {
             width: $width-navigation_icon_text_between + $width-navigation_icon_left;
             width: $width-navigation_icon_text_between + $width-navigation_icon_left;
         }
         }
+
+        &:focus-visible {
+            outline: $width-navigation-outline solid $color-navigation_outline-focus;
+            outline-offset: $width-navigation-outlineOffset;
+        }
     }
     }
 
 
     &-header-link,
     &-header-link,

+ 3 - 2
packages/semi-foundation/navigation/variables.scss

@@ -16,7 +16,8 @@ $width-navigation_dropdown_item_nav_item-minWidth: 150px; // 导航栏菜单项
 $width-navigation_border: 1px; // 导航栏描边宽度
 $width-navigation_border: 1px; // 导航栏描边宽度
 $width-navigation_footer_border: 1px; // 导航栏 footer 描边宽度
 $width-navigation_footer_border: 1px; // 导航栏 footer 描边宽度
 $width-navigation_icon_left-minWidth: 20px; // 导航栏菜单项展开收起按钮最小宽度
 $width-navigation_icon_left-minWidth: 20px; // 导航栏菜单项展开收起按钮最小宽度
-
+$width-navigation-outline: 2px; // 导航栏聚焦outline宽度
+$width-navigation-outlineOffset: -2px; // 导航栏聚焦outline偏移
 
 
 // Spacing
 // Spacing
 $spacing-navigation-paddingX: $spacing-tight; // 侧边导航栏水平方向内边距
 $spacing-navigation-paddingX: $spacing-tight; // 侧边导航栏水平方向内边距
@@ -109,7 +110,7 @@ $color-navigation_itemLn-text-hover: var(--semi-color-text-0); // 导航栏子
 $color-navigation_itemLn-bg-active: var(--semi-color-fill-1); // 导航栏子级菜单项按下态背景颜色
 $color-navigation_itemLn-bg-active: var(--semi-color-fill-1); // 导航栏子级菜单项按下态背景颜色
 $color-navigation_itemLn-text-active: var(--semi-color-text-0); // 导航栏子级菜单项按下态文字颜色
 $color-navigation_itemLn-text-active: var(--semi-color-text-0); // 导航栏子级菜单项按下态文字颜色
 $color-navigation_itemLn_selected-bg-default: var(--semi-color-primary-light-default); // 导航栏子级菜单项选中态背景颜色
 $color-navigation_itemLn_selected-bg-default: var(--semi-color-primary-light-default); // 导航栏子级菜单项选中态背景颜色
-
+$color-navigation_outline-focus: var(--semi-color-primary-light-active); // 导航栏子级菜单键盘聚焦颜色
 
 
 // Transition
 // Transition
 $motion-navigation_item_title: opacity 100ms 100s ease-out; // 导航栏菜单项标题收起时渐隐动画
 $motion-navigation_item_title: opacity 100ms 100s ease-out; // 导航栏菜单项标题收起时渐隐动画

+ 4 - 3
packages/semi-ui/dropdown/dropdownItem.tsx

@@ -22,7 +22,8 @@ export interface DropdownItemProps extends BaseProps {
     forwardRef?: (ele: HTMLLIElement) => void;
     forwardRef?: (ele: HTMLLIElement) => void;
     type?: Type;
     type?: Type;
     active?: boolean;
     active?: boolean;
-    icon?: React.ReactNode
+    icon?: React.ReactNode;
+    onKeyDown?: (e: React.KeyboardEvent) => void;
 }
 }
 
 
 const prefixCls = css.PREFIX;
 const prefixCls = css.PREFIX;
@@ -60,7 +61,7 @@ class DropdownItem extends BaseComponent<DropdownItemProps> {
 
 
 
 
     render() {
     render() {
-        const { children, disabled, className, forwardRef, style, type, active, icon } = this.props;
+        const { children, disabled, className, forwardRef, style, type, active, icon, onKeyDown } = this.props;
         const { showTick } = this.context;
         const { showTick } = this.context;
         const itemclass = cls(className, {
         const itemclass = cls(className, {
             [`${prefixCls}-item`]: true,
             [`${prefixCls}-item`]: true,
@@ -97,7 +98,7 @@ class DropdownItem extends BaseComponent<DropdownItemProps> {
             );
             );
         }
         }
         return (
         return (
-            <li role="menuitem" tabIndex={-1} aria-disabled={disabled} {...events} ref={ref => forwardRef(ref)} className={itemclass} style={style}>
+            <li role="menuitem" tabIndex={-1} aria-disabled={disabled} {...events} onKeyDown={onKeyDown} ref={ref => forwardRef(ref)} className={itemclass} style={style}>
                 {tick}
                 {tick}
                 {iconContent}
                 {iconContent}
                 {children}
                 {children}

+ 6 - 3
packages/semi-ui/navigation/Item.tsx

@@ -217,7 +217,7 @@ export default class NavItem extends BaseComponent<NavItemProps, NavItemState> {
 
 
         if (typeof link === 'string') {
         if (typeof link === 'string') {
             itemChildren = (
             itemChildren = (
-                <a className={`${prefixCls}-item-link`} href={link} {...(linkOptions as any)}>
+                <a className={`${prefixCls}-item-link`} href={link} tabIndex={-1} {...(linkOptions as any)}>
                     {itemChildren}
                     {itemChildren}
                 </a>
                 </a>
             );
             );
@@ -244,6 +244,7 @@ export default class NavItem extends BaseComponent<NavItemProps, NavItemState> {
                     onMouseEnter={onMouseEnter}
                     onMouseEnter={onMouseEnter}
                     onMouseLeave={onMouseLeave}
                     onMouseLeave={onMouseLeave}
                     disabled={disabled}
                     disabled={disabled}
+                    onKeyDown={this.handleKeyPress}
                 >
                 >
                     {itemChildren}
                     {itemChildren}
                 </Dropdown.Item>
                 </Dropdown.Item>
@@ -266,9 +267,11 @@ export default class NavItem extends BaseComponent<NavItemProps, NavItemState> {
             }
             }
 
 
             itemDom = (
             itemDom = (
+                // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
                 <li
                 <li
-                    role="menuitem"
-                    tabIndex={-1}
+                    // if role = menuitem, the narration will read all expanded li
+                    role={isSubNav ? null : "menuitem"}
+                    tabIndex={isSubNav ? -1 : 0}
                     {...ariaProps}
                     {...ariaProps}
                     style={style}
                     style={style}
                     ref={this.setItemRef}
                     ref={this.setItemRef}

+ 13 - 7
packages/semi-ui/navigation/SubNav.tsx

@@ -106,8 +106,8 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
         isOpen: false,
         isOpen: false,
         maxHeight: numbers.DEFAULT_SUBNAV_MAX_HEIGHT,
         maxHeight: numbers.DEFAULT_SUBNAV_MAX_HEIGHT,
         toggleIcon: {
         toggleIcon: {
-            open: <IconChevronUp />,
-            closed: <IconChevronDown />,
+            open: <IconChevronUp aria-hidden={true} />,
+            closed: <IconChevronDown aria-hidden={true} />,
         },
         },
         disabled: false,
         disabled: false,
     };
     };
@@ -211,6 +211,8 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
 
 
         const { mode, isInSubNav, isCollapsed, prefixCls, subNavMotion, limitIndent } = this.context;
         const { mode, isInSubNav, isCollapsed, prefixCls, subNavMotion, limitIndent } = this.context;
 
 
+        const isOpen = this.adapter.getIsOpen();
+
         const titleCls = cls(`${prefixCls}-sub-title`, {
         const titleCls = cls(`${prefixCls}-sub-title`, {
             [`${prefixCls}-sub-title-selected`]: this.adapter.getIsSelected(itemKey),
             [`${prefixCls}-sub-title-selected`]: this.adapter.getIsSelected(itemKey),
             [`${prefixCls}-sub-title-disabled`]: disabled,
             [`${prefixCls}-sub-title-disabled`]: disabled,
@@ -227,9 +229,9 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
             }
             }
         } else if (mode === strings.MODE_HORIZONTAL) {
         } else if (mode === strings.MODE_HORIZONTAL) {
             if (isInSubNav) {
             if (isInSubNav) {
-                toggleIconType = <IconChevronRight />;
+                toggleIconType = <IconChevronRight aria-hidden={true} />;
             } else {
             } else {
-                toggleIconType = <IconChevronDown />;
+                toggleIconType = <IconChevronDown aria-hidden={true} />;
                 // Horizontal mode does not require animation fix#1198
                 // Horizontal mode does not require animation fix#1198
                 // withTransition = true;
                 // withTransition = true;
             }
             }
@@ -237,7 +239,7 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
             if (subNavMotion) {
             if (subNavMotion) {
                 withTransition = true;
                 withTransition = true;
             }
             }
-            toggleIconType = <IconChevronDown />;
+            toggleIconType = <IconChevronDown aria-hidden={true} />;
         }
         }
 
 
         let placeholderIcons = null;
         let placeholderIcons = null;
@@ -247,14 +249,18 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
             placeholderIcons = times(iconAmount, index => this.renderIcon(null, strings.ICON_POS_RIGHT, false, false, index));
             placeholderIcons = times(iconAmount, index => this.renderIcon(null, strings.ICON_POS_RIGHT, false, false, index));
         }
         }
 
 
+        const isIconChevronRightShow = (!isCollapsed && isInSubNav && mode === strings.MODE_HORIZONTAL) || (isCollapsed && isInSubNav);
+
         const titleDiv = (
         const titleDiv = (
             <div
             <div
                 role="menuitem"
                 role="menuitem"
-                tabIndex={-1}
+                // to avoid nested horizontal navigation be focused
+                tabIndex={isIconChevronRightShow ? -1 : 0}
                 ref={this.setTitleRef as any}
                 ref={this.setTitleRef as any}
                 className={titleCls}
                 className={titleCls}
                 onClick={this.handleClick}
                 onClick={this.handleClick}
                 onKeyPress={this.handleKeyPress}
                 onKeyPress={this.handleKeyPress}
+                aria-expanded={isOpen ? 'true' : 'false'}
             >
             >
                 <div className={`${prefixCls}-item-inner`}>
                 <div className={`${prefixCls}-item-inner`}>
                     {placeholderIcons}
                     {placeholderIcons}
@@ -335,7 +341,7 @@ export default class SubNav extends BaseComponent<SubNavProps, SubNavState> {
                     className={subNavCls}
                     className={subNavCls}
                     render={(
                     render={(
                         <Dropdown.Menu>
                         <Dropdown.Menu>
-                            <li className={`${prefixCls}-popover-crumb`} />
+                            {/* <li className={`${prefixCls}-popover-crumb`} /> */}
                             {children}
                             {children}
                         </Dropdown.Menu>
                         </Dropdown.Menu>
                     )}
                     )}

+ 3 - 3
packages/semi-ui/navigation/_story/navigation.stories.jsx

@@ -57,14 +57,14 @@ export const Default = () => {
             <Nav.Item key={k} itemKey={String(k)} text={'Option ' + k} />
             <Nav.Item key={k} itemKey={String(k)} text={'Option ' + k} />
           ))}
           ))}
         </Nav.Sub>
         </Nav.Sub>
-        <Nav.Item itemKey={'6'} text={'Option 6 (with link)'} icon={<IconStar />} link="/star" />
+        <Nav.Item itemKey={'6'} text={'Option 6 (with link)'} icon={<IconStar />} link="/?path=/story/navigation--collapse-expand" linkOptions={{target: '_blank'}}/>
         <Nav.Sub text={'Group 7'} icon={<IconFolder />} stayWhenClick={true} itemKey={'7'}>
         <Nav.Sub text={'Group 7'} icon={<IconFolder />} stayWhenClick={true} itemKey={'7'}>
           {['7-1', '7-2'].map(k => (
           {['7-1', '7-2'].map(k => (
             <Nav.Item
             <Nav.Item
               key={k}
               key={k}
               itemKey={String(k)}
               itemKey={String(k)}
               text={'Option ' + k + ' (with link)'}
               text={'Option ' + k + ' (with link)'}
-              link={`folder/${k}`}
+              link={`/?path=/story/navigation--collapse-expand`}
             />
             />
           ))}
           ))}
           <Nav.Item itemKey={'7-3'} text={'Option 7-3'} />
           <Nav.Item itemKey={'7-3'} text={'Option 7-3'} />
@@ -158,7 +158,7 @@ export const Horizontal = () => (
           text: 'Group 3',
           text: 'Group 3',
           itemKey: '3',
           itemKey: '3',
           icon: <IconFile />,
           icon: <IconFile />,
-          items: ['3-1', '3-2', { text: 'Group 3-3', items: ['3-3-1', '3-3-2'] }],
+          items: ['3-1', {text: '3-2',  link: `/?path=/story/navigation--collapse-expand`}, { text: 'Group 3-3', items: ['3-3-1', '3-3-2'] }],
         },
         },
       ]}
       ]}
       onSelect={key => console.log(key)}
       onSelect={key => console.log(key)}