Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonlbeggs committed Oct 13, 2022
1 parent cf23ec8 commit 3f68ac1
Show file tree
Hide file tree
Showing 4 changed files with 414 additions and 2 deletions.
1 change: 1 addition & 0 deletions packages/alpinejs/src/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ let directiveOrder = [
'radio',
'switch',
'disclosure',
'menu',
'bind',
'init',
'for',
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import dialog from './dialog'
import disclosure from './disclosure'
import popover from './popover'
import menu from './menu'
import notSwitch from './switch'
import popover from './popover'
import tabs from './tabs'

export default function (Alpine) {
dialog(Alpine)
disclosure(Alpine)
popover(Alpine)
menu(Alpine)
notSwitch(Alpine)
popover(Alpine)
tabs(Alpine)
}
208 changes: 208 additions & 0 deletions packages/ui/src/menu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
export default function (Alpine) {
Alpine.directive('menu', (el, directive) => {
if (!directive.value) handleRoot(el, Alpine)
else if (directive.value === 'items') handleItems(el, Alpine)
else if (directive.value === 'item') handleItem(el, Alpine)
else if (directive.value === 'button') handleButton(el, Alpine)
});

Alpine.magic('menuItem', el => {
let $data = Alpine.$data(el)

return {
get isActive() {
return $data.__activeEl == $data.__itemEl
},
}
})
}

function handleRoot(el, Alpine) {
Alpine.bind(el, {
'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
'x-data'() {
return {
__itemEls: [],
__activeEl: null,
__isOpen: false,
__open() {
this.__isOpen = true

// Safari needs more of a "tick" for focusing after x-show for some reason.
// Probably because Alpine adds an extra tick when x-showing for @click.outside
let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))

nextTick(() => this.$refs.__items.focus({ preventScroll: true }))
},
__close() {
this.__isOpen = false

this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
}
}
},
})
}

function handleButton(el, Alpine) {
Alpine.bind(el, {
'x-ref': '__button',
'aria-haspopup': 'true',
':aria-labelledby'() { return this.$id('alpine-menu-label') },
':id'() { return this.$id('alpine-menu-button') },
':aria-expanded'() { return this.$data.__isOpen },
':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-menu-items') },
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
'@click'() { this.$data.__open() },
'@keydown.down.stop.prevent'() { this.$data.__open() },
'@keydown.up.stop.prevent'() { this.$data.__open(dom.Alpine, last) },
'@keydown.space.stop.prevent'() { this.$data.__open() },
'@keydown.enter.stop.prevent'() { this.$data.__open() },
})
}

function handleItems(el, Alpine) {
Alpine.bind(el, {
'x-ref': '__items',
'aria-orientation': 'vertical',
'role': 'menu',
':id'() { return this.$id('alpine-menu-items') },
':aria-labelledby'() { return this.$id('alpine-menu-button') },
':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
'x-show'() { return this.$data.__isOpen },
'x-trap'() { return this.$data.__isOpen },
'tabindex': '0',
'@click.outside'() { this.$data.__close() },
'@keydown'(e) { dom.search(Alpine, this.$refs.__items, e.key, el => el.__activate()) },
'@keydown.down.stop.prevent'() {
if (this.$data.__activeEl) dom.next(Alpine, this.$data.__activeEl, el => el.__activate())
else dom.first(Alpine, this.$refs.__items, el => el.__activate())
},
'@keydown.up.stop.prevent'() {
if (this.$data.__activeEl) dom.previous(Alpine, this.$data.__activeEl, el => el.__activate())
else dom.last(Alpine, this.$refs.__items, el => el.__activate())
},
'@keydown.home.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
'@keydown.end.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
'@keydown.page-up.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
'@keydown.page-down.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
'@keydown.escape.stop.prevent'() { this.$data.__close() },
'@keydown.space.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
'@keydown.enter.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
'@keyup.space.prevent'() { },
})
}

function handleItem(el, Alpine) {
Alpine.bind(el, () => {
return {
'x-data'() {
return {
__itemEl: this.$el,
init() {
// Add current element to element list for navigating.
let els = Alpine.raw(this.$data.__itemEls)
let inserted = false

for (let i = 0; i < els.length; i++) {
if (els[i].compareDocumentPosition(this.$el) & Node.DOCUMENT_POSITION_PRECEDING) {
els.splice(i, 0, this.$el)
inserted = true
break
}
}

if (!inserted) els.push(this.$el)

this.$el.__activate = () => {
this.$data.__activeEl = this.$el
this.$el.scrollIntoView({ block: 'nearest' })
}

this.$el.__deactivate = () => {
this.$data.__activeEl = null
}

this.$el.__isDisabled = !!this.$el.disabled
},
destroy() {
// Remove this element from the elements list.
let els = this.$data.__itemEls
els.splice(els.indexOf(this.$el), 1)
},
}
},
'x-id'() { return ['alpine-menu-item'] },
':id'() { return this.$id('alpine-menu-item') },
':tabindex'() { return this.$el.__isDisabled ? false : '-1' },
'role': 'menuitem',
'@mousemove'() { this.$el.__isDisabled || this.$menuItem.isActive || this.$el.__activate() },
'@mouseleave'() { this.$el.__isDisabled || !this.$menuItem.isActive || this.$el.__deactivate() },
}
})
}

let dom = {
first(Alpine, parent, receive = i => i, fallback = () => { }) {
let first = Alpine.$data(parent).__itemEls[0]

if (!first) return fallback()

if (first.tagName.toLowerCase() === 'template') {
return this.next(first, receive)
}

if (first.__isDisabled) return this.next(first, receive)

return receive(first)
},
last(Alpine, parent, receive = i => i, fallback = () => { }) {
let last = Alpine.$data(parent).__itemEls.slice(-1)[0]

if (!last) return fallback()
if (last.__isDisabled) return this.previous(last, receive)
return receive(last)
},
next(Alpine, el, receive = i => i, fallback = () => { }) {
if (! el) return fallback()

let els = Alpine.$data(el).__itemEls
let next = els[els.indexOf(el) + 1]

if (! next) return fallback()
if (next.__isDisabled || next.tagName.toLowerCase() === 'template') return this.next(next, receive, fallback)
return receive(next)
},
previous(Alpine, el, receive = i => i, fallback = () => { }) {
if (! el) return fallback()

let els = Alpine.$data(el).__itemEls
let prev = els[els.indexOf(el) - 1]

if (! prev) return fallback()
if (prev.__isDisabled || prev.tagName.toLowerCase() === 'template') return this.previous(prev, receive, fallback)
return receive(prev)
},
searchQuery: '',
clearSearch(Alpine) {
Alpine.debounce(function () { this.searchQuery = '' }, 350)
},
search(Alpine, parent, key, receiver) {
if (key.length > 1) return

this.searchQuery += key

let els = Alpine.raw(Alpine.$data(parent).__itemEls)

let el = els.find(el => {
return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
})

el && !el.__isDisabled && receiver(el)

this.clearSearch(Alpine)
},
}
Loading

0 comments on commit 3f68ac1

Please sign in to comment.