├── .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 |
33 |
34 |
35 | Select a wrestler:
36 |
37 |
38 | {{ selectedWrestler }}
39 |
40 |
41 |
47 |
51 | {{ wrestler }}
52 |

53 |
54 |
55 |
56 |
57 |
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 |
--------------------------------------------------------------------------------