Skip to content

Commit 039f79b

Browse files
committed
feat(Combobox,Listbox,Switcher): add generic type support for options
1 parent 2b710e5 commit 039f79b

File tree

11 files changed

+615
-593
lines changed

11 files changed

+615
-593
lines changed

src/components/Combobox.vue

Lines changed: 211 additions & 256 deletions
Large diffs are not rendered by default.

src/components/Listbox.vue

Lines changed: 75 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,115 @@
1-
<script lang="ts">
2-
import { defineComponent, PropType, ref, computed } from 'vue'
1+
<script
2+
setup
3+
lang="ts"
4+
generic="
5+
T extends BaseOption = ExtendedOption,
6+
Options extends T[] | OptionGroup<string, T>[] = T[] | OptionGroup<string, T>[],
7+
ModelValue extends string = string
8+
">
9+
import { ref, computed, useAttrs } from 'vue'
310
import { Listbox, ListboxButton } from '@headlessui/vue'
4-
5-
import type { Option } from './Option.vue'
6-
import { OptionGroup, areOptionsGrouped } from './OptionGroup.vue'
711
import AColorCircle from './ColorCircle.vue'
8-
import OptionsPanel, { Direction } from './internal/OptionsPanel.vue'
12+
import OptionsPanel, { type Direction } from './internal/OptionsPanel.vue'
913
import FloatingArrow from './internal/FloatingArrow.vue'
14+
import {
15+
type BaseOption,
16+
type ExtendedOption,
17+
type OptionGroup,
18+
areOptionsGrouped
19+
} from '../types/selection'
20+
import { type Color } from '../colors'
1021
11-
export default defineComponent({
12-
name: 'AListbox',
13-
components: {
14-
Listbox,
15-
ListboxButton,
16-
OptionsPanel,
17-
FloatingArrow,
18-
AColorCircle
19-
},
20-
props: {
22+
const props = withDefaults(
23+
defineProps<{
2124
/**
2225
* An option is at the minimum a `{ value: string, label: string }` object.
2326
* This prop is used to display the list of listbox options,
2427
* as well as the correct label of the currently selected value.
2528
*
2629
* You can also provide a grouped structure of options: `[{ title: string, options: Option[] }]`
2730
*/
28-
options: {
29-
type: Array as PropType<Option[] | OptionGroup[]>,
30-
required: true
31-
},
31+
options: Options
3232
/**
3333
* an optional placeholder can be displayed when no value is currently selected.
3434
*/
35-
placeholder: {
36-
type: String,
37-
default: 'Select value'
38-
},
35+
placeholder?: string
3936
/**
4037
* the size of the listbox component
4138
*/
42-
size: {
43-
type: String as PropType<'sm' | 'md'>,
44-
default: 'sm'
45-
},
39+
size?: 'sm' | 'md'
4640
/**
4741
* how the listbox button is displayed when not focused
4842
*/
49-
variant: {
50-
type: String as PropType<'subtle' | 'default'>,
51-
default: 'default'
52-
},
43+
variant?: 'subtle' | 'default'
5344
/**
5445
* the prop modelValue is required to use [v-model](https://vuejs.org/guide/components/events.html#usage-with-v-model) with a component.
5546
*/
56-
modelValue: {
57-
type: String,
58-
required: true
59-
},
47+
modelValue: ModelValue
6048
/**
6149
* direction in which the dropdown is opening.
6250
* possible values: `up`, `down`. Default is `down`
6351
*/
64-
direction: {
65-
type: String as PropType<Direction>,
66-
default: 'down'
67-
},
52+
direction?: Direction
6853
/**
6954
* extra classes to style the listbox options panel
7055
* useful for setting the panel height
7156
*/
72-
panelClasses: {
73-
type: String,
74-
default: ''
75-
},
57+
panelClasses?: string
7658
/**
7759
* the options panel can be rendered inline instead of the absolutely positioned dropdown
7860
*/
79-
inline: {
80-
type: Boolean,
81-
default: false
82-
},
61+
inline?: boolean
8362
/**
8463
* allow the options panel to be rendered outside the flow of a container that has content that needs scrolling
8564
*/
86-
escapeOverflow: {
87-
type: Boolean,
88-
default: false
89-
}
90-
},
91-
emits: ['update:modelValue'],
92-
setup() {
93-
const listboxButton = ref()
94-
const optionsPanelWidth = computed(() => listboxButton.value?.el.offsetWidth)
65+
escapeOverflow?: boolean
66+
}>(),
67+
{
68+
placeholder: 'Select value',
69+
size: 'sm',
70+
variant: 'default',
71+
direction: 'down',
72+
panelClasses: '',
73+
inline: false,
74+
escapeOverflow: false
75+
}
76+
)
77+
78+
const emit = defineEmits<{
79+
'update:modelValue': [value: ModelValue]
80+
}>()
9581
96-
// hack together a tab-out behavior for the listbox
97-
const handleTab = (event: KeyboardEvent) => {
98-
if (event.key === 'Tab') {
99-
const newEvent = new KeyboardEvent('keydown', { key: 'Escape' })
100-
event.target?.dispatchEvent(newEvent)
101-
}
102-
}
82+
const attrs = useAttrs()
10383
104-
return { handleTab, listboxButton, optionsPanelWidth }
105-
},
106-
computed: {
107-
flatOptions() {
108-
return areOptionsGrouped(this.options)
109-
? this.options.map(({ options }) => options).flat()
110-
: this.options
111-
},
112-
valueOption() {
113-
return this.flatOptions.find(option => option.value === this.model)
114-
},
115-
valueLabel() {
116-
return this.valueOption?.label || this.model
117-
},
118-
model: {
119-
get() {
120-
return this.modelValue
121-
},
122-
set(value: string) {
123-
this.$emit('update:modelValue', value)
124-
}
125-
}
84+
const listboxButton = ref()
85+
const optionsPanelWidth = computed(() => listboxButton.value?.el.offsetWidth)
86+
87+
// hack together a tab-out behavior for the listbox
88+
const handleTab = (event: KeyboardEvent) => {
89+
if (event.key === 'Tab') {
90+
const newEvent = new KeyboardEvent('keydown', { key: 'Escape' })
91+
event.target?.dispatchEvent(newEvent)
12692
}
93+
}
94+
95+
const flatOptions = computed((): T[] =>
96+
areOptionsGrouped(props.options)
97+
? props.options.map(({ options }) => options).flat()
98+
: (props.options as T[])
99+
)
100+
101+
const model = computed({
102+
get: (): ModelValue => props.modelValue,
103+
set: (value: ModelValue) => emit('update:modelValue', value)
104+
})
105+
106+
const valueOption = computed(() => flatOptions.value.find(option => option.value === model.value))
107+
108+
const valueLabel = computed(() => valueOption.value?.label || model.value)
109+
110+
const valueOptionColor = computed(() => {
111+
const opt = valueOption.value as (BaseOption & { color?: Color }) | undefined
112+
return opt?.color
127113
})
128114
</script>
129115
<template>
@@ -145,16 +131,16 @@ export default defineComponent({
145131
-->
146132
<slot name="listbox-value">
147133
<div v-if="model" class="truncate">
148-
<AColorCircle v-if="valueOption?.color" :color="valueOption?.color" class="mr-2" />
134+
<AColorCircle v-if="valueOptionColor" :color="valueOptionColor" class="mr-2" />
149135
<span>{{ valueLabel }}</span>
150136
</div>
151137
<span v-else class="a-text-input-placeholder">{{ placeholder }}</span>
152138
</slot>
153139
</div>
154140
<FloatingArrow
155141
v-if="!inline"
156-
:float="variant === 'subtle' && !$attrs.disabled"
157-
:class="{ 'text-warsaw': $attrs.disabled }" />
142+
:float="variant === 'subtle' && !attrs.disabled"
143+
:class="{ 'text-warsaw': attrs.disabled }" />
158144
</slot>
159145
</ListboxButton>
160146
<OptionsPanel

src/components/Option.vue

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,49 @@
11
<script lang="ts">
2-
import { computed, defineComponent, PropType } from 'vue'
2+
export type { Option, BaseOption, ExtendedOption, OptionValue } from '../types/selection'
3+
</script>
4+
5+
<script setup lang="ts" generic="T extends BaseOption = ExtendedOption">
6+
import { computed } from 'vue'
37
import { ListboxOption, ComboboxOption } from '@headlessui/vue'
48
import ACheckbox from './Checkbox.vue'
59
import AColorCircle from './ColorCircle.vue'
610
import { type Color } from '../colors'
11+
import { type BaseOption, type ExtendedOption } from '../types/selection'
712
8-
export type Option = {
9-
value: string
10-
label: string
11-
disabled?: boolean
12-
color?: Color
13-
}
14-
15-
export default defineComponent({
16-
name: 'AOption',
17-
components: { ListboxOption, ComboboxOption, ACheckbox, AColorCircle },
18-
props: {
13+
const props = withDefaults(
14+
defineProps<{
1915
/**
2016
* the component inside of which the option is rendered
2117
*
2218
* `'listbox' | 'combobox'`
2319
*/
24-
component: {
25-
type: String as PropType<'listbox' | 'combobox'>,
26-
default: 'listbox'
27-
},
20+
component?: 'listbox' | 'combobox'
2821
/**
2922
* the option to be rendered
3023
* containing `value`, `label` and optional `disabled` properties
3124
*
3225
* alternatively pass `value` and `disabled` props directly to a-option
3326
* and render a label via the default slot.
3427
*/
35-
option: {
36-
type: Object as PropType<Option>,
37-
default: () => ({})
38-
},
28+
option: T
3929
/**
4030
* whether the option is rendered in multiselect combobox (with a checkbox)
4131
*/
42-
multi: {
43-
type: Boolean,
44-
default: false
45-
}
46-
},
32+
multi?: boolean
33+
}>(),
34+
{
35+
component: 'listbox',
36+
multi: false
37+
}
38+
)
4739
48-
setup(props) {
49-
const componentOption = computed(() =>
50-
props.component === 'combobox' ? ComboboxOption : ListboxOption
51-
)
40+
const componentOption = computed(() =>
41+
props.component === 'combobox' ? ComboboxOption : ListboxOption
42+
)
5243
53-
return {
54-
componentOption
55-
}
56-
}
44+
const optionColor = computed(() => {
45+
const opt = props.option as BaseOption & { color?: Color }
46+
return opt.color
5747
})
5848
</script>
5949
<template>
@@ -76,7 +66,7 @@ export default defineComponent({
7666
@slot Named `#extra` slot. Use to add an icon or image preview, or any extra elements on the left-hand side of the option label.
7767
-->
7868
<slot name="extra">
79-
<a-color-circle v-if="option.color" class="mr-2" :color="option.color"></a-color-circle>
69+
<a-color-circle v-if="optionColor" class="mr-2" :color="optionColor"></a-color-circle>
8070
</slot>
8171
<!--
8272
@slot `#default` slot. Use to render custom styles or extra markup for the option. Renders option label by default.
@@ -93,7 +83,7 @@ export default defineComponent({
9383
@slot Named `#extra` slot. Use to add an icon or image preview, or any extra elements on the left-hand side of the option label.
9484
-->
9585
<slot name="extra">
96-
<a-color-circle v-if="option.color" class="ml-2" :color="option.color"></a-color-circle>
86+
<a-color-circle v-if="optionColor" class="ml-2" :color="optionColor"></a-color-circle>
9787
</slot>
9888
</span>
9989
<!--

0 commit comments

Comments
 (0)