Add supporting themes required for Lotusdocs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import ui from '../src/index.js'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.plugin(ui)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import ui from '../src/index.js'
|
||||
|
||||
export default ui
|
||||
|
||||
export { ui }
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
|
||||
<script src="//cdn.tailwindcss.com"></script>
|
||||
|
||||
<title>Examples</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
|
||||
<header class="relative z-10 flex items-center justify-between flex-shrink-0 px-4 py-4 bg-gray-700 border-b border-gray-200 sm:px-6 lg:px-8">
|
||||
<a href="/"><svg class="h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 243 42">
|
||||
<path fill="#fff" d="M65.74 13.663c-2.62 0-4.702.958-5.974 2.95V6.499h-4.163V33.32h4.163V23.051c0-3.908 2.159-5.518 4.896-5.518 2.62 0 4.317 1.533 4.317 4.445V33.32h4.162V21.557c0-4.982-3.083-7.894-7.4-7.894zM79.936 25.503h15.341c.077-.536.154-1.15.154-1.724 0-5.518-3.931-10.116-9.674-10.116-6.052 0-10.176 4.407-10.176 10.078 0 5.748 4.124 10.078 10.484 10.078 3.778 0 6.668-1.572 8.441-4.177l-3.43-1.993c-.925 1.341-2.66 2.376-4.972 2.376-3.084 0-5.512-1.533-6.168-4.521zm-.038-3.372c.578-2.873 2.698-4.713 5.82-4.713 2.506 0 4.934 1.418 5.512 4.713H79.898zM113.282 14.161v2.72c-1.465-1.992-3.739-3.218-6.746-3.218-5.242 0-9.597 4.368-9.597 10.078 0 5.67 4.355 10.078 9.597 10.078 3.007 0 5.281-1.227 6.746-3.258v2.76h4.162V14.16h-4.162zm-6.09 15.71c-3.469 0-6.091-2.567-6.091-6.13 0-3.564 2.622-6.131 6.091-6.131 3.469 0 6.09 2.567 6.09 6.13 0 3.564-2.621 6.132-6.09 6.132zM136.597 6.498v10.384c-1.465-1.993-3.739-3.219-6.746-3.219-5.242 0-9.597 4.368-9.597 10.078 0 5.67 4.355 10.078 9.597 10.078 3.007 0 5.281-1.227 6.746-3.258v2.76h4.163V6.497h-4.163zm-6.09 23.374c-3.469 0-6.09-2.568-6.09-6.131 0-3.564 2.621-6.131 6.09-6.131s6.09 2.567 6.09 6.13c0 3.564-2.621 6.132-6.09 6.132zM144.648 33.32h4.163V5.348h-4.163V33.32zM155.957 25.503h15.341c.077-.536.154-1.15.154-1.724 0-5.518-3.931-10.116-9.675-10.116-6.051 0-10.176 4.407-10.176 10.078 0 5.748 4.125 10.078 10.485 10.078 3.777 0 6.668-1.572 8.441-4.177l-3.43-1.993c-.926 1.341-2.66 2.376-4.973 2.376-3.083 0-5.512-1.533-6.167-4.521zm-.038-3.372c.578-2.873 2.698-4.713 5.82-4.713 2.505 0 4.934 1.418 5.512 4.713h-11.332zM177.137 19.45c0-1.38 1.311-2.032 2.814-2.032 1.581 0 2.93.69 3.623 2.184l3.508-1.954c-1.349-2.529-3.97-3.985-7.131-3.985-3.931 0-7.053 2.26-7.053 5.863 0 6.859 10.368 4.943 10.368 8.353 0 1.533-1.426 2.146-3.276 2.146-2.12 0-3.662-1.035-4.279-2.759l-3.584 2.07c1.233 2.758 4.008 4.483 7.863 4.483 4.163 0 7.516-2.07 7.516-5.902 0-7.088-10.369-4.98-10.369-8.468zM192.774 19.45c0-1.38 1.31-2.032 2.813-2.032 1.581 0 2.93.69 3.624 2.184l3.507-1.954c-1.349-2.529-3.97-3.985-7.131-3.985-3.931 0-7.053 2.26-7.053 5.863 0 6.859 10.368 4.943 10.368 8.353 0 1.533-1.426 2.146-3.276 2.146-2.12 0-3.662-1.035-4.278-2.759l-3.585 2.07c1.233 2.758 4.009 4.483 7.863 4.483 4.163 0 7.516-2.07 7.516-5.902 0-7.088-10.368-4.98-10.368-8.468zM224.523 28.9c2.889 0 5.027-1.715 5.027-4.53v-8.782h-2.588v8.577c0 1.268-.676 2.219-2.439 2.219s-2.438-.951-2.438-2.22v-8.576h-2.569v8.782c0 2.815 2.138 4.53 5.007 4.53zM232.257 15.588V28.64h2.588V15.588h-2.588z"> </path>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M233.817 9.328H220.42c-2.96 0-5.359 2.385-5.359 5.327v13.318c0 2.942 2.399 5.327 5.359 5.327h13.397c2.959 0 5.358-2.385 5.358-5.327V14.655c0-2.942-2.399-5.327-5.358-5.327zM220.42 6.664c-4.439 0-8.038 3.578-8.038 7.99v13.319c0 4.413 3.599 7.99 8.038 7.99h13.397c4.439 0 8.038-3.577 8.038-7.99V14.655c0-4.413-3.599-7.99-8.038-7.99H220.42z" clip-rule="evenodd"></path>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M220.42 9.328h13.397c2.959 0 5.358 2.385 5.358 5.327v13.318c0 2.942-2.399 5.327-5.358 5.327H220.42c-2.96 0-5.359-2.385-5.359-5.327V14.655c0-2.942 2.399-5.327 5.359-5.327zm-8.038 5.327c0-4.413 3.599-7.99 8.038-7.99h13.397c4.439 0 8.038 3.577 8.038 7.99v13.318c0 4.413-3.599 7.99-8.038 7.99H220.42c-4.439 0-8.038-3.577-8.038-7.99V14.655z" clip-rule="evenodd"></path>
|
||||
<path fill="url(#prefix__paint0_linear)" d="M8.577 26.097l25.779-8.556c-.514-3.201-.88-5.342-1.307-6.974-.457-1.756-.821-2.226-.965-2.39a5.026 5.026 0 00-1.81-1.306c-.2-.086-.762-.284-2.583-.175-1.924.116-4.453.507-8.455 1.137-4.003.63-6.529 1.035-8.395 1.516-1.766.456-2.239.817-2.403.96a4.999 4.999 0 00-1.315 1.8c-.085.198-.285.757-.175 2.568.116 1.913.51 4.426 1.143 8.405.178 1.114.337 2.113.486 3.015z"> </path>
|
||||
<path fill="url(#prefix__paint1_linear)" fill-rule="evenodd" d="M1.47 24.124C.244 16.427-.37 12.58.96 9.49A11.665 11.665 0 014.027 5.29c2.545-2.21 6.416-2.82 14.16-4.039C25.93.031 29.8-.578 32.907.743a11.729 11.729 0 014.225 3.05c2.223 2.53 2.836 6.38 4.063 14.076 1.226 7.698 1.84 11.546.511 14.636a11.666 11.666 0 01-3.069 4.199c-2.545 2.21-6.416 2.82-14.159 4.039-7.743 1.219-11.614 1.828-14.722.508a11.728 11.728 0 01-4.224-3.05C3.31 35.67 2.697 31.82 1.47 24.123zm13.657 13.668c2.074-.125 4.743-.54 8.697-1.163 3.953-.622 6.62-1.047 8.632-1.566 1.949-.502 2.846-.992 3.426-1.496a7.5 7.5 0 001.973-2.7c.302-.703.494-1.703.372-3.7-.125-2.063-.543-4.716-1.17-8.646-.625-3.93-1.053-6.582-1.574-8.582-.506-1.937-.999-2.83-1.505-3.405a7.54 7.54 0 00-2.716-1.961c-.707-.301-1.713-.492-3.723-.371-2.074.125-4.743.54-8.697 1.163-3.953.622-6.62 1.047-8.632 1.565-1.949.503-2.846.993-3.426 1.497a7.5 7.5 0 00-1.972 2.699c-.303.704-.495 1.704-.373 3.701.125 2.062.543 4.716 1.17 8.646.625 3.93 1.053 6.582 1.574 8.581.506 1.938 1 2.83 1.505 3.406a7.54 7.54 0 002.716 1.961c.707.3 1.713.492 3.723.37z" clip-rule="evenodd"></path>
|
||||
<defs>
|
||||
<linearGradient id="prefix__paint0_linear" x1="16.759" x2="23.386" y1="0" y2="41.662"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#66E3FF"></stop>
|
||||
<stop offset="1" stop-color="#7064F9"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="prefix__paint1_linear" x1="16.759" x2="23.386" y1="0" y2="41.662"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#66E3FF"></stop>
|
||||
<stop offset="1" stop-color="#7064F9"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg></a></header>
|
||||
<main class="flex-1 overflow-auto bg-gray-50">
|
||||
<div class="container my-24">
|
||||
<div class="prose">
|
||||
<h2>Examples</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">dialog</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/dialog/dialog.html">dialog</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">disclosure</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/disclosure/disclosure.html">disclosure</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">listbox</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="demo/listbox">listbox with
|
||||
pure tailwind</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">menu</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/menu/menu.html">menu</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">popover</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/popover/popover.html">popover</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">radio group</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/radio-group/radio-group.html">radio group</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">switch</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/switch/switch.html">switch with pure
|
||||
tailwind</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h3 class="text-xl capitalize">tabs</h3>
|
||||
<ul>
|
||||
<li><a class="capitalize" href="/tabs/tabs.html">tabs with pure
|
||||
tailwind</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
|
||||
|
||||
<script src="/packages/intersect/dist/cdn.js" defer></script>
|
||||
<script src="/packages/morph/dist/cdn.js" defer></script>
|
||||
<script src="/packages/persist/dist/cdn.js"></script>
|
||||
<script src="/packages/focus/dist/cdn.js"></script>
|
||||
<script src="/packages/mask/dist/cdn.js"></script>
|
||||
<script src="/packages/ui/dist/cdn.js" defer></script>
|
||||
<script src="/packages/alpinejs/dist/cdn.js" defer></script>
|
||||
<script src="//cdn.tailwindcss.com"></script>
|
||||
|
||||
<title>Listbox</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
|
||||
<div
|
||||
x-data="{ selected: undefined, people: [
|
||||
{ id: 1, name: 'Wade Cooper' },
|
||||
{ id: 2, name: 'Arlene Mccoy' },
|
||||
{ id: 3, name: 'Devon Webb' },
|
||||
{ id: 4, name: 'Tom Cook' },
|
||||
{ id: 5, name: 'Tanya Fox', disabled: true },
|
||||
{ id: 6, name: 'Hellen Schmidt' },
|
||||
{ id: 7, name: 'Caroline Schultz' },
|
||||
{ id: 8, name: 'Mason Heaney' },
|
||||
{ id: 9, name: 'Claudie Smitham' },
|
||||
{ id: 10, name: 'Emil Schaefer' },
|
||||
]}"
|
||||
class="flex justify-center w-screen h-full p-12 bg-gray-50"
|
||||
>
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<div class="flex justify-between mb-8">
|
||||
<button class="underline" @click="selected = people[1]">Change value</button>
|
||||
|
||||
<button class="underline" @click="
|
||||
people.sort((a, b) => a.name > b.name ? 1 : -1)
|
||||
">Reorder</button>
|
||||
|
||||
<button class="underline" @click="
|
||||
people = people.filter(i => i.name !== 'Arlene Mccoy')
|
||||
">Destroy item</button>
|
||||
</div>
|
||||
|
||||
<div x-listbox name="something" x-model="selected" class="space-y-1">
|
||||
<label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
|
||||
Assigned to
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<span class="inline-block w-full rounded-md shadow-sm">
|
||||
<button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
|
||||
<span class="block truncate" x-text="selected ? selected.name : 'Select Person'"></span>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
|
||||
<ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<template x-for="person in people" :key="person.id">
|
||||
<li
|
||||
x-listbox:option :value="person"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:disabled="person.disabled"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
|
||||
|
||||
<script src="/packages/intersect/dist/cdn.js" defer></script>
|
||||
<script src="/packages/morph/dist/cdn.js" defer></script>
|
||||
<script src="/packages/persist/dist/cdn.js"></script>
|
||||
<script src="/packages/focus/dist/cdn.js"></script>
|
||||
<script src="/packages/mask/dist/cdn.js"></script>
|
||||
<script src="/packages/ui/dist/cdn.js" defer></script>
|
||||
<script src="/packages/alpinejs/dist/cdn.js" defer></script>
|
||||
<script src="//cdn.tailwindcss.com"></script>
|
||||
|
||||
<title>Listbox</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
|
||||
<div x-data="{ selected: 'Wade Cooper' }" class="flex justify-center w-screen h-full p-12 bg-gray-50">
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<div class="flex justify-between mb-8">
|
||||
<button class="underline" @click="selected = Arlene Mccoy">Change value</button>
|
||||
</div>
|
||||
|
||||
<div x-listbox name="something" x-model="selected" class="space-y-1">
|
||||
<label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
|
||||
Assigned to
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<span class="inline-block w-full rounded-md shadow-sm">
|
||||
<button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
|
||||
<span class="block truncate" x-text="selected"></span>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
|
||||
<ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<li
|
||||
x-listbox:option value="Wade Cooper"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Wade Cooper</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Arlene Mccoy"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Arlene Mccoy</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Devon Webb"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Devon Webb</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Tom Cook"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Tom Cook</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Tanya Fox"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Tanya Fox</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Hellen Schmidt"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Hellen Schmidt</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Caroline Schultz"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Caroline Schultz</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Mason Heaney"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Mason Heaney</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Claudie Smitham"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Claudie Smitham</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
x-listbox:option value="Emil Schaefer"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Emil Schaefer</span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
|
||||
|
||||
<script src="/packages/intersect/dist/cdn.js" defer></script>
|
||||
<script src="/packages/morph/dist/cdn.js" defer></script>
|
||||
<script src="/packages/persist/dist/cdn.js"></script>
|
||||
<script src="/packages/focus/dist/cdn.js"></script>
|
||||
<script src="/packages/mask/dist/cdn.js"></script>
|
||||
<script src="/packages/ui/dist/cdn.js" defer></script>
|
||||
<script src="/packages/alpinejs/dist/cdn.js" defer></script>
|
||||
<script src="//cdn.tailwindcss.com"></script>
|
||||
|
||||
<title>Listbox</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
|
||||
<div
|
||||
x-data="{ selected: [], people: [
|
||||
{ id: 1, name: 'Wade Cooper' },
|
||||
{ id: 2, name: 'Arlene Mccoy' },
|
||||
{ id: 3, name: 'Devon Webb' },
|
||||
{ id: 4, name: 'Tom Cook' },
|
||||
{ id: 5, name: 'Tanya Fox', disabled: true },
|
||||
{ id: 6, name: 'Hellen Schmidt' },
|
||||
{ id: 7, name: 'Caroline Schultz' },
|
||||
{ id: 8, name: 'Mason Heaney' },
|
||||
{ id: 9, name: 'Claudie Smitham' },
|
||||
{ id: 10, name: 'Emil Schaefer' },
|
||||
]}"
|
||||
class="flex justify-center w-screen h-full p-12 bg-gray-50"
|
||||
>
|
||||
<div class="w-full max-w-xs mx-auto">
|
||||
<div class="flex justify-between mb-8">
|
||||
<button class="underline" @click="selected.push(people[1])">Change value</button>
|
||||
|
||||
<button class="underline" @click="
|
||||
people.sort((a, b) => a.name > b.name ? 1 : -1)
|
||||
">Reorder</button>
|
||||
|
||||
<button class="underline" @click="
|
||||
people = people.filter(i => i.name !== 'Arlene Mccoy')
|
||||
">Destroy item</button>
|
||||
</div>
|
||||
|
||||
<div x-listbox name="people" x-model="selected" multiple class="space-y-1">
|
||||
<label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
|
||||
Assigned to
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<span class="inline-block w-full rounded-md shadow-sm">
|
||||
<button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
|
||||
<span class="block truncate" x-text="selected.length > 0 ? selected.map(i => i.name).join(', ') : 'Select Person'"></span>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
|
||||
<ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<template x-for="person in people" :key="person.id">
|
||||
<li
|
||||
x-listbox:option :value="person"
|
||||
class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
|
||||
:disabled="person.disabled"
|
||||
:class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
|
||||
>
|
||||
<span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
|
||||
|
||||
<span
|
||||
x-show="$listboxOption.isSelected"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
:class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
|
||||
>
|
||||
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@alpinejs/ui",
|
||||
"version": "3.13.7-beta.0",
|
||||
"description": "Headless UI components for Alpine",
|
||||
"homepage": "https://alpinejs.dev/components#headless",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alpinejs/alpine.git",
|
||||
"directory": "packages/ui"
|
||||
},
|
||||
"author": "Caleb Porzio",
|
||||
"license": "MIT",
|
||||
"main": "dist/module.cjs.js",
|
||||
"module": "dist/module.esm.js",
|
||||
"unpkg": "dist/cdn.min.js",
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { generateContext, renderHiddenInputs } from './list-context'
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('combobox', (el, directive, { evaluate }) => {
|
||||
if (directive.value === 'input') handleInput(el, Alpine)
|
||||
else if (directive.value === 'button') handleButton(el, Alpine)
|
||||
else if (directive.value === 'label') handleLabel(el, Alpine)
|
||||
else if (directive.value === 'options') handleOptions(el, Alpine)
|
||||
else if (directive.value === 'option') handleOption(el, Alpine, directive, evaluate)
|
||||
else handleRoot(el, Alpine)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('combobox', el => {
|
||||
let data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return data.__value
|
||||
},
|
||||
get isOpen() {
|
||||
return data.__isOpen
|
||||
},
|
||||
get isDisabled() {
|
||||
return data.__isDisabled
|
||||
},
|
||||
get activeOption() {
|
||||
let active = data.__context?.getActiveItem()
|
||||
|
||||
return active && active.value
|
||||
},
|
||||
get activeIndex() {
|
||||
let active = data.__context?.getActiveItem()
|
||||
|
||||
if (active) {
|
||||
return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Alpine.magic('comboboxOption', el => {
|
||||
let data = Alpine.$data(el)
|
||||
|
||||
// It's not great depending on the existance of the attribute in the DOM
|
||||
// but it's probably the fastest and most reliable at this point...
|
||||
let optionEl = Alpine.findClosest(el, i => {
|
||||
return i.hasAttribute('x-combobox:option')
|
||||
})
|
||||
|
||||
if (! optionEl) throw 'No x-combobox:option directive found...'
|
||||
|
||||
return {
|
||||
get isActive() {
|
||||
return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
|
||||
},
|
||||
get isSelected() {
|
||||
return data.__isSelected(optionEl)
|
||||
},
|
||||
get isDisabled() {
|
||||
return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
|
||||
'x-modelable': '__value',
|
||||
|
||||
// Initialize...
|
||||
'x-data'() {
|
||||
return {
|
||||
/**
|
||||
* Combobox state...
|
||||
*/
|
||||
__ready: false,
|
||||
__value: null,
|
||||
__isOpen: false,
|
||||
__context: undefined,
|
||||
__isMultiple: undefined,
|
||||
__isStatic: false,
|
||||
__isDisabled: undefined,
|
||||
__displayValue: undefined,
|
||||
__compareBy: null,
|
||||
__inputName: null,
|
||||
__isTyping: false,
|
||||
__hold: false,
|
||||
|
||||
/**
|
||||
* Combobox initialization...
|
||||
*/
|
||||
init() {
|
||||
this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
|
||||
this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
|
||||
this.__inputName = Alpine.extractProp(el, 'name', null)
|
||||
this.__nullable = Alpine.extractProp(el, 'nullable', false)
|
||||
this.__compareBy = Alpine.extractProp(el, 'by')
|
||||
|
||||
this.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
|
||||
|
||||
let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
|
||||
|
||||
this.__value = defaultValue
|
||||
|
||||
// We have to wait again until after the "ready" processes are finished
|
||||
// to settle up currently selected Values (this prevents this next bit
|
||||
// of code from running multiple times on startup...)
|
||||
queueMicrotask(() => {
|
||||
Alpine.effect(() => {
|
||||
// Everytime the value changes, we need to re-render the hidden inputs,
|
||||
// if a user passed the "name" prop...
|
||||
this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
|
||||
})
|
||||
|
||||
// Set initial combobox values in the input and properly clear it when the value is reset programmatically...
|
||||
Alpine.effect(() => ! this.__isMultiple && this.__resetInput())
|
||||
})
|
||||
},
|
||||
__startTyping() {
|
||||
this.__isTyping = true
|
||||
},
|
||||
__stopTyping() {
|
||||
this.__isTyping = false
|
||||
},
|
||||
__resetInput() {
|
||||
let input = this.$refs.__input
|
||||
|
||||
if (! input) return
|
||||
|
||||
let value = this.__getCurrentValue()
|
||||
|
||||
input.value = value
|
||||
},
|
||||
__getCurrentValue() {
|
||||
if (! this.$refs.__input) return ''
|
||||
if (! this.__value) return ''
|
||||
if (this.__displayValue) return this.__displayValue(this.__value)
|
||||
if (typeof this.__value === 'string') return this.__value
|
||||
return ''
|
||||
},
|
||||
__open() {
|
||||
if (this.__isOpen) return
|
||||
this.__isOpen = true
|
||||
|
||||
let input = this.$refs.__input
|
||||
|
||||
// Make sure we always notify the parent component
|
||||
// that the starting value is the empty string
|
||||
// when we open the combobox (ignoring any existing value)
|
||||
// to avoid inconsistent displaying.
|
||||
// Setting the input to empty and back to the real value
|
||||
// also helps VoiceOver to annunce the content properly
|
||||
// See https://github.com/tailwindlabs/headlessui/pull/2153
|
||||
if (input) {
|
||||
let value = input.value
|
||||
let { selectionStart, selectionEnd, selectionDirection } = input
|
||||
input.value = ''
|
||||
input.dispatchEvent(new Event('change'))
|
||||
input.value = value
|
||||
if (selectionDirection !== null) {
|
||||
input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
|
||||
} else {
|
||||
input.setSelectionRange(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.__input.focus({ preventScroll: true })
|
||||
this.__activateSelectedOrFirst()
|
||||
})
|
||||
},
|
||||
__close() {
|
||||
this.__isOpen = false
|
||||
|
||||
this.__context.deactivate()
|
||||
},
|
||||
__activateSelectedOrFirst(activateSelected = true) {
|
||||
if (! this.__isOpen) return
|
||||
|
||||
if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
|
||||
|
||||
let firstSelectedValue
|
||||
|
||||
if (this.__isMultiple) {
|
||||
let selectedItem = this.__context.getItemsByValues(this.__value)
|
||||
|
||||
firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
|
||||
} else {
|
||||
firstSelectedValue = this.__value
|
||||
}
|
||||
|
||||
let firstSelected = null
|
||||
if (activateSelected && firstSelectedValue) {
|
||||
firstSelected = this.__context.getItemByValue(firstSelectedValue)
|
||||
}
|
||||
|
||||
if (firstSelected) {
|
||||
this.__context.activateAndScrollToKey(firstSelected.key)
|
||||
return
|
||||
}
|
||||
|
||||
this.__context.activateAndScrollToKey(this.__context.firstKey())
|
||||
},
|
||||
__selectActive() {
|
||||
let active = this.__context.getActiveItem()
|
||||
if (active) this.__toggleSelected(active.value)
|
||||
},
|
||||
__selectOption(el) {
|
||||
let item = this.__context.getItemByEl(el)
|
||||
|
||||
if (item) this.__toggleSelected(item.value)
|
||||
},
|
||||
__isSelected(el) {
|
||||
let item = this.__context.getItemByEl(el)
|
||||
|
||||
if (! item) return false
|
||||
if (item.value === null || item.value === undefined) return false
|
||||
|
||||
return this.__hasSelected(item.value)
|
||||
},
|
||||
__toggleSelected(value) {
|
||||
if (! this.__isMultiple) {
|
||||
this.__value = value
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let index = this.__value.findIndex(j => this.__compare(j, value))
|
||||
|
||||
if (index === -1) {
|
||||
this.__value.push(value)
|
||||
} else {
|
||||
this.__value.splice(index, 1)
|
||||
}
|
||||
},
|
||||
__hasSelected(value) {
|
||||
if (! this.__isMultiple) return this.__compare(this.__value, value)
|
||||
|
||||
return this.__value.some(i => this.__compare(i, value))
|
||||
},
|
||||
__compare(a, b) {
|
||||
let by = this.__compareBy
|
||||
|
||||
if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
|
||||
|
||||
if (typeof by === 'string') {
|
||||
let property = by
|
||||
by = (a, b) => {
|
||||
// Handle null values
|
||||
if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
|
||||
return Alpine.raw(a) === Alpine.raw(b)
|
||||
}
|
||||
|
||||
|
||||
return a[property] === b[property];
|
||||
}
|
||||
}
|
||||
|
||||
return by(a, b)
|
||||
},
|
||||
}
|
||||
},
|
||||
// Register event listeners..
|
||||
'@mousedown.window'(e) {
|
||||
if (
|
||||
!! ! this.$refs.__input.contains(e.target)
|
||||
&& ! this.$refs.__button.contains(e.target)
|
||||
&& ! this.$refs.__options.contains(e.target)
|
||||
) {
|
||||
this.__close()
|
||||
this.__resetInput()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleInput(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-ref': '__input',
|
||||
':id'() { return this.$id('alpine-combobox-input') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'role': 'combobox',
|
||||
'tabindex': '0',
|
||||
'aria-autocomplete': 'list',
|
||||
|
||||
// We need to defer this evaluation a bit because $refs that get declared later
|
||||
// in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
|
||||
async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
|
||||
':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
|
||||
':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
|
||||
':aria-activedescendant'() {
|
||||
if (! this.$data.__context.hasActive()) return
|
||||
|
||||
let active = this.$data.__context.getActiveItem()
|
||||
|
||||
return active ? active.el.id : null
|
||||
},
|
||||
':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
|
||||
|
||||
// Initialize...
|
||||
'x-init'() {
|
||||
let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
|
||||
if (displayValueFn) this.$data.__displayValue = displayValueFn
|
||||
},
|
||||
|
||||
// Register listeners...
|
||||
'@input.stop'(e) {
|
||||
if(this.$data.__isTyping) {
|
||||
this.$data.__open();
|
||||
this.$dispatch('change')
|
||||
}
|
||||
},
|
||||
'@blur'() { this.$data.__stopTyping(false) },
|
||||
'@keydown'(e) {
|
||||
queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
|
||||
},
|
||||
'@keydown.enter.prevent.stop'() {
|
||||
this.$data.__selectActive()
|
||||
|
||||
this.$data.__stopTyping()
|
||||
|
||||
if (! this.$data.__isMultiple) {
|
||||
this.$data.__close()
|
||||
this.$data.__resetInput()
|
||||
}
|
||||
},
|
||||
'@keydown.escape.prevent'(e) {
|
||||
if (! this.$data.__static) e.stopPropagation()
|
||||
|
||||
this.$data.__stopTyping()
|
||||
this.$data.__close()
|
||||
this.$data.__resetInput()
|
||||
|
||||
},
|
||||
'@keydown.tab'() {
|
||||
this.$data.__stopTyping()
|
||||
if (this.$data.__isOpen) { this.$data.__close() }
|
||||
this.$data.__resetInput()
|
||||
},
|
||||
'@keydown.backspace'(e) {
|
||||
if (this.$data.__isMultiple) return
|
||||
if (! this.$data.__nullable) return
|
||||
|
||||
let input = e.target
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (input.value === '') {
|
||||
this.$data.__value = null
|
||||
|
||||
let options = this.$refs.__options
|
||||
if (options) {
|
||||
options.scrollTop = 0
|
||||
}
|
||||
|
||||
this.$data.__context.deactivate()
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleButton(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-ref': '__button',
|
||||
':id'() { return this.$id('alpine-combobox-button') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'aria-haspopup': 'true',
|
||||
// We need to defer this evaluation a bit because $refs that get declared later
|
||||
// in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
|
||||
async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
|
||||
':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
|
||||
':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
|
||||
':disabled'() { return this.$data.__isDisabled },
|
||||
'tabindex': '-1',
|
||||
|
||||
// Initialize....
|
||||
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
|
||||
|
||||
// Register listeners...
|
||||
'@click'(e) {
|
||||
if (this.$data.__isDisabled) return
|
||||
if (this.$data.__isOpen) {
|
||||
this.$data.__close()
|
||||
this.$data.__resetInput()
|
||||
} else {
|
||||
e.preventDefault()
|
||||
this.$data.__open()
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleLabel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-ref': '__label',
|
||||
':id'() { return this.$id('alpine-combobox-label') },
|
||||
'@click'() { this.$refs.__input.focus({ preventScroll: true }) },
|
||||
})
|
||||
}
|
||||
|
||||
function handleOptions(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-ref': '__options',
|
||||
':id'() { return this.$id('alpine-combobox-options') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'role': 'listbox',
|
||||
':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
|
||||
|
||||
// Initialize...
|
||||
'x-init'() {
|
||||
this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
|
||||
|
||||
if (Alpine.bound(this.$el, 'hold')) {
|
||||
this.$data.__hold = true;
|
||||
}
|
||||
},
|
||||
|
||||
'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
|
||||
})
|
||||
}
|
||||
|
||||
function handleOption(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-id'() { return ['alpine-combobox-option'] },
|
||||
':id'() { return this.$id('alpine-combobox-option') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'role': 'option',
|
||||
':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
|
||||
|
||||
// Only the active element should have aria-selected="true"...
|
||||
'x-effect'() {
|
||||
this.$comboboxOption.isSelected
|
||||
? el.setAttribute('aria-selected', true)
|
||||
: el.setAttribute('aria-selected', false)
|
||||
},
|
||||
|
||||
':aria-disabled'() { return this.$comboboxOption.isDisabled },
|
||||
|
||||
// Initialize...
|
||||
'x-data'() {
|
||||
return {
|
||||
'__optionKey': null,
|
||||
|
||||
init() {
|
||||
this.__optionKey = (Math.random() + 1).toString(36).substring(7)
|
||||
|
||||
let value = Alpine.extractProp(this.$el, 'value')
|
||||
let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
|
||||
|
||||
// memoize the context as it's not going to change
|
||||
// and calling this.$data on mouse action is expensive
|
||||
this.__context.registerItem(this.__optionKey, this.$el, value, disabled)
|
||||
},
|
||||
destroy() {
|
||||
this.__context.unregisterItem(this.__optionKey)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Register listeners...
|
||||
'@click'() {
|
||||
if (this.$comboboxOption.isDisabled) return;
|
||||
|
||||
this.__selectOption(this.$el)
|
||||
|
||||
if (! this.__isMultiple) {
|
||||
this.__close()
|
||||
this.__resetInput()
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
|
||||
},
|
||||
'@mouseenter'(e) {
|
||||
this.__context.activateEl(this.$el)
|
||||
},
|
||||
'@mousemove'(e) {
|
||||
if (this.__context.isActiveEl(this.$el)) return
|
||||
|
||||
this.__context.activateEl(this.$el)
|
||||
},
|
||||
'@mouseleave'(e) {
|
||||
if (this.__hold) return
|
||||
|
||||
this.__context.deactivate()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Little utility to defer a callback into the microtask queue...
|
||||
function microtask(callback) {
|
||||
return new Promise(resolve => queueMicrotask(() => resolve(callback())))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
|
||||
export default function (Alpine) {
|
||||
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 {
|
||||
// Kept here for legacy. Remove after out of beta.
|
||||
get open() {
|
||||
return $data.__isOpen
|
||||
},
|
||||
get isOpen() {
|
||||
return $data.__isOpen
|
||||
},
|
||||
close() {
|
||||
$data.__close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-data'() {
|
||||
return {
|
||||
init() {
|
||||
// If the user chose to use :open and @close instead of x-model.
|
||||
(Alpine.bound(el, 'open') !== undefined) && Alpine.effect(() => {
|
||||
this.__isOpenState = Alpine.bound(el, 'open')
|
||||
})
|
||||
|
||||
if (Alpine.bound(el, 'initial-focus') !== undefined) this.$watch('__isOpenState', () => {
|
||||
if (! this.__isOpenState) return
|
||||
|
||||
setTimeout(() => {
|
||||
Alpine.bound(el, 'initial-focus').focus()
|
||||
}, 0);
|
||||
})
|
||||
},
|
||||
__isOpenState: false,
|
||||
__close() {
|
||||
if (Alpine.bound(el, 'open')) this.$dispatch('close')
|
||||
else this.__isOpenState = false
|
||||
},
|
||||
get __isOpen() {
|
||||
return Alpine.bound(el, 'static', this.__isOpenState)
|
||||
},
|
||||
}
|
||||
},
|
||||
'x-modelable': '__isOpenState',
|
||||
'x-id'() { return ['alpine-dialog-title', 'alpine-dialog-description'] },
|
||||
'x-show'() { return this.__isOpen },
|
||||
'x-trap.inert.noscroll'() { return this.__isOpen },
|
||||
'@keydown.escape'() { this.__close() },
|
||||
':aria-labelledby'() { return this.$id('alpine-dialog-title') },
|
||||
':aria-describedby'() { return this.$id('alpine-dialog-description') },
|
||||
'role': 'dialog',
|
||||
'aria-modal': 'true',
|
||||
})
|
||||
}
|
||||
|
||||
function handleOverlay(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:overlay" is missing a parent element with "x-dialog".') },
|
||||
'x-show'() { return this.__isOpen },
|
||||
'@click.prevent.stop'() { this.$data.__close() },
|
||||
})
|
||||
}
|
||||
|
||||
function handlePanel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'@click.outside'() { this.$data.__close() },
|
||||
'x-show'() { return this.$data.__isOpen },
|
||||
})
|
||||
}
|
||||
|
||||
function handleTitle(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:title" is missing a parent element with "x-dialog".') },
|
||||
':id'() { return this.$id('alpine-dialog-title') },
|
||||
})
|
||||
}
|
||||
|
||||
function handleDescription(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
':id'() { return this.$id('alpine-dialog-description') },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('disclosure', (el, directive) => {
|
||||
if (! directive.value) handleRoot(el, Alpine)
|
||||
else if (directive.value === 'panel') handlePanel(el, Alpine)
|
||||
else if (directive.value === 'button') handleButton(el, Alpine)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('disclosure', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isOpen() {
|
||||
return $data.__isOpen
|
||||
},
|
||||
close() {
|
||||
$data.__close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-modelable': '__isOpen',
|
||||
'x-data'() {
|
||||
return {
|
||||
// The panel will call this...
|
||||
// We can't do this inside a microtask in x-init because, when default-open is set to "true",
|
||||
// It will cause the panel to transition in for the first time, instead of showing instantly...
|
||||
__determineDefaultOpenState() {
|
||||
let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
|
||||
|
||||
if (defaultIsOpen) this.__isOpen = defaultIsOpen
|
||||
},
|
||||
__isOpen: false,
|
||||
__close() {
|
||||
this.__isOpen = false
|
||||
},
|
||||
__toggle() {
|
||||
this.__isOpen = ! this.__isOpen
|
||||
},
|
||||
}
|
||||
},
|
||||
'x-id'() { return ['alpine-disclosure-panel'] },
|
||||
})
|
||||
}
|
||||
|
||||
function handleButton(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() {
|
||||
if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
|
||||
},
|
||||
'@click'() {
|
||||
this.$data.__isOpen = ! this.$data.__isOpen
|
||||
},
|
||||
':aria-expanded'() {
|
||||
return this.$data.__isOpen
|
||||
},
|
||||
':aria-controls'() {
|
||||
return this.$data.$id('alpine-disclosure-panel')
|
||||
},
|
||||
'@keydown.space.prevent.stop'() { this.$data.__toggle() },
|
||||
'@keydown.enter.prevent.stop'() { this.$data.__toggle() },
|
||||
// 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 handlePanel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() {
|
||||
this.$data.__determineDefaultOpenState()
|
||||
},
|
||||
'x-show'() {
|
||||
return this.$data.__isOpen
|
||||
},
|
||||
':id'() {
|
||||
return this.$data.$id('alpine-disclosure-panel')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import combobox from './combobox'
|
||||
import dialog from './dialog'
|
||||
import disclosure from './disclosure'
|
||||
import listbox from './listbox'
|
||||
import popover from './popover'
|
||||
import menu from './menu'
|
||||
import notSwitch from './switch'
|
||||
import radio from './radio'
|
||||
import tabs from './tabs'
|
||||
|
||||
export default function (Alpine) {
|
||||
combobox(Alpine)
|
||||
dialog(Alpine)
|
||||
disclosure(Alpine)
|
||||
listbox(Alpine)
|
||||
menu(Alpine)
|
||||
notSwitch(Alpine)
|
||||
popover(Alpine)
|
||||
radio(Alpine)
|
||||
tabs(Alpine)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
|
||||
export function generateContext(Alpine, multiple, orientation, activateSelectedOrFirst) {
|
||||
return {
|
||||
/**
|
||||
* Main state...
|
||||
*/
|
||||
items: [],
|
||||
activeKey: switchboard(),
|
||||
orderedKeys: [],
|
||||
activatedByKeyPress: false,
|
||||
|
||||
/**
|
||||
* Initialization...
|
||||
*/
|
||||
activateSelectedOrFirst: Alpine.debounce(function () {
|
||||
activateSelectedOrFirst(false)
|
||||
}),
|
||||
|
||||
registerItemsQueue: [],
|
||||
|
||||
registerItem(key, el, value, disabled) {
|
||||
// We need to queue up these additions to not slow down the
|
||||
// init process for each row...
|
||||
if (this.registerItemsQueue.length === 0) {
|
||||
queueMicrotask(() => {
|
||||
if (this.registerItemsQueue.length > 0) {
|
||||
this.items = this.items.concat(this.registerItemsQueue)
|
||||
|
||||
this.registerItemsQueue = []
|
||||
|
||||
this.reorderKeys()
|
||||
this.activateSelectedOrFirst()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let item = {
|
||||
key, el, value, disabled
|
||||
}
|
||||
|
||||
this.registerItemsQueue.push(item)
|
||||
},
|
||||
|
||||
unregisterKeysQueue: [],
|
||||
|
||||
unregisterItem(key) {
|
||||
// This gets triggered when the mutation observer picks up DOM changes.
|
||||
// It will get called for every row that gets removed. If there are
|
||||
// 1000x rows, we want to trigger this cleanup when the first one
|
||||
// is handled, let the others add their keys to the queue, then
|
||||
// handle all the cleanup in bulk at the end. Big perf gain...
|
||||
if (this.unregisterKeysQueue.length === 0) {
|
||||
queueMicrotask(() => {
|
||||
if (this.unregisterKeysQueue.length > 0) {
|
||||
this.items = this.items.filter(i => ! this.unregisterKeysQueue.includes(i.key))
|
||||
this.orderedKeys = this.orderedKeys.filter(i => ! this.unregisterKeysQueue.includes(i))
|
||||
|
||||
this.unregisterKeysQueue = []
|
||||
|
||||
this.reorderKeys()
|
||||
this.activateSelectedOrFirst()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.unregisterKeysQueue.push(key)
|
||||
},
|
||||
|
||||
getItemByKey(key) {
|
||||
return this.items.find(i => i.key === key)
|
||||
},
|
||||
|
||||
getItemByValue(value) {
|
||||
return this.items.find(i => Alpine.raw(i.value) === Alpine.raw(value))
|
||||
},
|
||||
|
||||
getItemByEl(el) {
|
||||
return this.items.find(i => i.el === el)
|
||||
},
|
||||
|
||||
getItemsByValues(values) {
|
||||
let rawValues = values.map(i => Alpine.raw(i));
|
||||
let filteredValue = this.items.filter(i => rawValues.includes(Alpine.raw(i.value)))
|
||||
filteredValue = filteredValue.slice().sort((a, b) => {
|
||||
let position = a.el.compareDocumentPosition(b.el)
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
return 0
|
||||
})
|
||||
return filteredValue
|
||||
},
|
||||
|
||||
getActiveItem() {
|
||||
if (! this.hasActive()) return null
|
||||
|
||||
let item = this.items.find(i => i.key === this.activeKey.get())
|
||||
|
||||
if (! item) this.deactivateKey(this.activeKey.get())
|
||||
|
||||
return item
|
||||
},
|
||||
|
||||
activateItem(item) {
|
||||
if (! item) return
|
||||
|
||||
this.activateKey(item.key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle elements...
|
||||
*/
|
||||
reorderKeys: Alpine.debounce(function () {
|
||||
this.orderedKeys = this.items.map(i => i.key)
|
||||
|
||||
this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
|
||||
if (a === null || z === null) return 0
|
||||
|
||||
let aEl = this.items.find(i => i.key === a).el
|
||||
let zEl = this.items.find(i => i.key === z).el
|
||||
|
||||
let position = aEl.compareDocumentPosition(zEl)
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
// If there no longer is the active key in the items list, then
|
||||
// deactivate it...
|
||||
if (! this.orderedKeys.includes(this.activeKey.get())) this.deactivateKey(this.activeKey.get())
|
||||
}),
|
||||
|
||||
getActiveKey() {
|
||||
return this.activeKey.get()
|
||||
},
|
||||
|
||||
activeEl() {
|
||||
if (! this.activeKey.get()) return
|
||||
|
||||
return this.items.find(i => i.key === this.activeKey.get()).el
|
||||
},
|
||||
|
||||
isActiveEl(el) {
|
||||
let key = this.items.find(i => i.el === el)
|
||||
|
||||
return this.activeKey.is(key)
|
||||
},
|
||||
|
||||
activateEl(el) {
|
||||
let item = this.items.find(i => i.el === el)
|
||||
|
||||
this.activateKey(item.key)
|
||||
},
|
||||
|
||||
isDisabledEl(el) {
|
||||
return this.items.find(i => i.el === el).disabled
|
||||
},
|
||||
|
||||
get isScrollingTo() { return this.scrollingCount > 0 },
|
||||
|
||||
scrollingCount: 0,
|
||||
|
||||
activateAndScrollToKey(key, activatedByKeyPress) {
|
||||
if (! this.getItemByKey(key)) return
|
||||
|
||||
// This addresses the following problem:
|
||||
// If deactivate is hooked up to mouseleave,
|
||||
// scrolling to an element will trigger deactivation.
|
||||
// This "isScrollingTo" is exposed to prevent that.
|
||||
this.scrollingCount++
|
||||
|
||||
this.activateKey(key, activatedByKeyPress)
|
||||
|
||||
let targetEl = this.items.find(i => i.key === key).el
|
||||
|
||||
targetEl.scrollIntoView({ block: 'nearest' })
|
||||
|
||||
setTimeout(() => {
|
||||
this.scrollingCount--
|
||||
// Unfortunately, browser experimentation has shown me
|
||||
// that 25ms is the sweet spot when holding down an
|
||||
// arrow key to scroll the list of items...
|
||||
}, 25)
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle disabled keys...
|
||||
*/
|
||||
isDisabled(key) {
|
||||
let item = this.items.find(i => i.key === key)
|
||||
|
||||
if (! item) return false
|
||||
|
||||
return item.disabled
|
||||
},
|
||||
|
||||
get nonDisabledOrderedKeys() {
|
||||
return this.orderedKeys.filter(i => ! this.isDisabled(i))
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle activated keys...
|
||||
*/
|
||||
hasActive() { return !! this.activeKey.get() },
|
||||
|
||||
/**
|
||||
* Return true if the latest active element was activated
|
||||
* by the user (i.e. using the arrow keys) and false if was
|
||||
* activated automatically by alpine (i.e. first element automatically
|
||||
* activated after filtering the list)
|
||||
*/
|
||||
wasActivatedByKeyPress() {return this.activatedByKeyPress},
|
||||
|
||||
isActiveKey(key) { return this.activeKey.is(key) },
|
||||
|
||||
activateKey(key, activatedByKeyPress = false) {
|
||||
if (this.isDisabled(key)) return
|
||||
|
||||
this.activeKey.set(key)
|
||||
this.activatedByKeyPress = activatedByKeyPress
|
||||
},
|
||||
|
||||
deactivateKey(key) {
|
||||
if (this.activeKey.get() === key) {
|
||||
this.activeKey.set(null)
|
||||
this.activatedByKeyPress = false
|
||||
}
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
if (! this.activeKey.get()) return
|
||||
if (this.isScrollingTo) return
|
||||
|
||||
this.activeKey.set(null)
|
||||
this.activatedByKeyPress = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle active key traversal...
|
||||
*/
|
||||
nextKey() {
|
||||
if (! this.activeKey.get()) return
|
||||
|
||||
let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
|
||||
|
||||
return this.nonDisabledOrderedKeys[index + 1]
|
||||
},
|
||||
|
||||
prevKey() {
|
||||
if (! this.activeKey.get()) return
|
||||
|
||||
let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
|
||||
|
||||
return this.nonDisabledOrderedKeys[index - 1]
|
||||
},
|
||||
|
||||
firstKey() { return this.nonDisabledOrderedKeys[0] },
|
||||
|
||||
lastKey() { return this.nonDisabledOrderedKeys[this.nonDisabledOrderedKeys.length - 1] },
|
||||
|
||||
searchQuery: '',
|
||||
|
||||
clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
|
||||
|
||||
searchKey(query) {
|
||||
this.clearSearch()
|
||||
|
||||
this.searchQuery += query
|
||||
|
||||
let foundKey
|
||||
|
||||
for (let key in this.items) {
|
||||
let content = this.items[key].el.textContent.trim().toLowerCase()
|
||||
|
||||
if (content.startsWith(this.searchQuery)) {
|
||||
foundKey = this.items[key].key
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
|
||||
|
||||
return foundKey
|
||||
},
|
||||
|
||||
activateByKeyEvent(e, searchable = false, isOpen = () => false, open = () => {}, setIsTyping) {
|
||||
let targetKey, hasActive
|
||||
|
||||
setIsTyping(true)
|
||||
|
||||
let activatedByKeyPress = true
|
||||
|
||||
switch (e.key) {
|
||||
// case 'Backspace':
|
||||
// case 'Delete':
|
||||
case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
|
||||
setIsTyping(false)
|
||||
|
||||
if (! isOpen()) {
|
||||
open()
|
||||
break;
|
||||
}
|
||||
|
||||
this.reorderKeys(); hasActive = this.hasActive()
|
||||
|
||||
targetKey = hasActive ? this.nextKey() : this.firstKey()
|
||||
break;
|
||||
|
||||
case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
|
||||
setIsTyping(false)
|
||||
|
||||
if (! isOpen()) {
|
||||
open()
|
||||
break;
|
||||
}
|
||||
|
||||
this.reorderKeys(); hasActive = this.hasActive()
|
||||
|
||||
targetKey = hasActive ? this.prevKey() : this.lastKey()
|
||||
break;
|
||||
case 'Home':
|
||||
case 'PageUp':
|
||||
if (e.key == 'Home' && e.shiftKey) return;
|
||||
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
setIsTyping(false)
|
||||
this.reorderKeys(); hasActive = this.hasActive()
|
||||
targetKey = this.firstKey()
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
case 'PageDown':
|
||||
if (e.key == 'End' && e.shiftKey) return;
|
||||
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
setIsTyping(false)
|
||||
this.reorderKeys(); hasActive = this.hasActive()
|
||||
targetKey = this.lastKey()
|
||||
break;
|
||||
|
||||
default:
|
||||
activatedByKeyPress = this.activatedByKeyPress
|
||||
if (searchable && e.key.length === 1) {
|
||||
targetKey = this.searchKey(e.key)
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetKey) {
|
||||
this.activateAndScrollToKey(targetKey, activatedByKeyPress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keyByValue(object, value) {
|
||||
return Object.keys(object).find(key => object[key] === value)
|
||||
}
|
||||
|
||||
export function renderHiddenInputs(Alpine, el, name, value) {
|
||||
// Create input elements...
|
||||
let newInputs = generateInputs(name, value)
|
||||
|
||||
// Mark them for later tracking...
|
||||
newInputs.forEach(i => i._x_hiddenInput = true)
|
||||
|
||||
// Mark them for Alpine ignoring...
|
||||
newInputs.forEach(i => i._x_ignore = true)
|
||||
|
||||
// Gather old elements for removal...
|
||||
let children = el.children
|
||||
|
||||
let oldInputs = []
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
let child = children[i];
|
||||
|
||||
if (child._x_hiddenInput) oldInputs.push(child)
|
||||
else break
|
||||
}
|
||||
|
||||
// Remove old, and insert new ones into the DOM...
|
||||
Alpine.mutateDom(() => {
|
||||
oldInputs.forEach(i => i.remove())
|
||||
|
||||
newInputs.reverse().forEach(i => el.prepend(i))
|
||||
})
|
||||
}
|
||||
|
||||
function generateInputs(name, value, carry = []) {
|
||||
if (isObjectOrArray(value)) {
|
||||
for (let key in value) {
|
||||
carry = carry.concat(
|
||||
generateInputs(`${name}[${key}]`, value[key])
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let el = document.createElement('input')
|
||||
el.setAttribute('type', 'hidden')
|
||||
el.setAttribute('name', name)
|
||||
el.setAttribute('value', '' + value)
|
||||
|
||||
return [el]
|
||||
}
|
||||
|
||||
|
||||
return carry
|
||||
}
|
||||
|
||||
function isObjectOrArray(subject) {
|
||||
return typeof subject === 'object' && subject !== null
|
||||
}
|
||||
|
||||
function switchboard(value) {
|
||||
let lookup = {}
|
||||
|
||||
let current
|
||||
|
||||
let changeTracker = Alpine.reactive({ state: false })
|
||||
|
||||
let get = () => {
|
||||
// Depend on the change tracker so reading "get" becomes reactive...
|
||||
if (changeTracker.state) {
|
||||
//
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
let set = (newValue) => {
|
||||
if (newValue === current) return
|
||||
|
||||
if (current !== undefined) lookup[current].state = false
|
||||
|
||||
current = newValue
|
||||
|
||||
if (lookup[newValue] === undefined) {
|
||||
lookup[newValue] = Alpine.reactive({ state: true })
|
||||
} else {
|
||||
lookup[newValue].state = true
|
||||
}
|
||||
|
||||
changeTracker.state = ! changeTracker.state
|
||||
}
|
||||
|
||||
let is = (comparisonValue) => {
|
||||
if (lookup[comparisonValue] === undefined) {
|
||||
lookup[comparisonValue] = Alpine.reactive({ state: false })
|
||||
return lookup[comparisonValue].state
|
||||
}
|
||||
|
||||
return !! lookup[comparisonValue].state
|
||||
}
|
||||
|
||||
value === undefined || set(value)
|
||||
|
||||
return { get, set, is }
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { generateContext, renderHiddenInputs } from './list-context'
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('listbox', (el, directive) => {
|
||||
if (! directive.value) handleRoot(el, Alpine)
|
||||
else if (directive.value === 'label') handleLabel(el, Alpine)
|
||||
else if (directive.value === 'button') handleButton(el, Alpine)
|
||||
else if (directive.value === 'options') handleOptions(el, Alpine)
|
||||
else if (directive.value === 'option') handleOption(el, Alpine)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('listbox', (el) => {
|
||||
let data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
// @deprecated:
|
||||
get selected() {
|
||||
return data.__value
|
||||
},
|
||||
// @deprecated:
|
||||
get active() {
|
||||
let active = data.__context.getActiveItem()
|
||||
|
||||
return active && active.value
|
||||
},
|
||||
get value() {
|
||||
return data.__value
|
||||
},
|
||||
get isOpen() {
|
||||
return data.__isOpen
|
||||
},
|
||||
get isDisabled() {
|
||||
return data.__isDisabled
|
||||
},
|
||||
get activeOption() {
|
||||
let active = data.__context.getActiveItem()
|
||||
|
||||
return active && active.value
|
||||
},
|
||||
get activeIndex() {
|
||||
let active = data.__context.getActiveItem()
|
||||
|
||||
return active && active.key
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Alpine.magic('listboxOption', (el) => {
|
||||
let data = Alpine.$data(el)
|
||||
|
||||
// It's not great depending on the existance of the attribute in the DOM
|
||||
// but it's probably the fastest and most reliable at this point...
|
||||
let optionEl = Alpine.findClosest(el, i => {
|
||||
return i.hasAttribute('x-listbox:option')
|
||||
})
|
||||
|
||||
if (! optionEl) throw 'No x-listbox:option directive found...'
|
||||
|
||||
return {
|
||||
get isActive() {
|
||||
return data.__context.isActiveKey(Alpine.$data(optionEl).__optionKey)
|
||||
},
|
||||
get isSelected() {
|
||||
return data.__isSelected(optionEl)
|
||||
},
|
||||
get isDisabled() {
|
||||
return data.__context.isDisabled(Alpine.$data(optionEl).__optionKey)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
|
||||
'x-modelable': '__value',
|
||||
|
||||
// Initialize...
|
||||
'x-data'() {
|
||||
return {
|
||||
/**
|
||||
* Listbox state...
|
||||
*/
|
||||
__ready: false,
|
||||
__value: null,
|
||||
__isOpen: false,
|
||||
__context: undefined,
|
||||
__isMultiple: undefined,
|
||||
__isStatic: false,
|
||||
__isDisabled: undefined,
|
||||
__compareBy: null,
|
||||
__inputName: null,
|
||||
__orientation: 'vertical',
|
||||
__hold: false,
|
||||
|
||||
/**
|
||||
* Listbox initialization...
|
||||
*/
|
||||
init() {
|
||||
this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
|
||||
this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
|
||||
this.__inputName = Alpine.extractProp(el, 'name', null)
|
||||
this.__compareBy = Alpine.extractProp(el, 'by')
|
||||
this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
|
||||
|
||||
this.__context = generateContext(Alpine, this.__isMultiple, this.__orientation, () => this.__activateSelectedOrFirst())
|
||||
|
||||
let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
|
||||
|
||||
this.__value = defaultValue
|
||||
|
||||
// We have to wait again until after the "ready" processes are finished
|
||||
// to settle up currently selected Values (this prevents this next bit
|
||||
// of code from running multiple times on startup...)
|
||||
queueMicrotask(() => {
|
||||
Alpine.effect(() => {
|
||||
// Everytime the value changes, we need to re-render the hidden inputs,
|
||||
// if a user passed the "name" prop...
|
||||
this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
|
||||
})
|
||||
|
||||
// Keep the currently selected value in sync with the input value...
|
||||
Alpine.effect(() => {
|
||||
this.__resetInput()
|
||||
})
|
||||
})
|
||||
},
|
||||
__resetInput() {
|
||||
let input = this.$refs.__input
|
||||
if (! input) return
|
||||
|
||||
let value = this.$data.__getCurrentValue()
|
||||
|
||||
input.value = value
|
||||
},
|
||||
__getCurrentValue() {
|
||||
if (! this.$refs.__input) return ''
|
||||
if (! this.__value) return ''
|
||||
if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
|
||||
if (typeof this.__value === 'string') return this.__value
|
||||
return ''
|
||||
},
|
||||
__open() {
|
||||
if (this.__isOpen) return
|
||||
this.__isOpen = true
|
||||
|
||||
this.__activateSelectedOrFirst()
|
||||
|
||||
// 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.__options.focus({ preventScroll: true }))
|
||||
},
|
||||
__close() {
|
||||
this.__isOpen = false
|
||||
|
||||
this.__context.deactivate()
|
||||
|
||||
this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
|
||||
},
|
||||
__activateSelectedOrFirst(activateSelected = true) {
|
||||
if (! this.__isOpen) return
|
||||
|
||||
if (this.__context.getActiveKey()) {
|
||||
this.__context.activateAndScrollToKey(this.__context.getActiveKey())
|
||||
return
|
||||
}
|
||||
|
||||
let firstSelectedValue
|
||||
|
||||
if (this.__isMultiple) {
|
||||
firstSelectedValue = this.__value.find(i => {
|
||||
return !! this.__context.getItemByValue(i)
|
||||
})
|
||||
} else {
|
||||
firstSelectedValue = this.__value
|
||||
}
|
||||
|
||||
if (activateSelected && firstSelectedValue) {
|
||||
let firstSelected = this.__context.getItemByValue(firstSelectedValue)
|
||||
|
||||
firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
|
||||
} else {
|
||||
this.__context.activateAndScrollToKey(this.__context.firstKey())
|
||||
}
|
||||
},
|
||||
__selectActive() {
|
||||
let active = this.$data.__context.getActiveItem()
|
||||
if (active) this.__toggleSelected(active.value)
|
||||
},
|
||||
__selectOption(el) {
|
||||
let item = this.__context.getItemByEl(el)
|
||||
|
||||
if (item) this.__toggleSelected(item.value)
|
||||
},
|
||||
__isSelected(el) {
|
||||
let item = this.__context.getItemByEl(el)
|
||||
|
||||
if (! item) return false
|
||||
if (item.value === null || item.value === undefined) return false
|
||||
|
||||
return this.__hasSelected(item.value)
|
||||
},
|
||||
__toggleSelected(value) {
|
||||
if (! this.__isMultiple) {
|
||||
this.__value = value
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let index = this.__value.findIndex(j => this.__compare(j, value))
|
||||
|
||||
if (index === -1) {
|
||||
this.__value.push(value)
|
||||
} else {
|
||||
this.__value.splice(index, 1)
|
||||
}
|
||||
},
|
||||
__hasSelected(value) {
|
||||
if (! this.__isMultiple) return this.__compare(this.__value, value)
|
||||
|
||||
return this.__value.some(i => this.__compare(i, value))
|
||||
},
|
||||
__compare(a, b) {
|
||||
let by = this.__compareBy
|
||||
|
||||
if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
|
||||
|
||||
if (typeof by === 'string') {
|
||||
let property = by
|
||||
by = (a, b) => {
|
||||
// Handle null values
|
||||
if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
|
||||
return Alpine.raw(a) === Alpine.raw(b)
|
||||
}
|
||||
|
||||
return a[property] === b[property];
|
||||
}
|
||||
}
|
||||
|
||||
return by(a, b)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleLabel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-ref': '__label',
|
||||
':id'() { return this.$id('alpine-listbox-label') },
|
||||
'@click'() { this.$refs.__button.focus({ preventScroll: true }) },
|
||||
})
|
||||
}
|
||||
|
||||
function handleButton(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-ref': '__button',
|
||||
':id'() { return this.$id('alpine-listbox-button') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'aria-haspopup': 'true',
|
||||
':aria-labelledby'() { return this.$id('alpine-listbox-label') },
|
||||
':aria-expanded'() { return this.$data.__isOpen },
|
||||
':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
|
||||
|
||||
// Initialize....
|
||||
'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
|
||||
|
||||
// Register listeners...
|
||||
'@click'() { this.$data.__open() },
|
||||
'@keydown'(e) {
|
||||
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
this.$data.__open()
|
||||
}
|
||||
},
|
||||
'@keydown.space.stop.prevent'() { this.$data.__open() },
|
||||
'@keydown.enter.stop.prevent'() { this.$data.__open() },
|
||||
})
|
||||
}
|
||||
|
||||
function handleOptions(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
// Setup...
|
||||
'x-ref': '__options',
|
||||
':id'() { return this.$id('alpine-listbox-options') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'role': 'listbox',
|
||||
tabindex: '0',
|
||||
':aria-orientation'() {
|
||||
return this.$data.__orientation
|
||||
},
|
||||
':aria-labelledby'() { return this.$id('alpine-listbox-button') },
|
||||
':aria-activedescendant'() {
|
||||
if (! this.$data.__context.hasActive()) return
|
||||
|
||||
let active = this.$data.__context.getActiveItem()
|
||||
|
||||
return active ? active.el.id : null
|
||||
},
|
||||
|
||||
// Initialize...
|
||||
'x-init'() {
|
||||
this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
|
||||
|
||||
if (Alpine.bound(this.$el, 'hold')) {
|
||||
this.$data.__hold = true;
|
||||
}
|
||||
},
|
||||
|
||||
'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
|
||||
'x-trap'() { return this.$data.__isOpen },
|
||||
'@click.outside'() { this.$data.__close() },
|
||||
'@keydown.escape.stop.prevent'() { this.$data.__close() },
|
||||
'@focus'() { this.$data.__activateSelectedOrFirst() },
|
||||
'@keydown'(e) {
|
||||
queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
|
||||
},
|
||||
'@keydown.enter.stop.prevent'() {
|
||||
this.$data.__selectActive();
|
||||
|
||||
this.$data.__isMultiple || this.$data.__close()
|
||||
},
|
||||
'@keydown.space.stop.prevent'() {
|
||||
this.$data.__selectActive();
|
||||
|
||||
this.$data.__isMultiple || this.$data.__close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleOption(el, Alpine) {
|
||||
Alpine.bind(el, () => {
|
||||
return {
|
||||
'x-id'() { return ['alpine-listbox-option'] },
|
||||
':id'() { return this.$id('alpine-listbox-option') },
|
||||
|
||||
// Accessibility attributes...
|
||||
'role': 'option',
|
||||
':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
|
||||
':aria-selected'() { return this.$listboxOption.isSelected },
|
||||
|
||||
// Initialize...
|
||||
'x-data'() {
|
||||
return {
|
||||
'__optionKey': null,
|
||||
|
||||
init() {
|
||||
this.__optionKey = (Math.random() + 1).toString(36).substring(7)
|
||||
|
||||
let value = Alpine.extractProp(el, 'value')
|
||||
let disabled = Alpine.extractProp(el, 'disabled', false, false)
|
||||
|
||||
this.$data.__context.registerItem(this.__optionKey, el, value, disabled)
|
||||
},
|
||||
destroy() {
|
||||
this.$data.__context.unregisterItem(this.__optionKey)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// Register listeners...
|
||||
'@click'() {
|
||||
if (this.$listboxOption.isDisabled) return;
|
||||
|
||||
this.$data.__selectOption(el)
|
||||
|
||||
this.$data.__isMultiple || this.$data.__close()
|
||||
},
|
||||
'@mouseenter'() { this.$data.__context.activateEl(el) },
|
||||
'@mouseleave'() {
|
||||
this.$data.__hold || this.$data.__context.deactivate()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Little utility to defer a callback into the microtask queue...
|
||||
function microtask(callback) {
|
||||
return new Promise(resolve => queueMicrotask(() => resolve(callback())))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('menuItem', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isActive() {
|
||||
return $data.__activeEl == $data.__itemEl
|
||||
},
|
||||
get isDisabled() {
|
||||
return $data.__itemEl.__isDisabled.value
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
|
||||
'x-modelable': '__isOpen',
|
||||
'x-data'() {
|
||||
return {
|
||||
__itemEls: [],
|
||||
__activeEl: null,
|
||||
__isOpen: false,
|
||||
__open(activationStrategy) {
|
||||
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 })
|
||||
|
||||
// Activate the first item every time the menu is open...
|
||||
activationStrategy && activationStrategy(Alpine, this.$refs.__items, el => el.__activate())
|
||||
})
|
||||
},
|
||||
__close(focusAfter = true) {
|
||||
this.__isOpen = false
|
||||
|
||||
focusAfter && this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
|
||||
},
|
||||
__contains(outer, inner) {
|
||||
return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
|
||||
}
|
||||
}
|
||||
},
|
||||
'@focusin.window'() {
|
||||
if (! this.$data.__contains(this.$el, document.activeElement)) {
|
||||
this.$data.__close(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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.last) },
|
||||
'@keydown.space.stop.prevent'() { this.$data.__open() },
|
||||
'@keydown.enter.stop.prevent'() { this.$data.__open() },
|
||||
})
|
||||
}
|
||||
|
||||
// When patching children:
|
||||
// The child isn't initialized until it is reached. This is normally fine
|
||||
// except when something like this happens where an "id" is added during the initializing phase
|
||||
// because the "to" element hasn't initialized yet, it doesn't have the ID, so there is a "key" mismatch
|
||||
|
||||
|
||||
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 },
|
||||
'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 = Alpine.reactive({ value: false })
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.$el.__isDisabled.value = Alpine.bound(this.$el, 'disabled', false)
|
||||
})
|
||||
},
|
||||
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.__itemEl.__isDisabled.value ? false : '-1' },
|
||||
'role': 'menuitem',
|
||||
'@mousemove'() { this.__itemEl.__isDisabled.value || this.$menuItem.isActive || this.__itemEl.__activate() },
|
||||
'@mouseleave'() { this.__itemEl.__isDisabled.value || ! this.$menuItem.isActive || this.__itemEl.__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(Alpine, first, receive)
|
||||
}
|
||||
|
||||
if (first.__isDisabled.value) return this.next(Alpine, 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.value) return this.previous(Alpine, 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.value || next.tagName.toLowerCase() === 'template') return this.next(Alpine, 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.value || prev.tagName.toLowerCase() === 'template') return this.previous(Alpine, prev, receive, fallback)
|
||||
return receive(prev)
|
||||
},
|
||||
searchQuery: '',
|
||||
debouncedClearSearch: undefined,
|
||||
clearSearch(Alpine) {
|
||||
if (! this.debouncedClearSearch) {
|
||||
this.debouncedClearSearch = Alpine.debounce(function () { this.searchQuery = '' }, 350)
|
||||
}
|
||||
|
||||
this.debouncedClearSearch()
|
||||
},
|
||||
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.value && receiver(el)
|
||||
|
||||
this.clearSearch(Alpine)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('popover', (el, directive) => {
|
||||
if (! directive.value) handleRoot(el, Alpine)
|
||||
else if (directive.value === 'overlay') handleOverlay(el, Alpine)
|
||||
else if (directive.value === 'button') handleButton(el, Alpine)
|
||||
else if (directive.value === 'panel') handlePanel(el, Alpine)
|
||||
else if (directive.value === 'group') handleGroup(el, Alpine)
|
||||
})
|
||||
|
||||
Alpine.magic('popover', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isOpen() {
|
||||
return $data.__isOpenState
|
||||
},
|
||||
open() {
|
||||
$data.__open()
|
||||
},
|
||||
close() {
|
||||
$data.__close()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
|
||||
'x-modelable': '__isOpenState',
|
||||
'x-data'() {
|
||||
return {
|
||||
init() {
|
||||
if (this.$data.__groupEl) {
|
||||
this.$data.__groupEl.addEventListener('__close-others', ({ detail }) => {
|
||||
if (detail.el.isSameNode(this.$el)) return
|
||||
|
||||
this.__close(false)
|
||||
})
|
||||
}
|
||||
},
|
||||
__buttonEl: undefined,
|
||||
__panelEl: undefined,
|
||||
__isStatic: false,
|
||||
get __isOpen() {
|
||||
if (this.__isStatic) return true
|
||||
|
||||
return this.__isOpenState
|
||||
},
|
||||
__isOpenState: false,
|
||||
__open() {
|
||||
this.__isOpenState = true
|
||||
|
||||
this.$dispatch('__close-others', { el: this.$el })
|
||||
},
|
||||
__toggle() {
|
||||
this.__isOpenState ? this.__close() : this.__open()
|
||||
},
|
||||
__close(el) {
|
||||
if (this.__isStatic) return
|
||||
|
||||
this.__isOpenState = false
|
||||
|
||||
if (el === false) return
|
||||
|
||||
el = el || this.$data.__buttonEl
|
||||
|
||||
if (document.activeElement.isSameNode(el)) return
|
||||
|
||||
setTimeout(() => el.focus())
|
||||
},
|
||||
__contains(outer, inner) {
|
||||
return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
|
||||
}
|
||||
}
|
||||
},
|
||||
'@keydown.escape.stop.prevent'() {
|
||||
this.__close()
|
||||
},
|
||||
'@focusin.window'() {
|
||||
if (this.$data.__groupEl) {
|
||||
if (! this.$data.__contains(this.$data.__groupEl, document.activeElement)) {
|
||||
this.$data.__close(false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (! this.$data.__contains(this.$el, document.activeElement)) {
|
||||
this.$data.__close(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleButton(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-ref': 'button',
|
||||
':id'() { return this.$id('alpine-popover-button') },
|
||||
':aria-expanded'() { return this.$data.__isOpen },
|
||||
':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-popover-panel') },
|
||||
'x-init'() {
|
||||
if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
|
||||
|
||||
this.$data.__buttonEl = this.$el
|
||||
},
|
||||
'@click'() { this.$data.__toggle() },
|
||||
'@keydown.tab'(e) {
|
||||
if (! e.shiftKey && this.$data.__isOpen) {
|
||||
let firstFocusableEl = this.$focus.within(this.$data.__panelEl).getFirst()
|
||||
|
||||
if (firstFocusableEl) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
this.$focus.focus(firstFocusableEl)
|
||||
}
|
||||
}
|
||||
},
|
||||
'@keyup.tab'(e) {
|
||||
if (this.$data.__isOpen) {
|
||||
// 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.
|
||||
&& (lastEl && (this.$el.compareDocumentPosition(lastEl) & Node.DOCUMENT_POSITION_FOLLOWING))
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
this.$focus.within(this.$data.__panelEl).last()
|
||||
}
|
||||
}
|
||||
},
|
||||
'@keydown.space.stop.prevent'() { this.$data.__toggle() },
|
||||
'@keydown.enter.stop.prevent'() { this.$data.__toggle() },
|
||||
// This is to stop Firefox from firing a "click".
|
||||
'@keyup.space.stop.prevent'() { },
|
||||
})
|
||||
}
|
||||
|
||||
function handlePanel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() {
|
||||
this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
|
||||
this.$data.__panelEl = this.$el
|
||||
},
|
||||
'x-effect'() {
|
||||
this.$data.__isOpen && Alpine.bound(el, 'focus') && this.$focus.first()
|
||||
},
|
||||
'x-ref': 'panel',
|
||||
':id'() { return this.$id('alpine-popover-panel') },
|
||||
'x-show'() { return this.$data.__isOpen },
|
||||
'@mousedown.window'($event) {
|
||||
if (! this.$data.__isOpen) return
|
||||
if (this.$data.__contains(this.$data.__buttonEl, $event.target)) return
|
||||
if (this.$data.__contains(this.$el, $event.target)) return
|
||||
|
||||
if (! this.$focus.focusable($event.target)) {
|
||||
this.$data.__close()
|
||||
}
|
||||
},
|
||||
'@keydown.tab'(e) {
|
||||
if (e.shiftKey && this.$focus.isFirst(e.target)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
Alpine.bound(el, 'focus') ? this.$data.__close() : this.$data.__buttonEl.focus()
|
||||
} else if (! e.shiftKey && this.$focus.isLast(e.target)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Get the next panel button:
|
||||
let els = this.$focus.within(document).all()
|
||||
let buttonIdx = els.indexOf(this.$data.__buttonEl)
|
||||
|
||||
let nextEls = els
|
||||
.splice(buttonIdx + 1) // Elements after button
|
||||
.filter(el => ! this.$el.contains(el)) // Ignore items in panel
|
||||
|
||||
nextEls[0].focus()
|
||||
|
||||
Alpine.bound(el, 'focus') && this.$data.__close(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleGroup(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-ref': 'container',
|
||||
'x-data'() {
|
||||
return {
|
||||
__groupEl: this.$el,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleOverlay(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-show'() { return this.$data.__isOpen }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('radio', (el, directive) => {
|
||||
if (! directive.value) handleRoot(el, Alpine)
|
||||
else if (directive.value === 'option') handleOption(el, Alpine)
|
||||
else if (directive.value === 'label') handleLabel(el, Alpine)
|
||||
else if (directive.value === 'description') handleDescription(el, Alpine)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('radioOption', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isActive() {
|
||||
return $data.__option === $data.__active
|
||||
},
|
||||
get isChecked() {
|
||||
return $data.__option === $data.__value
|
||||
},
|
||||
get isDisabled() {
|
||||
let disabled = $data.__disabled
|
||||
|
||||
if ($data.__rootDisabled) return true
|
||||
|
||||
return disabled
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-modelable': '__value',
|
||||
'x-data'() {
|
||||
return {
|
||||
init() {
|
||||
queueMicrotask(() => {
|
||||
this.__rootDisabled = Alpine.bound(el, 'disabled', false);
|
||||
this.__value = Alpine.bound(this.$el, 'default-value', false)
|
||||
this.__inputName = Alpine.bound(this.$el, 'name', false)
|
||||
this.__inputId = 'alpine-radio-'+Date.now()
|
||||
})
|
||||
|
||||
// Add `role="none"` to all non role elements.
|
||||
this.$nextTick(() => {
|
||||
let walker = document.createTreeWalker(
|
||||
this.$el,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: node => {
|
||||
if (node.getAttribute('role') === 'radio') return NodeFilter.FILTER_REJECT
|
||||
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
},
|
||||
false
|
||||
)
|
||||
|
||||
while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
|
||||
})
|
||||
},
|
||||
__value: undefined,
|
||||
__active: undefined,
|
||||
__rootEl: this.$el,
|
||||
__optionValues: [],
|
||||
__disabledOptions: new Set,
|
||||
__optionElsByValue: new Map,
|
||||
__hasLabel: false,
|
||||
__hasDescription: false,
|
||||
__rootDisabled: false,
|
||||
__inputName: undefined,
|
||||
__inputId: undefined,
|
||||
__change(value) {
|
||||
if (this.__rootDisabled) return
|
||||
|
||||
this.__value = value
|
||||
},
|
||||
__addOption(option, el, disabled) {
|
||||
// Add current element to element list for navigating.
|
||||
let options = Alpine.raw(this.__optionValues)
|
||||
let els = options.map(i => this.__optionElsByValue.get(i))
|
||||
let inserted = false
|
||||
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
if (els[i].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
options.splice(i, 0, option)
|
||||
this.__optionElsByValue.set(option, el)
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
options.push(option)
|
||||
this.__optionElsByValue.set(option, el)
|
||||
}
|
||||
|
||||
disabled && this.__disabledOptions.add(option)
|
||||
},
|
||||
__isFirstOption(option) {
|
||||
return this.__optionValues.indexOf(option) === 0
|
||||
},
|
||||
__setActive(option) {
|
||||
this.__active = option
|
||||
},
|
||||
__focusOptionNext() {
|
||||
let option = this.__active
|
||||
let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
|
||||
let next = all[this.__optionValues.indexOf(option) + 1]
|
||||
next = next || all[0]
|
||||
|
||||
this.__optionElsByValue.get(next).focus()
|
||||
this.__change(next)
|
||||
},
|
||||
__focusOptionPrev() {
|
||||
let option = this.__active
|
||||
let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
|
||||
let prev = all[all.indexOf(option) - 1]
|
||||
prev = prev || all.slice(-1)[0]
|
||||
|
||||
this.__optionElsByValue.get(prev).focus()
|
||||
this.__change(prev)
|
||||
},
|
||||
}
|
||||
},
|
||||
'x-effect'() {
|
||||
let value = this.__value
|
||||
|
||||
// Only render a hidden input if the "name" prop is passed...
|
||||
if (! this.__inputName) return
|
||||
|
||||
// First remove a previously appended hidden input (if it exists)...
|
||||
let nextEl = this.$el.nextElementSibling
|
||||
if (nextEl && String(nextEl.id) === String(this.__inputId)) {
|
||||
nextEl.remove()
|
||||
}
|
||||
|
||||
// If the value is true, create the input and append it, otherwise,
|
||||
// we already removed it in the previous step...
|
||||
if (value) {
|
||||
let input = document.createElement('input')
|
||||
|
||||
input.type = 'hidden'
|
||||
input.value = value
|
||||
input.name = this.__inputName
|
||||
input.id = this.__inputId
|
||||
|
||||
this.$el.after(input)
|
||||
}
|
||||
},
|
||||
'role': 'radiogroup',
|
||||
'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
|
||||
':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
|
||||
':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
|
||||
'@keydown.up.prevent.stop'() { this.__focusOptionPrev() },
|
||||
'@keydown.left.prevent.stop'() { this.__focusOptionPrev() },
|
||||
'@keydown.down.prevent.stop'() { this.__focusOptionNext() },
|
||||
'@keydown.right.prevent.stop'() { this.__focusOptionNext() },
|
||||
})
|
||||
}
|
||||
|
||||
function handleOption(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-data'() {
|
||||
return {
|
||||
init() {
|
||||
queueMicrotask(() => {
|
||||
this.__disabled = Alpine.bound(el, 'disabled', false)
|
||||
this.__option = Alpine.bound(el, 'value')
|
||||
this.$data.__addOption(this.__option, this.$el, this.__disabled)
|
||||
})
|
||||
},
|
||||
__option: undefined,
|
||||
__disabled: false,
|
||||
__hasLabel: false,
|
||||
__hasDescription: false,
|
||||
}
|
||||
},
|
||||
'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
|
||||
'role': 'radio',
|
||||
':aria-checked'() { return this.$radioOption.isChecked },
|
||||
':aria-disabled'() { return this.$radioOption.isDisabled },
|
||||
':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
|
||||
':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
|
||||
':tabindex'() {
|
||||
if (this.$radioOption.isDisabled) return -1
|
||||
if (this.$radioOption.isChecked) return 0
|
||||
if (! this.$data.__value && this.$data.__isFirstOption(this.$data.__option)) return 0
|
||||
|
||||
return -1
|
||||
},
|
||||
'@click'() {
|
||||
if (this.$radioOption.isDisabled) return
|
||||
this.$data.__change(this.$data.__option)
|
||||
this.$el.focus()
|
||||
},
|
||||
'@focus'() {
|
||||
if (this.$radioOption.isDisabled) return
|
||||
this.$data.__setActive(this.$data.__option)
|
||||
},
|
||||
'@blur'() {
|
||||
if (this.$data.__active === this.$data.__option) this.$data.__setActive(undefined)
|
||||
},
|
||||
'@keydown.space.stop.prevent'() { this.$data.__change(this.$data.__option) },
|
||||
})
|
||||
}
|
||||
|
||||
function handleLabel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { this.$data.__hasLabel = true },
|
||||
':id'() { return this.$id('alpine-radio-label') },
|
||||
})
|
||||
}
|
||||
|
||||
function handleDescription(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { this.$data.__hasDescription = true },
|
||||
':id'() { return this.$id('alpine-radio-description') },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
export default function (Alpine) {
|
||||
Alpine.directive('switch', (el, directive) => {
|
||||
if (directive.value === 'group') handleGroup(el, Alpine)
|
||||
else if (directive.value === 'label') handleLabel(el, Alpine)
|
||||
else if (directive.value === 'description') handleDescription(el, Alpine)
|
||||
else handleRoot(el, Alpine)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('switch', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isChecked() {
|
||||
return $data.__value === true
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleGroup(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-id'() { return ['alpine-switch-label', 'alpine-switch-description'] },
|
||||
'x-data'() {
|
||||
return {
|
||||
__hasLabel: false,
|
||||
__hasDescription: false,
|
||||
__switchEl: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-modelable': '__value',
|
||||
'x-data'() {
|
||||
return {
|
||||
init() {
|
||||
queueMicrotask(() => {
|
||||
this.__value = Alpine.bound(this.$el, 'default-checked', false)
|
||||
this.__inputName = Alpine.bound(this.$el, 'name', false)
|
||||
this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
|
||||
this.__inputId = 'alpine-switch-'+Date.now()
|
||||
})
|
||||
},
|
||||
__value: undefined,
|
||||
__inputName: undefined,
|
||||
__inputValue: undefined,
|
||||
__inputId: undefined,
|
||||
__toggle() {
|
||||
this.__value = ! this.__value;
|
||||
},
|
||||
}
|
||||
},
|
||||
'x-effect'() {
|
||||
let value = this.__value
|
||||
|
||||
// Only render a hidden input if the "name" prop is passed...
|
||||
if (! this.__inputName) return
|
||||
|
||||
// First remove a previously appended hidden input (if it exists)...
|
||||
let nextEl = this.$el.nextElementSibling
|
||||
if (nextEl && String(nextEl.id) === String(this.__inputId)) {
|
||||
nextEl.remove()
|
||||
}
|
||||
|
||||
// If the value is true, create the input and append it, otherwise,
|
||||
// we already removed it in the previous step...
|
||||
if (value) {
|
||||
let input = document.createElement('input')
|
||||
|
||||
input.type = 'hidden'
|
||||
input.value = this.__inputValue
|
||||
input.name = this.__inputName
|
||||
input.id = this.__inputId
|
||||
|
||||
this.$el.after(input)
|
||||
}
|
||||
},
|
||||
'x-init'() {
|
||||
if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
|
||||
this.$data.__switchEl = this.$el
|
||||
},
|
||||
'role': 'switch',
|
||||
'tabindex': "0",
|
||||
':aria-checked'() { return !!this.__value },
|
||||
':aria-labelledby'() { return this.$data.__hasLabel && this.$id('alpine-switch-label') },
|
||||
':aria-describedby'() { return this.$data.__hasDescription && this.$id('alpine-switch-description') },
|
||||
'@click.prevent'() { this.__toggle() },
|
||||
'@keyup'(e) {
|
||||
if (e.key !== 'Tab') e.preventDefault()
|
||||
if (e.key === ' ') this.__toggle()
|
||||
},
|
||||
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
|
||||
'@keypress.prevent'() { },
|
||||
})
|
||||
}
|
||||
|
||||
function handleLabel(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { this.$data.__hasLabel = true },
|
||||
':id'() { return this.$id('alpine-switch-label') },
|
||||
'@click'() {
|
||||
this.$data.__switchEl.click()
|
||||
this.$data.__switchEl.focus({ preventScroll: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleDescription(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-init'() { this.$data.__hasDescription = true },
|
||||
':id'() { return this.$id('alpine-switch-description') },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
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)
|
||||
}).before('bind')
|
||||
|
||||
Alpine.magic('tab', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isSelected() {
|
||||
return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
|
||||
},
|
||||
get isDisabled() {
|
||||
return $data.__isDisabled
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Alpine.magic('panel', el => {
|
||||
let $data = Alpine.$data(el)
|
||||
|
||||
return {
|
||||
get isSelected() {
|
||||
return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRoot(el, Alpine) {
|
||||
Alpine.bind(el, {
|
||||
'x-modelable': '__selectedIndex',
|
||||
'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) {
|
||||
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.__tabEl.__disabled = Alpine.bound(this.$el, 'disabled', false)
|
||||
this.__isDisabled = this.__tabEl.__disabled
|
||||
},
|
||||
__tabEl: undefined,
|
||||
__isDisabled: false,
|
||||
}},
|
||||
'@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.isSelected ? 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.$panel.isSelected ? 0 : -1 },
|
||||
'x-data'() { return {
|
||||
init() {
|
||||
this.__panelEl = this.$el
|
||||
this.$data.__addPanel(this.$el)
|
||||
},
|
||||
__panelEl: undefined,
|
||||
}},
|
||||
'x-show'() { return this.$panel.isSelected },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user