Skip to content

Commit

Permalink
Merge pull request #206 from adrienpoly/improve-dropdown-dx
Browse files Browse the repository at this point in the history
Improve dropdown developer experience
  • Loading branch information
excid3 authored Mar 14, 2024
2 parents efee3c7 + d7519df commit 8da97e2
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 74 deletions.
2 changes: 1 addition & 1 deletion dist/tailwindcss-stimulus-components.cjs

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/tailwindcss-stimulus-components.cjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/tailwindcss-stimulus-components.module.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/tailwindcss-stimulus-components.module.js.map

Large diffs are not rendered by default.

41 changes: 18 additions & 23 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,32 +96,27 @@ <h2 class="text-2xl text-gray-800 font-semibold mb-4">Slideovers</h2>
<div class="my-12">
<h2 class="text-2xl text-gray-800 font-semibold mb-4">Dropdowns</h2>
<p class="mb-4 text-gray-700">When open, you can navigate menu items with Up and Down keys.</p>

<div data-controller="dropdown" data-action="click->dropdown#toggle click@window->dropdown#hide">
<div id="dropdown-button" class="relative inline-block">
<div role="button" tabindex="0" data-dropdown-target="button" class="inline-block select-none">
<span class="appearance-none flex items-center inline-block">
<span>Dropdown Example</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="fill-current h-4 w-4"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path></svg>
</span>
</div>
<div data-dropdown-target="menu"
data-transition-enter="transition ease-out duration-200"
data-transition-enter-from="opacity-0 translate-y-1"
data-transition-enter-to="opacity-100 translate-y-0"
data-transition-leave="transition ease-in duration-150"
data-transition-leave-from="opacity-100 translate-y-0"
data-transition-leave-to="opacity-0 translate-y-1"
class="hidden absolute top-4 right-0 z-10 mt-5 flex w-screen max-w-max">
<div class="text-sm bg-white shadow-lg rounded border overflow-hidden w-32">
<a data-dropdown-target="menuItem" data-action="keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Account</a>
<a data-dropdown-target="menuItem" data-action="keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Billing</a>
<hr class="border-t" />
<a data-dropdown-target="menuItem" data-action="keydown.up->dropdown#previousItem:prevent keydown.down->dropdown#nextItem:prevent" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Sign Out</a>
<div class="flex">

<div data-controller="dropdown">
<div id="dropdown-button" class="relative inline-block">
<div role="button" tabindex="0" data-dropdown-target="button" class="inline-block select-none">
<span class="appearance-none flex items-center inline-block">
<span>Dropdown Example</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="fill-current h-4 w-4"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path></svg>
</span>
</div>
<div data-dropdown-target="menu" class="hidden absolute top-4 right-0 z-10 mt-5 flex w-screen max-w-max">
<div class="text-sm bg-white shadow-lg rounded border overflow-hidden w-32">
<a data-dropdown-target="menuItem" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Account</a>
<a data-dropdown-target="menuItem" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Billing</a>
<hr class="border-t" />
<a data-dropdown-target="menuItem" href="#" class='no-underline block pl-4 py-2 text-gray-900 bg-white hover:bg-gray-100 whitespace-no-wrap focus:bg-gray-100'>Sign Out</a>
</div>
</div>
</div>
</div>
</div>
</div>

<div class="my-12">
<h2 class="text-2xl text-gray-800 font-semibold mb-4">Modals</h2>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"tests": false
},
"scripts": {
"dev": "esbuild src/index.js --format=esm --bundle --outfile=dist/tailwindcss-stimulus-components.module.js --watch",
"build": "npm run build-esm && npm run build-cjs",
"build-cjs": "esbuild src/index.js --format=cjs --target=es2020 --minify --bundle --sourcemap=external --external:@hotwired/stimulus --outfile=dist/tailwindcss-stimulus-components.cjs",
"build-esm": "esbuild src/index.js --format=esm --target=es2020 --minify --bundle --sourcemap=external --external:@hotwired/stimulus --outfile=dist/tailwindcss-stimulus-components.module.js",
Expand Down
88 changes: 72 additions & 16 deletions src/dropdown.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Controller } from '@hotwired/stimulus'
import { transition } from "./transition"
import { transition } from './transition'

