-
Notifications
You must be signed in to change notification settings - Fork 298
feat: tree-shakeable string icons <Button icon="menu" /> #627
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,12 @@ | |
| <Tooltip :text="tooltip" :disabled="!tooltip?.length"> | ||
| <button | ||
| v-bind="$attrs" | ||
| ref="rootRef" | ||
| :class="buttonClasses" | ||
| @click="handleClick" | ||
| :disabled="isDisabled" | ||
| :ariaLabel="label" | ||
| :type = "props.type" | ||
| ref="rootRef" | ||
| :aria-label="label" | ||
| :type="props.type" | ||
| @click="handleClick" | ||
| > | ||
| <LoadingIndicator | ||
| v-if="loading" | ||
|
|
@@ -18,24 +18,22 @@ | |
| 'h-4.5 w-4.5': size == 'xl' || size == '2xl', | ||
| }" | ||
| /> | ||
| <slot name="prefix" v-else-if="$slots['prefix'] || iconLeft"> | ||
| <FeatherIcon | ||
| v-if="iconLeft && typeof iconLeft === 'string'" | ||
| :name="iconLeft" | ||
| <slot name="prefix" v-else-if="$slots.prefix || iconLeftComponent"> | ||
| <component | ||
| v-if="iconLeftComponent" | ||
| :is="iconLeftComponent" | ||
| :class="slotClasses" | ||
| aria-hidden="true" | ||
| /> | ||
| <component v-else-if="iconLeft" :is="iconLeft" :class="slotClasses" /> | ||
| </slot> | ||
|
|
||
| <template v-if="loading && loadingText">{{ loadingText }}</template> | ||
| <template v-else-if="isIconButton && !loading"> | ||
| <FeatherIcon | ||
| v-if="icon && typeof icon === 'string'" | ||
| :name="icon" | ||
| <component | ||
| v-if="iconComponent" | ||
| :is="iconComponent" | ||
| :class="slotClasses" | ||
| /> | ||
| <component v-else-if="icon" :is="icon" :class="slotClasses" /> | ||
| <slot name="icon" v-else-if="$slots.icon" /> | ||
| <div v-else-if="hasLucideIconInDefaultSlot" :class="slotClasses"> | ||
| <slot>{{ label }}</slot> | ||
|
|
@@ -46,28 +44,25 @@ | |
| </span> | ||
|
|
||
| <slot name="suffix"> | ||
| <FeatherIcon | ||
| v-if="iconRight && typeof iconRight === 'string'" | ||
| :name="iconRight" | ||
| <component | ||
| v-if="iconRightComponent" | ||
| :is="iconRightComponent" | ||
| :class="slotClasses" | ||
| aria-hidden="true" | ||
| /> | ||
| <component | ||
| v-else-if="iconRight" | ||
| :is="iconRight" | ||
| :class="slotClasses" | ||
| /> | ||
| </slot> | ||
| </button> | ||
| </Tooltip> | ||
| </template> | ||
|
|
||
| <script lang="ts" setup> | ||
| import { computed, useSlots, ref } from 'vue' | ||
| import FeatherIcon from '../FeatherIcon.vue' | ||
| import LoadingIndicator from '../LoadingIndicator.vue' | ||
| import { computed, ref, useSlots, watchEffect } from 'vue' | ||
| import { useRouter } from 'vue-router' | ||
| import type { ButtonProps, ThemeVariant } from './types' | ||
| import LoadingIndicator from '../LoadingIndicator.vue' | ||
| import Tooltip from '../Tooltip/Tooltip.vue' | ||
| import type { ButtonProps, ThemeVariant } from './types' | ||
|
|
||
| const warnedRuntimeStringIcons = new Set<string>() | ||
|
|
||
| defineOptions({ inheritAttrs: false }) | ||
|
|
||
|
|
@@ -77,62 +72,62 @@ const props = withDefaults(defineProps<ButtonProps>(), { | |
| variant: 'subtle', | ||
| loading: false, | ||
| disabled: false, | ||
| type: "button" | ||
| type: 'button', | ||
| }) | ||
|
|
||
| const slots = useSlots() | ||
| const router = useRouter() | ||
|
|
||
| const buttonClasses = computed(() => { | ||
| let solidClasses = { | ||
| const solidClasses = { | ||
| gray: 'text-ink-white bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5', | ||
| blue: 'text-ink-white bg-blue-500 hover:bg-surface-blue-3 active:bg-blue-700', | ||
| green: | ||
| 'text-ink-white bg-surface-green-3 hover:bg-green-700 active:bg-green-800', | ||
| red: 'text-ink-white bg-surface-red-5 hover:bg-surface-red-6 active:bg-surface-red-7', | ||
| }[props.theme] | ||
|
|
||
| let subtleClasses = { | ||
| const subtleClasses = { | ||
| gray: 'text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4', | ||
| blue: 'text-ink-blue-3 bg-surface-blue-2 hover:bg-blue-200 active:bg-blue-300', | ||
| green: | ||
| 'text-green-800 bg-surface-green-2 hover:bg-green-200 active:bg-green-300', | ||
| red: 'text-red-700 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4', | ||
| }[props.theme] | ||
|
|
||
| let outlineClasses = { | ||
| const outlineClasses = { | ||
| gray: 'text-ink-gray-8 bg-surface-white bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-3 active:bg-surface-gray-4', | ||
| blue: 'text-ink-blue-3 bg-surface-white border border-outline-blue-1 hover:border-blue-400 active:border-blue-400 active:bg-blue-300', | ||
| green: | ||
| 'text-green-800 bg-surface-white border border-outline-green-2 hover:border-green-500 active:border-green-500 active:bg-green-300', | ||
| red: 'text-red-700 bg-surface-white border border-outline-red-1 hover:border-outline-red-2 active:border-outline-red-2 active:bg-surface-red-3', | ||
| }[props.theme] | ||
|
|
||
| let ghostClasses = { | ||
| const ghostClasses = { | ||
| gray: 'text-ink-gray-8 bg-transparent hover:bg-surface-gray-3 active:bg-surface-gray-4', | ||
| blue: 'text-ink-blue-3 bg-transparent hover:bg-blue-200 active:bg-blue-300', | ||
| green: | ||
| 'text-green-800 bg-transparent hover:bg-green-200 active:bg-green-300', | ||
| red: 'text-red-700 bg-transparent hover:bg-surface-red-3 active:bg-surface-red-4', | ||
| }[props.theme] | ||
|
|
||
| let focusClasses = { | ||
| const focusClasses = { | ||
| gray: 'focus-visible:ring focus-visible:ring-outline-gray-3', | ||
| blue: 'focus-visible:ring focus-visible:ring-blue-400', | ||
| green: 'focus-visible:ring focus-visible:ring-outline-green-2', | ||
| red: 'focus-visible:ring focus-visible:ring-outline-red-2', | ||
| }[props.theme] | ||
|
|
||
| let variantClasses = { | ||
| const variantClasses = { | ||
| subtle: subtleClasses, | ||
| solid: solidClasses, | ||
| outline: outlineClasses, | ||
| ghost: ghostClasses, | ||
| }[props.variant] | ||
|
|
||
| let themeVariant: ThemeVariant = `${props.theme}-${props.variant}` | ||
| const themeVariant: ThemeVariant = `${props.theme}-${props.variant}` | ||
|
|
||
| let disabledClassesMap: Record<ThemeVariant, string> = { | ||
| const disabledClassesMap: Record<ThemeVariant, string> = { | ||
| 'gray-solid': 'bg-surface-gray-2 text-ink-gray-4', | ||
| 'gray-subtle': 'bg-surface-gray-2 text-ink-gray-4', | ||
| 'gray-outline': | ||
|
|
@@ -157,7 +152,7 @@ const buttonClasses = computed(() => { | |
| 'bg-surface-red-2 text-ink-red-2 border border-outline-red-1', | ||
| 'red-ghost': 'text-ink-red-2', | ||
| } | ||
| let disabledClasses = disabledClassesMap[themeVariant] | ||
| const disabledClasses = disabledClassesMap[themeVariant] | ||
|
|
||
| let sizeClasses = { | ||
| sm: 'h-7 text-base px-2 rounded', | ||
|
|
@@ -186,51 +181,81 @@ const buttonClasses = computed(() => { | |
| }) | ||
|
|
||
| const slotClasses = computed(() => { | ||
| let classes = { | ||
| return { | ||
| sm: 'h-4', | ||
| md: 'h-4.5', | ||
| lg: 'h-5', | ||
| xl: 'h-6', | ||
| '2xl': 'h-6', | ||
| }[props.size] | ||
|
|
||
| return classes | ||
| }) | ||
|
|
||
| const isDisabled = computed(() => { | ||
| return props.disabled || props.loading | ||
| }) | ||
|
|
||
| const iconComponent = computed(() => getRenderableIcon(props.icon)) | ||
| const iconLeftComponent = computed(() => getRenderableIcon(props.iconLeft)) | ||
| const iconRightComponent = computed(() => getRenderableIcon(props.iconRight)) | ||
|
|
||
| const isIconButton = computed(() => { | ||
| return props.icon || slots.icon || hasLucideIconInDefaultSlot.value | ||
| return Boolean( | ||
| iconComponent.value || slots.icon || hasLucideIconInDefaultSlot.value, | ||
| ) | ||
| }) | ||
|
|
||
| const hasLucideIconInDefaultSlot = computed(() => { | ||
| if (!slots.default) return false | ||
|
|
||
| const slotContent = slots.default() | ||
| if (!Array.isArray(slotContent)) return false | ||
| // if the slot contains only one element and it's a lucide icon | ||
| // render it as an icon button | ||
| let firstVNode = slotContent[0] | ||
| if ( | ||
| typeof firstVNode.type?.name == 'string' && | ||
| firstVNode.type?.name?.startsWith('lucide-') | ||
| ) { | ||
| return true | ||
| } | ||
| return false | ||
|
|
||
| const firstVNode = slotContent[0] | ||
| return ( | ||
| typeof firstVNode.type?.name === 'string' && | ||
| firstVNode.type.name.startsWith('lucide-') | ||
| ) | ||
| }) | ||
|
|
||
| const handleClick = () => { | ||
| if (import.meta.env.DEV) { | ||
| watchEffect(() => { | ||
| warnForRuntimeStringIcon('icon', props.icon) | ||
| warnForRuntimeStringIcon('iconLeft', props.iconLeft) | ||
| warnForRuntimeStringIcon('iconRight', props.iconRight) | ||
| }) | ||
| } | ||
|
|
||
| function getRenderableIcon(icon?: ButtonProps['icon']) { | ||
| if (!icon || typeof icon === 'string') return null | ||
| return icon | ||
| } | ||
|
|
||
| function warnForRuntimeStringIcon( | ||
| propName: 'icon' | 'iconLeft' | 'iconRight', | ||
| icon?: string | ButtonProps['icon'], | ||
| ) { | ||
| if (!icon || typeof icon !== 'string') return | ||
|
|
||
| const key = `${propName}:${icon}` | ||
| if (warnedRuntimeStringIcons.has(key)) return | ||
|
|
||
| warnedRuntimeStringIcons.add(key) | ||
| console.warn( | ||
| `[frappe-ui] Button ${propName}="${icon}" requires the frappe-ui/vite transform or a component binding like :${propName}="LucideX".`, | ||
| ) | ||
| } | ||
|
Comment on lines
+220
to
+246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find dynamic (v-bind / :icon) usages on Button that are not component references
rg -nP --type=vue -C2 '<Button\b[^>]*\s(?::|v-bind:)(?:icon|icon-left|icon-right|iconLeft|iconRight)\s*='
# Find call sites that forward a string-typed prop into Button icon props
rg -nP --type=vue -C2 '<Button\b[^>]*\s(?::|v-bind:)(?:icon|icon-left|icon-right|iconLeft|iconRight)\s*="[a-zA-Z_$][\w.$]*"'Repository: frappe/frappe-ui Length of output: 115 🏁 Script executed: #!/bin/bash
# Find all .vue files and search for Button component with icon bindings
fd -e vue -x rg -l '<Button\b' {} \; | head -20
# Then search specifically for dynamic icon bindings in those files
echo "=== Dynamic icon bindings in Button usages ==="
fd -e vue -x grep -l '<Button' {} \; | xargs rg -C2 ':(icon|iconLeft|iconRight|icon-left|icon-right)\s*=' 2>/dev/null | head -50
# Also search for string-typed icon props being forwarded
echo "=== String icon prop forwarding patterns ==="
fd -e vue -x grep -E '<Button[^>]*(icon|iconLeft|iconRight)' {} \; 2>/dev/null | grep -E '"[a-zA-Z_]' | head -30Repository: frappe/frappe-ui Length of output: 4991 🏁 Script executed: #!/bin/bash
# Find ButtonProps definition
rg 'interface ButtonProps|type ButtonProps' -A 15 src/components/Button/Repository: frappe/frappe-ui Length of output: 961 🏁 Script executed: #!/bin/bash
# Get full ButtonProps icon property definition
rg 'icon\?' src/components/Button/types.ts -A 3Repository: frappe/frappe-ui Length of output: 128 🏁 Script executed: #!/bin/bash
# Check CalendarWeekly and CalendarDaily for context
cat -n src/components/Calendar/CalendarWeekly.vue | sed -n '1,30p'
cat -n src/components/Calendar/CalendarDaily.vue | sed -n '1,30p'Repository: frappe/frappe-ui Length of output: 2625 🏁 Script executed: #!/bin/bash
# Search for Button.vue to find what Phase 2/3 components are mentioned or planned
rg 'Phase|TODO|FIXME|migration' src/components/Button/ -i
rg 'DropdownOption|Input|Switch' src/components/ --type=vue -l | head -10Repository: frappe/frappe-ui Length of output: 87 🏁 Script executed: #!/bin/bash
# Find all Button usages with string literal icons (not component references)
rg -U '<Button[^>]*\s(?:icon|iconLeft|iconRight|icon-left|icon-right)\s*=\s*["\x27][a-z\-]+["\x27]' --type vue -c
# Also find computed/dynamic string icons
rg -U 'iconRight\s*=\s*".*\?" ' src/components/ --type vue -lRepository: frappe/frappe-ui Length of output: 115 🏁 Script executed: #!/bin/bash
# Search for any TODO, FIXME, or comments about the string icon deprecation
rg 'string.*icon|icon.*string|migration|Phase' src/components/Button/ -i -A 2 -B 2Repository: frappe/frappe-ui Length of output: 2754 🏁 Script executed: #!/bin/bash
# List all components that use Button with potential string icons
fd -e vue -path "*/src/components/*" -x grep -l '<Button' {} \; | head -20Repository: frappe/frappe-ui Length of output: 228 Dynamic string icons in Button cause silent null rendering in production — migrate CalendarDaily and CalendarWeekly to component references.
The type signature still allows 🤖 Prompt for AI Agents |
||
|
|
||
| function handleClick() { | ||
| if (props.route) { | ||
| return router.push(props.route) | ||
| } else if (props.link) { | ||
| } | ||
|
|
||
| if (props.link) { | ||
| return window.open(props.link, '_blank') | ||
| } | ||
| } | ||
|
|
||
| const rootRef = ref() | ||
| const rootRef = ref<HTMLElement | null>(null) | ||
| defineExpose({ rootRef }) | ||
|
|
||
| defineSlots<{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential TypeError when default slot returns an empty array.
slotContent[0]isundefinedwhen a consumer passes an empty-returning default slot (e.g.default: () => []or<template#default/>). AccessingfirstVNode.type?.namethen throwsCannot read properties of undefined. The optional chain needs to start atfirstVNodeitself.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents