소스 검색

Add the keyboard control with following the ARIA tab spec.

NaotoshiFujita 3 년 전
부모
커밋
e235eec461

+ 65 - 26
dist/js/splide.js

@@ -719,7 +719,8 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
   var CLASS_NEXT = "is-next";
   var CLASS_VISIBLE = "is-visible";
   var CLASS_LOADING = "is-loading";
-  var STATUS_CLASSES = [CLASS_ACTIVE, CLASS_VISIBLE, CLASS_PREV, CLASS_NEXT, CLASS_LOADING];
+  var CLASS_FOCUS = "has-focus";
+  var STATUS_CLASSES = [CLASS_ACTIVE, CLASS_VISIBLE, CLASS_PREV, CLASS_NEXT, CLASS_LOADING, CLASS_FOCUS];
   var CLASSES = {
     slide: CLASS_SLIDE,
     clone: CLASS_CLONE,
@@ -2236,7 +2237,19 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
     };
   }
 
-  var IE_ARROW_KEYS = ["Left", "Right", "Up", "Down"];
+  var NORMALIZATION_MAP = {
+    spacebar: " ",
+    Right: "ArrowRight",
+    Left: "ArrowLeft",
+    Up: "ArrowUp",
+    Down: "ArrowDown"
+  };
+
+  function normalizeKey(key) {
+    key = isString(key) ? key : key.key;
+    return NORMALIZATION_MAP[key] || key;
+  }
+
   var KEYBOARD_EVENT = "keydown";
 
   function Keyboard(Splide2, Components2, options) {
@@ -2252,7 +2265,8 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
 
     function mount() {
       init();
-      on(EVENT_UPDATED, onUpdated);
+      on(EVENT_UPDATED, destroy);
+      on(EVENT_UPDATED, init);
       on(EVENT_MOVE, onMove);
     }
 
@@ -2287,19 +2301,13 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
       });
     }
 
