forked from alpinejs/alpine
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
cf23ec8
commit 3f68ac1
Showing
4 changed files
with
414 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -196,6 +196,7 @@ let directiveOrder = [ | |
'radio', | ||
'switch', | ||
'disclosure', | ||
'menu', | ||
'bind', | ||
'init', | ||
'for', | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}, | ||
} |
Oops, something went wrong.