Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
422 changes: 231 additions & 191 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"peerDependencies": {
"@headlessui/vue": "^1.7.23",
"tailwindcss": "^3.4.15",
"vue": "^3.5.13"
"vue": "^3.5.18"
},
"dependencies": {
"@floating-ui/vue": "^1.1.5",
Expand Down Expand Up @@ -117,7 +117,7 @@
"vite-plugin-dts": "^4.3.0",
"vite-svg-loader": "^5.1.0",
"vitest": "^2.1.8",
"vue": "^3.5.13",
"vue": "^3.5.18",
"vue-component-type-helpers": "^1.8.27",
"vue-tsc": "^2.1.10"
},
Expand Down
2 changes: 1 addition & 1 deletion src/components/Avatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { defineComponent, PropType, computed } from 'vue'
import AIcon from './Icon.vue'

type AvatarSize = 'md' | 'lg'
export type AvatarSize = 'md' | 'lg'

export default defineComponent({
name: 'AAvatar',
Expand Down
4 changes: 2 additions & 2 deletions src/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import ASpinner from './Spinner.vue'
import AIcon from './Icon.vue'
import { type IconIdentifier, type AnyIconName } from './icons/types'

type ButtonVariant = 'primary' | 'subtle' | 'standard'
export type ButtonVariant = 'primary' | 'subtle' | 'standard'

type ButtonSize = 'sm' | 'md' | 'lg' | 'auto'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'auto'

const isIconIdentifier = (str: string): boolean => {
return /-(sm|md|lg|other)$/.test(str)
Expand Down
7 changes: 3 additions & 4 deletions src/components/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,11 @@ const container = ref()
const query = ref('')
const inputRef = ref()
const comboboxButton = ref()
const optionsPanelWidth = computed(() => comboboxButton.value?.el.offsetWidth)
const optionsPanelWidth = computed(() => comboboxButton.value?.el?.offsetWidth)

const selectInputValue = () => {
if (inputRef.value) {
const element = inputRef.value.$el as HTMLInputElement
element.select()
if (inputRef.value?.$el) {
inputRef.value.$el.select()
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/Counter.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue'

type CounterVariant = 'default' | 'primary' | 'highlight' | 'subtle'
export type CounterVariant = 'default' | 'primary' | 'highlight' | 'subtle'

export default defineComponent({
name: 'ACounter',
Expand Down
14 changes: 1 addition & 13 deletions src/components/Icon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type {

<script setup lang="ts">
import { computed } from 'vue'
import icons, { DEPRECATED_ICONS } from './icons'
import icons from './icons'
import { type IconSize, type IconIdentifier, type AnyIconName } from './icons/types'

defineOptions({
Expand Down Expand Up @@ -65,23 +65,11 @@ const iconComponent = computed(() => {
} else if (props.name) {
size = props.size
name = props.name.charAt(0).toUpperCase() + props.name.slice(1)
const kebabName = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
console.warn(
`The "name" and "size" props are deprecated and will be removed in the next major version.
Use the "icon" prop instead: <a-icon icon="${kebabName}-${size}" />`
)
} else {
console.error('a-icon: either "icon" or "name" prop is required')
return null
}

if (DEPRECATED_ICONS[size].includes(name)) {
console.warn(
`Icon "${name}" in size "${size}" is deprecated and will be removed in the next major version.
Use another supported size or alternative icon, see storybook docs https://honeycomb.archilogic.com`
)
}

if (icons[size][name]) {
return icons[size][name]
} else {
Expand Down
4 changes: 2 additions & 2 deletions src/components/KeyboardShortcut.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import { defineComponent, PropType, computed } from 'vue'
import { isMac } from '@archilogic/toolbox'

type ShortcutModifier =
export type ShortcutModifier =
| 'alt' // Alt on Windows, Option ⌥ on Mac
| 'ctrl'
| 'meta' // Windows Key on Windows, Command ⌘ on Mac
| 'shift'

type ShortcutMouseEvent = 'click' | 'drag' | 'double-click'
export type ShortcutMouseEvent = 'click' | 'drag' | 'double-click'

export type Shortcut =
| { modifiers: ShortcutModifier[] }
Expand Down
4 changes: 2 additions & 2 deletions src/components/Link.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue'

type LinkVariant = 'primary' | 'standard'
export type LinkVariant = 'primary' | 'standard'

type LinkSize = 'sm' | 'md'
export type LinkSize = 'sm' | 'md'

export default defineComponent({
name: 'ALink',
Expand Down
2 changes: 1 addition & 1 deletion src/components/Listbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const emit = defineEmits<{
const attrs = useAttrs()

const listboxButton = ref()
const optionsPanelWidth = computed(() => listboxButton.value?.el.offsetWidth)
const optionsPanelWidth = computed(() => listboxButton.value?.el?.offsetWidth)

// hack together a tab-out behavior for the listbox
const handleTab = (event: KeyboardEvent) => {
Expand Down
42 changes: 28 additions & 14 deletions src/components/Option.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ const props = withDefaults(
* alternatively pass `value` and `disabled` props directly to a-option
* and render a label via the default slot.
*/
option: T
option?: T
/**
* standalone value prop for backward compatibility
* use when rendering custom slot content without an option object
*/
value?: string
/**
* standalone disabled prop for backward compatibility
*/
disabled?: boolean
/**
* whether the option is rendered in multiselect combobox (with a checkbox)
*/
Expand All @@ -41,27 +50,32 @@ const componentOption = computed(() =>
props.component === 'combobox' ? ComboboxOption : ListboxOption
)

// support both `option` prop and standalone `value`/`disabled` props
const optionValue = computed(() => props.option?.value ?? props.value)
const optionDisabled = computed(() => props.option?.disabled ?? props.disabled)
const optionLabel = computed(() => props.option?.label ?? '')

const optionColor = computed(() => {
const opt = props.option as BaseOption & { color?: Color }
return opt.color
const opt = props.option as (BaseOption & { color?: Color }) | undefined
return opt?.color
})
</script>
<template>
<component
:is="componentOption"
v-slot="{ active, selected, disabled }"
v-slot="{ active, selected, disabled: slotDisabled }"
as="template"
:value="option.value"
:disabled="option.disabled">
:value="optionValue"
:disabled="optionDisabled">
<div
class="flex min-h-8 min-w-8 items-center px-2 cursor-pointer"
:class="{
'bg-athens': active,
'bg-zurich48 text-mediumblue': selected && !multi,
'text-newyork': !selected && !disabled,
'cursor-not-allowed text-warsaw': disabled
'text-newyork': !selected && !slotDisabled,
'cursor-not-allowed text-warsaw': slotDisabled
}">
<a-checkbox v-if="multi" readonly :model-value="selected" :disabled="disabled">
<a-checkbox v-if="multi" readonly :model-value="selected" :disabled="slotDisabled">
<!--
@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.
-->
Expand All @@ -71,9 +85,9 @@ const optionColor = computed(() => {
<!--
@slot `#default` slot. Use to render custom styles or extra markup for the option. Renders option label by default.
-->
<slot :active="active" :selected="selected" :disabled="disabled" :option="option">
<span class="truncate" :class="disabled && 'text-warsaw'">
{{ option.label }}
<slot :active="active" :selected="selected" :disabled="slotDisabled" :option="option">
<span class="truncate" :class="slotDisabled && 'text-warsaw'">
{{ optionLabel }}
</span>
</slot>
</a-checkbox>
Expand All @@ -89,9 +103,9 @@ const optionColor = computed(() => {
<!--
@slot `#default` slot. Use to render custom styles or extra markup for the option. Renders option label by default.
-->
<slot :active="active" :selected="selected" :disabled="disabled" :option="option">
<slot :active="active" :selected="selected" :disabled="slotDisabled" :option="option">
<span class="truncate">
{{ option.label }}
{{ optionLabel }}
</span>
</slot>
</template>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Popup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { defineComponent, PropType, ref, computed, nextTick, onMounted } from 'v
import { onClickOutside } from '@vueuse/core'
import { focusable } from 'tabbable'

type Align = 'right' | 'left'
type Direction = 'up' | 'down'
export type Align = 'right' | 'left'
export type Direction = 'up' | 'down'

let id = 0

Expand Down
2 changes: 1 addition & 1 deletion src/components/Status.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue'

type StatusVariant = 'primary' | 'success' | 'neutral' | 'danger'
export type StatusVariant = 'primary' | 'success' | 'neutral' | 'danger'

export default defineComponent({
name: 'AStatus',
Expand Down
104 changes: 104 additions & 0 deletions src/components/__tests__/Combobox.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'

import Combobox from '../Combobox.vue'

Expand Down Expand Up @@ -223,6 +224,109 @@ describe('Combobox.vue - Multi-select tag display', () => {
})
})

describe('keyboard interaction', () => {
it('opens dropdown with ArrowDown key', async () => {
render(Combobox, {
props: {
options: defaultOptions,
modelValue: ''
}
})

const input = screen.getByRole('combobox')
await userEvent.click(input) // focus
await userEvent.keyboard('{ArrowDown}')

// Options should now be visible
expect(screen.getByText('Option A')).toBeVisible()
})

it('opens dropdown when typing', async () => {
render(Combobox, {
props: {
options: defaultOptions,
modelValue: ''
}
})

const input = screen.getByRole('combobox')
await userEvent.type(input, 'Opt')

// Options should now be visible (filtered)
expect(screen.getByText('Option A')).toBeVisible()
})
})

describe('scoped slots', () => {
it('provides filteredOptions via default slot', async () => {
render(Combobox, {
props: {
options: defaultOptions,
modelValue: ''
},
slots: {
default: `
<template #default="{ filteredOptions }">
<div v-for="opt in filteredOptions" :key="opt.value" data-testid="custom-option">
Custom: {{ opt.label }}
</div>
</template>
`
}
})

const input = screen.getByRole('combobox')
await userEvent.click(input)
await userEvent.keyboard('{ArrowDown}')

// Custom options should be rendered via slot
const customOptions = await screen.findAllByTestId('custom-option')
expect(customOptions.length).toBe(5)
expect(customOptions[0]).toHaveTextContent('Custom: Option A')
})
})

describe('grouped options', () => {
it('handles groups with empty options arrays', () => {
// Regression test: empty groups should not crash when rendering
// Previously crashed with "Cannot read properties of undefined (reading 'value')"
const groupedOptions = [
{ title: 'Group A', options: [{ value: 'a', label: 'Option A' }] },
{ title: 'Empty Group', options: [] },
{ title: 'Group B', options: [{ value: 'b', label: 'Option B' }] }
]

// Should render without throwing
const { container } = render(Combobox, {
props: {
options: groupedOptions,
modelValue: ''
}
})

// Verify the combobox rendered successfully
expect(container.querySelector('[role="combobox"]')).toBeInTheDocument()
})

it('handles group without title and empty options', () => {
// Edge case: group has neither title nor options
const groupedOptions = [
{ title: '', options: [] },
{ title: 'Valid Group', options: [{ value: 'a', label: 'Option A' }] }
]

// Should render without throwing
const { container } = render(Combobox, {
props: {
options: groupedOptions,
modelValue: ''
}
})

expect(container.querySelector('[role="combobox"]')).toBeInTheDocument()
})
})

describe('edge cases', () => {
it('ignores maxTags in single-select mode', () => {
render(Combobox, {
Expand Down
29 changes: 28 additions & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,31 @@ export { default as APanelCombobox } from './PanelCombobox.vue'
export { default as APopupCombobox } from './PopupCombobox.vue'
export { default as ASwitcher } from './Switcher.vue'
export { default as ATooltip } from './Tooltip.vue'
export { default as AKeyboardShortcut, type Shortcut } from './KeyboardShortcut.vue'
export {
default as AKeyboardShortcut,
type Shortcut,
type ShortcutModifier,
type ShortcutMouseEvent
} from './KeyboardShortcut.vue'

export type {
IconIdentifier,
IconSize,
SmIcon,
MdIcon,
LgIcon,
OtherIcon,
SmIconId,
MdIconId,
LgIconId,
OtherIconId,
AnyIcon,
AnyIconName
} from './icons/types'

export type { ButtonVariant, ButtonSize } from './Button.vue'
export type { LinkVariant, LinkSize } from './Link.vue'
export type { StatusVariant } from './Status.vue'
export type { CounterVariant } from './Counter.vue'
export type { AvatarSize } from './Avatar.vue'
export type { Align, Direction } from './Popup.vue'
Loading