Browse Source

Merge branch 'jlb/listbox'

Caleb Porzio 2 years ago
parent
commit
79ca59a7d5

+ 161 - 26
index.html

@@ -10,33 +10,168 @@
     <script src="//cdn.tailwindcss.com"></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
-    <!-- Play around. -->
-    <main x-data="{ active: null, access: [
-        {
-            id: 'access-1',
-            name: 'Public access',
-            description: 'This project would be available to anyone who has the link',
-            disabled: false,
-        },
-    ]}">
-        <div x-radio group x-model="active">
-            <fieldset>
-                <legend>
-                    <h2 x-radio:label>Privacy setting</h2>
-                    <p x-radio:description>Some description</p>
-                </legend>
-
-                <div>
-                    <template x-for="(item, i) in access" :key="item.id">
-                        <div :option="item.id" x-radio:option :value="item" :disabled="item.disabled">
-                            <span :label="item.id" x-radio:label x-text="item.name"></span>
-                            <span :description="item.id" x-radio:description x-text="item.description"></span>
-                        </div>
-                    </template>
+    <!-- <div x-data="{ value: null }">
+        Value: <span x-text="value"></span>
+
+        <button @click="value = 'bar'">Change value</button>
+
+        <div x-listbox x-model="value">
+            <button x-listbox:button>toggle</button>
+
+            <ul x-listbox:options>
+                <li x-listbox:option value="foo" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Foo</li>
+                <li x-listbox:option value="bar" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Bar</li>
+                <li x-listbox:option value="baz" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Baz</li>
+            </ul>
+        </div>
+    </div> -->
+
+    <!-- <div
+    x-data="{ selected: undefined, people: [
+        { id: 1, name: 'Wade Cooper', foo: { bar: 'baz' } },
+        { 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"
+    >
+        <button @click="selected = people[1]">Change value</button>
+
+        <button @click="
+            people.sort((a, b) => a.name > b.name ? 1 : -1)
+        ">Reorder</button>
+
+        <button @click="
+            people = people.filter(i => i.name !== 'Arlene Mccoy')
+        ">Destroy item</button>
+
+        <div class="w-full max-w-xs mx-auto">
+            <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>
-            </fieldset>
+            </div>
         </div>
+    </div> -->
+
+
+    <!-- MULTIPLE: -->
+    <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">
+
+            <button @click="selected.push(people[1])">Change value</button>
+
+            <button @click="
+                people.sort((a, b) => a.name > b.name ? 1 : -1)
+            ">Reorder</button>
+
+            <button @click="
+                people = people.filter(i => i.name !== 'Arlene Mccoy')
+            ">Destroy item</button>
 
-        <span x-text="JSON.stringify(active)"></span>
-    </main>
+            <div x-listbox name="people" 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">
+                        <div x-show="$listbox.isOpen">
+                            wrapper
+                            <ul x-listbox:options static 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>
 </html>

File diff suppressed because it is too large
+ 18 - 0
packages/ui/demo/index.html


+ 105 - 0
packages/ui/demo/listbox/data-driven.html

@@ -0,0 +1,105 @@
+<!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/history/dist/cdn.js"></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>
+
+
+

+ 233 - 0
packages/ui/demo/listbox/index.html

@@ -0,0 +1,233 @@
+<!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/history/dist/cdn.js"></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>
+
+
+

+ 105 - 0
packages/ui/demo/listbox/multiple.html

@@ -0,0 +1,105 @@
+<!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/history/dist/cdn.js"></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>
+
+
+

+ 3 - 1
packages/ui/src/index.js

@@ -1,14 +1,16 @@
 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 popover from './popover'
 import radio from './radio'
 import tabs from './tabs'
 
 export default function (Alpine) {
     dialog(Alpine)
     disclosure(Alpine)
+    listbox(Alpine)
     menu(Alpine)
     notSwitch(Alpine)
     popover(Alpine)

+ 460 - 0
packages/ui/src/list-context.js

@@ -0,0 +1,460 @@
+import Alpine from "../../alpinejs/src/alpine"
+
+export function generateContext(multiple, orientation) {
+    return {
+        /**
+         * Main state...
+         */
+        searchableText: {},
+        disabledKeys: [],
+        activeKey: null,
+        selectedKeys: [],
+        orderedKeys: [],
+        elsByKey: {},
+        values: {},
+
+        /**
+         *  Initialization...
+         */
+        initItem(el, value, disabled) {
+            let key = (Math.random() + 1).toString(36).substring(7)
+
+            // Register value by key...
+            this.values[key] = value
+
+            // Associate key with element...
+            this.elsByKey[key] = el
+
+            // Register key for ordering...
+            this.orderedKeys.push(key)
+
+            // Register key for searching...
+            this.searchableText[key] = el.textContent.trim().toLowerCase()
+
+            // Store whether disabled or not...
+            disabled && this.disabledKeys.push(key)
+
+            return key
+        },
+
+        destroyItem(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            delete this.values[key]
+            delete this.elsByKey[key]
+            delete this.orderedKeys[this.orderedKeys.indexOf(key)]
+            delete this.searchableText[key]
+            delete this.disabledKeys[key]
+
+            this.reorderKeys()
+        },
+
+        /**
+         * Handle elements...
+         */
+         reorderKeys() {
+            // Filter out elements removed from the dom...
+            this.orderedKeys.forEach((key) => {
+                let el = this.elsByKey[key]
+
+                if (el.isConnected) return
+
+                this.destroyItem(el)
+            })
+
+            this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
+                if (a === null || z === null) return 0
+
+                let aEl = this.elsByKey[a]
+                let zEl = this.elsByKey[z]
+
+                let position = aEl.compareDocumentPosition(zEl)
+
+                if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+                if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+                return 0
+            })
+        },
+
+        activeEl() {
+            if (! this.activeKey) return
+
+            return this.elsByKey[this.activeKey]
+        },
+
+        isActiveEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.activeKey === key
+        },
+
+        activateEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            this.activateKey(key)
+        },
+
+        selectEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            this.selectKey(key)
+        },
+
+        isSelectedEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.isSelected(key)
+        },
+
+        isDisabledEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.isDisabled(key)
+        },
+
+        get isScrollingTo() { return this.scrollingCount > 0 },
+
+        scrollingCount: 0,
+
+        activateAndScrollToKey(key) {
+            // 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)
+
+            let targetEl = this.elsByKey[key]
+
+            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 values...
+         */
+        selectedValueOrValues() {
+            if (multiple) {
+                return this.selectedValues()
+            } else {
+                return this.selectedValue()
+            }
+        },
+
+        selectedValues() {
+            return this.selectedKeys.map(i => this.values[i])
+        },
+
+        selectedValue() {
+            return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
+        },
+
+        selectValue(value, by) {
+            if (!value) value = (multiple ? [] : null)
+            if (! by) by = (a, b) => a === b
+
+            if (typeof by === 'string') {
+                let property = by
+                by = (a, b) => a[property] === b[property]
+            }
+
+            if (multiple) {
+                // debugger
+                let keys = []
+
+                value.forEach(i => {
+                    for (let key in this.values) {
+                        if (by(this.values[key], i)) {
+                            if (! keys.includes(key)) {
+                                keys.push(key)
+                            }
+                        }
+                    }
+                })
+
+                this.selectExclusive(keys)
+            } else {
+                for (let key in this.values) {
+                    if (value && by(this.values[key], value)) {
+                        this.selectKey(key)
+                    }
+                }
+            }
+        },
+
+        /**
+         * Handle disabled keys...
+         */
+        isDisabled(key) { return this.disabledKeys.includes(key) },
+
+        get nonDisabledOrderedKeys() {
+            return this.orderedKeys.filter(i => ! this.isDisabled(i))
+        },
+
+        /**
+         * Handle selected keys...
+         */
+        selectKey(key) {
+            if (this.isDisabled(key)) return
+
+            if (multiple) {
+                this.toggleSelected(key)
+            } else {
+                this.selectOnly(key)
+            }
+        },
+
+        toggleSelected(key) {
+            if (this.selectedKeys.includes(key)) {
+                this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
+            } else {
+                this.selectedKeys.push(key)
+            }
+        },
+
+        selectOnly(key) {
+            this.selectedKeys = []
+            this.selectedKeys.push(key)
+        },
+
+        selectExclusive(keys) {
+            // We can't just do this.selectedKeys = keys,
+            // because we need to preserve reactivity...
+
+            let toAdd = [...keys]
+
+            for (let i = 0; i < this.selectedKeys.length; i++) {
+                if (keys.includes(this.selectedKeys[i])) {
+                    delete toAdd[toAdd.indexOf(this.selectedKeys[i])]
+                    continue;
+                }
+
+                if (! keys.includes(this.selectedKeys[i])) {
+                    delete this.selectedKeys[i]
+                }
+            }
+
+            toAdd.forEach(i => {
+                this.selectedKeys.push(i)
+            })
+        },
+
+        selectActive(key) {
+            if (! this.activeKey) return
+
+            this.selectKey(this.activeKey)
+        },
+
+        isSelected(key) { return this.selectedKeys.includes(key) },
+
+
+        firstSelectedKey() { return this.selectedKeys[0] },
+
+        /**
+         * Handle activated keys...
+         */
+        hasActive() { return !! this.activeKey },
+
+        isActiveKey(key) { return this.activeKey === key },
+
+        get active() { return this.hasActive() && this.values[this.activeKey] },
+
+        activateSelectedOrFirst() {
+            let firstSelected = this.firstSelectedKey()
+
+            if (firstSelected) {
+                return this.activateKey(firstSelected)
+            }
+
+            let firstKey = this.firstKey()
+
+            if (firstKey) {
+                this.activateKey(firstKey)
+            }
+        },
+
+        activateKey(key) {
+            if (this.isDisabled(key)) return
+
+            this.activeKey = key
+        },
+
+        deactivate() {
+            if (! this.activeKey) return
+            if (this.isScrollingTo) return
+
+            this.activeKey = null
+        },
+
+        /**
+         * Handle active key traveral...
+         */
+        nextKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            return this.nonDisabledOrderedKeys[index + 1]
+        },
+
+        prevKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            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.searchableText) {
+                let content = this.searchableText[key]
+
+                if (content.startsWith(this.searchQuery)) {
+                    foundKey = key
+                    break;
+                }
+            }
+
+            if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
+
+            return foundKey
+        },
+
+        activateByKeyEvent(e) {
+            this.reorderKeys()
+
+            let hasActive = this.hasActive()
+
+            let targetKey
+
+            switch (e.key) {
+                case 'Tab':
+                case 'Backspace':
+                case 'Delete':
+                case 'Meta':
+                    break;
+
+                    break;
+                case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = hasActive ? this.nextKey() : this.firstKey()
+                    break;
+
+                case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = hasActive ? this.prevKey() : this.lastKey()
+                    break;
+                case 'Home':
+                case 'PageUp':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.firstKey()
+                    break;
+
+                case 'End':
+                case 'PageDown':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.lastKey()
+                    break;
+
+                default:
+                    if (e.key.length === 1) {
+                        targetKey = this.searchKey(e.key)
+                    }
+                    break;
+            }
+
+            if (targetKey) {
+                this.activateAndScrollToKey(targetKey)
+            }
+        }
+    }
+}
+
+function keyByValue(object, value) {
+    return Object.keys(object).find(key => object[key] === value)
+}
+
+export function renderHiddenInputs(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
+}

