├── .gitignore ├── src ├── index.js └── Listbox.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn.lock -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Listbox, ListboxButton, ListboxLabel, ListboxList, ListboxOption } from './Listbox' 2 | export { Listbox, ListboxButton, ListboxLabel, ListboxList, ListboxOption } 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tailwindui/vue", 3 | "version": "0.1.0-alpha.0", 4 | "author": "Adam Wathan", 5 | "license": "MIT", 6 | "source": "src/index.js", 7 | "main": "dist/index.js", 8 | "module": "dist/index.module.js", 9 | "unpkg": "dist/index.umd.js", 10 | "scripts": { 11 | "build": "microbundle" 12 | }, 13 | "dependencies": { 14 | "debounce": "^1.2.0" 15 | }, 16 | "devDependencies": { 17 | "microbundle": "^0.12.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

⚠️ Deprecated: use Headless UI instead ⚠️

3 |

4 | 5 | --- 6 | 7 | # @tailwindui/vue 8 | 9 | **This project is still in a pre-alpha state and could change dramatically at any time. Not for production.** 10 | 11 | A set of completely unstyled, fully accessible UI components for Vue.js, designed to integrate beautifully with Tailwind CSS. 12 | 13 | You bring the styles and the markup, we handle all of the complex keyboard interactions and ARIA management. 14 | 15 | ## Installation 16 | 17 | ```sh 18 | # npm 19 | npm install @tailwindui/vue 20 | 21 | # Yarn 22 | yarn add @tailwindui/vue 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Listbox 28 | 29 | Basic example: 30 | 31 | ```html 32 | 58 | 59 | 94 | ``` 95 | -------------------------------------------------------------------------------- /src/Listbox.js: -------------------------------------------------------------------------------- 1 | import debounce from 'debounce' 2 | 3 | const ListboxSymbol = Symbol('Listbox') 4 | 5 | let id = 0 6 | 7 | function generateId() { 8 | return `tailwind-ui-listbox-id-${++id}` 9 | } 10 | 11 | function defaultSlot(parent, scope) { 12 | return parent.$slots.default ? parent.$slots.default : parent.$scopedSlots.default(scope) 13 | } 14 | 15 | function isString(value) { 16 | return typeof value === 'string' || value instanceof String 17 | } 18 | 19 | export const ListboxLabel = { 20 | inject: { 21 | context: ListboxSymbol, 22 | }, 23 | data: () => ({ 24 | id: generateId(), 25 | }), 26 | mounted() { 27 | this.context.labelId.value = this.id 28 | }, 29 | render(h) { 30 | return h( 31 | 'span', 32 | { 33 | attrs: { 34 | id: this.id, 35 | }, 36 | }, 37 | defaultSlot(this, {}) 38 | ) 39 | }, 40 | } 41 | 42 | export const ListboxButton = { 43 | inject: { 44 | context: ListboxSymbol, 45 | }, 46 | data: () => ({ 47 | id: generateId(), 48 | isFocused: false, 49 | }), 50 | created() { 51 | this.context.listboxButtonRef.value = () => this.$el 52 | this.context.buttonId.value = this.id 53 | }, 54 | render(h) { 55 | return h( 56 | 'button', 57 | { 58 | attrs: { 59 | id: this.id, 60 | type: 'button', 61 | 'aria-haspopup': 'listbox', 62 | 'aria-labelledby': `${this.context.labelId.value} ${this.id}`, 63 | ...(this.context.isOpen.value ? { 'aria-expanded': 'true' } : {}), 64 | }, 65 | on: { 66 | focus: () => { 67 | this.isFocused = true 68 | }, 69 | blur: () => { 70 | this.isFocused = false 71 | }, 72 | click: this.context.toggle, 73 | }, 74 | }, 75 | defaultSlot(this, { isFocused: this.isFocused }) 76 | ) 77 | }, 78 | } 79 | 80 | export const ListboxList = { 81 | inject: { 82 | context: ListboxSymbol, 83 | }, 84 | created() { 85 | this.context.listboxListRef.value = () => this.$refs.listboxList 86 | }, 87 | render(h) { 88 | const children = defaultSlot(this, {}) 89 | const values = children.map((node) => node.componentOptions.propsData.value) 90 | this.context.values.value = values 91 | const focusedIndex = values.indexOf(this.context.activeItem.value) 92 | 93 | return h( 94 | 'ul', 95 | { 96 | ref: 'listboxList', 97 | attrs: { 98 | tabindex: '-1', 99 | role: 'listbox', 100 | 'aria-activedescendant': this.context.getActiveDescendant(), 101 | 'aria-labelledby': this.context.props.labelledby, 102 | }, 103 | on: { 104 | focusout: (e) => { 105 | if (e.relatedTarget === this.context.listboxButtonRef.value()) { 106 | return 107 | } 108 | this.context.close() 109 | }, 110 | mouseleave: () => { 111 | this.context.activeItem.value = null 112 | }, 113 | keydown: (e) => { 114 | let indexToFocus 115 | switch (e.key) { 116 | case 'Esc': 117 | case 'Escape': 118 | e.preventDefault() 119 | this.context.close() 120 | break 121 | case 'Tab': 122 | e.preventDefault() 123 | break 124 | case 'Up': 125 | case 'ArrowUp': 126 | e.preventDefault() 127 | indexToFocus = focusedIndex - 1 < 0 ? values.length - 1 : focusedIndex - 1 128 | this.context.focus(values[indexToFocus]) 129 | break 130 | case 'Down': 131 | case 'ArrowDown': 132 | e.preventDefault() 133 | indexToFocus = focusedIndex + 1 > values.length - 1 ? 0 : focusedIndex + 1 134 | this.context.focus(values[indexToFocus]) 135 | break 136 | case 'Spacebar': 137 | case ' ': 138 | e.preventDefault() 139 | if (this.context.typeahead.value !== '') { 140 | this.context.type(' ') 141 | } else { 142 | this.context.select(this.context.activeItem.value || this.context.props.value) 143 | } 144 | break 145 | case 'Enter': 146 | e.preventDefault() 147 | this.context.select(this.context.activeItem.value || this.context.props.value) 148 | break 149 | default: 150 | if (!(isString(e.key) && e.key.length === 1)) { 151 | return 152 | } 153 | 154 | e.preventDefault() 155 | this.context.type(e.key) 156 | return 157 | } 158 | }, 159 | }, 160 | }, 161 | children 162 | ) 163 | }, 164 | } 165 | 166 | export const ListboxOption = { 167 | inject: { 168 | context: ListboxSymbol, 169 | }, 170 | data: () => ({ 171 | id: generateId(), 172 | }), 173 | props: ['value'], 174 | watch: { 175 | value(newValue, oldValue) { 176 | this.context.unregisterOptionId(oldValue) 177 | this.context.unregisterOptionRef(this.value) 178 | this.context.registerOptionId(newValue, this.id) 179 | this.context.registerOptionRef(this.value, this.$el) 180 | }, 181 | }, 182 | created() { 183 | this.context.registerOptionId(this.value, this.id) 184 | }, 185 | mounted() { 186 | this.context.registerOptionRef(this.value, this.$el) 187 | }, 188 | beforeDestroy() { 189 | this.context.unregisterOptionId(this.value) 190 | this.context.unregisterOptionRef(this.value) 191 | }, 192 | render(h) { 193 | const isActive = this.context.activeItem.value === this.value 194 | const isSelected = this.context.props.value === this.value 195 | 196 | return h( 197 | 'li', 198 | { 199 | attrs: { 200 | id: this.id, 201 | role: 'option', 202 | ...(isSelected 203 | ? { 204 | 'aria-selected': true, 205 | } 206 | : {}), 207 | }, 208 | on: { 209 | click: () => { 210 | this.context.select(this.value) 211 | }, 212 | mousemove: () => { 213 | if (this.context.activeItem.value === this.value) { 214 | return 215 | } 216 | 217 | this.context.activeItem.value = this.value 218 | }, 219 | }, 220 | }, 221 | defaultSlot(this, { 222 | isActive, 223 | isSelected, 224 | }) 225 | ) 226 | }, 227 | } 228 | 229 | export const Listbox = { 230 | props: ['value'], 231 | data: (vm) => ({ 232 | typeahead: { value: '' }, 233 | listboxButtonRef: { value: null }, 234 | listboxListRef: { value: null }, 235 | isOpen: { value: false }, 236 | activeItem: { value: vm.$props.value }, 237 | values: { value: null }, 238 | labelId: { value: null }, 239 | buttonId: { value: null }, 240 | optionIds: { value: [] }, 241 | optionRefs: { value: [] }, 242 | }), 243 | provide() { 244 | return { 245 | [ListboxSymbol]: { 246 | getActiveDescendant: this.getActiveDescendant, 247 | registerOptionId: this.registerOptionId, 248 | unregisterOptionId: this.unregisterOptionId, 249 | registerOptionRef: this.registerOptionRef, 250 | unregisterOptionRef: this.unregisterOptionRef, 251 | toggle: this.toggle, 252 | open: this.open, 253 | close: this.close, 254 | select: this.select, 255 | focus: this.focus, 256 | clearTypeahead: this.clearTypeahead, 257 | typeahead: this.$data.typeahead, 258 | type: this.type, 259 | listboxButtonRef: this.$data.listboxButtonRef, 260 | listboxListRef: this.$data.listboxListRef, 261 | isOpen: this.$data.isOpen, 262 | activeItem: this.$data.activeItem, 263 | values: this.$data.values, 264 | labelId: this.$data.labelId, 265 | buttonId: this.$data.buttonId, 266 | props: this.$props, 267 | }, 268 | } 269 | }, 270 | methods: { 271 | getActiveDescendant() { 272 | const [_value, id] = this.optionIds.value.find(([value]) => { 273 | return value === this.activeItem.value 274 | }) || [null, null] 275 | 276 | return id 277 | }, 278 | registerOptionId(value, optionId) { 279 | this.unregisterOptionId(value) 280 | this.optionIds.value = [...this.optionIds.value, [value, optionId]] 281 | }, 282 | unregisterOptionId(value) { 283 | this.optionIds.value = this.optionIds.value.filter(([candidateValue]) => { 284 | return candidateValue !== value 285 | }) 286 | }, 287 | type(value) { 288 | this.typeahead.value = this.typeahead.value + value 289 | 290 | const [match] = this.optionRefs.value.find(([_value, ref]) => { 291 | return ref.innerText.toLowerCase().startsWith(this.typeahead.value.toLowerCase()) 292 | }) || [null] 293 | 294 | if (match !== null) { 295 | this.focus(match) 296 | } 297 | 298 | this.clearTypeahead() 299 | }, 300 | clearTypeahead: debounce(function () { 301 | this.typeahead.value = '' 302 | }, 500), 303 | registerOptionRef(value, optionRef) { 304 | this.unregisterOptionRef(value) 305 | this.optionRefs.value = [...this.optionRefs.value, [value, optionRef]] 306 | }, 307 | unregisterOptionRef(value) { 308 | this.optionRefs.value = this.optionRefs.value.filter(([candidateValue]) => { 309 | return candidateValue !== value 310 | }) 311 | }, 312 | toggle() { 313 | this.$data.isOpen.value ? this.close() : this.open() 314 | }, 315 | open() { 316 | this.$data.isOpen.value = true 317 | this.focus(this.$props.value) 318 | this.$nextTick(() => { 319 | this.$data.listboxListRef.value().focus() 320 | }) 321 | }, 322 | close() { 323 | this.$data.isOpen.value = false 324 | this.$data.listboxButtonRef.value().focus() 325 | }, 326 | select(value) { 327 | this.$emit('input', value) 328 | this.$nextTick(() => { 329 | this.close() 330 | }) 331 | }, 332 | focus(value) { 333 | this.activeItem.value = value 334 | 335 | if (value === null) { 336 | return 337 | } 338 | 339 | this.$nextTick(() => { 340 | this.listboxListRef 341 | .value() 342 | .children[this.values.value.indexOf(this.activeItem.value)].scrollIntoView({ 343 | block: 'nearest', 344 | }) 345 | }) 346 | }, 347 | }, 348 | render(h) { 349 | return h('div', {}, defaultSlot(this, { isOpen: this.$data.isOpen.value })) 350 | }, 351 | } 352 | --------------------------------------------------------------------------------