Caleb Porzio 3 年之前
父节点
当前提交
9dc80f7d32

+ 63 - 33
index.html

@@ -9,50 +9,80 @@
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
     <script src="//cdn.tailwindcss.com"></script>
-    <script src="https://unpkg.com/@popperjs/core@2"></script>
+    <!-- <script src="https://unpkg.com/@popperjs/core@2"></script> -->
 
-    <script src="//unpkg.com/@ungap/custom-elements"></script>
-
-    <x-button style="background: black">Hey</x-button>
-
-    <template id="template">
-        <button>
-            <slot></slot>
-        </button>
-    </template>
+    <div x-data x-tabs>
+        <div x-tabs:list>
+            <div><button x-tabs:tab>Hey!</button></div>
+            <div><button x-tabs:tab>There!</button></div>
+            <div><button x-tabs:tab>You!</button></div>
+        </div>
 
-    <script>
-        customElements.define('x-button', class extends HTMLElement {
-            constructor() {
-                super()
-                let template = document.getElementById('template')
-                let templateContent = template.content
-                let shadowRoot = this.attachShadow({mode: 'closed'})
-                shadowRoot.appendChild(templateContent.cloneNode(true))
-            }
-        })
-    </script>
+        <div x-tabs:panels>
+            <div x-tabs:panel>
+                <h1>Hey!</h1>
+            </div>
+            <div x-tabs:panel>
+                <h1>There!</h1>
+            </div>
+            <div x-tabs:panel>
+                <h1>You!</h1>
+            </div>
+        </div>
+    </div>
 
     <div x-data="{ open: false }">
         <button @click="open = ! open">Open</button>
 
-        <x-dialog x-model="open">
-            <x-dialog.panel>
+        <div x-dialog x-model="open">
+            <div x-dialog:panel>
                 Hey!
-            </x-dialog.panel>
-        </x-dialog>
+            </div>
+        </div>
+    </div>
+
+    <div x-data x-popover:group>
+        <div x-data x-popover>
+            <button x-popover:button>Open</button>
+            <ul x-popover:panel>
+                <li><button>yo</button></li>
+                <li><button>there</button></li>
+                <li><button>you</button></li>
+            </ul>
+        </div>
+        <div x-data x-popover>
+            <button x-popover:button>Open 2</button>
+            <ul x-popover:panel>
+                <li><button>yo</button></li>
+                <li><button>yins</button></li>
+                <li><button>yack</button></li>
+            </ul>
+        </div>
     </div>
 
+    <div></div>
 
-    <x-popover>
-        <x-popover.button>Open</x-popover.button>
+    <!-- <div x-data x-tabs>
+        <button x-tabs:button>Open</button>
 
-        <x-popover.panel as="ul">
-            <li><a href="#">First</a></li>
-            <li><a href="#">Second</a></li>
-            <li><a href="#">Third</a></li>
-        </x-popover.panel>
-    </div>
+        <ul x-tabs:panel>
+            hey
+        </ul>
+    </div> -->
+
+    <button>focus away</button>
+
+    <button id="hey">
+        hey
+    </button>
+
+    <script>
+        document.addEventListener('alpine:init', () => {
+            Alpine.bind(document.querySelector('#hey'), {
+                '@keyup.esc'() { console.log(this.$el); return 'there' }
+            })
+        })
+    </script>
 
     <!-- <main class="flex-1 overflow-auto bg-gray-50 h-screen">
         <div class="flex justify-center items-center space-x-12 p-12">

+ 6 - 0
packages/docs/src/en/ui.md

@@ -0,0 +1,6 @@
+---
+order: 5
+title: UI
+font-type: mono
+type: sub-directory
+---

+ 94 - 0
packages/docs/src/en/ui/dialog.md