+ 244 - 0
packages/ui/src/listbox.js

@@ -0,0 +1,244 @@
+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)
+    })
+
+    Alpine.magic('listbox', (el) => {
+        let data = Alpine.$data(el)
+
+        if (! data.__ready) return {
+            isDisabled: false,
+            isOpen: false,
+            selected: null,
+            active: null,
+        }
+
+        return {
+            get isOpen() {
+                return data.__isOpen
+            },
+            get isDisabled() {
+                return data.__isDisabled
+            },
+            get selected() {
+                return data.__value
+            },
+            get active() {
+                return data.__context.active
+            },
+        }
+    })
+
+    Alpine.magic('listboxOption', (el) => {
+        let data = Alpine.$data(el)
+
+        let stub = {
+            isDisabled: false,
+            isSelected: false,
+            isActive: false,
+        }
+
+        if (! data.__ready) return stub
+
+        let optionEl = Alpine.findClosest(el, i => i.__optionKey)
+
+        if (! optionEl) return stub
+
+        let context = data.__context
+
+        return {
+            get isActive() {
+                return context.isActiveEl(optionEl)
+            },
+            get isSelected() {
+                return context.isSelectedEl(optionEl)
+            },
+            get isDisabled() {
+                return context.isDisabledEl(optionEl)
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                __ready: false,
+                __value: null,
+                __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isStatic: false,
+                __isDisabled: undefined,
+                __compareBy: null,
+                __inputName: null,
+                __orientation: 'vertical',
+                init() {
+                    this.__isMultiple = Alpine.bound(el, 'multiple', false)
+                    this.__isDisabled = Alpine.bound(el, 'disabled', false)
+                    this.__inputName = Alpine.bound(el, 'name', null)
+                    this.__compareBy = Alpine.bound(el, 'by')
+                    this.__orientation = Alpine.bound(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+
+                    this.__context = generateContext(this.__isMultiple, this.__orientation)
+
+                    let defaultValue = Alpine.bound(el, 'default-value', null)
+
+                    this.__value = defaultValue
+
+                    // We have to wait for the rest of the HTML to initialize in Alpine before
+                    // we mark this component as "ready".
+                    queueMicrotask(() => {
+                        this.__ready = true
+
+                        // 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(() => {
+                            // This "fingerprint" acts as a checksum of the last-known "value"
+                            // passed into x-model. We need to track this so that we can determine
+                            // from the reactive effect if it was the value that changed externally
+                            // or an option was selected internally...
+                            let lastValueFingerprint = false
+
+                            Alpine.effect(() => {
+                                // Accessing selected keys, so a change in it always triggers this effect...
+                                this.__context.selectedKeys
+
+                                if (lastValueFingerprint === false || lastValueFingerprint !== JSON.stringify(this.__value)) {
+                                    // Here we know that the value changed externally and we can add the selection...
+                                    this.__context.selectValue(this.__value, this.__compareBy)
+                                } else {
+                                    // Here we know that an option was selected and we can change the value...
+                                    this.__value = this.__context.selectedValueOrValues()
+                                }
+
+                                // Generate the "value" checksum for comparison next time...
+                                lastValueFingerprint = JSON.stringify(this.__value)
+
+                                // Everytime the value changes, we need to re-render the hidden inputs,
+                                // if a user passed the "name" prop...
+                                this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
+                            })
+                        })
+                    })
+                },
+                __open() {
+                    this.__isOpen = true
+
+                    this.__context.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.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                }
+            }
+        },
+    })
+}
+
+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, {
+        'x-ref': '__button',
+        ':id'() { return this.$id('alpine-listbox-button') },
+        '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') },
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        '@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, {
+        'x-ref': '__options',
+        ':id'() { return this.$id('alpine-listbox-options') },
+        'x-init'() {
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
+        },
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
+        '@click.outside'() { this.$data.__close() },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        tabindex: '0',
+        'role': 'listbox',
+        ':aria-orientation'() {
+            return this.$data.__orientation
+        },
+        ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
+        ':aria-activedescendant'() { return this.__context.activeEl() && this.__context.activeEl().id },
+        '@focus'() { this.__context.activateSelectedOrFirst() },
+        'x-trap'() { return this.$data.__isOpen },
+        '@keydown'(e) { this.__context.activateByKeyEvent(e) },
+        '@keydown.enter.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
+        '@keydown.space.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
+    })
+}
+
+function handleOption(el, Alpine) {
+    Alpine.bind(el, () => {
+        return {
+            ':id'() { return this.$id('alpine-listbox-option') },
+            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
+            'role': 'option',
+            'x-init'() {
+                queueMicrotask(() => {
+                    let value = Alpine.bound(el, 'value')
+                    let disabled = Alpine.bound(el, 'disabled')
+
+                    el.__optionKey = this.$data.__context.initItem(el, value, disabled)
+                })
+            },
+            ':aria-selected'() { return this.$listboxOption.isSelected },
+            '@click'() {
+                if (this.$listboxOption.isDisabled) return;
+                this.$data.__context.selectEl(el);
+                this.$data.__isMultiple || this.$data.__close()
+            },
+            '@mousemove'() { this.$data.__context.activateEl(el) },
+            '@mouseleave'() { this.$data.__context.deactivate() },
+        }
+    })
+}

