Repository: nextcloud-libraries/nextcloud-vue
Pull request #6033: refactor: Migrate `NcButton` from render function to template
Author: login: susnux
Labels: name: 3. to review
description: Waiting for reviews
name: feature: button
name: refactor ♻️
description: Pull request that is neither a fix nor a feature
Created: 2024-08-30T18:06:24Z | Merged: 2025-04-16T08:49:55Z

### ☑️ Resolves

1. Make it a template component instead of a render function
2. Add a `text` prop to allow passing text content as prop
3. Add `noIconAriaHidden` to allow making the icon appear on the accessible tree

### 🏁 Checklist

- [x] ⛑️ Tests are included or are not applicable
- [x] 📘 Component documentation has been extended, updated or is not applicable
- [x] 3️⃣ Backport to `next` requested with a Vue 3 upgrade



Diff:
diff --git a/package.json b/package.json
index 3765a5c299..5503f4b852 100644
--- a/package.json
+++ b/package.json
@@ -60,10 +60,10 @@
     "styleguide:build": "vue-styleguidist --config styleguide.config.cjs build",
     "stylelint": "stylelint \"src/**/*.vue\" \"src/**/*.scss\" \"src/**/*.css\"",
     "stylelint:fix": "stylelint \"src/**/*.vue\" \"src/**/*.scss\" \"src/**/*.css\" --fix",
-    "test": "TZ=UTC vitest run",
+    "test": "vitest run",
     "test:component": "playwright test -c playwright.config.ts",
     "test:component:gui": "playwright test --ui -c playwright.config.ts",
-    "test:coverage": "TZ=UTC vitest run --coverage",
+    "test:coverage": "vitest run --coverage",
     "ts:check": "vue-tsc --noEmit",
     "update:snapshots": "npm run test:component -- --grep @visual --update-snapshots",
     "watch": "npm run dev:watch"
diff --git a/src/components/NcButton/NcButton.vue b/src/components/NcButton/NcButton.vue
index fcf8cc72c3..54c532005c 100644
--- a/src/components/NcButton/NcButton.vue
+++ b/src/components/NcButton/NcButton.vue
@@ -35,46 +35,46 @@ It can be used with one or multiple actions.
 		<p>Primary</p>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="tertiary-no-background">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="tertiary">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size">
+			:disabled
+			:size
+			:text>
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="primary">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 	</div>
 
@@ -83,13 +83,13 @@ It can be used with one or multiple actions.
 	<NcButton
 		:disabled="disabled"
 		:size="size"
+		:text="text"
 		:wide="true"
 		text="Example text">
 		<template #icon>
 			<Video
 				:size="20" />
 		</template>
-		Example text
 	</NcButton>
 
 	<!-- Special buttons -->
@@ -100,34 +100,34 @@ It can be used with one or multiple actions.
 		<p>Error</p>
 		<p> - </p>
 		<NcButton
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			text="Example text"
 			variant="success">
 			<template #icon>
 				<Video
 					:size="20" />
 			</template>
-			Example text
 		</NcButton>
 		<NcButton
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			text="Example text"
 			variant="warning">
 			<template #icon>
 				<Video
 					:size="20" />
 			</template>
-			Example text
 		</NcButton>
 		<NcButton
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			text="Example text"
 			variant="error">
 			<template #icon>
 				<Video
 					:size="20" />
 			</template>
-			Example text
 		</NcButton>
 		<p> - </p>
 	</div>
@@ -148,7 +148,14 @@ export default {
 			size: 'normal',
 			style: 'icontext',
 		}
-	}
+	},
+	computed: {
+		text() {
+			if (this.style.includes('text')) {
+				return 'Example text'
+			}
+		},
+	},
 }
 </script>
 
@@ -189,45 +196,39 @@ Sometimes it is required to change the icon alignment on the button, like for sw
 ```vue
 <template>
 	<div style="display: flex; flex-direction: column; gap: 12px;">
-		<NcButton aria-label="center (default)" variant="secondary" wide>
+		<NcButton text="center (default)" variant="secondary" wide>
 			<template #icon>
 				<IconLeft :size="20" />
 			</template>
-			center (default)
 		</NcButton>
-		<NcButton alignment="center-reverse" aria-label="center-reverse" variant="secondary" wide>
+		<NcButton alignment="center-reverse" text="center-reverse" variant="secondary" wide>
 			<template #icon>
 				<IconRight :size="20" />
 			</template>
-			center-reverse
 		</NcButton>
 		<div style="display: flex; gap: 12px;">
 			<div style="display: flex; flex-direction: column; gap: 12px; flex: 1">
-				<NcButton alignment="start" aria-label="start" variant="secondary" wide>
+				<NcButton alignment="start" text="start" variant="secondary" wide>
 					<template #icon>
 						<IconLeft :size="20" />
 					</template>
-					start
 				</NcButton>
-				<NcButton alignment="start-reverse" aria-label="start-reverse" variant="secondary" wide>
+				<NcButton alignment="start-reverse" text="start-reverse" variant="secondary" wide>
 					<template #icon>
 						<IconRight :size="20" />
 					</template>
-					start-reverse
 				</NcButton>
 			</div>
 			<div style="display: flex; flex-direction: column; gap: 12px; flex: 1">
-				<NcButton alignment="end" aria-label="end" variant="secondary" wide>
+				<NcButton alignment="end" text="end" variant="secondary" wide>
 					<template #icon>
 						<IconLeft :size="20" />
 					</template>
-					end
 				</NcButton>
-				<NcButton alignment="end-reverse" aria-label="end-reverse" variant="secondary" wide>
+				<NcButton alignment="end-reverse" text="end-reverse" variant="secondary" wide>
 					<template #icon>
 						<IconRight :size="20" />
 					</template>
-					end-reverse
 				</NcButton>
 			</div>
 		</div>
@@ -263,12 +264,11 @@ Do not change `text` or `aria-label` of the pressed/unpressed button. See: https
 					<IconStarOutline v-else :size="20" />
 				</template>
 			</NcButton>
-			<NcButton v-model:pressed="isFavorite" variant="tertiary">
+			<NcButton v-model:pressed="isFavorite" text="Favorite" variant="tertiary">
 				<template #icon>
 					<IconStar v-if="isFavorite" :size="20" />
 					<IconStarOutline v-else :size="20" />
 				</template>
-				Favorite
 			</NcButton>
 			<NcButton v-model:pressed="isFavorite" aria-label="Favorite">
 				<template #icon>
@@ -305,6 +305,21 @@ export default {
 </script>
 ```
 
+### Usage example: Custom content
+Sometimes custom content, meaning more than text and icon, is required.
+For this the `default`-slot can be used.
+
+**Important**: Never include interactive elements inside the button,
+this results in invalid HTML and is not accessible!
+
+```vue
+<template>
+	<NcButton>
+		Some <del>formatted</del> content
+	</NcButton>
+</template>
+```
+
 ### Usage example: Sorting table columns
 The standard way to implement sortable table column headers should be like this:
 
@@ -415,317 +430,298 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonType, ButtonVariant } from './types.ts'
-import { isSlotPopulated } from '../../utils/isSlotPopulated.ts'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the HTML button type.
-		 * Accepted values: submit, reset, button.
-		 *
-		 * @default 'button'
-		 */
-		type: {
-			type: String as PropType<ButtonType | `${ButtonType}`>,
-			default: ButtonType.Button,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
+<script setup lang="ts">
+import type { Slot } from 'vue'
+import type { RouteLocation } from 'vue-router'
+
+import { computed, inject, toRef } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+
+export type ButtonAlignment = 'start'
+	| 'start-reverse'
+	| 'center'
+	| 'center-reverse'
+	| 'end'
+	| 'end-reverse'
+
+export type ButtonSize = 'small'
+	| 'normal'
+	| 'large'
+
+export type ButtonType =
+	| 'submit'
+	| 'reset'
+	| 'button'
+
+export type ButtonVariant = 'primary'
+	| 'secondary'
+	| 'tertiary'
+	| 'tertiary-no-background'
+	| 'tertiary-on-primary'
+	| 'error'
+	// Design-wise not recommended for new code
+	| 'warning'
+	| 'success'
+
+interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 */
+	alignment?: ButtonAlignment
 
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: ButtonSize
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
 
-		/**
-		 * Specifies the button variant.
-		 *
-		 * Accepted values: primary, secondary, tertiary, tertiary-no-background, tertiary-on-primary, error, warning, success.
-		 *
-		 * @default 'secondary'
-		 * @since 8.24.0
-		 */
-		 variant: {
-			type: String as PropType<ButtonVariant | `${ButtonVariant}`>,
-			validator(value: string) {
-				return ['primary', 'secondary', 'tertiary', 'tertiary-no-background', 'tertiary-on-primary', 'error', 'warning', 'success'].includes(value)
-			},
-			default: 'secondary',
-		},
-	},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string|RouteLocation
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: ButtonType
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realVariant() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.variant === 'primary') {
-				return 'secondary'
-			}
-			return this.variant
-		},
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: ButtonVariant
 