@@ -0,0 +1,94 @@
+---
+order: 1
+title: Dialog
+description: ...
+graph_image: https://alpinejs.dev/social_modal.jpg
+---
+
+# Dialog (Modal)
+
+Building a modal with Alpine might appear as simple as putting `x-show` on an element styled as a modal. Unfortunately, much more goes into building a robust, accessible modal such as:
+
+* Close on escape
+* Close when you click outside the modal onto the overlay
+* Trap focus within the modal when it's open
+* Disable scrolling the background when modal is active
+* Proper accessibility attributes
+
+...
+
+## A Basic Example
+
+```alpine
+<div x-data="{ open: false }">
+    <button @click="open = true">Open Modal</button>
+
+    <div x-dialog x-model="open">
+        <div x-dialog:overlay></div>
+
+        <div x-dialog:panel>
+            Some modal
+
+            <button @click="$dialog.close()">Close</button>
+        </div>
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+<div x-data="{ open: false }">
+    <button @click="open = true">Open Modal</button>
+
+    <div x-dialog x-model="open" class="relative z-50">
+        <div x-dialog:overlay x-transition.opacity class="fixed inset-0 bg-black bg-opacity-25"></div>
+
+        <div class="fixed inset-0 overflow-y-auto">
+            <div class="flex min-h-full items-center justify-center p-4 text-center">
+                <div x-dialog:panel x-transition class="w-full max-w-md transform overflow-hidden rounded bg-white p-6 text-left align-middle shadow-xl transition-all">
+                    <h2 x-dialog:title class="text-lg">Your Title</h2>
+
+                    <p x-dialog:description class="pt-2">Your description.</p>
+
+                    <button @click="$dialog.close()">Close</button>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+</div>
+<!-- END_VERBATIM -->
+
+## Directives
+
+* x-dialog
+* x-dialog:overlay
+* x-dialog:panel
+* x-dialog:title
+* x-dialog:description
+
+## Magics
+
+* $dialog
+
+## Props
+
+* open
+* @close
+* static
+* initial-focus
+
+## Adding an overlay
+
+## Specifying initial focus
+
+## Controlling state without x-model
+
+## Statically controlling
+
+## Adding a title and description
+
+## Accessibility Notes
+
+## Keyboard Shortcuts
+

+ 71 - 0
packages/docs/src/en/ui/popover.md

@@ -0,0 +1,71 @@
+---
+order: 2
+title: Popover
+description: ...
+graph_image: https://alpinejs.dev/social_popover.jpg
+---
+
+# Popover (Dropdown)
+
+Building a popover with Alpine might appear as simple as putting `x-show` on an element styled as a dropdown. Unfortunately, much more goes into building a robust, accessible dropdown such as:
+
+* Close on escape
+* Close when you click outside the dropdown
+* Close the dropdown when focus leaves it
+* Disable scrolling the background when modal is active
+* Support tabbing between popovers in a group
+* Adding proper ARIA attributes
+
+...
+
+## A Basic Example
+
+```alpine
+<div x-popover>
+    <button x-popover:button>Open Dropdown</button>
+
+    <div x-popover:panel>
+        <a href="#">Link #1</a>
+        <a href="#">Link #2</a>
+        <a href="#">Link #3</a>
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data class="demo">
+<div x-popover>
+    <button x-popover:button>Open Dropdown</button>
+
+    <div x-popover:panel>
+        <a href="#">Link #1</a>
+        <a href="#">Link #2</a>
+        <a href="#">Link #3</a>
+    </div>
+</div>
+</div>
+<!-- END_VERBATIM -->
+
+## Directives
+
+* x-popover
+* x-popover:overlay
+* x-popover:panel
+* x-popover:title
+* x-popover:description
+
+## Magics
+
+* $popover
+
+## Props
+
+* open
+* @close
+* focus
+* static
+
+## Accessibility Notes
+
+## Keyboard Shortcuts
+

+ 79 - 0
packages/docs/src/en/ui/tabs.md