+ 868 - 0
tests/cypress/integration/plugins/ui/listbox.spec.js

@@ -0,0 +1,868 @@
+import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test} from '../../../utils'
+
+test('it works with x-model',
+    [html`
+        <div
+            x-data="{ active: null, 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('button').should(haveText('Arlene Mccoy'))
+    },
+)
+
+test('it works with internal state',
+    [html`
+        <div
+            x-data="{ 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' },
+            ]}"
+            x-listbox
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('button').should(haveText('Arlene Mccoy'))
+    },
+)
+
+test('$listbox/$listboxOption',
+    [html`
+        <div
+            x-data="{ 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' },
+            ]}"
+            x-listbox
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <article x-text="$listbox.active?.name"></article>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                            'disabled': $listboxOption.isDisabled,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(haveText(''))
+        get('[option="5"]').should(haveClasses(['disabled']))
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('article').should(haveText('Wade Cooper'))
+        get('[option="1"]').should(haveClasses(['active']))
+        get('ul').type('{downarrow}')
+        get('article').should(haveText('Arlene Mccoy'))
+        get('[option="2"]').should(haveClasses(['active']))
+        get('button').should(haveText('Select Person'))
+        get('[option="2"]').click()
+        get('button').should(haveText('Arlene Mccoy'))
+        get('[option="2"]').should(haveClasses(['selected']))
+    },
+)
+
+test('"name" prop',
+    [html`
+        <div
+            x-data="{ 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' },
+            ]}"
+            x-listbox
+            name="person"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('input').should(haveAttribute('value', 'null'))
+        get('[option="2"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"name" prop with object value',
+    [html`
+        <div
+            x-data="{ 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' },
+            ]}"
+            x-listbox
+            name="person"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="person"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="person[id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('input[name="person"]').should(notExist())
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"default-value" prop',
+    [html`
+        <div
+            x-data="{ 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' },
+            ]}"
+            x-listbox
+            name="person"
+            default-value="2"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"multiple" prop',
+    [html`
+        <div
+            x-data="{
+                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' },
+                ]
+            }"
+            x-listbox
+            multiple
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.join(',') : 'Select People'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('[option="4"]').click()
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        get('[option="4"]').click()
+        get('button').should(haveText('2'))
+        get('ul').should(beVisible())
+    },
+);
+
+test('"multiple" and "name" props together',
+    [html`
+        <div
+            x-data="{
+                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' },
+                ]
+            }"
+            x-listbox
+            multiple
+            name="people"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.map(p => p.id).join(',') : 'Select People'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="people"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="people[0][id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('input[name="people"]').should(notExist())
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(notExist())
+        get('[name="people[1][name]"]').should(notExist())
+        get('button').should(haveText('2'))
+        get('ul').should(beVisible())
+    },
+);
+
+test('keyboard controls',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('button').focus().type(' ')
+        get('[options]')
+            .should(beVisible())
+            .should(haveFocus())
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{downarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{downarrow}')
+        get('[option="4"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{uparrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{home}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{end}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{pageUp}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{pageDown}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .tab()
+            .should(haveFocus())
+            .should(beVisible())
+            .tab({ shift: true })
+            .should(haveFocus())
+            .should(beVisible())
+            .type('{esc}')
+            .should(notBeVisible())
+    },
+)
+
+test('"horizontal" keyboard controls',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+            horizontal
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('button').focus().type(' ')
+        get('[options]')
+            .should(haveAttribute('aria-orientation', 'horizontal'))
+            .should(beVisible())
+            .should(haveFocus())
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{rightarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{rightarrow}')
+        get('[option="4"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{leftarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('search',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get, wait }) => {
+        get('.active').should(notExist())
+        get('button').click()
+        get('[options]')
+            .type('ar')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        wait(500)
+        get('[options]')
+            .type('ma')
+        get('[option="8"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('changing value manually changes internal state',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button toggle x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <button select-tim @click="active = 4">Select Tim</button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('[select-tim]').click()
+        get('[option="4"]').should(haveClasses(['selected']))
+        get('[option="1"]').should(notHaveClasses(['selected']))
+        get('[toggle]').should(haveText('4'))
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button')
+            .should(haveAttribute('aria-haspopup', 'true'))
+            .should(haveAttribute('aria-labelledby', 'alpine-listbox-label-1'))
+            .should(haveAttribute('aria-expanded', 'false'))
+            .should(notHaveAttribute('aria-controls'))
+            .should(haveAttribute('id', 'alpine-listbox-button-1'))
+            .click()
+            .should(haveAttribute('aria-expanded', 'true'))
+            .should(haveAttribute('aria-controls', 'alpine-listbox-options-1'))
+
+        get('[options]')
+            .should(haveAttribute('aria-orientation', 'vertical'))
+            .should(haveAttribute('role', 'listbox'))
+            .should(haveAttribute('id', 'alpine-listbox-options-1'))
+            .should(haveAttribute('aria-labelledby', 'alpine-listbox-button-1'))
+            .should(notHaveAttribute('aria-activedescendant'))
+            .should(haveAttribute('tabindex', '0'))
+            .should(haveAttribute('aria-activedescendant', 'alpine-listbox-option-1'))
+
+        get('[option="1"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-listbox-option-1'))
+            .should(haveAttribute('tabindex', '-1'))
+            .should(haveAttribute('aria-selected', 'false'))
+
+        get('[option="2"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-listbox-option-2'))
+            .should(haveAttribute('tabindex', '-1'))
+            .should(haveAttribute('aria-selected', 'false'))
+
+        get('[options]')
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-listbox-option-2'))
+
+        get('[option="2"]')
+            .click()
+            .should(haveAttribute('aria-selected', 'true'))
+    },
+)
+
+test('"static" prop',
+    [html`
+        <div
+            x-data="{ active: null, show: false, 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' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button normal-toggle x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <button real-toggle @click="show = ! show">Toggle</button>
+
+            <ul x-listbox:options x-show="show" static>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('[normal-toggle]')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(notBeVisible())
+        get('[real-toggle]').click()
+        get('ul').should(beVisible())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('[normal-toggle]').should(haveText('Arlene Mccoy'))
+    },
+)
+
+// test "by" attribute

Some files were not shown because too many files changed in this diff