Skip to content

Commit 8910e4a

Browse files
committed
feat(Switcher): migrate to script setup with generics
1 parent 41980e6 commit 8910e4a

File tree

9 files changed

+76
-89
lines changed

9 files changed

+76
-89
lines changed

src/components/Combobox.vue

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ const removeValue = (value: string, event: Event) => {
236236
}
237237
}
238238
239-
// Type-safe color access
240239
const getOptionColor = (option: T | undefined): Color | undefined => {
241240
const opt = option as (BaseOption & { color?: Color }) | undefined
242241
return opt?.color
@@ -263,9 +262,7 @@ const hiddenCount = computed(() => {
263262
264263
const showSelectableGroups = computed(
265264
() =>
266-
props.selectableGroups &&
267-
isMultiSelect(props.modelValue) &&
268-
areOptionsGrouped(props.options)
265+
props.selectableGroups && isMultiSelect(props.modelValue) && areOptionsGrouped(props.options)
269266
)
270267
271268
const handleDelete = () => {
@@ -296,8 +293,7 @@ const isMulti = computed(() => isMultiSelect(model.value))
296293
:class="[
297294
{
298295
'h-auto min-h-10 body-lg placeholder:body-lg': size === 'md' && isMulti,
299-
'my-px h-auto min-h-[1.875rem] body-sm placeholder:body-sm':
300-
size === 'sm' && isMulti,
296+
'my-px h-auto min-h-[1.875rem] body-sm placeholder:body-sm': size === 'sm' && isMulti,
301297
'a-text-input--sm': size === 'sm' && !isMulti,
302298
'a-text-input--md': size === 'md' && !isMulti
303299
},

src/components/Listbox.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ const valueOption = computed(() => flatOptions.value.find(option => option.value
100100
101101
const valueLabel = computed(() => valueOption.value?.label || model.value)
102102
103-
// Type-safe color access
104103
const valueOptionColor = computed(() => {
105104
const opt = valueOption.value as (BaseOption & { color?: Color }) | undefined
106105
return opt?.color

src/components/Option.vue

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
<script lang="ts">
2-
// Re-export types for backwards compatibility
3-
// This separate script block is needed because <script setup> doesn't support named exports
42
export type { Option, BaseOption, ExtendedOption, OptionValue } from '../types/selection'
53
</script>
64

@@ -39,14 +37,10 @@ const props = withDefaults(
3937
}
4038
)
4139
42-
// Note: Slots are typed via template usage, not defineSlots, for better compatibility
43-
// Slot props: default({ active, selected, disabled, option }), extra()
44-
4540
const componentOption = computed(() =>
4641
props.component === 'combobox' ? ComboboxOption : ListboxOption
4742
)
4843
49-
// Type-safe check for color property (only available on ExtendedOption)
5044
const optionColor = computed(() => {
5145
const opt = props.option as BaseOption & { color?: Color }
5246
return opt.color

src/components/OptionGroup.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
// Re-export types for backwards compatibility
32
export type { OptionGroup } from '../types/selection'
43
export { areOptionsGrouped } from '../types/selection'
54
</script>

src/components/SelectableOptionGroup.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
// Re-export GROUP_VALUE_PREFIX for backwards compatibility
32
export { GROUP_VALUE_PREFIX } from '../types/selection'
43
</script>
54

src/components/Switcher.vue

Lines changed: 29 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,35 @@
11
<script lang="ts">
2-
import { defineComponent, computed, PropType } from 'vue'
2+
export type { SwitcherOption } from '../types/selection'
3+
</script>
4+
5+
<script setup lang="ts" generic="T extends SwitcherOption = SwitcherOption">
6+
import { computed } from 'vue'
37
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
48
import AIcon from './Icon.vue'
9+
import { type SwitcherOption } from '../types/selection'
510
6-
export type SwitcherOption = {
7-
label: string
8-
value: string
9-
disabled?: boolean
10-
icon?: string
11-
}
12-
13-
export default defineComponent({
14-
name: 'ASwitcher',
15-
components: {
16-
RadioGroup,
17-
RadioGroupLabel,
18-
RadioGroupOption,
19-
AIcon
20-
},
21-
props: {
22-
modelValue: {
23-
type: String,
24-
required: true
25-
},
26-
options: {
27-
type: Array as PropType<SwitcherOption[]>,
28-
required: true
29-
},
30-
label: {
31-
type: String,
32-
default: ''
33-
},
34-
raised: {
35-
type: Boolean,
36-
default: false
37-
},
38-
disabled: {
39-
type: Boolean,
40-
default: false
41-
}
42-
},
43-
emits: ['update:modelValue'],
44-
setup(props, { emit }) {
45-
const model = computed({
46-
get: () => {
47-
return props.modelValue
48-
},
49-
set: value => {
50-
emit('update:modelValue', value)
51-
}
52-
})
53-
return {
54-
model
55-
}
11+
const props = withDefaults(
12+
defineProps<{
13+
modelValue: string
14+
options: T[]
15+
label?: string
16+
raised?: boolean
17+
disabled?: boolean
18+
}>(),
19+
{
20+
label: '',
21+
raised: false,
22+
disabled: false
5623
}
24+
)
25+
26+
const emit = defineEmits<{
27+
'update:modelValue': [value: string]
28+
}>()
29+
30+
const model = computed({
31+
get: () => props.modelValue,
32+
set: value => emit('update:modelValue', value)
5733
})
5834
</script>
5935
<template>
@@ -65,7 +41,7 @@ export default defineComponent({
6541
<RadioGroupLabel class="sr-only">{{ label }}</RadioGroupLabel>
6642
<RadioGroupOption
6743
v-for="option in options"
68-
:key="option.value"
44+
:key="String(option.value)"
6945
v-slot="{ checked }"
7046
:value="option.value"
7147
:disabled="option.disabled || disabled"
@@ -80,7 +56,7 @@ export default defineComponent({
8056
}"
8157
:aria-label="option.icon ? option.label : undefined"
8258
:title="option.icon ? option.label : undefined">
83-
<slot :name="option.value">
59+
<slot :name="String(option.value)">
8460
<a-icon v-if="option.icon" :name="option.icon" size="sm"></a-icon>
8561
<template v-else>
8662
{{ option.label }}

src/components/__tests__/Combobox.test.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ const defaultOptions = [
1010
{ value: 'e', label: 'Option E' }
1111
]
1212

13+
// Helper to count visible tags in the combobox trigger
14+
// Tags have removable buttons with aria-label="Remove"
15+
const getVisibleTagCount = () => {
16+
return screen.queryAllByLabelText('Remove').length
17+
}
18+
19+
// Helper to check if a tag with specific text is visible
20+
const hasVisibleTag = (label: string) => {
21+
// Tags are rendered as ATag components with removable buttons
22+
// The tag text appears alongside the Remove button
23+
const removeButtons = screen.queryAllByLabelText('Remove')
24+
return removeButtons.some(button => {
25+
const tag = button.closest('[class*="a-tag"]') || button.parentElement
26+
return tag?.textContent?.includes(label)
27+
})
28+
}
29+
1330
describe('Combobox.vue - Multi-select tag display', () => {
1431
describe('maxTags prop', () => {
1532
it('displays all tags when maxTags is undefined', () => {
@@ -20,11 +37,13 @@ describe('Combobox.vue - Multi-select tag display', () => {
2037
}
2138
})
2239

23-
expect(screen.getByText('Option A')).toBeInTheDocument()
24-
expect(screen.getByText('Option B')).toBeInTheDocument()
25-
expect(screen.getByText('Option C')).toBeInTheDocument()
26-
expect(screen.getByText('Option D')).toBeInTheDocument()
27-
expect(screen.getByText('Option E')).toBeInTheDocument()
40+
// All 5 tags should be visible (each has a Remove button)
41+
expect(getVisibleTagCount()).toBe(5)
42+
expect(hasVisibleTag('Option A')).toBe(true)
43+
expect(hasVisibleTag('Option B')).toBe(true)
44+
expect(hasVisibleTag('Option C')).toBe(true)
45+
expect(hasVisibleTag('Option D')).toBe(true)
46+
expect(hasVisibleTag('Option E')).toBe(true)
2847
})
2948

3049
it('limits visible tags to maxTags count', () => {
@@ -36,11 +55,13 @@ describe('Combobox.vue - Multi-select tag display', () => {
3655
}
3756
})
3857

39-
expect(screen.getByText('Option A')).toBeInTheDocument()
40-
expect(screen.getByText('Option B')).toBeInTheDocument()
41-
expect(screen.queryByText('Option C')).not.toBeInTheDocument()
42-
expect(screen.queryByText('Option D')).not.toBeInTheDocument()
43-
expect(screen.queryByText('Option E')).not.toBeInTheDocument()
58+
// Only 2 tags should be visible
59+
expect(getVisibleTagCount()).toBe(2)
60+
expect(hasVisibleTag('Option A')).toBe(true)
61+
expect(hasVisibleTag('Option B')).toBe(true)
62+
expect(hasVisibleTag('Option C')).toBe(false)
63+
expect(hasVisibleTag('Option D')).toBe(false)
64+
expect(hasVisibleTag('Option E')).toBe(false)
4465
})
4566

4667
it('shows collapsed indicator when tags exceed maxTags', () => {
@@ -76,9 +97,12 @@ describe('Combobox.vue - Multi-select tag display', () => {
7697
}
7798
})
7899

79-
expect(screen.queryByText('Option A')).not.toBeInTheDocument()
80-
expect(screen.queryByText('Option B')).not.toBeInTheDocument()
81-
expect(screen.queryByText('Option C')).not.toBeInTheDocument()
100+
// With maxTags: 0, no tags should be visible (no Remove buttons)
101+
expect(getVisibleTagCount()).toBe(0)
102+
expect(hasVisibleTag('Option A')).toBe(false)
103+
expect(hasVisibleTag('Option B')).toBe(false)
104+
expect(hasVisibleTag('Option C')).toBe(false)
105+
// The collapsed indicator should be shown
82106
expect(screen.getByText('+3 more')).toBeInTheDocument()
83107
})
84108
})

src/components/internal/OptionsPanel.vue

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
// Re-export Direction type for backwards compatibility
32
export type Direction = 'up' | 'down'
43
</script>
54
@@ -15,7 +14,12 @@ import {
1514
areOptionsGrouped
1615
} from '../../types/selection'
1716
18-
const props = withDefaults(
17+
const optionsComponents = {
18+
listbox: ListboxOptions,
19+
combobox: ComboboxOptions
20+
}
21+
22+
withDefaults(
1923
defineProps<{
2024
/**
2125
* the component inside of which the option is rendered
@@ -78,14 +82,13 @@ const props = withDefaults(
7882
}
7983
)
8084
81-
// Type guard for checking if options are grouped
8285
const isGrouped = (opts: T[] | OptionGroup<string, T>[]): opts is OptionGroup<string, T>[] => {
8386
return areOptionsGrouped(opts)
8487
}
8588
</script>
8689
<template>
8790
<component
88-
:is="`${component}-options`"
91+
:is="optionsComponents[component]"
8992
as="div"
9093
:static="inline"
9194
class="focus-visible:outline-none"

src/types/selection.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,7 @@ export interface ExtendedOption<V extends OptionValue = string> extends BaseOpti
5252
* { value: 1, label: 'One', metadata: { priority: 1 } }
5353
* ]
5454
*/
55-
export type Option<
56-
V extends OptionValue = string,
57-
T extends BaseOption<V> = ExtendedOption<V>
58-
> = T
55+
export type Option<V extends OptionValue = string, T extends BaseOption<V> = ExtendedOption<V>> = T
5956

6057
/**
6158
* Generic OptionGroup for grouped options.

0 commit comments

Comments
 (0)