export default class extends Controller {
static targets = ['menu', 'button', 'menuItem']
static values = { open: Boolean, default: false }
static values = {
open: { type: Boolean, default: false },
closeOnEscape: { type: Boolean, default: true },
closeOnClickOutside: { type: Boolean, default: true },
}

static classes = ['enter', 'enterFrom', 'enterTo', 'leave', 'leaveFrom', 'leaveTo', 'toggle']

// lifecycle
connect() {
document.addEventListener("turbo:before-cache", this.beforeCache.bind(this))

if (this.hasButtonTarget) {
this.buttonTarget.addEventListener("keydown", this._onMenuButtonKeydown)
this.buttonTarget.setAttribute("aria-haspopup", "true")
}
this.#initializeDropdownActions()
}

disconnect() {
Expand All @@ -23,24 +26,37 @@ export default class extends Controller {
}
}

// callbacks
openValueChanged() {
transition(this.menuTarget, this.openValue)
transition(this.menuTarget, this.openValue, this.transitionOptions)

if (this.openValue === true && this.hasMenuItemTarget) {
this.menuItemTargets[0].focus()
}
}

// actions
show() {
this.openValue = true
this.openValue = true
}

close() {
this.openValue = false
}

hide(event) {
if (event.target.nodeType && this.element.contains(event.target) === false && this.openValue) {
// if the event is a click and the target is not inside the dropdown, then close it
if (
this.closeOnClickOutsideValue &&
event.target.nodeType &&
this.element.contains(event.target) === false &&
this.openValue
) {
this.openValue = false
}

// if the event is a keydown and the key is escape, then close it
if (this.closeOnEscapeValue && event.key === 'Escape' && this.openValue) {
this.openValue = false
}
}
Expand All @@ -49,20 +65,60 @@ export default class extends Controller {
this.openValue = !this.openValue
}

nextItem() {
const nextIndex = Math.min(this.currentItemIndex + 1, this.menuItemTargets.length - 1)
this.menuItemTargets[nextIndex].focus()
nextItem(event) {
event.preventDefault()

this.menuItemTargets[this.nextIndex].focus()
}

previousItem() {
const previousIndex = Math.max(this.currentItemIndex - 1, 0)
this.menuItemTargets[previousIndex].focus()
previousItem(event) {
event.preventDefault()

this.menuItemTargets[this.previousIndex].focus()
}

// getters and setters
get currentItemIndex() {
return this.menuItemTargets.indexOf(document.activeElement)
}

get nextIndex() {
return Math.min(this.currentItemIndex + 1, this.menuItemTargets.length - 1)
}

get previousIndex() {
return Math.max(this.currentItemIndex - 1, 0)
}

get transitionOptions() {
// once the Class API default values are available, we can simplify this
return {
enter: this.hasEnterClass ? this.enterClass : 'transition ease-out duration-100',
enterFrom: this.hasEnterFromClass ? this.enterFromClass : 'transform opacity-0 scale-95',
enterTo: this.hasEnterToClass ? this.enterToClass : 'transform opacity-100 scale-100',
leave: this.hasLeaveClass ? this.leaveClass : 'transition ease-in duration-75',
leaveFrom: this.hasLeaveFromClass ? this.leaveFromClass : 'transform opacity-100 scale-100',
leaveTo: this.hasLeaveToClass ? this.leaveToClass : 'transform opacity-0 scale-95',
toggleClass: this.hasToggleClass ? this.toggleClass : 'hidden',
}
}

// private

#initializeDropdownActions() {
// this will set the necessary actions on the dropdown element for it to work
// data-action="click->dropdown#toggle click@window->dropdown#hide keydown.up->dropdown#previousItem keydown.down->dropdown#nextItem"
// Note: If existing actions are already specified by the user, they will be preserved and augmented without any redundancy.

const actions = this.element.dataset.action ? this.element.dataset.action.split(' ') : []
actions.push('click->dropdown#toggle')
actions.push('click@window->dropdown#hide')
actions.push('keydown.up->dropdown#previousItem')
actions.push('keydown.down->dropdown#nextItem')
actions.push('keydown.esc->dropdown#hide')
this.element.dataset.action = [...new Set(actions)].join(' ')
}

// Ensures the menu is hidden before Turbo caches the page
beforeCache() {
this.openValue = false
Expand Down
56 changes: 29 additions & 27 deletions src/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
// Leave transition:
//
// transition(this.element, false)
export async function transition(element, state) {
export async function transition(element, state, transitionOptions = {}) {
if (!!state) {
enter(element)
enter(element, transitionOptions)
} else {
leave(element)
leave(element, transitionOptions)
}
}

Expand All @@ -20,51 +20,53 @@ export async function transition(element, state) {
// data-transition-leave="transition-all ease-in-out duration-300"
// data-transition-leave-from="bg-opacity-80"
// data-transition-leave-to="bg-opacity-0"
export async function enter(element) {
const transitionClasses = element.dataset.transitionEnter || "enter"
const fromClasses = element.dataset.transitionEnterFrom || "enter-from"
const toClasses = element.dataset.transitionEnterTo || "enter-to"
const toggleClass = element.dataset.toggleClass || "hidden"
export async function enter(element, transitionOptions = {}) {
const transitionClasses = element.dataset.transitionEnter || transitionOptions.enter || 'enter'
const fromClasses =
element.dataset.transitionEnterFrom || transitionOptions.enterFrom || 'enter-from'
const toClasses = element.dataset.transitionEnterTo || transitionOptions.enterTo || 'enter-to'
const toggleClass = element.dataset.toggleClass || transitionOptions.toggleClass || 'hidden'

// Prepare transition
element.classList.add(...transitionClasses.split(" "))
element.classList.add(...fromClasses.split(" "))
element.classList.remove(...toClasses.split(" "))
element.classList.remove(...toggleClass.split(" "))
element.classList.add(...transitionClasses.split(' '))
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))
element.classList.remove(...toggleClass.split(' '))

await nextFrame()

element.classList.remove(...fromClasses.split(" "))
element.classList.add(...toClasses.split(" "))
element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))

try {
await afterTransition(element)
} finally {
element.classList.remove(...transitionClasses.split(" "))
element.classList.remove(...transitionClasses.split(' '))
}
}

export async function leave(element) {
const transitionClasses = element.dataset.transitionLeave || "leave"
const fromClasses = element.dataset.transitionLeaveFrom || "leave-from"
const toClasses = element.dataset.transitionLeaveTo || "leave-to"
const toggleClass = element.dataset.toggleClass || "hidden"
export async function leave(element, transitionOptions = {}) {
const transitionClasses = element.dataset.transitionLeave || transitionOptions.leave || 'leave'
const fromClasses =
element.dataset.transitionLeaveFrom || transitionOptions.leaveFrom || 'leave-from'
const toClasses = element.dataset.transitionLeaveTo || transitionOptions.leaveTo || 'leave-to'
const toggleClass = element.dataset.toggleClass || transitionOptions.toogle || 'hidden'

// Prepare transition
element.classList.add(...transitionClasses.split(" "))
element.classList.add(...fromClasses.split(" "))
element.classList.remove(...toClasses.split(" "))
element.classList.add(...transitionClasses.split(' '))
element.classList.add(...fromClasses.split(' '))
element.classList.remove(...toClasses.split(' '))

await nextFrame()

element.classList.remove(...fromClasses.split(" "))
element.classList.add(...toClasses.split(" "))
element.classList.remove(...fromClasses.split(' '))
element.classList.add(...toClasses.split(' '))

try {
await afterTransition(element)
} finally {
element.classList.remove(...transitionClasses.split(" "))
element.classList.add(...toggleClass.split(" "))
element.classList.remove(...transitionClasses.split(' '))
element.classList.add(...toggleClass.split(' '))
}
}

Expand Down

0 comments on commit 8da97e2

Please sign in to comment.