Skip to content

Commit 936ebd8

Browse files
committed
feat(Icon,Button): add type-safe icon props with IconIdentifier
1 parent 039f79b commit 936ebd8

File tree

11 files changed

+422
-84
lines changed

11 files changed

+422
-84
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
# [2.23.0-test-icon-props.1](https://github.com/archilogic-com/honeycomb/compare/v2.22.0...v2.23.0-test-icon-props.1) (2025-12-18)
4+
5+
6+
### Features
7+
8+
* **Combobox,Listbox,Switcher:** add generic type support for options ([039f79b](https://github.com/archilogic-com/honeycomb/commit/039f79b209d9f10ee7561282a2eb63c4de5c9b9b))
9+
* **Icon,Button:** add type-safe icon props with IconIdentifier ([708e493](https://github.com/archilogic-com/honeycomb/commit/708e493dc64ba459a71c11d0289ddb29bf0592ac))
10+
311
## [2.22.1-test-skip-pre-release.1](https://github.com/archilogic-com/honeycomb/compare/v2.22.0...v2.22.1-test-skip-pre-release.1) (2025-12-11)
412

513

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@archilogic/honeycomb",
3-
"version": "2.22.1-test-skip-pre-release.1",
3+
"version": "2.23.0-test-icon-props.2",
44
"publishConfig": {
55
"access": "public"
66
},
@@ -54,7 +54,8 @@
5454
"format": "prettier . -w -u --loglevel silent",
5555
"codecheck": "npm run typecheck && npm run lint && npm run format",
5656
"chromatic": "chromatic --exit-zero-on-changes",
57-
"test": "vitest"
57+
"test": "vitest",
58+
"generate:icon-types": "npx tsx scripts/generate-icon-types.ts"
5859
},
5960
"peerDependencies": {
6061
"@headlessui/vue": "^1.7.23",

scripts/generate-icon-types.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { readdirSync, writeFileSync } from 'fs'
2+
import { dirname, join } from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url))
6+
const ICONS_DIR = join(__dirname, '../src/components/icons')
7+
const OUTPUT_FILE = join(ICONS_DIR, 'types.ts')
8+
9+
const sizes = ['sm', 'md', 'lg', 'other'] as const
10+
11+
function getIconNames(size: string): string[] {
12+
const dir = join(ICONS_DIR, size)
13+
return readdirSync(dir)
14+
.filter(f => f.endsWith('.svg'))
15+
.map(f => f.replace('.svg', ''))
16+
.sort()
17+
}
18+
19+
function generateUnionType(name: string, icons: string[]): string {
20+
if (icons.length === 0) return `export type ${name} = never`
21+
return `export type ${name} =\n${icons.map(i => ` | '${i}'`).join('\n')}`
22+
}
23+
24+
const iconsBySize = Object.fromEntries(sizes.map(s => [s, getIconNames(s)]))
25+
26+
const output = `// This file is auto-generated by scripts/generate-icon-types.ts
27+
// Do not edit manually. Run \`npm run generate:icon-types\` to regenerate.
28+
29+
${generateUnionType('SmIcon', iconsBySize.sm)}
30+
31+
${generateUnionType('MdIcon', iconsBySize.md)}
32+
33+
${generateUnionType('LgIcon', iconsBySize.lg)}
34+
35+
${generateUnionType('OtherIcon', iconsBySize.other)}
36+
37+
export type IconSize = 'sm' | 'md' | 'lg' | 'other'
38+
39+
export type IconName<S extends IconSize> = S extends 'sm'
40+
? SmIcon
41+
: S extends 'md'
42+
? MdIcon
43+
: S extends 'lg'
44+
? LgIcon
45+
: S extends 'other'
46+
? OtherIcon
47+
: never
48+
49+
export type AnyIcon = SmIcon | MdIcon | LgIcon | OtherIcon
50+
export type AnyIconName = AnyIcon | Uncapitalize<AnyIcon>
51+
52+
type KebabCase<S extends string> = S extends \`\${infer First}\${infer Rest}\`
53+
? Rest extends Uncapitalize<Rest>
54+
? \`\${Lowercase<First>}\${KebabCase<Rest>}\`
55+
: \`\${Lowercase<First>}-\${KebabCase<Rest>}\`
56+
: S
57+
58+
export type SmIconId = \`\${KebabCase<SmIcon>}-sm\`
59+
export type MdIconId = \`\${KebabCase<MdIcon>}-md\`
60+
export type LgIconId = \`\${KebabCase<LgIcon>}-lg\`
61+
export type OtherIconId = \`\${KebabCase<OtherIcon>}-other\`
62+
63+
export type IconIdentifier = SmIconId | MdIconId | LgIconId | OtherIconId
64+
`
65+
66+
writeFileSync(OUTPUT_FILE, output)
67+
console.log(`Generated ${OUTPUT_FILE}`)
68+
console.log(` sm: ${iconsBySize.sm.length} icons`)
69+
console.log(` md: ${iconsBySize.md.length} icons`)
70+
console.log(` lg: ${iconsBySize.lg.length} icons`)
71+
console.log(` other: ${iconsBySize.other.length} icons`)

src/components/Button.vue

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
import { computed, defineComponent, PropType } from 'vue'
33
import ASpinner from './Spinner.vue'
44
import AIcon from './Icon.vue'
5+
import { type IconIdentifier, type AnyIconName } from './icons/types'
56
67
type ButtonVariant = 'primary' | 'subtle' | 'standard'
78
89
type ButtonSize = 'sm' | 'md' | 'lg' | 'auto'
910
11+
const isIconIdentifier = (str: string): boolean => {
12+
return /-(sm|md|lg|other)$/.test(str)
13+
}
14+
1015
export default defineComponent({
1116
name: 'AButton',
1217
components: { ASpinner, AIcon },
@@ -41,15 +46,9 @@ export default defineComponent({
4146
type: String as PropType<ButtonSize>,
4247
default: 'md'
4348
},
44-
/**
45-
* deprecated usage: Boolean
46-
* removes horizontal padding, use size `auto` instead
47-
*
48-
* recommended usage: icon name as a String
49-
* the icon size will be inferred from the button size prop
50-
*/
49+
/** IconIdentifier (e.g., "search-sm") or icon name (e.g., "Search") */
5150
icon: {
52-
type: [Boolean, String],
51+
type: [Boolean, String] as PropType<boolean | AnyIconName | IconIdentifier>,
5352
default: false
5453
},
5554
/**
@@ -99,18 +98,15 @@ export default defineComponent({
9998
return tagName.value === 'button' && (props.disabled || props.loading)
10099
})
101100
102-
const iconName = computed(() => {
103-
if (typeof props.icon === 'string' && props.icon.length) {
104-
return props.icon
101+
const resolvedIconIdentifier = computed(() => {
102+
if (typeof props.icon !== 'string' || !props.icon.length) {
103+
return undefined
105104
}
106-
return undefined
107-
})
108-
109-
const iconSize = computed(() => {
110-
if (props.size === 'auto') {
111-
return 'other'
105+
if (isIconIdentifier(props.icon)) {
106+
return props.icon as IconIdentifier
112107
}
113-
return props.size
108+
const iconSize = props.size === 'auto' ? 'other' : props.size
109+
return `${props.icon}-${iconSize}` as IconIdentifier
114110
})
115111
116112
const ariaLabel = computed(() => {
@@ -123,8 +119,7 @@ export default defineComponent({
123119
return {
124120
tagName,
125121
isDisabled,
126-
iconName,
127-
iconSize,
122+
resolvedIconIdentifier,
128123
ariaLabel
129124
}
130125
}
@@ -164,9 +159,7 @@ export default defineComponent({
164159
the value of label prop or an icon
165160
-->
166161
<slot>
167-
<template v-if="!!(iconName && iconSize)">
168-
<a-icon :name="iconName" :size="iconSize"></a-icon>
169-
</template>
162+
<a-icon v-if="resolvedIconIdentifier" :icon="resolvedIconIdentifier"></a-icon>
170163
<template v-else>{{ label }}</template>
171164
</slot>
172165
</div>

src/components/Icon.vue

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,91 @@
11
<script lang="ts">
2-
import { defineComponent, PropType, capitalize as capitalizeFirstLetter } from 'vue'
3-
import icons, { IconSize, DEPRECATED_ICONS } from './icons'
2+
export type {
3+
IconSize,
4+
IconName,
5+
SmIcon,
6+
MdIcon,
7+
LgIcon,
8+
OtherIcon,
9+
AnyIcon,
10+
AnyIconName,
11+
IconIdentifier,
12+
SmIconId,
13+
MdIconId,
14+
LgIconId,
15+
OtherIconId
16+
} from './icons/types'
17+
</script>
18+
19+
<script setup lang="ts">
20+
import { computed } from 'vue'
21+
import icons, { DEPRECATED_ICONS } from './icons'
22+
import { type IconSize, type IconIdentifier, type AnyIconName } from './icons/types'
23+
24+
defineOptions({
25+
name: 'AIcon'
26+
})
427
5-
export default defineComponent({
6-
name: 'AIcon',
7-
props: {
28+
const props = withDefaults(
29+
defineProps<{
830
/**
9-
* icon name in PascalCase (e.g. ArrowDown, Warning) or camelCase (e.g. arrowDown, warning)
10-
* together with the size identifies the correct icon to load
31+
* Type-safe icon identifier in format "name-size" (e.g., "search-sm", "check-md").
32+
* This is the recommended way to specify icons as it enforces valid name+size combinations.
1133
*/
12-
name: {
13-
type: String,
14-
required: true
15-
},
34+
icon?: IconIdentifier
1635
/**
17-
* icon size - used to set width and height on an `svg` element
18-
* currently used sizes: `sm`: 1rem/16px, `md` (default): 2rem/32px, `lg`: 2.5rem/40px, `other`: various sizes for special cases
36+
* @deprecated Use `icon` prop instead (e.g., icon="search-sm").
37+
* Icon name in PascalCase (e.g. ArrowDown, Warning) or camelCase (e.g. arrowDown, warning).
1938
*/
20-
size: {
21-
type: String as PropType<IconSize>,
22-
default: 'md'
23-
}
24-
},
25-
computed: {
26-
iconComponent() {
27-
// need to use the props directly inside the computed function,
28-
// so that Vue tracks changes
29-
// https://stackoverflow.com/a/58577362/6917800
30-
const size = this.size
31-
const name = capitalizeFirstLetter(this.name) // allow camelCase name
32-
if (DEPRECATED_ICONS[size].includes(name)) {
33-
console.warn(
34-
`Icon "${name}" in size "${size}" is deprecated and will be removed in the next major version.
35-
Use another supported size or alternative icon, see storybook docs https://honeycomb.archilogic.com`
36-
)
37-
}
38-
if (icons[size][name]) {
39-
return icons[size][name]
40-
} else {
41-
console.error(`Icon ${name} of size ${size} does not exist.`, 'Available icons', icons)
42-
return null
43-
}
44-
}
39+
name?: AnyIconName
40+
/**
41+
* @deprecated Use `icon` prop instead (e.g., icon="search-sm").
42+
* Icon size - used to set width and height on an `svg` element.
43+
*/
44+
size?: IconSize
45+
}>(),
46+
{
47+
icon: undefined,
48+
name: undefined,
49+
size: 'md'
50+
}
51+
)
52+
53+
const iconComponent = computed(() => {
54+
let size: IconSize
55+
let name: string
56+
57+
if (props.icon) {
58+
const lastDash = props.icon.lastIndexOf('-')
59+
size = props.icon.slice(lastDash + 1) as IconSize
60+
name = props.icon
61+
.slice(0, lastDash)
62+
.split('-')
63+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
64+
.join('')
65+
} else if (props.name) {
66+
size = props.size
67+
name = props.name.charAt(0).toUpperCase() + props.name.slice(1)
68+
} else {
69+
console.error('[Honeycomb] a-icon: either icon or name prop is required')
70+
return null
71+
}
72+
73+
if (DEPRECATED_ICONS[size].includes(name)) {
74+
console.warn(
75+
`Icon "${name}" in size "${size}" is deprecated and will be removed in the next major version.
76+
Use another supported size or alternative icon, see storybook docs https://honeycomb.archilogic.com`
77+
)
78+
}
79+
80+
if (icons[size][name]) {
81+
return icons[size][name]
82+
} else {
83+
console.error(`Icon ${name} of size ${size} does not exist.`, 'Available icons', icons)
84+
return null
4585
}
4686
})
4787
</script>
88+
4889
<template>
4990
<component :is="iconComponent" v-if="iconComponent" aria-hidden class="flex-shrink-0" />
5091
</template>

src/components/Switcher.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { computed } from 'vue'
1010
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
1111
import AIcon from './Icon.vue'
1212
import { type SwitcherOption } from '../types/selection'
13+
import { type SmIcon, type SmIconId } from './icons/types'
1314
1415
const props = withDefaults(
1516
defineProps<{
@@ -60,7 +61,11 @@ const model = computed({
6061
:aria-label="option.icon ? option.label : undefined"
6162
:title="option.icon ? option.label : undefined">
6263
<slot :name="String(option.value)">
63-
<a-icon v-if="option.icon" :name="option.icon" size="sm"></a-icon>
64+
<a-icon
65+
v-if="option.icon"
66+
:icon="option.icon.endsWith('-sm') ? (option.icon as SmIconId) : undefined"
67+
:name="option.icon.endsWith('-sm') ? undefined : (option.icon as SmIcon)"
68+
:size="option.icon.endsWith('-sm') ? undefined : 'sm'"></a-icon>
6469
<template v-else>
6570
{{ option.label }}
6671
</template>

src/components/icons/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Component } from 'vue'
2-
export type IconSize = 'sm' | 'md' | 'lg' | 'other'
2+
import { type IconSize } from './types'
3+
export type { IconSize } from './types'
34

45
const iconModules: Record<string, { default: Component }> = import.meta.glob('./**/*.svg', {
56
eager: true

0 commit comments

Comments
 (0)