-		/**
-		 * The HTML button type
-		 */
-		realType() {
-			if (typeof this.pressed === 'boolean') {
-				return 'button'
-			}
-			return this.type
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
+}
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	ariaLabel: undefined,
+	alignment: 'center',
+	download: undefined,
+	href: undefined,
+	pressed: undefined,
+	size: 'normal',
+	target: '_self',
+	text: undefined,
+	to: undefined,
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+const emit = defineEmits<{
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	 click: [e: MouseEvent]
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
+	/**
+	 * Update the current pressed state of the button (if the `pressed` property was configured).
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}>()
 
+defineSlots<{
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
-
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+	default?: Slot
 
-		const isLink = (this.to || this.href)
+	/**
+	 * Icon (optional) to show within the button
+	 */
+	icon?: Slot
+}>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to!) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
+</script>
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		class="button-vue"
+		:class="[
+			`button-vue--size-${size}`,
 			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realVariant}`]: this.realVariant,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.type,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							'aria-hidden': 'true',
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
-		}
-		// Otherwise we simply return the button
-		return renderButton()
-	},
-})
-</script>
+		]"
+		:disabled
+		:aria-label
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
 <style lang="scss" scoped>
 .button-vue {
 	--button-size: var(--default-clickable-area);
 	--button-radius: var(--border-radius-element, calc(var(--button-size) / 2));
-	--button-padding: clamp(var(--default-grid-baseline), var(--button-radius), calc(var(--default-grid-baseline) * 4));
+	--button-padding-default: min(calc(var(--default-grid-baseline) + var(--button-radius)), calc(var(--default-grid-baseline) * 4));
+	--button-padding: var(--default-grid-baseline) var(--button-padding-default);
 
 	// General styles
 	// by default use secondary styling
@@ -738,7 +734,8 @@ export default defineComponent({
 	position: relative;
 	width: fit-content;
 	overflow: hidden;
-	padding: 0;
+	padding-block: 0;
+	padding-inline: var(--button-padding);
 	min-height: var(--button-size);
 	min-width: var(--button-size);
 	// display setup
@@ -773,17 +770,20 @@ export default defineComponent({
 		outline: none;
 	}
 
+	// Disabled styles
 	&:disabled {
-		cursor: default;
-		opacity: $opacity_disabled;
 		// Gives a wash out effect
 		filter: saturate($opacity_normal);
+		opacity: $opacity_disabled;
 
-		& * {
+		// Reset the cursor
+		cursor: default;
+		* {
 			cursor: default;
 		}
 	}
 
+	// Hover styles when not disabled (for the default theming of 'secondary')
 	&:hover:not(:disabled) {
 		background-color: var(--color-primary-element-light-hover);
 	}
@@ -823,7 +823,12 @@ export default defineComponent({
 		display: flex;
 		justify-content: center;
 		align-items: center;
+
+		&:empty {
+			display: none;
+		}
 	}
+
 	// For small buttons we need to adjust the icon size
 	&--size-small &__icon {
 		:deep(> *) {
@@ -843,32 +848,28 @@ export default defineComponent({
 		white-space: nowrap;
 		text-overflow: ellipsis;
 		overflow: hidden;
+
+		&:empty {
+			display: none;
+		}
 	}
 
 	// Icon-only button
-	&--icon-only {
+	&:has(#{&}__text:empty) {
+		--button-padding: clamp(var(--default-grid-baseline), var(--button-radius), calc(var(--default-grid-baseline) * 4));
 		line-height: 1;
 		width: var(--button-size) !important;
 	}
 
 	// Text-only button
-	&--text-only {
-		padding: 0 var(--button-padding);
+	&:has(#{&}__icon:empty) {
+		--button-padding: var(--button-padding-default);
 
 		.button-vue__text {
-			margin-inline: 4px;
+			padding-inline: var(--default-grid-baseline);
 		}
 	}
 
-	// Icon and text button
-	&--icon-and-text {
-		// icon and text means the icon adds "visual" padding thus we need to adjust the text padding
-		--button-padding: min(calc(var(--default-grid-baseline) + var(--button-radius)), calc(var(--default-grid-baseline) * 4));
-		// Adjust padding as the icon already got some padding we need to reduce the padding on the icon side and only add larger padding to the text side
-		padding-block: 0;
-		padding-inline: var(--default-grid-baseline) var(--button-padding);
-	}
-
 	// Wide button spans the whole width of the container
 	&--wide {
 		width: 100%;
@@ -887,7 +888,7 @@ export default defineComponent({
 	// Button types
 
 	// Primary
-	&--vue-primary {
+	&--primary {
 		background-color: var(--color-primary-element);
 		color: var(--color-primary-element-text);
 		&:hover:not(:disabled) {
@@ -901,7 +902,7 @@ export default defineComponent({
 	}
 
 	// Secondary
-	&--vue-secondary {
+	&--secondary {
 		color: var(--color-primary-element-light-text);
 		background-color: var(--color-primary-element-light);
 		&:hover:not(:disabled) {
@@ -911,7 +912,7 @@ export default defineComponent({
 	}
 
 	// Tertiary
-	&--vue-tertiary {
+	&--tertiary {
 		color: var(--color-main-text);
 		background-color: transparent;
 		&:hover:not(:disabled) {
@@ -920,7 +921,7 @@ export default defineComponent({
 	}
 
 	// Tertiary, no background
-	&--vue-tertiary-no-background {
+	&--tertiary-no-background {
 		color: var(--color-main-text);
 		background-color: transparent;
 		&:hover:not(:disabled) {
@@ -929,7 +930,7 @@ export default defineComponent({
 	}
 
 	// Tertiary on primary color (like the header)
-	&--vue-tertiary-on-primary {
+	&--tertiary-on-primary {
 		color: var(--color-primary-element-text);
 		background-color: transparent;
 
@@ -939,7 +940,7 @@ export default defineComponent({
 	}
 
 	// Success
-	&--vue-success {
+	&--success {
 		background-color: var(--color-success);
 		color: white;
 		&:hover:not(:disabled) {
@@ -953,7 +954,7 @@ export default defineComponent({
 	}
 
 	// Warning
-	&--vue-warning {
+	&--warning {
 		background-color: var(--color-warning);
 		color: white;
 		&:hover:not(:disabled) {
@@ -967,7 +968,7 @@ export default defineComponent({
 	}
 
 	// Error
-	&--vue-error {
+	&--error {
 		background-color: var(--color-error);
 		color: white;
 		&:hover:not(:disabled) {
diff --git a/src/components/NcButton/index.ts b/src/components/NcButton/index.ts
index 11bc480627..6a4a5ec036 100644
--- a/src/components/NcButton/index.ts
+++ b/src/components/NcButton/index.ts
@@ -1,7 +1,7 @@
-/**
+/*!
  * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
 
+export type * from './NcButton.vue'
 export { default } from './NcButton.vue'
-export * from './types'
diff --git a/src/components/NcButton/types.ts b/src/components/NcButton/types.ts
deleted file mode 100644
index de6d56d31d..0000000000
--- a/src/components/NcButton/types.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-export enum ButtonAlignment {
-	Start = 'start',
-	StartReverse = 'start-reverse',
-	Center = 'center',
-	CenterReverse = 'center-reverse',
-	End = 'end',
-	EndReverse = 'end-reverse',
-}
-
-export enum ButtonType {
-	Submit = 'submit',
-	Reset = 'reset',
-	Button = 'button',
-}
-
-export enum ButtonVariant {
-	Primary = 'primary',
-	Secondary = 'secondary',
-	Tertiary = 'tertiary',
-	TertiaryNoBackground = 'tertiary-no-background',
-	TertiaryOnPrimary = 'tertiary-on-primary',
-	Error = 'error',
-	/**
-	 * @deprecated Design-wise not recommended for new code
-	 */
-	Warning = 'warning',
-	Success = 'success',
-}
diff --git a/src/components/NcDialogButton/NcDialogButton.vue b/src/components/NcDialogButton/NcDialogButton.vue
index 14c83ce62b..0107287880 100644
--- a/src/components/NcDialogButton/NcDialogButton.vue
+++ b/src/components/NcDialogButton/NcDialogButton.vue
@@ -26,80 +26,70 @@ Dialog button component used by NcDialog in the actions slot to display the butt
 </template>
 
 <script setup lang="ts">
-import type { PropType } from 'vue'
+import type { ButtonType, ButtonVariant } from '../NcButton/index.ts'
 import { ref } from 'vue'
 import { t } from '../../l10n.js'
 
-import NcButton, { ButtonType, ButtonVariant } from '../NcButton/index'
+import NcButton from '../NcButton/index'
 import NcIconSvgWrapper from '../NcIconSvgWrapper/index.js'
 import NcLoadingIcon from '../NcLoadingIcon/index.js'
 
-const props = defineProps({
+const props = withDefaults(defineProps<{
 	/**
 	 * The function that will be called when the button is pressed.
 	 * If the function returns `false` the click is ignored and the dialog will not be closed,
 	 * which is the default behavior of "reset"-buttons.
 	 *
-	 * @type {() => unknown|false|Promise<unknown|false>}
+	 * @default '() => {}'
 	 */
-	callback: {
-		type: Function,
-		required: false,
-		default: () => {},
-	},
+	callback?: () => unknown|false
 
 	/**
-	 * The label of the button
+	 * If the button should be shown as disabled.
+	 *
+	 * @default false
 	 */
-	label: {
-		type: String,
-		required: true,
-	},
+	disabled?: boolean
 
 	/**
-	 * Optional inline SVG icon for the button
+	 * Optional inline SVG icon for the button.
+	 *
+	 * @default undefined
 	 */
-	icon: {
-		type: String,
-		required: false,
-		default: undefined,
-	},
+	icon?: string
 
 	/**
-	 * The button type, see `NcButton`.
-	 *
-	 * @type {'button'|'submit'|'reset'}
+	 * The label of the button.
 	 */
-	type: {
-		type: String as PropType<ButtonType | `${ButtonType}`>,
-		default: 'button',
-		validator: (type: string) => ['button', 'submit', 'reset'].includes(type),
-	},
+	label: string
 
 	/**
-	 * If the button should be shown as disabled
+	 * The button type, see `NcButton`.
+	 *
+	 * @default 'button'
 	 */
-	disabled: {
-		type: Boolean,
-		default: false,
-	},
+	type?: ButtonType
 
 	/**
 	 * The button variant, see `NcButton`.
 	 *
-	 * @type {'primary'|'secondary'|'tertiary'|'error'|'warning'|'success'}
-	 * @since 8.23.0
+	 * @default 'tertiary'
+	 * @since 8.24.0
 	 */
-	variant: {
-		type: String as PropType<ButtonVariant | `${ButtonVariant}`>,
-		required: false,
-		default: 'secondary',
-		validator: (type) => typeof type === 'string' && ['primary', 'secondary', 'tertiary', 'error', 'warning', 'success'].includes(type),
-	},
+	variant?: ButtonVariant
+}>(), {
+	callback: () => {},
+	disabled: false,
+	icon: undefined,
+	type: 'button',
+	variant: 'tertiary',
 })
 
 const emit = defineEmits<{
-	(name: 'click', event: MouseEvent, payload: unknown): void
+	/**
+	 * The click event (`MouseEvent`) and the value returned by the callback
+	 */
+	'click': [event: MouseEvent, payload: unknown]
 }>()
 
 const isLoading = ref(false)
@@ -120,9 +110,6 @@ async function handleClick(e: MouseEvent) {
 		const fallback = props.type === 'reset' ? false : undefined
 		const result = await props.callback?.() ?? fallback
 		if (result !== false) {
-			/**
-			 * The click event (`MouseEvent`) and the value returned by the callback
-			 */
 			emit('click', e, result)
 		}
 	} finally {
diff --git a/src/globals.d.ts b/src/globals.d.ts
index 2606ef35ad..3ebab588f1 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -24,6 +24,9 @@ declare global {
 		// eslint-disable-next-line @typescript-eslint/no-explicit-any
 		OCP: any
 		// internal global variables
+		_oc_config?: {
+			loglevel?: number
+		}
 		_nc_vue_element_id?: number
 		_nc_contacts_menu_hooks: { [id: string]: ContactsMenuAction },
 	}
diff --git a/tests/component/components/NcButton.spec.ts b/tests/component/components/NcButton.spec.ts
index 5be303afe0..c0e4e16e88 100644
--- a/tests/component/components/NcButton.spec.ts
+++ b/tests/component/components/NcButton.spec.ts
@@ -7,6 +7,16 @@ import { expect, test } from '@playwright/experimental-ct-vue'
 import NcButton from '../../../src/components/NcButton/NcButton.vue'
 
 test('Text is rendered when provided', async ({ mount, page }) => {
+	await mount(NcButton, {
+		props: {
+			text: 'Nextcloud',
+		},
+	})
+	await expect(page.getByRole('button')).toHaveText('Nextcloud')
+	await expect(page.getByRole('button')).toHaveAccessibleName('Nextcloud')
+})
+
+test('Text is rendered when provided as slot', async ({ mount, page }) => {
 	await mount(NcButton, {
 		slots: {
 			default: 'Nextcloud',
@@ -19,7 +29,18 @@ test('Text is rendered when provided', async ({ mount, page }) => {
 test('Can overwrite accessible name', async ({ mount, page }) => {
 	await mount(NcButton, {
 		props: {
-			ariaLabel: 'Nextcloud'
+			ariaLabel: 'Nextcloud',
+			text: 'Your favorite cloud',
+		},
+	})
+	await expect(page.getByRole('button')).toHaveText('Your favorite cloud')
+	await expect(page.getByRole('button')).toHaveAccessibleName('Nextcloud')
+})
+
+test('Can overwrite accessible name provided as slot', async ({ mount, page }) => {
+	await mount(NcButton, {
+		props: {
+			ariaLabel: 'Nextcloud',
 		},
 		slots: {
 			default: 'Your favorite cloud',
diff --git a/tests/global-setup.js b/tests/global-setup.js
new file mode 100644
index 0000000000..835d857b19
--- /dev/null
+++ b/tests/global-setup.js
@@ -0,0 +1,7 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+export const setup = () => {
+	process.env.TZ = 'UTC'
+}
diff --git a/tests/unit/components/NcDialogButton/NcDialogButton.spec.ts b/tests/unit/components/NcDialogButton/NcDialogButton.spec.ts
index fe4b5f876f..12fc382e0e 100644
--- a/tests/unit/components/NcDialogButton/NcDialogButton.spec.ts
+++ b/tests/unit/components/NcDialogButton/NcDialogButton.spec.ts
@@ -2,23 +2,23 @@
  * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
+import type { ButtonType } from '../../../../src/components/NcButton/index.ts'
 
 import { describe, expect, it } from 'vitest'
 import { mount } from '@vue/test-utils'
 import { nextTick } from 'vue'
-import { ButtonType } from '../../../../src/components/NcButton/index.ts'
 import NcDialogButton from '../../../../src/components/NcDialogButton/NcDialogButton.vue'
 
 describe('NcDialogButton', () => {
 	it.each([
-		[ButtonType.Reset],
-		[ButtonType.Button],
-		[ButtonType.Submit],
-	])('forwards the native type', async (type: ButtonType) => {
+		['reset'],
+		['button'],
+		['submit'],
+	])('forwards the native type', async (type: string) => {
 		const wrapper = mount(NcDialogButton, {
 			props: {
 				label: 'button',
-				type,
+				type: type as ButtonType,
 			},
 		})
 		expect(wrapper.find('button').attributes('type')).toBe(type)
@@ -75,7 +75,7 @@ describe('NcDialogButton', () => {
 		const wrapper = mount(NcDialogButton, {
 			props: {
 				label: 'button',
-				type: ButtonType.Reset,
+				type: 'reset',
 			},
 		})
 		await wrapper.find('button').trigger('click')
@@ -86,7 +86,7 @@ describe('NcDialogButton', () => {
 		const wrapper = mount(NcDialogButton, {
 			props: {
 				label: 'button',
-				type: ButtonType.Reset,
+				type: 'reset',
 				callback: () => true,
 			},
 		})
diff --git a/tsconfig.json b/tsconfig.json
index e79b53cfdb..b854b3225f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,23 +1,27 @@
 {
-  "extends": "@vue/tsconfig",
-  "include": ["./src/**/*.js","./src/**/*.ts", "./src/**/*.vue", "**/*.ts"],
-  "exclude": ["tests", "vitest.config.ts"],
+	"extends": "@vue/tsconfig",
+	"include": ["./src/**/*.js","./src/**/*.ts", "./src/**/*.vue", "**/*.ts"],
+	"exclude": ["tests", "vitest.config.ts"],
 
-  "compilerOptions": {
-    "allowJs": true,
-    "allowImportingTsExtensions": true,
-    "allowSyntheticDefaultImports": true,
-    "target": "ESNext",
-    "module": "ESNext",
-    "moduleResolution": "Bundler",
-    "noImplicitAny": false,
-    "outDir": "./dist",
-    "rootDir": ".",
-    "strict": true,
-    "noEmit": true,
-  },
+	"compilerOptions": {
+		"allowJs": true,
+		"allowImportingTsExtensions": true,
+		"allowSyntheticDefaultImports": true,
+		"lib": [
+			"DOM",
+			"ESNext",
+		],
+		"target": "ESNext",
+		"module": "ESNext",
+		"moduleResolution": "Bundler",
+		"noImplicitAny": false,
+		"outDir": "./dist",
+		"rootDir": ".",
+		"strict": true,
+		"noEmit": true,
+	},
 
-  "vueCompilerOptions": {
-    "target": 3.3,
-  },
+	"vueCompilerOptions": {
+		"target": 3.5,
+	},
 }
diff --git a/vitest.config.ts b/vitest.config.ts
index b7c33154e9..a2190f7b9a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -17,6 +17,7 @@ export default async (env) => {
 		test: {
 			environment: 'jsdom',
 			setupFiles: resolve(__dirname, './tests/setup.js'),
+			globalSetup: resolve(__dirname, './tests/global-setup.js'),
 			exclude: [
 				'tests/component/**',
 				'node_modules/**',


Reviews:
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T21:23:41Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T23:06:25Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T23:07:45Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T23:08:53Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T23:09:33Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-21T23:11:35Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:13:01Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:29:10Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:34:38Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:34:41Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:41:54Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T11:41:57Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T18:27:22Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T18:30:04Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-22T19:12:54Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-29T21:03:58Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-28T22:35:02Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:40:25Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:35:25Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:37:12Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:39:40Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:40:05Z
user: login: ShGKme
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:40:08Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T21:03:59Z
user: login: st3iny
body: Left some nitpicks at your discretion. Looks fine overall.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:21:22Z
user: login: st3iny
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:24:39Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:35:24Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:44:10Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:45:31Z
user: login: ShGKme
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:29:23Z

Review comments:
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: If using TS anyway, why use JS (runtime) props definition?
created_at: 2025-01-19T22:42:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: This means that `vue-router` is now built-in in the component... 
created_at: 2025-01-19T22:45:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: `"default"` is the default name for *non-named* slots
```suggestion
				<slot>
