Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ logs
.DS_Store
.fleet
.idea
.vscode

# Local env files
.env
Expand Down
41 changes: 41 additions & 0 deletions docs/content/docs/2.components/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,47 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.lo
:::
::

### Loading Position

Use the `loading-position` prop to control where the loading icon appears. The prop accepts three values:
- `left`: displays loading icon on the left (default)
- `center`: displays loading icon in the center, hiding the label
- `right`: displays loading icon on the right

::component-code
---
props:
loading: true
loadingPosition: 'left'
label: Button
---
Button
::

::component-code
---
props:
loading: true
loadingPosition: 'center'
label: Button
---
Button
::

::component-code
---
props:
loading: true
loadingPosition: 'right'
label: Button
---
Button
::

:::tip
The `loading-position` prop maintains full backward compatibility. Existing usage with `leading` and `trailing` props for loading buttons continues to work without any changes. When `loading-position` is not specified, the component automatically determines the position based on the `trailing` prop.
:::

### Disabled

Use the `disabled` prop to disable the Button.
Expand Down
5 changes: 3 additions & 2 deletions playgrounds/nuxt/app/pages/components/button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ function onClick() {
<UButton label="Link" to="/" v-bind="props" />
<UButton label="Disabled" disabled v-bind="props" />
<UButton label="Disabled link" to="#" disabled v-bind="props" />
<UButton label="Loading" loading v-bind="props" />
<UButton label="Loading" loading trailing v-bind="props" />
<UButton label="Loading" loading loading-position="left" v-bind="props" />
<UButton label="Loading" loading loading-position="center" v-bind="props" />
<UButton label="Loading" loading trailing loading-position="right" v-bind="props" />
<UButton label="Loading auto" loading-auto v-bind="props" @click="onClick" />
<UButton label="Icon" icon="i-lucide-rocket" v-bind="props" />
<UButton label="Icon" icon="i-lucide-chevron-down" trailing v-bind="props" />
Expand Down
62 changes: 52 additions & 10 deletions src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export interface ButtonProps extends UseComponentIconsProps, Omit<LinkProps, 'ra
block?: boolean
/** Set loading state automatically based on the `@click` promise state */
loadingAuto?: boolean
/**
* Position of the loading icon when loading is true.
* @defaultValue 'left'
*/
loadingPosition?: 'left' | 'center' | 'right'
onClick?: ((event: MouseEvent) => void | Promise<void>) | Array<((event: MouseEvent) => void | Promise<void>)>
class?: any
ui?: Button['slots']
Expand Down Expand Up @@ -83,9 +88,36 @@ const isLoading = computed(() => {
return props.loading || (props.loadingAuto && (loadingAutoState.value || (formLoading?.value && props.type === 'submit')))
})

const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(
computed(() => ({ ...props, loading: isLoading.value }))
)
const loadingPosition = computed(() => {
if (props.loadingPosition) {
return props.loadingPosition
}
return props.trailing ? 'right' : 'left'
})

const iconProps = computed(() => {
const baseProps = { ...props, loading: isLoading.value }

if (isLoading.value) {
if (loadingPosition.value === 'right') {
return { ...baseProps, trailing: true }
} else if (loadingPosition.value === 'left') {
return { ...baseProps, trailing: false }
}
return { ...baseProps, trailing: false }
}

return baseProps
})

const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(iconProps)

const centerLoadingIcon = computed(() => {
if (isLoading.value && loadingPosition.value === 'center') {
return props.loadingIcon || appConfig.ui.icons.loading
}
return undefined
})

const ui = computed(() => tv({
extend: tv(theme),
Expand Down Expand Up @@ -126,26 +158,36 @@ const ui = computed(() => tv({
v-bind="slotProps"
data-slot="base"
:class="ui.base({
class: [props.ui?.base, props.class],
class: [props.ui?.base, props.class, ...(loadingPosition === 'center' && isLoading ? ['relative'] : [])],
active,
...(active && activeVariant ? { variant: activeVariant } : {}),
...(active && activeColor ? { color: activeColor } : {})
})"
@click="onClickWrapper"
>
<slot name="leading" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" data-slot="leadingIcon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon, active })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" data-slot="leadingAvatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar, active })" />
<UIcon v-if="loadingPosition !== 'center' && isLeading && leadingIconName" :name="leadingIconName" data-slot="leadingIcon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon, active })" />
<UAvatar v-else-if="loadingPosition !== 'center' && !!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" data-slot="leadingAvatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar, active })" />
</slot>

<slot :ui="ui">
<span v-if="label !== undefined && label !== null" data-slot="label" :class="ui.label({ class: props.ui?.label, active })">
{{ label }}
</span>
<template v-if="loadingPosition === 'center' && isLoading">
<div class="flex flex-col items-center">
<span v-if="label !== undefined && label !== null" data-slot="label" :class="ui.loadingLabel({ class: props.ui?.loadingLabel })">
{{ label }}
</span>
<UIcon v-if="centerLoadingIcon" :name="centerLoadingIcon" data-slot="loadingIcon" :class="ui.loadingIcon({ class: props.ui?.loadingIcon })" />
</div>
</template>
<template v-else>
<span v-if="label !== undefined && label !== null" data-slot="label" :class="ui.label({ class: props.ui?.label, active })">
{{ label }}
</span>
</template>
</slot>

<slot name="trailing" :ui="ui">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" data-slot="trailingIcon" :class="ui.trailingIcon({ class: props.ui?.trailingIcon, active })" />
<UIcon v-if="loadingPosition !== 'center' && isTrailing && trailingIconName" :name="trailingIconName" data-slot="trailingIcon" :class="ui.trailingIcon({ class: props.ui?.trailingIcon, active })" />
</slot>
</ULinkBase>
</ULink>
Expand Down
9 changes: 8 additions & 1 deletion src/theme/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export default (options: Required<ModuleOptions>) => ({
leadingIcon: 'shrink-0',
leadingAvatar: 'shrink-0',
leadingAvatarSize: '',
trailingIcon: 'shrink-0'
trailingIcon: 'shrink-0',
loadingIcon: 'shrink-0 size-5',
loadingLabel: 'opacity-0 h-0'
},
variants: {
...fieldGroupVariant,
Expand Down Expand Up @@ -164,6 +166,11 @@ export default (options: Required<ModuleOptions>) => ({
class: {
trailingIcon: 'animate-spin'
}
}, {
loading: true,
class: {
loadingIcon: 'animate-spin'
}
}],
defaultVariants: {
color: 'primary',
Expand Down
3 changes: 3 additions & 0 deletions test/components/Button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ describe('Button', () => {
['with loading and avatar', { props: { loading: true, avatar: { src: 'https://github.com/benjamincanac.png' } } }],
['with loading trailing', { props: { loading: true, trailing: true } }],
['with loading trailing and avatar', { props: { loading: true, trailing: true, avatar: { src: 'https://github.com/benjamincanac.png' } } }],
['with loadingPosition left', { props: { loading: true, loadingPosition: 'left', label: 'Button' } }],
['with loadingPosition center', { props: { loading: true, loadingPosition: 'center', label: 'Button' } }],
['with loadingPosition right', { props: { loading: true, loadingPosition: 'right', label: 'Button' } }],
['with loadingIcon', { props: { loading: true, loadingIcon: 'i-lucide-loader' } }],
['with disabled', { props: { label: 'Button', disabled: true } }],
['with disabled and with link', { props: { label: 'Button', disabled: true, to: '/link' } }],
Expand Down
20 changes: 20 additions & 0 deletions test/components/__snapshots__/Button-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ exports[`Button > renders with loadingIcon correctly 1`] = `
</button>"
`;

exports[`Button > renders with loadingPosition center correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary relative">
<!--v-if-->
<div class="flex flex-col items-center"><span data-slot="label" class="opacity-0 h-0">Button</span><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="loadingIcon" class="shrink-0 size-5 animate-spin"></svg></div>
<!--v-if-->
</button>"
`;

exports[`Button > renders with loadingPosition left correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="leadingIcon" class="shrink-0 size-5 animate-spin"></svg><span data-slot="label" class="truncate">Button</span>
<!--v-if-->
</button>"
`;

exports[`Button > renders with loadingPosition right correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
<!--v-if--><span data-slot="label" class="truncate">Button</span><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" data-slot="trailingIcon" class="shrink-0 size-5 animate-spin"></svg>
</button>"
`;

exports[`Button > renders with neutral variant ghost correctly 1`] = `
"<button type="button" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent">
<!--v-if--><span data-slot="label" class="truncate">Button</span>
Expand Down
20 changes: 20 additions & 0 deletions test/components/__snapshots__/Button.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ exports[`Button > renders with loadingIcon correctly 1`] = `
</button>"
`;

exports[`Button > renders with loadingPosition center correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary relative">
<!--v-if-->
<div class="flex flex-col items-center"><span data-slot="label" class="opacity-0 h-0">Button</span><span class="iconify i-lucide:loader-circle shrink-0 size-5 animate-spin" aria-hidden="true" data-slot="loadingIcon"></span></div>
<!--v-if-->
</button>"
`;

exports[`Button > renders with loadingPosition left correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"><span class="iconify i-lucide:loader-circle shrink-0 size-5 animate-spin" aria-hidden="true" data-slot="leadingIcon"></span><span data-slot="label" class="truncate">Button</span>
<!--v-if-->
</button>"
`;

exports[`Button > renders with loadingPosition right correctly 1`] = `
"<button type="button" disabled="" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
<!--v-if--><span data-slot="label" class="truncate">Button</span><span class="iconify i-lucide:loader-circle shrink-0 size-5 animate-spin" aria-hidden="true" data-slot="trailingIcon"></span>
</button>"
`;

exports[`Button > renders with neutral variant ghost correctly 1`] = `
"<button type="button" data-slot="base" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent">
<!--v-if--><span data-slot="label" class="truncate">Button</span>
Expand Down
Loading