-    function onUpdated() {
-      destroy();
-      init();
-    }
-
     function onKeydown(e) {
       if (!disabled) {
-        var key = e.key;
-        var normalizedKey = includes(IE_ARROW_KEYS, key) ? "Arrow" + key : key;
+        var key = normalizeKey(e);
 
-        if (normalizedKey === resolve("ArrowLeft")) {
+        if (key === resolve("ArrowLeft")) {
           Splide2.go("<");
-        } else if (normalizedKey === resolve("ArrowRight")) {
+        } else if (key === resolve("ArrowRight")) {
           Splide2.go(">");
         }
       }
@@ -2443,7 +2451,9 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
         Elements = Components2.Elements,
         Controller = Components2.Controller;
     var hasFocus = Controller.hasFocus,
-        getIndex = Controller.getIndex;
+        getIndex = Controller.getIndex,
+        go = Controller.go;
+    var resolve = Components2.Direction.resolve;
     var items = [];
     var list;
 
@@ -2470,7 +2480,7 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
       if (list) {
         remove(list);
         items.forEach(function (item) {
-          unbind(item.button, "click");
+          unbind(item.button, "click keydown focus");
         });
         empty(items);
         list = null;
@@ -2485,6 +2495,8 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
       var parent = options.pagination === "slider" && Elements.slider || Elements.root;
       var max = hasFocus() ? length : ceil(length / perPage);
       list = create("ul", classes.pagination, parent);
+      bind(list, "focusin", apply(addClass, list, CLASS_FOCUS));
+      bind(list, "focusout", apply(removeClass, list, CLASS_FOCUS));
       setAttribute(list, ROLE, "tablist");
       setAttribute(list, ARIA_LABEL, i18n.select);
 
@@ -2499,10 +2511,12 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
         });
         var text = !hasFocus() && perPage > 1 ? i18n.pageX : i18n.slideX;
         bind(button, "click", apply(onClick, i));
+        bind(button, "keydown", apply(onKeydown, i));
         setAttribute(li, ROLE, "none");
         setAttribute(button, ROLE, "tab");
         setAttribute(button, ARIA_CONTROLS, controls.join(" "));
         setAttribute(button, ARIA_LABEL, format(text, i + 1));
+        setAttribute(button, TAB_INDEX, -1);
         items.push({
           li: li,
           button: button,
@@ -2512,10 +2526,31 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
     }
 
     function onClick(page) {
-      Controller.go(">" + page, true, function () {
-        var Slide = Slides.getAt(Controller.toIndex(page));
-        Slide && focus(Slide.slide);
-      });
+      go(">" + page, true);
+    }
+
+    function onKeydown(page, e) {
+      var length = items.length;
+      var key = normalizeKey(e);
+      var nextPage = -1;
+
+      if (key === resolve("ArrowRight")) {
+        nextPage = ++page % length;
+      } else if (key === resolve("ArrowLeft")) {
+        nextPage = (--page + length) % length;
+      } else if (key === "Home") {
+        nextPage = 0;
+      } else if (key === "End") {
+        nextPage = length - 1;
+      }
+
+      var item = items[nextPage];
+
+      if (item) {
+        focus(item.button);
+        go(">" + page);
+        prevent(e, true);
+      }
     }
 
     function getAt(index) {
@@ -2527,13 +2562,17 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
       var curr = getAt(getIndex());
 
       if (prev) {
-        removeClass(prev.button, CLASS_ACTIVE);
-        removeAttribute(prev.button, ARIA_SELECTED);
+        var button = prev.button;
+        removeClass(button, CLASS_ACTIVE);
+        removeAttribute(button, ARIA_SELECTED);
+        setAttribute(button, TAB_INDEX, -1);
       }
 
       if (curr) {
-        addClass(curr.button, CLASS_ACTIVE);
-        setAttribute(curr.button, ARIA_SELECTED, true);
+        var _button = curr.button;
+        addClass(_button, CLASS_ACTIVE);
+        setAttribute(_button, ARIA_SELECTED, true);
+        setAttribute(_button, TAB_INDEX, null);
       }
 
       emit(EVENT_PAGINATION_UPDATED, {
@@ -2551,7 +2590,7 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
     };
   }
 
-  var TRIGGER_KEYS = [" ", "Enter", "Spacebar"];
+  var TRIGGER_KEYS = [" ", "Enter"];
 
   function Sync(Splide2, Components2, options) {
     var list = Components2.Elements.list;
@@ -2596,13 +2635,13 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
       on(EVENT_CLICK, onClick);
       on(EVENT_SLIDE_KEYDOWN, onKeydown);
       on([EVENT_MOUNTED, EVENT_UPDATED], update);
-      setAttribute(list, ROLE, "menu");
+      setAttribute(list, ROLE, "tablist");
       events.push(event);
       event.emit(EVENT_NAVIGATION_MOUNTED, Splide2.splides);
     }
 
     function update() {
-      setAttribute(list, ARIA_ORIENTATION, options.direction !== TTB ? "horizontal" : null);
+      setAttribute(list, ARIA_ORIENTATION, options.direction === TTB ? "vertical" : null);
     }
 
     function onClick(Slide) {
@@ -2610,7 +2649,7 @@ function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _d
     }
 
     function onKeydown(Slide, e) {
-      if (includes(TRIGGER_KEYS, e.key)) {
+      if (includes(TRIGGER_KEYS, normalizeKey(e))) {
         onClick(Slide);
         prevent(e);
       }

+ 6 - 20
src/js/components/Keyboard/Keyboard.ts

@@ -4,6 +4,7 @@ import { EventInterface } from '../../constructors';
 import { Splide } from '../../core/Splide/Splide';
 import { BaseComponent, Components, Options } from '../../types';
 import { includes, nextTick, setAttribute } from '../../utils';
+import { normalizeKey } from '../../utils/dom/normalizeKey/normalizeKey';
 
 
 /**
@@ -15,13 +16,6 @@ export interface KeyboardComponent extends BaseComponent {
   disable( disabled: boolean ): void;
 }
 
-/**
- * Arrow keys of IE.
- *
- * @since 3.0.0
- */
-const IE_ARROW_KEYS = [ 'Left', 'Right', 'Up', 'Down' ];
-
 /**
  * The keyboard event name.
  *
@@ -60,7 +54,8 @@ export function Keyboard( Splide: Splide, Components: Components, options: Optio
    */
   function mount(): void {
     init();
-    on( EVENT_UPDATED, onUpdated );
+    on( EVENT_UPDATED, destroy );
+    on( EVENT_UPDATED, init );
     on( EVENT_MOVE, onMove );
   }
 
@@ -108,14 +103,6 @@ export function Keyboard( Splide: Splide, Components: Components, options: Optio
     nextTick( () => { disabled = _disabled } );
   }
 
-  /**
-   * Called when options are update.
-   */
-  function onUpdated(): void {
-    destroy();
-    init();
-  }
-
   /**
    * Called when any key is pressed on the target.
    *
@@ -123,12 +110,11 @@ export function Keyboard( Splide: Splide, Components: Components, options: Optio
    */
   function onKeydown( e: KeyboardEvent ): void {
     if ( ! disabled ) {
-      const { key } = e;
-      const normalizedKey = includes( IE_ARROW_KEYS, key ) ? `Arrow${ key }` : key;
+      const key = normalizeKey( e );
 
-      if ( normalizedKey === resolve( 'ArrowLeft' ) ) {
+      if ( key === resolve( 'ArrowLeft' ) ) {
         Splide.go( '<' );
-      } else if ( normalizedKey === resolve( 'ArrowRight' ) ) {
+      } else if ( key === resolve( 'ArrowRight' ) ) {
         Splide.go( '>' );
       }
     }

+ 55 - 13
src/js/components/Pagination/Pagination.ts

@@ -1,5 +1,5 @@
-import { ARIA_CONTROLS, ARIA_LABEL, ARIA_SELECTED, ROLE } from '../../constants/attributes';
-import { CLASS_ACTIVE } from '../../constants/classes';
+import { ARIA_CONTROLS, ARIA_LABEL, ARIA_SELECTED, ROLE, TAB_INDEX } from '../../constants/attributes';
+import { CLASS_ACTIVE, CLASS_FOCUS } from '../../constants/classes';
 import {
   EVENT_MOVE,
   EVENT_PAGINATION_MOUNTED,
@@ -19,11 +19,13 @@ import {
   empty,
   focus,
   format,
+  prevent,
   remove,
   removeAttribute,
   removeClass,
   setAttribute,
 } from '../../utils';
+import { normalizeKey } from '../../utils/dom/normalizeKey/normalizeKey';
 
 
 /**
@@ -73,8 +75,8 @@ export interface PaginationItem {
 export function Pagination( Splide: Splide, Components: Components, options: Options ): PaginationComponent {
   const { on, emit, bind, unbind } = EventInterface( Splide );
   const { Slides, Elements, Controller } = Components;
-  const { hasFocus, getIndex } = Controller;
-  const { isTab } = Elements;
+  const { hasFocus, getIndex, go } = Controller;
+  const { resolve } = Components.Direction;
 
   /**
    * Stores all pagination items.
@@ -114,7 +116,7 @@ export function Pagination( Splide: Splide, Components: Components, options: Opt
   function destroy(): void {
     if ( list ) {
       remove( list );
-      items.forEach( item => { unbind( item.button, 'click' ) } );
+      items.forEach( item => { unbind( item.button, 'click keydown focus' ) } );
       empty( items );
       list = null;
     }
@@ -130,6 +132,9 @@ export function Pagination( Splide: Splide, Components: Components, options: Opt
     const max    = hasFocus() ? length : ceil( length / perPage );
 
     list = create( 'ul', classes.pagination, parent );
+
+    bind( list, 'focusin', apply( addClass, list, CLASS_FOCUS ) );
+    bind( list, 'focusout', apply( removeClass, list, CLASS_FOCUS ) );
     setAttribute( list, ROLE, 'tablist' );
     setAttribute( list, ARIA_LABEL, i18n.select );
 
@@ -140,11 +145,13 @@ export function Pagination( Splide: Splide, Components: Components, options: Opt
       const text     = ! hasFocus() && perPage > 1 ? i18n.pageX : i18n.slideX;
 
       bind( button, 'click', apply( onClick, i ) );
+      bind( button, 'keydown', apply( onKeydown, i ) );
 
       setAttribute( li, ROLE, 'none' );
       setAttribute( button, ROLE, 'tab' );
       setAttribute( button, ARIA_CONTROLS, controls.join( ' ' ) );
       setAttribute( button, ARIA_LABEL, format( text, i + 1 ) );
+      setAttribute( button, TAB_INDEX, -1 );
 
       items.push( { li, button, page: i } );
     }
@@ -159,10 +166,41 @@ export function Pagination( Splide: Splide, Components: Components, options: Opt
    * @param page - A clicked page index.
    */
   function onClick( page: number ): void {
-    Controller.go( `>${ page }`, true, () => {
-      const Slide = Slides.getAt( Controller.toIndex( page ) );
-      Slide && focus( Slide.slide );
-    } );
+    go( `>${ page }`, true );
+  }
+
+  /**
+   * Called when any key is pressed on the pagination.
+   *
+   * @todo option?
+   * @link https://www.w3.org/TR/2021/NOTE-wai-aria-practices-1.2-20211129/#keyboard-interaction-21
+   *
+   * @param page - A page index.
+   * @param e    - A KeyboardEvent object.
+   */
+  function onKeydown( page: number, e: KeyboardEvent ): void {
+    const { length } = items;
+    const key = normalizeKey( e );
+
+    let nextPage = -1
+
+    if ( key === resolve( 'ArrowRight' ) ) {
+      nextPage = ++page % length;
+    } else if ( key === resolve( 'ArrowLeft' ) ) {
+      nextPage = ( --page + length ) % length;
+    } else if ( key === 'Home' ) {
+      nextPage = 0;
+    } else if ( key === 'End' ) {
+      nextPage = length - 1;
+    }
+
+    const item = items[ nextPage ];
+
+    if ( item ) {
+      focus( item.button );
+      go( `>${ page }` );
+      prevent( e, true );
+    }
   }
 
   /**
@@ -184,13 +222,17 @@ export function Pagination( Splide: Splide, Components: Components, options: Opt
     const curr = getAt( getIndex() );
 
     if ( prev ) {
-      removeClass( prev.button, CLASS_ACTIVE );
-      removeAttribute( prev.button, ARIA_SELECTED );
+      const { button } = prev;
+      removeClass( button, CLASS_ACTIVE );
+      removeAttribute( button, ARIA_SELECTED );
+      setAttribute( button, TAB_INDEX, -1 );
     }
 
     if ( curr ) {
-      addClass( curr.button, CLASS_ACTIVE );
-      setAttribute( curr.button, ARIA_SELECTED, true );
+      const { button } = curr;
+      addClass( button, CLASS_ACTIVE );
+      setAttribute( button, ARIA_SELECTED, true );
+      setAttribute( button, TAB_INDEX, null );
     }
 
     emit( EVENT_PAGINATION_UPDATED, { list, items }, prev, curr );

+ 3 - 2
src/js/components/Sync/Sync.ts

@@ -13,6 +13,7 @@ import { EventInterface, EventInterfaceObject } from '../../constructors';
 import { Splide } from '../../core/Splide/Splide';
 import { BaseComponent, Components, Options } from '../../types';
 import { empty, includes, prevent, setAttribute } from '../../utils';
+import { normalizeKey } from '../../utils/dom/normalizeKey/normalizeKey';
 import { SlideComponent } from '../Slides/Slide';
 
 
@@ -30,7 +31,7 @@ export interface SyncComponent extends BaseComponent {
  *
  * @since 3.0.0
  */
-const TRIGGER_KEYS = [ ' ', 'Enter', 'Spacebar' ];
+const TRIGGER_KEYS = [ ' ', 'Enter' ];
 
 /**
  * The component for syncing multiple sliders.
@@ -136,7 +137,7 @@ export function Sync( Splide: Splide, Components: Components, options: Options )
    * @param e     - A KeyboardEvent object.
    */
   function onKeydown( Slide: SlideComponent, e: KeyboardEvent ): void {
-    if ( includes( TRIGGER_KEYS, e.key ) ) {
+    if ( includes( TRIGGER_KEYS, normalizeKey( e ) ) ) {
       onClick( Slide );
       prevent( e );
     }

+ 2 - 2
src/js/constants/classes.ts

@@ -20,20 +20,20 @@ export const CLASS_AUTOPLAY        = `${ PROJECT_CODE }__autoplay`;
 export const CLASS_PLAY            = `${ PROJECT_CODE }__play`;
 export const CLASS_PAUSE           = `${ PROJECT_CODE }__pause`;
 export const CLASS_SPINNER         = `${ PROJECT_CODE }__spinner`;
-export const CLASS_SR              = `${ PROJECT_CODE }__sr`;
 export const CLASS_INITIALIZED     = 'is-initialized';
 export const CLASS_ACTIVE          = 'is-active';
 export const CLASS_PREV            = 'is-prev';
 export const CLASS_NEXT            = 'is-next';
 export const CLASS_VISIBLE         = 'is-visible';
 export const CLASS_LOADING         = 'is-loading';
+export const CLASS_FOCUS           = 'has-focus';
 
 /**
  * The array with all status classes.
  *
  * @since 3.0.0
  */
-export const STATUS_CLASSES = [ CLASS_ACTIVE, CLASS_VISIBLE, CLASS_PREV, CLASS_NEXT, CLASS_LOADING ];
+export const STATUS_CLASSES = [ CLASS_ACTIVE, CLASS_VISIBLE, CLASS_PREV, CLASS_NEXT, CLASS_LOADING, CLASS_FOCUS ];
 
 /**
  * The collection of classes for elements that Splide dynamically creates.

+ 31 - 0
src/js/utils/dom/normalizeKey/normalizeKey.test.ts

@@ -0,0 +1,31 @@
+import { fire } from '../../../test';
+import { NORMALIZATION_MAP, normalizeKey } from './normalizeKey';
+
+
+describe( 'normalizeKey', () => {
+  test( 'can normalize a key into a standard name.', () => {
+    const keys     = Object.keys( NORMALIZATION_MAP );
+    const callback = jest.fn();
+
+    keys.forEach( key => {
+      expect( normalizeKey( key ) ).toBe( NORMALIZATION_MAP[ key ] );
+      callback();
+    } );
+
+    expect( callback ).toHaveBeenCalled();
+  } );
+
+  test( 'can return a normalized key from a Keyboard event object.', done => {
+    window.addEventListener( 'keydown', e => {
+      expect( normalizeKey( e ) ).toBe( 'ArrowUp' );
+      done();
+    } );
+
+    fire( window, 'keydown', { key: 'Up' } );
+  } );
+
+  test( 'should do the provided key as is if the normalization map does not include the passed key.', () => {
+    expect( normalizeKey( 'a' ) ).toBe( 'a' );
+    expect( normalizeKey( 'F1' ) ).toBe( 'F1' );
+  } );
+} );

+ 27 - 0
src/js/utils/dom/normalizeKey/normalizeKey.ts

@@ -0,0 +1,27 @@
+import { isString } from '../../type/type';
+
+
+/**
+ * The map to associate a non-standard name to the standard one.
+ *
+ * @since 3.7.0
+ */
+export const NORMALIZATION_MAP = {
+  spacebar: ' ',
+  Right   : 'ArrowRight',
+  Left    : 'ArrowLeft',
+  Up      : 'ArrowUp',
+  Down    : 'ArrowDown',
+};
+
+/**
+ * Normalizes the key.
+ *
+ * @param key - A string or a KeyboardEvent object.
+ *
+ * @return A normalized key.
+ */
+export function normalizeKey( key: string | KeyboardEvent ): string {
+  key = isString( key ) ? key : key.key;
+  return NORMALIZATION_MAP[ key ] || key;
+}