```
created_at: 2025-01-19T22:46:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: ```suggestion
import type { Slot } from 'vue'

const slots = defineSlots<{
  /** docs */
  default?: Slot,
  /** docs */
  icon?: Slot,
}>()
```
created_at: 2025-01-19T22:47:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realType = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.type === 'primary') {
+		return 'secondary'
+	}
+	return props.type
+})
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.nativeType,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const isLink = (this.to || this.href)
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return isSlotPopulated(slots.icon?.({}))
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: To reduces double-render when it is not required. Unlike `#default` slot, named slots are always passed via `<template v-slot>`, and developers should pass slot icon only when it contains an icon.

```suggestion
	return !!slots.icon
```
created_at: 2025-01-19T22:50:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realType = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.type === 'primary') {
+		return 'secondary'
+	}
+	return props.type
+})
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.nativeType,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const isLink = (this.to || this.href)
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return isSlotPopulated(slots.icon?.({}))
+}
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		/**
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
+		 *
+		 * @property {boolean} newValue The new `pressed`-state
+		 */
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
-			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
-			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							attrs: {
-								'aria-hidden': 'true',
-							},
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
+/**
+ * Warn developers about missing aria label for icon-only buttons
+ */
+watch(
+	() => props.ariaLabel,
+	() => {
+		if (!props.ariaLabel && !hasText()) {
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: This also means that we execute a render function outside rendering...
And we only check it initially. (Sometimes button looses the text when there is not enough space)
created_at: 2025-01-19T22:52:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Is there any other cases, except loading? If it's only for loading, I think, it's more useful to provide built-in loading feature.
created_at: 2025-01-19T22:55:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: I have no idea how to do it the other way, though...

In Options API it was fully injected via global component `<RouterLink>` or global properties `this.$router`. With Composition API 

And because it is a Symbol, we need to make sure that vue-router is deduped during build.
created_at: 2025-01-20T11:11:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: Thought it is easier to extend props as you can do it like:

```ts
defineProps({
    ...useMyComposableProps(),

    myCustomProp: {
        // ...
    }
})
```

But no strong opinion
created_at: 2025-01-21T23:06:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: > And because it is a Symbol, we need to make sure that vue-router is deduped during build.

We need to make sure the same for Vue itself too anyway.
That why it should be a peer dependency.

But if you do not use the router it will not be a problem for the component (it works with and without the router defined).
created_at: 2025-01-21T23:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: It seems I do not keep up with fancy new changes :see_no_evil: 
Will have a look at the documentation but looks promising :)
created_at: 2025-01-21T23:08:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realType = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.type === 'primary') {
+		return 'secondary'
+	}
+	return props.type
+})
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.nativeType,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const isLink = (this.to || this.href)
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return isSlotPopulated(slots.icon?.({}))
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: This really makes sense and is much cleaner!
created_at: 2025-01-21T23:09:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: You could also have other accessible names on icons if you use the icon only variant.
But yes in that case it probably makes sense to set the aria label directly on the button.
created_at: 2025-01-21T23:11:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realType = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.type === 'primary') {
+		return 'secondary'
+	}
+	return props.type
+})
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.nativeType,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const isLink = (this.to || this.href)
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return isSlotPopulated(slots.icon?.({}))
+}
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		/**
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
+		 *
+		 * @property {boolean} newValue The new `pressed`-state
+		 */
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
-			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
-			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							attrs: {
-								'aria-hidden': 'true',
-							},
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
+/**
+ * Warn developers about missing aria label for icon-only buttons
+ */
+watch(
+	() => props.ariaLabel,
+	() => {
+		if (!props.ariaLabel && !hasText()) {
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: Resolved, will only be triggered now if the instance is in debug mode
created_at: 2025-01-22T11:13:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: My fear with non-hidden icons with accessible names is that developers will use icon name as an accessible name (like it's done in some icon libraries) while it's usually not correct:
- Icon names are not translated
- Icon name may not represent the action correctly, like `alarm` name for `set reminder` or `laptop` for `edit locally`
created_at: 2025-01-22T11:29:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: > Thought it is easier to extend props as you can do it like:

We can still extend props as types

```ts
defineProps<BaseProps & {
  myCustomProp: string,
}>()

// or 

interface NcTextFieldProp extends NcInputFieldProps {
  myCustomProp: string
}
defineProps<NcTextFieldProp>()
```

It misses default values, though, but they can be handled separately.

> `useMyComposableProps`

From my understanding, composables by design shouldn't define props. They are a part of setup composition, not "mixins", setup component options.


created_at: 2025-01-22T11:34:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Runtime-based declaration is primary for JS, and type-based declaration for TS. One of the reasons to move to `<script setup>` was to be able to have type-based declaration in v3.2 (this is not possible with normal ESM SFC).

Also, only type-based declaration has micro-optimization, removing all the development-only declarations in a production build.

Using `<script setup lang="ts>` without type-based declarations feels like we're only getting half the benefits of TypeScript.

created_at: 2025-01-22T11:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: > We need to make sure the same for Vue itself too anyway

We have, for example, an alias to make sure Vue is shared in the build from `npm link`. Then we need to same for `vue-router`.

> That why it should be a peer dependency

`peerDependencies` vs `dependencies` only changes what happens on versions conflict.

They are still installed and then bundled into the `NcButton` even when used in a project without `vue-router`.

Of course, it's just `3kB` in bundle size, but still...


created_at: 2025-01-22T11:41:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 */
+	disabled: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
+	/**
+	 * Specify the button size
+	 * Accepted values: `'small'`, `'normal'` (default), `'large'`
+	 */
+	size: {
+		type: String,
+		default: 'normal',
+		validator(value: string) {
+			return ['small', 'normal', 'large'].includes(value)
 		},
+	},
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text: {
+		type: String,
+		default: '',
+	},
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
+	/**
+	 * Specifies the button type
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
+	 */
+	type: {
+		type: String as PropType<ButtonType>,
+		default: ButtonType.Secondary,
+		validator(value: string) {
+			return Object.values(ButtonType).includes(value as ButtonType)
 		},
+	},
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 * @type {'submit' | 'reset' | 'button'}
+	 */
+	nativeType: {
+		type: String as PropType<ButtonNativeType>,
+		default: ButtonNativeType.Button,
+		validator(value: string) {
+			return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
 		},
+	},
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 */
+	wide: {
+		type: Boolean,
+		default: false,
+	},
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * Always try to provide an aria-label to your button. Make it more
+	 * specific than the button's name by provide some more context. E.g. if
+	 * the name of the button is "send" in the Mail app, the aria label could
+	 * be "Send email".
+	 */
+	ariaLabel: {
+		type: String,
+		default: null,
 	},
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the href attribute turns the button component into an `a`
+	 * element.
+	 */
+	href: {
+		type: String,
+		default: null,
+	},
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target: {
+		type: String,
+		default: '_self',
+	},
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+	/**
+	 * Providing the download attribute with href downloads file when clicking.
+	 */
+	download: {
+		type: String,
+		default: null,
+	},
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * The pressed state of the button if it has a checked state
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Pressed state is not supported for links
+	 */
+	pressed: {
+		type: Boolean,
+		default: null,
+	},
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
+	/**
+	 * Providing the to attribute turns the button component into a `router-link`
+	 * element. Takes precedence over the href attribute.
+	 */
+	to: {
+		type: [String, Object],
+		default: null,
 	},
+})
 
+const emit = defineEmits<{
+	(e: 'click', event: MouseEvent): void
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Update the current pressed state of the button (if the `pressed` property was configured)
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+	(e: 'update:pressed', pressed: boolean): void
+}>()
+
+const slots = useSlots()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => inject(routerKey, null) !== null && props.to
+	? useLink({ to: toRef(() => props.to) })
+	: undefined,
+)
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Anyway, I have no idea how to avoid it in Composition API, and I don't see any non-overcomplicated alternatives here...
created_at: 2025-01-22T11:41:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
 import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
 
-export default defineComponent({
-	name: 'NcButton',
+import { computed, inject, toRef, useSlots, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
+const props = defineProps({
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
+	 */
+	 alignment: {
+		type: String as PropType<ButtonAlignment>,
+		default: ButtonAlignment.Center,
+		validator(value: string) {
+			return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
 		},
 	},
 
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden: {
+		type: Boolean,
+		default: false,
+	},
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: Maybe this not needed at all? Imagine its a loading spinner for a dialog button to save a form:

What about this:
`<NcButton aria-label="Saving…" title="Saving…"><template #icon><NcLoadingIcon /></template></NcButton>`

Meaning put it directly into the aria-label.
So we need no prop at all here.
created_at: 2025-01-22T18:27:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: side note: I am not a fan of `withDefaults` but even less of [reactive props destructure](https://vuejs.org/guide/components/props#reactive-props-destructure)
created_at: 2025-01-22T18:30:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,334 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--vue-${realType}`]: realType,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
+			},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot name="default">
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-import { defineComponent, h, resolveComponent } from 'vue'
+<script setup lang="ts">
+import type { PropType } from 'vue'
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Neither do I, and I really hoped they wouldn't merge reactive props destructure, as well as `<script setup>` with compile-macroses in general in 3.2.

But that's what we have now, and some features are only available in this way.

In the context of props, for example, only in type-based props definition we can define input attributes in typings of `NcInputField` without defining all the props.
created_at: 2025-01-22T19:12:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,3 +41,135 @@ export enum ButtonType {
 	Warning = 'warning',
 	Success = 'success',
 }
+
+export interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default ButtonAlignment.Center
+	 */
+	alignment?: EnumOrValue<ButtonAlignment>
+
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden?: boolean
+
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
+
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: EnumOrValue<ButtonSize>
+
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
+
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: EnumOrValue<ButtonVariant>
+
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: EnumOrValue<ButtonType>
+
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
+
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
+
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
+
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
+
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
+
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
+
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string | RouteLocation
+}
+
+export interface NcButtonEvents {
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	click: [e: MouseEvent]
+
+	/**
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}
+
+export interface NcButtonSlots {
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
+
+	/**
+	 * Icon (optional) to show within the button
+	 */
+	icon?: Slot
+}
path: src/components/NcButton/types.ts
user: login: ShGKme
body: Please let's keep component props/events/slots in the component. 

SFC concept is about having everything that is direct component definition in one file. Props are very part of the component, even more important than styles. When a component is developed/debugged, a developer very often needs to see component props and other interfaces.
created_at: 2025-01-29T20:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,20 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Helper to allow either the enum T or its string variant.
+ *
path: src/utils/enumOrValue.ts
user: login: ShGKme
body: Or just remove enum-s as a kind of legacy feature?
created_at: 2025-01-29T20:53:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: To separate static values and dynamic binding

```suggestion
		class="button-vue"
		:class="[
```
created_at: 2025-01-29T20:54:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Vue 3.5 💅✨
```suggestion
		:disabled
```
created_at: 2025-01-29T20:55:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: ```suggestion
import isSlotPopulated from '../../utils/isSlotPopulated.ts'
import logger from '../../utils/logger.ts'
```
created_at: 2025-01-29T20:55:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	alignment: 'center',
+	size: 'normal',
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+const emit = defineEmits<NcButtonEvents>()
+const slots = defineSlots<NcButtonSlots>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
 		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Let's keep docs only in interface docs. Then it's compatible with all the tooling
created_at: 2025-01-29T20:58:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -734,44 +626,49 @@ export default defineComponent({
 	width: fit-content;
 	overflow: hidden;
 	border: 0;
-	padding: 0;
+	border-radius: var(--button-radius);
+	cursor: pointer;
+	padding-block: 0;
+	padding-inline: var(--button-padding);
 	font-size: var(--default-font-size);
 	font-weight: bold;
 	min-height: var(--button-size);
 	min-width: var(--button-size);
 	display: flex;
 	align-items: center;
 	justify-content: center;
+	// Transition styles for hover and pressed toggle
+	transition-property: color, border-color, background-color;
+	transition-duration: var(--animation-quick);
+	transition-timing-function: linear;
+	// Default button type
+	color: var(--color-primary-element-light-text);
+	background-color: var(--color-primary-element-light);
 
-	// Cursor pointer on element and all children
-	cursor: pointer;
-	& *,
-	span {
+	// Also apply the Cursor pointer on element and all children
+	:deep(*) {
 		cursor: pointer;
 	}
-	border-radius: var(--button-radius);
-	transition-property: color, border-color, background-color;
-	transition-duration: 0.1s;
-	transition-timing-function: linear;
 
 	// No outline feedback for focus. Handled with a toggled class in js (see data)
 	&:focus {
 		outline: none;
 	}
 
+	// Disabled styles
 	&:disabled {
+		// Gives a wash out effect
+		filter: saturate($opacity_normal);
+		opacity: $opacity_disabled;
+
+		// Reset the cursor
 		cursor: default;
-		& * {
+		:deep(*) {
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Does nothing in Vue 3
```suggestion
		* {
```
created_at: 2025-01-29T20:59:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	alignment: 'center',
+	size: 'normal',
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+const emit = defineEmits<NcButtonEvents>()
+const slots = defineSlots<NcButtonSlots>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
 		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
 		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
-
-	emits: ['update:pressed', 'click'],
-
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
-
-		/**
-		 * The flexbox alignment of the button content
+		 * @property {boolean} newValue The new `pressed`-state
 		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
-
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
-
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
-
-	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
-	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
+/**
+ * Warn developers about missing aria label for icon-only buttons
+ */
+watch(
+	() => props.ariaLabel,
+	() => {
+		// This is purely for developers
+		// So we skip if not in debug mode to prevent performance issues as `hasText` requires a full render function call
+		if (window._oc_config?.loglevel !== 0) {
+			return
 		}
 
-		const isLink = (this.to || this.href)
-
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
-
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
-			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
-			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							attrs: {
-								'aria-hidden': 'true',
-							},
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
+		if (!props.ariaLabel && !hasText()) {
+			// Warning but we made sure that the log level is DEBUG above so we do not spam users
+			logger.warn('You need to fill either the text or the ariaLabel props in the button component.')
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: This works if a developer dynamically removes `aria-label` (never happens), but it doesn't work if a developer removes text on mobile (happens often).


created_at: 2025-01-29T21:01:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	alignment: 'center',
+	size: 'normal',
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+const emit = defineEmits<NcButtonEvents>()
+const slots = defineSlots<NcButtonSlots>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
 		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
 		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
-
-	emits: ['update:pressed', 'click'],
-
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
-
-		/**
-		 * The flexbox alignment of the button content
+		 * @property {boolean} newValue The new `pressed`-state
 		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
-
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
-
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
-
-	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
-	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
+/**
+ * Warn developers about missing aria label for icon-only buttons
+ */
+watch(
+	() => props.ariaLabel,
+	() => {
+		// This is purely for developers
+		// So we skip if not in debug mode to prevent performance issues as `hasText` requires a full render function call
+		if (window._oc_config?.loglevel !== 0) {
+			return
 		}
 
-		const isLink = (this.to || this.href)
-
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
-
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
-			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
-			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							attrs: {
-								'aria-hidden': 'true',
-							},
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
+		if (!props.ariaLabel && !hasText()) {
+			// Warning but we made sure that the log level is DEBUG above so we do not spam users
+			logger.warn('You need to fill either the text or the ariaLabel props in the button component.')
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: And I'm still not fun of rendering content 3 times instead of 1...
1. In watch
2. In `v-if`
3. In content render

2 + 3 happens each re-render
created_at: 2025-01-29T21:01:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	alignment: 'center',
+	size: 'normal',
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+const emit = defineEmits<NcButtonEvents>()
+const slots = defineSlots<NcButtonSlots>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
 		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
 		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
-
-	emits: ['update:pressed', 'click'],
-
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
-
-		/**
-		 * The flexbox alignment of the button content
+		 * @property {boolean} newValue The new `pressed`-state
 		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
-
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
-
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
-
-	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
-	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
 
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
+/**
+ * Warn developers about missing aria label for icon-only buttons
+ */
+watch(
+	() => props.ariaLabel,
+	() => {
+		// This is purely for developers
+		// So we skip if not in debug mode to prevent performance issues as `hasText` requires a full render function call
+		if (window._oc_config?.loglevel !== 0) {
+			return
 		}
 
-		const isLink = (this.to || this.href)
-
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
-
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
-			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
-			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							attrs: {
-								'aria-hidden': 'true',
-							},
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
+		if (!props.ariaLabel && !hasText()) {
+			// Warning but we made sure that the log level is DEBUG above so we do not spam users
+			logger.warn('You need to fill either the text or the ariaLabel props in the button component.')
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: I'd consider getting rid of this check at all in flavor of check on e2e test level.
- Developers still successfully ignore this warning :(
- Much effort for only one of many places where we need to worry about a11y
created_at: 2025-01-29T21:03:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,3 +41,135 @@ export enum ButtonType {
 	Warning = 'warning',
 	Success = 'success',
 }
+
+export interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default ButtonAlignment.Center
+	 */
+	alignment?: EnumOrValue<ButtonAlignment>
+
+	/**
+	 * By default the icon is set to `aria-hidden="true"` to hide it from the accessibility tree.
+	 * But sometimes this is not desired, e.g. for a loading icon with an accessible name,
+	 * setting this prop will make the icon appear in the accessible tree.
+	 * @default false
+	 * @since 9.0.0
+	 */
+	noIconAriaHidden?: boolean
+
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
+
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: EnumOrValue<ButtonSize>
+
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
+
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: EnumOrValue<ButtonVariant>
+
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: EnumOrValue<ButtonType>
+
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
+
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
+
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
+
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
+
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
+
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
+
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string | RouteLocation
+}
+
+export interface NcButtonEvents {
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	click: [e: MouseEvent]
+
+	/**
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}
+
+export interface NcButtonSlots {
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
+
+	/**
+	 * Icon (optional) to show within the button
+	 */
+	icon?: Slot
+}
path: src/components/NcButton/types.ts
user: login: susnux
body: makes sense
created_at: 2025-02-28T22:35:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,310 +430,187 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
-
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
-
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
-
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		:class="[
+			'button-vue',
+			`button-vue--size-${size}`,
+			{
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-		},
-
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+		]"
+		:disabled="disabled"
+		:aria-label="ariaLabel"
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()"
+				:aria-hidden="noIconAriaHidden ? undefined : 'true'"
+				class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
+				<slot>
+					{{ text }}
+				</slot>
+			</span>
+		</span>
+	</component>
+</template>
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+<script setup lang="ts">
+import type { NcButtonEvents, NcButtonProps, NcButtonSlots } from './types.ts'
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+import { computed, inject, toRef, watch } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated'
+import logger from '../../utils/logger'
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	alignment: 'center',
+	size: 'normal',
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+const emit = defineEmits<NcButtonEvents>()
+const slots = defineSlots<NcButtonSlots>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
 		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+		 * Update the current pressed state of the button (if the `pressed` property was configured)
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: sounds good
created_at: 2025-04-01T17:40:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,308 +430,314 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
+<script setup lang="ts">
+import type { Slot } from 'vue'
+import type { RouteLocation } from 'vue-router'
+
+import { computed, inject, toRef } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated.ts'
+
+export type ButtonAlignment = 'start'
+	| 'start-reverse'
+	| 'center'
+	| 'center-reverse'
+	| 'end'
+	| 'end-reverse'
+
+export type ButtonSize = 'small'
+	| 'normal'
+	| 'large'
+
+export type ButtonType =
+	| 'submit'
+	| 'reset'
+	| 'button'
+
+export type ButtonVariant = 'primary'
+	| 'secondary'
+	| 'tertiary'
+	| 'tertiary-no-background'
+	| 'tertiary-on-primary'
+	| 'error'
+	// Design-wise not recommended for new code
+	| 'warning'
+	| 'success'
+
+interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 */
+	alignment?: ButtonAlignment
 
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
 
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType | `${ButtonType}`>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: ButtonSize
 
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
 
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: ButtonVariant
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: ButtonType
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string|RouteLocation
+}
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	ariaLabel: undefined,
+	alignment: 'center',
+	download: undefined,
+	href: undefined,
+	pressed: undefined,
+	size: 'normal',
+	target: '_self',
+	text: undefined,
+	to: undefined,
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+const emit = defineEmits<{
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	 click: [e: MouseEvent]
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * Update the current pressed state of the button (if the `pressed` property was configured).
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}>()
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
+const slots = defineSlots<{
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
 
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Icon (optional) to show within the button
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
-
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+	icon?: Slot
+}>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: Isn't it the same as
```suggestion
		? useLink({ to: toRef(() => props.to!) })
```
?
created_at: 2025-04-01T19:35:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -732,44 +753,49 @@ export default defineComponent({
 	width: fit-content;
 	overflow: hidden;
 	border: 0;
-	padding: 0;
+	border-radius: var(--button-radius);
+	cursor: pointer;
+	padding-block: 0;
+	padding-inline: var(--button-padding);
 	font-size: var(--default-font-size);
 	font-weight: bold;
 	min-height: var(--button-size);
 	min-width: var(--button-size);
 	display: flex;
 	align-items: center;
 	justify-content: center;
+	// Transition styles for hover and pressed toggle
+	transition-property: color, border-color, background-color;
+	transition-duration: var(--animation-quick);
+	transition-timing-function: linear;
+	// Default button type
+	color: var(--color-primary-element-light-text);
+	background-color: var(--color-primary-element-light);
 
-	// Cursor pointer on element and all children
-	cursor: pointer;
-	& *,
-	span {
+	// Also apply the Cursor pointer on element and all children
+	:deep(*) {
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: ```suggestion
	* {
```
created_at: 2025-04-01T19:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,308 +430,314 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
+<script setup lang="ts">
+import type { Slot } from 'vue'
+import type { RouteLocation } from 'vue-router'
+
+import { computed, inject, toRef } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated.ts'
+
+export type ButtonAlignment = 'start'
+	| 'start-reverse'
+	| 'center'
+	| 'center-reverse'
+	| 'end'
+	| 'end-reverse'
+
+export type ButtonSize = 'small'
+	| 'normal'
+	| 'large'
+
+export type ButtonType =
+	| 'submit'
+	| 'reset'
+	| 'button'
+
+export type ButtonVariant = 'primary'
+	| 'secondary'
+	| 'tertiary'
+	| 'tertiary-no-background'
+	| 'tertiary-on-primary'
+	| 'error'
+	// Design-wise not recommended for new code
+	| 'warning'
+	| 'success'
+
+interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 */
+	alignment?: ButtonAlignment
 
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
 
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType | `${ButtonType}`>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: ButtonSize
 
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
 
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: ButtonVariant
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: ButtonType
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string|RouteLocation
+}
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	ariaLabel: undefined,
+	alignment: 'center',
+	download: undefined,
+	href: undefined,
+	pressed: undefined,
+	size: 'normal',
+	target: '_self',
+	text: undefined,
+	to: undefined,
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+const emit = defineEmits<{
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	 click: [e: MouseEvent]
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * Update the current pressed state of the button (if the `pressed` property was configured).
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}>()
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
+const slots = defineSlots<{
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
 
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Icon (optional) to show within the button
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
-
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+	icon?: Slot
+}>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		const isLink = (this.to || this.href)
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
+</script>
+
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		class="button-vue"
+		:class="[
+			`button-vue--size-${size}`,
 			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							'aria-hidden': 'true',
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
-		}
-		// Otherwise we simply return the button
-		return renderButton()
-	},
-})
-</script>
+		]"
+		:disabled
+		:aria-label
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()" class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: I'm still not fun of x2 rendering 👀


created_at: 2025-04-01T19:39:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,308 +430,314 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
+<script setup lang="ts">
+import type { Slot } from 'vue'
+import type { RouteLocation } from 'vue-router'
+
+import { computed, inject, toRef } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated.ts'
+
+export type ButtonAlignment = 'start'
+	| 'start-reverse'
+	| 'center'
+	| 'center-reverse'
+	| 'end'
+	| 'end-reverse'
+
+export type ButtonSize = 'small'
+	| 'normal'
+	| 'large'
+
+export type ButtonType =
+	| 'submit'
+	| 'reset'
+	| 'button'
+
+export type ButtonVariant = 'primary'
+	| 'secondary'
+	| 'tertiary'
+	| 'tertiary-no-background'
+	| 'tertiary-on-primary'
+	| 'error'
+	// Design-wise not recommended for new code
+	| 'warning'
+	| 'success'
+
+interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 */
+	alignment?: ButtonAlignment
 
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
 
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType | `${ButtonType}`>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: ButtonSize
 
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
 
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: ButtonVariant
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: ButtonType
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string|RouteLocation
+}
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	ariaLabel: undefined,
+	alignment: 'center',
+	download: undefined,
+	href: undefined,
+	pressed: undefined,
+	size: 'normal',
+	target: '_self',
+	text: undefined,
+	to: undefined,
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+const emit = defineEmits<{
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	 click: [e: MouseEvent]
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * Update the current pressed state of the button (if the `pressed` property was configured).
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}>()
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
+const slots = defineSlots<{
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
 
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Icon (optional) to show within the button
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
-
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+	icon?: Slot
+}>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		const isLink = (this.to || this.href)
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
+</script>
+
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		class="button-vue"
+		:class="[
+			`button-vue--size-${size}`,
 			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							'aria-hidden': 'true',
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
-		}
-		// Otherwise we simply return the button
-		return renderButton()
-	},
-})
-</script>
+		]"
+		:disabled
+		:aria-label
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()" class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: And it is not really compatible with vapor mode
created_at: 2025-04-01T19:40:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,308 +430,314 @@ td.row-size {
 }
 </style>
 ```
-
 </docs>
 
-<script lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent, h, resolveComponent } from 'vue'
-import { ButtonAlignment, ButtonNativeType, ButtonType } from './types'
-import isSlotPopulated from '../../utils/isSlotPopulated'
-
-export default defineComponent({
-	name: 'NcButton',
-
-	inject: {
-		getNcPopoverTriggerAttrs: {
-			from: 'NcPopover:trigger:attrs',
-			default: () => () => ({}),
-		},
-	},
-
-	props: {
-		/**
-		 * Set the text and icon alignment
-		 *
-		 * @default 'center'
-		 * @type {'start' | 'start-reverse' | 'center' | 'center-reverse' | 'end' | 'end-reverse'}
-		 */
-		alignment: {
-			type: String as PropType<ButtonAlignment>,
-			default: ButtonAlignment.Center,
-			validator(value: string) {
-				return Object.values(ButtonAlignment).includes(value as ButtonAlignment)
-			},
-		},
-
-		/**
-		 * Toggles the disabled state of the button on and off.
-		 */
-		disabled: {
-			type: Boolean,
-			default: false,
-		},
+<script setup lang="ts">
+import type { Slot } from 'vue'
+import type { RouteLocation } from 'vue-router'
+
+import { computed, inject, toRef } from 'vue'
+import { routerKey, useLink } from 'vue-router'
+import isSlotPopulated from '../../utils/isSlotPopulated.ts'
+
+export type ButtonAlignment = 'start'
+	| 'start-reverse'
+	| 'center'
+	| 'center-reverse'
+	| 'end'
+	| 'end-reverse'
+
+export type ButtonSize = 'small'
+	| 'normal'
+	| 'large'
+
+export type ButtonType =
+	| 'submit'
+	| 'reset'
+	| 'button'
+
+export type ButtonVariant = 'primary'
+	| 'secondary'
+	| 'tertiary'
+	| 'tertiary-no-background'
+	| 'tertiary-on-primary'
+	| 'error'
+	// Design-wise not recommended for new code
+	| 'warning'
+	| 'success'
+
+interface NcButtonProps {
+	/**
+	 * Set the text and icon alignment
+	 *
+	 * @default 'center'
+	 */
+	alignment?: ButtonAlignment
 
-		/**
-		 * Specify the button size
-		 * Accepted values: `'small'`, `'normal'` (default), `'large'`
-		 */
-		size: {
-			type: String,
-			default: 'normal',
-			validator(value: string) {
-				return ['small', 'normal', 'large'].includes(value)
-			},
-		},
+	/**
+	 * Toggles the disabled state of the button on and off.
+	 * @default false
+	 */
+	disabled?: boolean
 
-		/**
-		 * Specifies the button type
-		 * If left empty, the default button style will be applied.
-		 *
-		 * @default 'secondary'
-		 * @type {'primary' | 'secondary' | 'tertiary' | 'tertiary-no-background' | 'tertiary-on-primary' | 'error' | 'warning' | 'success'}
-		 */
-		type: {
-			type: String as PropType<ButtonType | `${ButtonType}`>,
-			default: ButtonType.Secondary,
-			validator(value: string) {
-				return Object.values(ButtonType).includes(value as ButtonType)
-			},
-		},
+	/**
+	 * Specify the button size.
+	 * @default 'normal'
+	 */
+	size?: ButtonSize
 
-		/**
-		 * Specifies the button native type
-		 * If left empty, the default "button" type will be used.
-		 *
-		 * @default 'button'
-		 * @type {'submit' | 'reset' | 'button'}
-		 */
-		nativeType: {
-			type: String as PropType<ButtonNativeType>,
-			default: ButtonNativeType.Button,
-			validator(value: string) {
-				return Object.values(ButtonNativeType).includes(value as ButtonNativeType)
-			},
-		},
+	/**
+	 * The main button text.
+	 * This can be overwritten by using the *default* slot.
+	 * @since 9.0.0
+	 */
+	text?: string
 
-		/**
-		 * Specifies whether the button should span all the available width.
-		 * By default, buttons span the whole width of the container.
-		 */
-		wide: {
-			type: Boolean,
-			default: false,
-		},
+	/**
+	 * Specifies the button variant.
+	 * If left empty, the default button style will be applied.
+	 *
+	 * @default 'secondary'
+	 */
+	variant?: ButtonVariant
 
-		/**
-		 * Always try to provide an aria-label to your button. Make it more
-		 * specific than the button's name by provide some more context. E.g. if
-		 * the name of the button is "send" in the Mail app, the aria label could
-		 * be "Send email".
-		 */
-		ariaLabel: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies the button native type
+	 * If left empty, the default "button" type will be used.
+	 *
+	 * @default 'button'
+	 */
+	type?: ButtonType
 
-		/**
-		 * Providing the href attribute turns the button component into an `a`
-		 * element.
-		 */
-		href: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Specifies whether the button should span all the available width.
+	 * By default, buttons span the whole width of the container.
+	 * @default false
+	 */
+	wide?: boolean
 
-		/**
-		 * Target for the `a` element if `href` is set.
-		 * @default '_self'
-		 */
-		target: {
-			type: String,
-			default: '_self',
-		},
+	/**
+	 * Always try to provide an aria-label to your button.
+	 *
+	 * Make it more specific than the button's name by provide some more context.
+	 * E.g. if the name of the button is "send" in the Mail app,
+	 * the aria label could be "Send email".
+	 */
+	ariaLabel?: string
 
-		/**
-		 * Providing the download attribute with href downloads file when clicking.
-		 */
-		download: {
-			type: String,
-			default: null,
-		},
+	/**
+	 * Providing the href attribute turns the button component into an `a` element.
+	 */
+	href?: string
 
-		/**
-		 * Providing the to attribute turns the button component into a `router-link`
-		 * element. Takes precedence over the href attribute.
-		 */
-		to: {
-			type: [String, Object],
-			default: null,
-		},
+	/**
+	 * Target for the `a` element if `href` is set.
+	 * @default '_self'
+	 */
+	target?: string
 
-		/**
-		 * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components.
-		 * @todo Add a check in @nextcloud/vue 9 that this prop is not provided,
-		 * otherwise root element will inherit incorrect aria-hidden.
-		 */
-		ariaHidden: {
-			type: Boolean,
-			default: null,
-		},
+	/**
+	 * When `href` is set this will make the browser try to download the target.
+	 * If a string is passed the browser will use it as the filename.
+	 *
+	 * Note that the browser might adjust it for allowed characters (e.g. '/' or '\').
+	 * Also this only works with same-origin URLs and `blob:` or `data:` schemas.
+	 * Moreover a `Content-Disposition` header set by the server will override the filename.
+	 */
+	download?: string|true
 
-		/**
-		 * The pressed state of the button if it has a checked state
-		 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
-		 *
-		 * Pressed state is not supported for links
-		 */
-		pressed: {
-			type: Boolean,
-			default: null,
-		},
-	},
+	/**
+	 * The pressed state of the button if it has a checked state.
+	 * This will add the `aria-pressed` attribute and for the button to have the primary style in checked state.
+	 *
+	 * Note: Pressed state is not supported for links.
+	 */
+	pressed?: boolean
 
-	emits: ['update:pressed', 'click'],
+	/**
+	 * Providing the to attribute turns the button component into a `router-link` element.
+	 *
+	 * Note: This takes precedence over the href attribute.
+	 */
+	to?: string|RouteLocation
+}
 
-	computed: {
-		/**
-		 * The real type to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
-		 * Otherwise the type property is used.
-		 */
-		realType() {
-			// Force *primary* when pressed
-			if (this.pressed) {
-				return 'primary'
-			}
-			// If not pressed but button is configured as stateful button then the type must not be primary
-			if (this.pressed === false && this.type === 'primary') {
-				return 'secondary'
-			}
-			return this.type
-		},
+const props = withDefaults(defineProps<NcButtonProps>(), {
+	ariaLabel: undefined,
+	alignment: 'center',
+	download: undefined,
+	href: undefined,
+	pressed: undefined,
+	size: 'normal',
+	target: '_self',
+	text: undefined,
+	to: undefined,
+	type: 'button',
+	variant: 'secondary',
+})
 
-		/**
-		 * The flexbox alignment of the button content
-		 */
-		flexAlignment() {
-			return this.alignment.split('-')[0]
-		},
+const emit = defineEmits<{
+	/**
+	 * Emitted when the button was clicked.
+	 */
+	 click: [e: MouseEvent]
 
-		/**
-		 * If the button content should be reversed (icon on the end)
-		 */
-		isReverseAligned() {
-			return this.alignment.includes('-')
-		},
+	/**
+	 * Update the current pressed state of the button (if the `pressed` property was configured).
+	 * If the button is a toggle button (`pressed` state is boolean),
+	 * then this will be emitted if the user toggled the state.
+	 */
+	'update:pressed': [pressed: boolean]
+}>()
 
-		ncPopoverTriggerAttrs() {
-			return (this.getNcPopoverTriggerAttrs as () => Record<string, string|undefined>)()
-		},
-	},
+const slots = defineSlots<{
+	/**
+	 * Custom button content.
+	 * This can be used for custom formatted content, ensure to not include any interactive elements.
+	 * For plain text it is preferred to use the `text` prop instead.
+	 */
+	default?: Slot
 
 	/**
-	 * The render function to display the component
-	 *
-	 * @return {object|undefined} The created VNode
+	 * Icon (optional) to show within the button
 	 */
-	render() {
-		const hasText = isSlotPopulated(this.$slots.default?.())
-		const hasIcon = isSlotPopulated(this.$slots.icon?.())
-
-		/**
-		 * Always fill either the text prop or the ariaLabel one.
-		 */
-		if (!hasText && !this.ariaLabel) {
-			console.warn('You need to fill either the text or the ariaLabel props in the button component.', {
-				text: this.$slots.default?.()?.[0]?.children,
-				ariaLabel: this.ariaLabel,
-			},
-			this)
-		}
+	icon?: Slot
+}>()
+
+// Make sure the component also works if the app does not use any router
+// And if the app uses a router we need to make sure a `to` prop was passed to use to router
+const routerLink = computed(() => (
+	inject(routerKey, null) !== null && props.to
+		? useLink({ to: toRef(() => props.to as Required<typeof props.to>) })
+		: undefined
+))
+
+/**
+ * If this is a link (<a>) element
+ */
+const isLink = computed(() => props.href)
+
+/**
+ * If the button has a pressed state (only if not a link)
+ */
+const hasPressedState = computed(() => !isLink.value && typeof props.pressed === 'boolean')
+
+/**
+ * The real variant to be used for the button, enforces `primary` for pressed state and, if stateful button, any other type for not pressed state
+ * Otherwise the type property is used.
+ */
+const realVariant = computed(() => {
+	// Force *primary* when pressed
+	if (props.pressed) {
+		return 'primary'
+	}
+	// If not pressed but button is configured as stateful button then the type must not be primary
+	if (props.pressed === false && props.variant === 'primary') {
+		return 'secondary'
+	}
+	return props.variant
+})
 
-		const isLink = (this.to || this.href)
+/**
+ * The flexbox alignment of the button content
+ */
+const flexAlignment = computed(() => props.alignment.split('-')[0])
+
+/**
+ * If the button content should be reversed (icon on the end)
+ */
+const isReverseAligned = computed(() => props.alignment.includes('-'))
+
+const getNcPopoverTriggerAttrs = inject<() => Record<string, string|undefined>>('NcPopover:trigger:attrs', () => ({}), false)
+const ncPopoverTriggerAttrs = computed(() => getNcPopoverTriggerAttrs())
+
+/**
+ * Attributes to pass to the <a> element
+ */
+const linkAttrs = computed(() => ({
+	role: 'button',
+	href: props.href || '#',
+	target: props.target,
+	rel: 'nofollow noreferrer noopener',
+	download: props.download || null,
+}))
+
+/**
+ * Attribute to pass to the <button> element
+ */
+const buttonAttrs = computed(() => ({
+	'aria-pressed': hasPressedState.value ? String(props.pressed) : undefined,
+	type: props.type,
+}))
+
+/**
+ * Check if the button has a text content (function because slots are not reactive)
+ */
+function hasText(): boolean {
+	return Boolean(props.text) || isSlotPopulated(slots.default?.({}))
+}
 
-		const hasPressed = !isLink && typeof this.pressed === 'boolean'
+/**
+ * Check if the button has an icon (function because slots are not reactive)
+ */
+function hasIcon(): boolean {
+	return !!slots.icon
+}
 
-		const renderButton = ({ href, navigate, isActive }: {href?: string, navigate?: (ev: Event) => void, isActive?: boolean } = {}) => h(isLink ? 'a' : 'button',
+/**
+ * Handle the click on the link / button
+ * @param event The mouse click event
+ */
+function onClick(event: MouseEvent) {
+	if (hasPressedState.value) {
+		emit('update:pressed', !props.pressed)
+	}
+	// We have to both navigate and emit the click event
+	emit('click', event)
+	routerLink.value?.navigate(event)
+}
+</script>
+
+<template>
+	<component :is="isLink ? 'a' : 'button'"
+		class="button-vue"
+		:class="[
+			`button-vue--size-${size}`,
 			{
-				class: [
-					'button-vue',
-					`button-vue--size-${this.size}`,
-					{
-						'button-vue--icon-only': hasIcon && !hasText,
-						'button-vue--text-only': hasText && !hasIcon,
-						'button-vue--icon-and-text': hasIcon && hasText,
-						[`button-vue--vue-${this.realType}`]: this.realType,
-						'button-vue--wide': this.wide,
-						[`button-vue--${this.flexAlignment}`]: this.flexAlignment !== 'center',
-						'button-vue--reverse': this.isReverseAligned,
-						active: isActive,
-					},
-				],
-				'aria-label': this.ariaLabel,
-				'aria-pressed': hasPressed ? this.pressed.toString() : undefined,
-				disabled: this.disabled,
-				type: isLink ? null : this.nativeType,
-				role: isLink ? 'button' : null,
-				href: this.to ? href : (this.href || null),
-				target: isLink ? this.target : null,
-				rel: isLink ? 'nofollow noreferrer noopener' : null,
-				download: (!this.to && this.href && this.download) ? this.download : null,
-				// If this button is used as a popover trigger, we need to apply trigger attrs, e.g. aria attributes
-				...this.ncPopoverTriggerAttrs,
-				onClick: ($event) => {
-					// Update pressed prop on click if it is set
-					if (hasPressed) {
-						/**
-						 * Update the current pressed state of the button (if the `pressed` property was configured)
-						 *
-						 * @property {boolean} newValue The new `pressed`-state
-						 */
-						this.$emit('update:pressed', !this.pressed)
-					}
-					// We have to both navigate and emit the click event
-					this.$emit('click', $event)
-					navigate?.($event)
-				},
+				[`button-vue--${realVariant}`]: realVariant,
+				'button-vue--wide': wide,
+				[`button-vue--${flexAlignment}`]: flexAlignment !== 'center',
+				'button-vue--reverse': isReverseAligned,
+				active: routerLink?.isActive,
 			},
-			[
-				h('span', { class: 'button-vue__wrapper' }, [
-					hasIcon
-						? h('span', {
-							class: 'button-vue__icon',
-							'aria-hidden': 'true',
-						},
-						[this.$slots.icon?.()],
-						)
-						: null,
-					hasText ? h('span', { class: 'button-vue__text' }, [this.$slots.default?.()]) : null,
-				]),
-			],
-		)
-
-		// If we have a router-link, we wrap the button in it
-		if (this.to) {
-			return h(resolveComponent('router-link'), {
-				custom: true,
-				to: this.to,
-			}, {
-				default: renderButton,
-			})
-		}
-		// Otherwise we simply return the button
-		return renderButton()
-	},
-})
-</script>
+		]"
+		:disabled
+		:aria-label
+		v-bind="{
+			...ncPopoverTriggerAttrs,
+			...(isLink ? linkAttrs : buttonAttrs),
+		}"
+		@click="onClick">
+		<span class="button-vue__wrapper">
+			<span v-if="hasIcon()" class="button-vue__icon">
+				<slot name="icon" />
+			</span>
+			<span v-if="hasText()" class="button-vue__text">
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: see fixup commit to make it css only
created_at: 2025-04-03T21:03:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,7 @@
-/**
+/*!
path: src/components/NcButton/index.ts
user: login: st3iny
body: Typo?
created_at: 2025-04-16T08:16:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,46 +35,46 @@ It can be used with one or multiple actions.
 		<p>Primary</p>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="tertiary-no-background">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
path: src/components/NcButton/NcButton.vue
user: login: st3iny
body: TIL, that you can shorthand those. Is this only available in Vue 3?
created_at: 2025-04-16T08:17:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,6 +7,10 @@
     "allowJs": true,
     "allowImportingTsExtensions": true,
     "allowSyntheticDefaultImports": true,
+    "lib": [
+			"DOM",
+			"ESNext",
+		],
path: tsconfig.json
user: login: st3iny
body: ```suggestion
      "DOM",
      "ESNext",
    ],
```
created_at: 2025-04-16T08:20:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,46 +35,46 @@ It can be used with one or multiple actions.
 		<p>Primary</p>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="tertiary-no-background">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
path: src/components/NcButton/NcButton.vue
user: login: susnux
body: yes thats Vue3 only. Its basically the same as native HTML where you can set e.g. `<input disabled>`.

created_at: 2025-04-16T08:35:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,6 +7,10 @@
     "allowJs": true,
     "allowImportingTsExtensions": true,
     "allowSyntheticDefaultImports": true,
+    "lib": [
+			"DOM",
+			"ESNext",
+		],
path: tsconfig.json
user: login: susnux
body: thank you - fixed!
created_at: 2025-04-16T08:44:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,7 @@
-/**
+/*!
path: src/components/NcButton/index.ts
user: login: susnux
body: No `/**` is interpreted as documentation but this is legal information which has somewhat "standard" `/*!`.
The benefit of doing so: Some toolings will recognize the comment block as license. AND it will not be shown in docs (e.g. when using Typedoc / JSDoc).
created_at: 2025-04-16T08:45:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,46 +35,46 @@ It can be used with one or multiple actions.
 		<p>Primary</p>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
 			variant="tertiary-no-background">
 			<template v-if="style.indexOf('icon') !== -1" #icon>
 				<Video
 					:size="20" />
 			</template>
-			<template v-if="style.indexOf('text') !== -1">Example text</template>
 		</NcButton>
 		<NcButton
 			aria-label="Example text"
-			:disabled="disabled"
-			:size="size"
+			:disabled
+			:size
+			:text
path: src/components/NcButton/NcButton.vue
user: login: ShGKme
body: More specifically, Vue 3.5
But it isn't like `<input disabled>` (boolean prop shortcut for `<input :disabled="true">` from Vue 2, but a JS like shortcut, like `{ disabled: disabled }` for `{ disabled }`
created_at: 2025-04-16T11:29:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: ShGKme
created_at: 2025-04-01T19:31:48Z
body: With a force push instead of a `fixup!` it's hard to check what was changed since the last review, requiring a complete review again 👀 
author_association: CONTRIBUTOR
user: login: susnux
created_at: 2025-04-01T20:54:41Z
body: > With a force push instead of a `fixup!` it's hard to check what was changed since the last review, requiring a complete review again 👀

Sorry :see_no_evil: 
Mostly only rebased.

Nevertheless this PR needs this backport first: https://github.com/nextcloud-libraries/nextcloud-vue/pull/6595
author_association: CONTRIBUTOR
user: login: susnux
created_at: 2025-04-02T11:05:18Z
body: Rebased to fix merge conflicts - no code changes.
author_association: CONTRIBUTOR