@@ -0,0 +1,79 @@
+---
+order: 3
+title: Tabs
+description: ...
+graph_image: https://alpinejs.dev/social_tabs.jpg
+---
+
+# Tabs
+
+Building a tabs component with Alpine might appear as simple as putting `x-show` on an various element styled as tabs. Unfortunately, much more goes into building robust, accessible tabs such as:
+
+* Cycle through tabs with arrow keys
+* Making only the active tab button focusable
+* Proper accessibility attributes
+
+...
+
+## A Basic Example
+
+```alpine
+<div x-tabs>
+    <div x-tabs:list>
+        <button x-tabs:tab>Tab #1</button>
+        <button x-tabs:tab>Tab #2</button>
+        <button x-tabs:tab>Tab #3</button>
+    </div>
+
+    <div x-tabs:panels>
+        <div x-tabs:panel>Tab Panel #1</div>
+        <div x-tabs:panel>Tab Panel #2</div>
+        <div x-tabs:panel>Tab Panel #3</div>
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data class="demo">
+<div x-tabs>
+    <div x-tabs:list>
+        <button x-tabs:tab>Tab #1</button>
+        <button x-tabs:tab>Tab #2</button>
+        <button x-tabs:tab>Tab #3</button>
+    </div>
+
+    <div x-tabs:panels>
+        <div x-tabs:panel>Tab Panel #1</div>
+        <div x-tabs:panel>Tab Panel #2</div>
+        <div x-tabs:panel>Tab Panel #3</div>
+    </div>
+</div>
+</div>
+<!-- END_VERBATIM -->
+
+## Directives
+
+* x-tabs
+* x-tabs:list
+* x-tabs:tab
+* x-tabs:panels
+* x-tabs:panel
+
+## Magics
+
+* $tab
+* $tabPanel
+
+## Props
+
+* manual
+* defaultIndex
+* selectedIndex
+* onChange
+* vertical
+* disabled
+
+## Accessibility Notes
+
+## Keyboard Shortcuts
+

+ 1 - 1
packages/focus/src/index.js

@@ -1,5 +1,5 @@
 import { createFocusTrap } from 'focus-trap'
-import { focusable, tabbable, isFocusable } from 'tabbable'
+import { focusable, isFocusable } from 'tabbable'
 
 export default function (Alpine) {
     let lastFocused

+ 24 - 28
packages/ui/src/dialog.js

@@ -1,10 +1,29 @@
 
 export default function (Alpine) {
-    Alpine.element('dialog.panel', () => ({
-        '@click.outside'() { this.$data.__close() },
-    }))
+    Alpine.directive('dialog', (el, directive) => {
+        if      (directive.value === 'overlay')     handleOverlay(el, Alpine)
+        else if (directive.value === 'panel')       handlePanel(el, Alpine)
+        else if (directive.value === 'title')       handleTitle(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+        else                                        handleRoot(el, Alpine)
+    })
+
+    Alpine.magic('dialog', el => {
+        let $data = Alpine.$data(el)
 
-    Alpine.element('dialog', el => ({
+        return {
+            get open() {
+                return $data.__isOpen
+            },
+            close() {
+                $data.__close()
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
         'x-data'() {
             return {
                 init() {
@@ -40,30 +59,6 @@ export default function (Alpine) {
         ':aria-describedby'() { return this.$id('alpine-dialog-description') },
         'role': 'dialog',
         'aria-modal': 'true',
-    }))
-
-    Alpine.directive('dialog', (el, directive) => {
-        if      (directive.value === 'overlay')     handleOverlay(el, Alpine)
-        else if (directive.value === 'panel')       handlePanel(el, Alpine)
-        else if (directive.value === 'title')       handleTitle(el, Alpine)
-        else if (directive.value === 'description') handleDescription(el, Alpine)
-        else                                        handleRoot(el, Alpine)
-    })
-
-    Alpine.magic('dialog', el => {
-        let $data = Alpine.$data(el)
-
-        return {
-            get open() {
-                return $data.__isOpen
-            }
-        }
-    })
-}
-
-function handleRoot(el, Alpine) {
-    Alpine.bind(el, {
-
     })
 }
 
@@ -78,6 +73,7 @@ function handleOverlay(el, Alpine) {
 function handlePanel(el, Alpine) {
     Alpine.bind(el, {
         '@click.outside'() { this.$data.__close() },
+        'x-show'() { return this.$data.__isOpen },
     })
 }
 

+ 2 - 0
packages/ui/src/index.js

@@ -1,7 +1,9 @@
 import dialog from './dialog'
 import popover from './popover'
+import tabs from './tabs'
 
 export default function (Alpine) {
     dialog(Alpine)
     popover(Alpine)
+    tabs(Alpine)
 }

+ 11 - 4
packages/ui/src/popover.js

@@ -93,9 +93,14 @@ function handleButton(el, Alpine) {
         '@click'() { this.$data.__toggle() },
         '@keydown.tab'(e) {
             if (! e.shiftKey && this.$data.__isOpen) {
-                e.preventDefault()
-                e.stopPropagation()
-                this.$focus.within(this.$data.__panelEl).first()
+                let firstFocusableEl = this.$focus.within(this.$data.__panelEl).getFirst()
+
+                if (firstFocusableEl) {
+                    e.preventDefault()
+                    e.stopPropagation()
+
+                    this.$focus.focus(firstFocusableEl)
+                }
             }
         },
         '@keyup.tab'(e) {
@@ -103,11 +108,13 @@ function handleButton(el, Alpine) {
                 // Check if the last focused element was "after" this one
                 let lastEl = this.$focus.previouslyFocused()
 
+                if (! lastEl) return
+
                 if (
                     // Make sure the last focused wasn't part of this popover.
                     (! this.$data.__buttonEl.contains(lastEl) && ! this.$data.__panelEl.contains(lastEl))
                     // Also make sure it appeared "after" this button in the DOM.
-                    && this.$el.compareDocumentPosition(lastEl) & Node.DOCUMENT_POSITION_FOLLOWING
+                    && (lastEl && (this.$el.compareDocumentPosition(lastEl) & Node.DOCUMENT_POSITION_FOLLOWING))
                 ) {
                     e.preventDefault()
                     e.stopPropagation()

+ 136 - 0
packages/ui/src/tabs.js

@@ -0,0 +1,136 @@
+
+export default function (Alpine) {
+    Alpine.directive('tabs', (el, directive) => {
+        if      (! directive.value)                handleRoot(el, Alpine)
+        else if (directive.value === 'list')       handleList(el, Alpine)
+        else if (directive.value === 'tab')        handleTab(el, Alpine)
+        else if (directive.value === 'panels')     handlePanels(el, Alpine)
+        else if (directive.value === 'panel')      handlePanel(el, Alpine)
+    })
+
+    Alpine.magic('tab', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get selected() {
+                return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
+            }
+        }
+    })
+
+    Alpine.magic('tabPanel', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get selected() {
+                return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        let defaultIndex = this.__selectedIndex || Number(Alpine.bound(this.$el, 'default-index', 0))
+                        let tabs = this.__activeTabs()
+                        let clamp = (number, min, max) => Math.min(Math.max(number, min), max)
+
+                        this.__selectedIndex = clamp(defaultIndex, 0, tabs.length -1)
+
+                        Alpine.effect(() => {
+                            this.__manualActivation = Alpine.bound(this.$el, 'manual', false)
+                        })
+                    })
+                },
+                __tabs: [],
+                __panels: [],
+                __selectedIndex: null,
+                __tabGroupEl: undefined,
+                __manualActivation: false,
+                __addTab(el) { this.__tabs.push(el) },
+                __addPanel(el) { this.__panels.push(el) },
+                __selectTab(el) {
+                    this.__selectedIndex = this.__tabs.indexOf(el)
+                },
+                __activeTabs() {
+                   return this.__tabs.filter(i => !i.__disabled)
+                },
+            }
+        }
+    })
+}
+
+function handleList(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__tabGroupEl = this.$el }
+    })
+}
+
+function handleTab(el, Alpine) {
+    let options = {}
+    Alpine.bind(el, {
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        'x-data'() { return {
+            init() {
+                this.__tabEl = this.$el
+                this.$data.__addTab(this.$el)
+                this.$el.__disabled = options.disabled
+            },
+            __tabEl: undefined,
+        }},
+        '@click'() {
+            if (this.$el.__disabled) return
+
+            this.$data.__selectTab(this.$el)
+
+            this.$el.focus()
+        },
+        '@keydown.enter.prevent.stop'() { this.__selectTab(this.$el) },
+        '@keydown.space.prevent.stop'() { this.__selectTab(this.$el) },
+        '@keydown.home.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+        '@keydown.page-up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+        '@keydown.end.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+        '@keydown.page-down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+        '@keydown.down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+        '@keydown.right.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+        '@keydown.up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+        '@keydown.left.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+        ':tabindex'() { return this.$tab.selected ? 0 : -1 },
+        '@focus'() {
+            if (this.$data.__manualActivation) {
+                this.$el.focus()
+            } else {
+                if (this.$el.__disabled) return
+
+                this.$data.__selectTab(this.$el)
+
+                this.$el.focus()
+            }
+        },
+    })
+}
+
+function handlePanels(el, Alpine) {
+    Alpine.bind(el, {
+        //
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        ':tabindex'() { return this.$tabPanel.selected ? 0 : -1 },
+        'x-data'() { return {
+            init() {
+                this.__panelEl = this.$el
+                this.$data.__addPanel(this.$el)
+            },
+            __panelEl: undefined,
+        }},
+        'x-show'() { return this.$tabPanel.selected },
+    })
+}
+

+ 30 - 17
tests/cypress/integration/plugins/ui/popover.spec.js

@@ -1,4 +1,4 @@
-import { beVisible, haveAttribute, haveText, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
+import { beVisible, haveAttribute, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
 
 test('button toggles panel',
     [html`
@@ -80,7 +80,7 @@ test('clicking outside closes panel',
     },
 )
 
-test('clicking outside closes panel',
+test('focusing away closes panel',
     [html`
         <div>
             <div x-data x-popover>
@@ -91,37 +91,50 @@ test('clicking outside closes panel',
                 </ul>
             </div>
 
-            <h1>Click away to me</h1>
+            <a href="#">Focus Me</a>
         </div>
     `],
     ({ get }) => {
         get('ul').should(notBeVisible())
         get('button').click()
         get('ul').should(beVisible())
-        get('h1').click()
+        cy.focused().tab()
         get('ul').should(notBeVisible())
     },
 )
 
-test('focusing away closes panel',
+test('focusing away doesnt close panel if focusing inside a group',
     [html`
-        <div>
-            <div x-data x-popover>
-                <button x-popover:button>Toggle</button>
-
-                <ul x-popover:panel>
-                    Dialog Contents!
-                </ul>
+        <div x-data>
+            <div x-popover:group>
+                <div x-data x-popover id="1">
+                    <button x-popover:button>Toggle 1</button>
+                    <ul x-popover:panel>
+                        Dialog 1 Contents!
+                    </ul>
+                </div>
+                <div x-data x-popover id="2">
+                    <button x-popover:button>Toggle 2</button>
+                    <ul x-popover:panel>
+                        Dialog 2 Contents!
+                    </ul>
+                </div>
             </div>
 
-            <button>Focus Me</button>
+            <a href="#">Focus Me</a>
         </div>
     `],
     ({ get }) => {
-        get('ul').should(notBeVisible())
-        get('button').click()
-        get('ul').should(beVisible())
+        get('#1 ul').should(notBeVisible())
+        get('#2 ul').should(notBeVisible())
+        get('#1 button').click()
+        get('#1 ul').should(beVisible())
+        get('#2 ul').should(notBeVisible())
         cy.focused().tab()
-        get('ul').should(notBeVisible())
+        get('#1 ul').should(beVisible())
+        get('#2 ul').should(notBeVisible())
+        cy.focused().tab()
+        get('#1 ul').should(notBeVisible())
+        get('#2 ul').should(notBeVisible())
     },
 )

+ 85 - 0
tests/cypress/integration/plugins/ui/tabs.spec.js

@@ -0,0 +1,85 @@
+import { beVisible, haveFocus, html, notBeVisible, test } from '../../../utils'
+
+test('can use tabs to toggle panels',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1>First</button>
+                <button x-tabs:tab button-2>Second</button>
+            </div>
+
+            <div x-tabs:panels>
+                <div x-tabs:panel panel-1>First Panel</div>
+                <div x-tabs:panel panel-2>Second Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-2]').click()
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test('can use arrow keys to cycle through tabs',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1>First</button>
+                <button x-tabs:tab button-2>Second</button>
+            </div>
+
+            <div x-tabs:panels>
+                <div x-tabs:panel panel-1>First Panel</div>
+                <div x-tabs:panel panel-2>Second Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-2]').click()
+        get('[button-2]').should(haveFocus())
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+        get('[button-2]').type('{rightArrow}')
+        get('[button-1]').should(haveFocus())
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-1]').type('{rightArrow}')
+        get('[button-2]').should(haveFocus())
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test.only('cant tab through tabs, can only use arrows',
+    [html`
+        <div>
+            <button button-1>first focusable</button>
+            <div x-data x-tabs>
+                <div x-tabs:list>
+                    <button x-tabs:tab button-2>First</button>
+                    <button x-tabs:tab button-3>Second</button>
+                </div>
+                <div x-tabs:panels>
+                    <div x-tabs:panel panel-1>First Panel</div>
+                    <div x-tabs:panel panel-2>Second Panel</div>
+                </div>
+            </div>
+            <button button-4>first focusable</button>
+        </div>
+    `],
+    ({ get }) => {
+        get('[button-1]').click()
+        get('[button-1]').should(haveFocus())
+        get('[button-1]').tab()
+        get('[button-2]').should(haveFocus())
+        get('[button-2]').tab()
+        get('[panel-1]').should(haveFocus())
+        get('[panel-1]').tab()
+        get('[button-4]').should(haveFocus())
+    },
+)