+ const trimmedLabel = props.label.trim();
+ const label = trimmedLabel !== '' ? trimmedLabel : t('common.loading');
+ return html`
`;
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 33baf3b..ff8dea2 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -79,6 +79,9 @@ export const en = {
chip: {
remove: 'Remove',
},
+ iconButton: {
+ defaultLabel: 'Icon button',
+ },
} as const;
export type EnLocale = typeof en;
diff --git a/tests/icon-button.test.ts b/tests/icon-button.test.ts
index 95df9c0..71c17e4 100644
--- a/tests/icon-button.test.ts
+++ b/tests/icon-button.test.ts
@@ -33,6 +33,13 @@ describe('BqIconButton', () => {
expect(btn?.getAttribute('aria-label')).toBe('Refresh data');
});
+ it('should fall back to a localized default accessible name when no label is provided', () => {
+ const el = doc.createElement('bq-icon-button');
+ doc.body.appendChild(el);
+ const btn = el.shadowRoot?.querySelector('button');
+ expect(btn?.getAttribute('aria-label')).toBe('Icon button');
+ });
+
afterAll(() => {
doc.body.innerHTML = '';
});
From e47edf2914b1c21487b3d9d2572e752a90712f34 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 18:43:15 +0000
Subject: [PATCH 4/6] fix: refine button and icon-button link semantics
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
---
docs/components/icon-button.md | 4 +-
src/components/button/BqButton.ts | 28 ++++++++----
src/components/icon-button/BqIconButton.ts | 50 ++++++++++++++++------
tests/button.test.ts | 24 +++++++++++
tests/icon-button.test.ts | 23 ++++++++++
5 files changed, 107 insertions(+), 22 deletions(-)
diff --git a/docs/components/icon-button.md b/docs/components/icon-button.md
index 072137c..5a6beef 100644
--- a/docs/components/icon-button.md
+++ b/docs/components/icon-button.md
@@ -28,7 +28,7 @@ If you already need a tooltip-like title, you can use `title` as the accessible-
```
-Loading icon buttons expose `aria-busy="true"` and announce a localized loading message to assistive technologies.
+Loading icon buttons expose `aria-busy="true"` and attach a localized status description without changing the control's accessible name.
## As a Link
@@ -74,7 +74,7 @@ Loading icon buttons expose `aria-busy="true"` and announce a localized loading
## Accessibility Notes
-- Always provide `label` unless `title` already conveys the full action name.
+- Prefer `label` as the explicit accessible name. When `label` is omitted, `title` is used as the fallback name before the component falls back to a localized generic label.
- If neither `label` nor `title` is provided, the component falls back to a localized generic label so the control never becomes unnamed.
- Mark decorative icon content with `aria-hidden="true"` so screen readers announce only the control label.
- Prefer `bq-button` when the action should show visible text.
diff --git a/src/components/button/BqButton.ts b/src/components/button/BqButton.ts
index 6808845..d1883f7 100644
--- a/src/components/button/BqButton.ts
+++ b/src/components/button/BqButton.ts
@@ -18,6 +18,7 @@ import { component, html } from '@bquery/bquery/component';
import type { ComponentDefinition } from '@bquery/bquery/component';
import { escapeHtml } from '@bquery/bquery/security';
import { t } from '../../i18n/index.js';
+import { uniqueId } from '../../utils/dom.js';
import { getBaseStyles } from '../../utils/styles.js';
type BqButtonProps = {
@@ -30,8 +31,9 @@ type BqButtonProps = {
target: string;
label: string;
};
+type BqButtonState = { statusId: string };
-const definition: ComponentDefinition
= {
+const definition: ComponentDefinition = {
props: {
variant: { type: String, default: 'primary' },
size: { type: String, default: 'md' },
@@ -42,6 +44,9 @@ const definition: ComponentDefinition = {
target: { type: String, default: '' },
label: { type: String, default: '' },
},
+ state: {
+ statusId: '',
+ },
styles: `
${getBaseStyles()}
*, *::before, *::after { box-sizing: border-box; }
@@ -91,7 +96,9 @@ const definition: ComponentDefinition = {
}
`,
connected() {
- const self = this;
+ type BqButtonElement = HTMLElement & { setState(k: 'statusId', v: string): void; getState(k: string): T };
+ const self = this as unknown as BqButtonElement;
+ if (!self.getState('statusId')) self.setState('statusId', uniqueId('bq-button-status'));
const handler = (e: Event) => {
if (self.hasAttribute('disabled') || self.hasAttribute('loading')) {
e.preventDefault(); e.stopPropagation(); return;
@@ -106,32 +113,37 @@ const definition: ComponentDefinition = {
const handler = (self as unknown as Record)['_clickHandler'] as EventListener | undefined;
if (handler) self.shadowRoot?.removeEventListener('click', handler);
},
- render({ props }) {
+ render({ props, state }) {
const tag = props.href ? 'a' : 'button';
+ const isLink = tag === 'a';
const disabled = props.disabled || props.loading;
const accessibleLabel = props.label.trim();
const loadingLabel = t('common.loading');
+ const statusId = state.statusId || 'bq-button-status';
return html`
<${tag}
part="button"
class="btn"
data-variant="${escapeHtml(props.variant)}"
data-size="${escapeHtml(props.size)}"
- type="${tag === 'button' ? escapeHtml(props.type) : ''}"
+ ${!isLink ? `type="${escapeHtml(props.type)}"` : ''}
${props.href ? `href="${escapeHtml(props.href)}"` : ''}
${props.target ? `target="${escapeHtml(props.target)}"` : ''}
- ${disabled ? (props.disabled ? 'disabled aria-disabled="true"' : 'aria-disabled="true"') : ''}
+ ${!isLink && disabled ? 'disabled' : ''}
+ ${disabled ? 'aria-disabled="true"' : ''}
+ ${isLink && disabled ? 'tabindex="-1"' : ''}
${props.loading ? 'aria-busy="true"' : ''}
+ ${props.loading ? `aria-describedby="${escapeHtml(statusId)}"` : ''}
${accessibleLabel ? `aria-label="${escapeHtml(accessibleLabel)}"` : ''}
- ${tag === 'a' ? 'role="button"' : ''}
>
- ${props.loading ? `${escapeHtml(loadingLabel)}` : ''}
+ ${props.loading ? '' : ''}
${tag}>
+ ${props.loading ? `${escapeHtml(loadingLabel)}` : ''}
`;
},
};
-component('bq-button', definition);
+component('bq-button', definition);
diff --git a/src/components/icon-button/BqIconButton.ts b/src/components/icon-button/BqIconButton.ts
index 3dd780e..96000e8 100644
--- a/src/components/icon-button/BqIconButton.ts
+++ b/src/components/icon-button/BqIconButton.ts
@@ -5,8 +5,8 @@
* @prop {string} size - sm | md | lg
* @prop {boolean} disabled
* @prop {boolean} loading
- * @prop {string} label - Accessible label (required)
- * @prop {string} title - Optional tooltip and label fallback
+ * @prop {string} label - Preferred accessible label
+ * @prop {string} title - Optional tooltip and accessible-name fallback
* @prop {string} href
* @slot - Icon content
* @fires bq-click
@@ -15,14 +15,16 @@ import { component, html } from '@bquery/bquery/component';
import type { ComponentDefinition } from '@bquery/bquery/component';
import { escapeHtml } from '@bquery/bquery/security';
import { t } from '../../i18n/index.js';
+import { uniqueId } from '../../utils/dom.js';
import { getBaseStyles } from '../../utils/styles.js';
type BqIconButtonProps = {
variant: string; size: string; disabled: boolean; loading: boolean;
label: string; href: string; title: string;
};
+type BqIconButtonState = { statusId: string };
-const definition: ComponentDefinition = {
+const definition: ComponentDefinition = {
props: {
variant: { type: String, default: 'ghost' },
size: { type: String, default: 'md' },
@@ -32,6 +34,9 @@ const definition: ComponentDefinition = {
href: { type: String, default: '' },
title: { type: String, default: '' },
},
+ state: {
+ statusId: '',
+ },
styles: `
${getBaseStyles()}
*, *::before, *::after { box-sizing: border-box; }
@@ -64,7 +69,9 @@ const definition: ComponentDefinition = {
}
`,
connected() {
- const self = this;
+ type BqIconButtonElement = HTMLElement & { setState(k: 'statusId', v: string): void; getState(k: string): T };
+ const self = this as unknown as BqIconButtonElement;
+ if (!self.getState('statusId')) self.setState('statusId', uniqueId('bq-icon-button-status'));
const handler = (e: Event) => {
if (self.hasAttribute('disabled') || self.hasAttribute('loading')) { e.preventDefault(); e.stopPropagation(); return; }
self.dispatchEvent(new CustomEvent('bq-click', { detail: { originalEvent: e }, bubbles: true, composed: true }));
@@ -76,20 +83,39 @@ const definition: ComponentDefinition = {
const handler = (this as unknown as Record)['_clickHandler'] as EventListener | undefined;
if (handler) this.shadowRoot?.removeEventListener('click', handler);
},
- render({ props }) {
+ render({ props, state }) {
const tag = props.href ? 'a' : 'button';
+ const isLink = tag === 'a';
const disabled = props.disabled || props.loading;
const trimmedLabel = props.label.trim();
const trimmedTitle = props.title.trim();
const accessibleLabel = trimmedLabel || trimmedTitle || t('iconButton.defaultLabel');
const loadingLabel = t('common.loading');
- return html`<${tag} part="button" class="btn" data-variant="${escapeHtml(props.variant)}" data-size="${escapeHtml(props.size)}"
- aria-label="${escapeHtml(accessibleLabel)}" ${props.title ? `title="${escapeHtml(props.title)}"` : ''} type="${tag==='button'?'button':''}"
- ${props.href?`href="${escapeHtml(props.href)}"`:''} ${disabled?(props.disabled?'disabled aria-disabled="true"':'aria-disabled="true"'):''}
- ${props.loading?'aria-busy="true"':''} ${tag==='a'?'role="button"':''}
- >${props.loading ? `${escapeHtml(loadingLabel)}` : ''}
- ${tag}>`;
+ const statusId = state.statusId || 'bq-icon-button-status';
+ const statusMarkup = props.loading
+ ? `${escapeHtml(loadingLabel)}`
+ : '';
+ return html`
+ <${tag}
+ part="button"
+ class="btn"
+ data-variant="${escapeHtml(props.variant)}"
+ data-size="${escapeHtml(props.size)}"
+ aria-label="${escapeHtml(accessibleLabel)}"
+ ${trimmedTitle ? `title="${escapeHtml(trimmedTitle)}"` : ''}
+ ${!isLink ? 'type="button"' : ''}
+ ${props.href ? `href="${escapeHtml(props.href)}"` : ''}
+ ${!isLink && disabled ? 'disabled' : ''}
+ ${disabled ? 'aria-disabled="true"' : ''}
+ ${isLink && disabled ? 'tabindex="-1"' : ''}
+ ${props.loading ? `aria-busy="true" aria-describedby="${escapeHtml(statusId)}"` : ''}
+ >
+ ${props.loading ? '' : ''}
+
+ ${tag}>
+ ${statusMarkup}
+ `;
},
};
-component('bq-icon-button', definition);
+component('bq-icon-button', definition);
diff --git a/tests/button.test.ts b/tests/button.test.ts
index 04e6bf4..9c6a996 100644
--- a/tests/button.test.ts
+++ b/tests/button.test.ts
@@ -77,6 +77,30 @@ describe('BqButton', () => {
expect(anchor?.getAttribute('href')).toBe('https://example.com');
});
+ it('should not emit button-only attributes on anchor rendering', () => {
+ const el = doc.createElement('bq-button');
+ el.setAttribute('href', 'https://example.com');
+ el.setAttribute('disabled', '');
+ doc.body.appendChild(el);
+ const anchor = el.shadowRoot?.querySelector('a');
+ expect(anchor?.hasAttribute('type')).toBe(false);
+ expect(anchor?.hasAttribute('disabled')).toBe(false);
+ expect(anchor?.getAttribute('aria-disabled')).toBe('true');
+ expect(anchor?.getAttribute('tabindex')).toBe('-1');
+ });
+
+ it('should describe loading state without changing the button accessible name', () => {
+ const el = doc.createElement('bq-button');
+ el.setAttribute('label', 'Save changes');
+ el.setAttribute('loading', '');
+ doc.body.appendChild(el);
+ const btn = el.shadowRoot?.querySelector('button');
+ const status = el.shadowRoot?.querySelector('[role="status"]');
+ expect(btn?.getAttribute('aria-label')).toBe('Save changes');
+ expect(btn?.getAttribute('aria-describedby')).toBe(status?.id);
+ expect(status?.textContent).toBe('Loading');
+ });
+
it('should apply size class', () => {
const el = doc.createElement('bq-button');
el.setAttribute('size', 'lg');
diff --git a/tests/icon-button.test.ts b/tests/icon-button.test.ts
index 71c17e4..59bbe5f 100644
--- a/tests/icon-button.test.ts
+++ b/tests/icon-button.test.ts
@@ -33,6 +33,15 @@ describe('BqIconButton', () => {
expect(btn?.getAttribute('aria-label')).toBe('Refresh data');
});
+ it('should trim title before reflecting it as a tooltip fallback', () => {
+ const el = doc.createElement('bq-icon-button');
+ el.setAttribute('title', ' Refresh data ');
+ doc.body.appendChild(el);
+ const btn = el.shadowRoot?.querySelector('button');
+ expect(btn?.getAttribute('title')).toBe('Refresh data');
+ expect(btn?.getAttribute('aria-label')).toBe('Refresh data');
+ });
+
it('should fall back to a localized default accessible name when no label is provided', () => {
const el = doc.createElement('bq-icon-button');
doc.body.appendChild(el);
@@ -40,6 +49,20 @@ describe('BqIconButton', () => {
expect(btn?.getAttribute('aria-label')).toBe('Icon button');
});
+ it('should not emit button-only attributes on anchor rendering', () => {
+ const el = doc.createElement('bq-icon-button');
+ el.setAttribute('href', 'https://example.com');
+ el.setAttribute('loading', '');
+ doc.body.appendChild(el);
+ const anchor = el.shadowRoot?.querySelector('a');
+ const status = el.shadowRoot?.querySelector('[role="status"]');
+ expect(anchor?.hasAttribute('type')).toBe(false);
+ expect(anchor?.hasAttribute('disabled')).toBe(false);
+ expect(anchor?.getAttribute('aria-disabled')).toBe('true');
+ expect(anchor?.getAttribute('tabindex')).toBe('-1');
+ expect(anchor?.getAttribute('aria-describedby')).toBe(status?.id);
+ });
+
afterAll(() => {
doc.body.innerHTML = '';
});
From 0239136a1be7858acc1125a071469a1c61e7890f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 18:59:41 +0000
Subject: [PATCH 5/6] fix: harden anchor action semantics
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
---
docs/components/button.md | 2 ++
src/components/button/BqButton.ts | 12 +++++++-----
src/components/icon-button/BqIconButton.ts | 4 ++--
tests/button.test.ts | 16 ++++++++++++++++
tests/icon-button.test.ts | 7 +++++++
5 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/docs/components/button.md b/docs/components/button.md
index 68604ba..fe648eb 100644
--- a/docs/components/button.md
+++ b/docs/components/button.md
@@ -43,6 +43,8 @@ When `loading` is enabled, the inner control exposes `aria-busy="true"` and adds
```
+Links opened in a new tab automatically receive `rel="noopener noreferrer"`.
+
## With Icons (Slots)
```html
diff --git a/src/components/button/BqButton.ts b/src/components/button/BqButton.ts
index d1883f7..36f20a8 100644
--- a/src/components/button/BqButton.ts
+++ b/src/components/button/BqButton.ts
@@ -74,15 +74,15 @@ const definition: ComponentDefinition = {
.btn[data-size="xl"] { font-size: 1.25rem; padding: 0.75rem 1.5rem; min-height: 3.5rem; }
/* Variants */
.btn[data-variant="primary"] { background-color: var(--bq-color-primary-600,#2563eb); color: #fff; border-color: var(--bq-color-primary-600,#2563eb); }
- .btn[data-variant="primary"]:hover:not(:disabled) { background-color: var(--bq-color-primary-700,#1d4ed8); border-color: var(--bq-color-primary-700,#1d4ed8); }
+ .btn[data-variant="primary"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-primary-700,#1d4ed8); border-color: var(--bq-color-primary-700,#1d4ed8); }
.btn[data-variant="secondary"] { background-color: var(--bq-color-secondary-100,#f1f5f9); color: var(--bq-color-secondary-700,#334155); border-color: var(--bq-color-secondary-200,#e2e8f0); }
- .btn[data-variant="secondary"]:hover:not(:disabled) { background-color: var(--bq-color-secondary-200,#e2e8f0); }
+ .btn[data-variant="secondary"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-secondary-200,#e2e8f0); }
.btn[data-variant="outline"] { background-color: transparent; color: var(--bq-color-primary-600,#2563eb); border-color: var(--bq-color-primary-600,#2563eb); }
- .btn[data-variant="outline"]:hover:not(:disabled) { background-color: var(--bq-color-primary-50,#eff6ff); }
+ .btn[data-variant="outline"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-primary-50,#eff6ff); }
.btn[data-variant="ghost"] { background-color: transparent; color: var(--bq-color-secondary-700,#334155); border-color: transparent; }
- .btn[data-variant="ghost"]:hover:not(:disabled) { background-color: var(--bq-color-secondary-100,#f1f5f9); }
+ .btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-secondary-100,#f1f5f9); }
.btn[data-variant="danger"] { background-color: var(--bq-color-danger-600,#dc2626); color: #fff; border-color: var(--bq-color-danger-600,#dc2626); }
- .btn[data-variant="danger"]:hover:not(:disabled) { background-color: var(--bq-color-danger-700,#b91c1c); }
+ .btn[data-variant="danger"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-danger-700,#b91c1c); }
/* States */
.btn:focus-visible { outline: 2px solid transparent; box-shadow: var(--bq-focus-ring); }
.btn[data-variant="danger"]:focus-visible { box-shadow: var(--bq-focus-ring-danger); }
@@ -120,6 +120,7 @@ const definition: ComponentDefinition = {
const accessibleLabel = props.label.trim();
const loadingLabel = t('common.loading');
const statusId = state.statusId || 'bq-button-status';
+ const safeRel = props.target === '_blank' ? 'noopener noreferrer' : '';
return html`
<${tag}
part="button"
@@ -129,6 +130,7 @@ const definition: ComponentDefinition = {
${!isLink ? `type="${escapeHtml(props.type)}"` : ''}
${props.href ? `href="${escapeHtml(props.href)}"` : ''}
${props.target ? `target="${escapeHtml(props.target)}"` : ''}
+ ${isLink && safeRel ? `rel="${escapeHtml(safeRel)}"` : ''}
${!isLink && disabled ? 'disabled' : ''}
${disabled ? 'aria-disabled="true"' : ''}
${isLink && disabled ? 'tabindex="-1"' : ''}
diff --git a/src/components/icon-button/BqIconButton.ts b/src/components/icon-button/BqIconButton.ts
index 96000e8..20e9ad0 100644
--- a/src/components/icon-button/BqIconButton.ts
+++ b/src/components/icon-button/BqIconButton.ts
@@ -53,11 +53,11 @@ const definition: ComponentDefinition = {
.btn[data-size="md"] { width: 2.5rem; height: 2.5rem; font-size: 1.125rem; }
.btn[data-size="lg"] { width: 3rem; height: 3rem; font-size: 1.25rem; }
.btn[data-variant="primary"] { background-color: var(--bq-color-primary-600,#2563eb); color: #fff; border-color: var(--bq-color-primary-600,#2563eb); }
- .btn[data-variant="primary"]:hover:not(:disabled) { background-color: var(--bq-color-primary-700,#1d4ed8); }
+ .btn[data-variant="primary"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-primary-700,#1d4ed8); }
.btn[data-variant="secondary"] { background-color: var(--bq-color-secondary-100,#f1f5f9); color: var(--bq-color-secondary-700,#334155); border-color: var(--bq-color-secondary-200,#e2e8f0); }
.btn[data-variant="outline"] { background-color: transparent; color: var(--bq-color-primary-600,#2563eb); border-color: var(--bq-color-primary-600,#2563eb); }
.btn[data-variant="ghost"] { background-color: transparent; color: var(--bq-color-secondary-700,#334155); }
- .btn[data-variant="ghost"]:hover:not(:disabled) { background-color: var(--bq-color-secondary-100,#f1f5f9); }
+ .btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]) { background-color: var(--bq-color-secondary-100,#f1f5f9); }
.btn[data-variant="danger"] { background-color: var(--bq-color-danger-600,#dc2626); color: #fff; border-color: var(--bq-color-danger-600,#dc2626); }
.btn:focus-visible { outline: 2px solid transparent; box-shadow: var(--bq-focus-ring); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
diff --git a/tests/button.test.ts b/tests/button.test.ts
index 9c6a996..57d58cd 100644
--- a/tests/button.test.ts
+++ b/tests/button.test.ts
@@ -89,6 +89,15 @@ describe('BqButton', () => {
expect(anchor?.getAttribute('tabindex')).toBe('-1');
});
+ it('should default blank-target links to a safe rel value', () => {
+ const el = doc.createElement('bq-button');
+ el.setAttribute('href', 'https://example.com');
+ el.setAttribute('target', '_blank');
+ doc.body.appendChild(el);
+ const anchor = el.shadowRoot?.querySelector('a');
+ expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer');
+ });
+
it('should describe loading state without changing the button accessible name', () => {
const el = doc.createElement('bq-button');
el.setAttribute('label', 'Save changes');
@@ -119,6 +128,13 @@ describe('BqButton', () => {
expect(fired).toBe(true);
});
+ it('should prevent hover selectors from matching aria-disabled links', () => {
+ const el = doc.createElement('bq-button');
+ doc.body.appendChild(el);
+ const styles = el.shadowRoot?.querySelector('style')?.textContent ?? '';
+ expect(styles).toContain(':hover:not(:disabled):not([aria-disabled="true"])');
+ });
+
afterAll(() => {
doc.body.innerHTML = '';
});
diff --git a/tests/icon-button.test.ts b/tests/icon-button.test.ts
index 59bbe5f..1b598cc 100644
--- a/tests/icon-button.test.ts
+++ b/tests/icon-button.test.ts
@@ -63,6 +63,13 @@ describe('BqIconButton', () => {
expect(anchor?.getAttribute('aria-describedby')).toBe(status?.id);
});
+ it('should prevent hover selectors from matching aria-disabled links', () => {
+ const el = doc.createElement('bq-icon-button');
+ doc.body.appendChild(el);
+ const styles = el.shadowRoot?.querySelector('style')?.textContent ?? '';
+ expect(styles).toContain(':hover:not(:disabled):not([aria-disabled="true"])');
+ });
+
afterAll(() => {
doc.body.innerHTML = '';
});
From 9fa6158c5be454a1530bdfb6bb58c2ff4eb11e3d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 17 Mar 2026 19:26:05 +0000
Subject: [PATCH 6/6] fix: normalize button target handling
Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com>
---
src/components/button/BqButton.ts | 5 +++--
tests/button.test.ts | 19 +++++++++++++++++++
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/components/button/BqButton.ts b/src/components/button/BqButton.ts
index 36f20a8..c06994b 100644
--- a/src/components/button/BqButton.ts
+++ b/src/components/button/BqButton.ts
@@ -120,7 +120,8 @@ const definition: ComponentDefinition = {
const accessibleLabel = props.label.trim();
const loadingLabel = t('common.loading');
const statusId = state.statusId || 'bq-button-status';
- const safeRel = props.target === '_blank' ? 'noopener noreferrer' : '';
+ const normalizedTarget = props.target.trim();
+ const safeRel = normalizedTarget.toLowerCase() === '_blank' ? 'noopener noreferrer' : '';
return html`
<${tag}
part="button"
@@ -129,7 +130,7 @@ const definition: ComponentDefinition = {
data-size="${escapeHtml(props.size)}"
${!isLink ? `type="${escapeHtml(props.type)}"` : ''}
${props.href ? `href="${escapeHtml(props.href)}"` : ''}
- ${props.target ? `target="${escapeHtml(props.target)}"` : ''}
+ ${isLink && normalizedTarget ? `target="${escapeHtml(normalizedTarget)}"` : ''}
${isLink && safeRel ? `rel="${escapeHtml(safeRel)}"` : ''}
${!isLink && disabled ? 'disabled' : ''}
${disabled ? 'aria-disabled="true"' : ''}
diff --git a/tests/button.test.ts b/tests/button.test.ts
index 57d58cd..c6f3e3b 100644
--- a/tests/button.test.ts
+++ b/tests/button.test.ts
@@ -98,6 +98,25 @@ describe('BqButton', () => {
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer');
});
+ it('should normalize blank targets before applying the safe rel value', () => {
+ const el = doc.createElement('bq-button');
+ el.setAttribute('href', 'https://example.com');
+ el.setAttribute('target', ' _BlAnK ');
+ doc.body.appendChild(el);
+ const anchor = el.shadowRoot?.querySelector('a');
+ expect(anchor?.getAttribute('target')).toBe('_BlAnK');
+ expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer');
+ });
+
+ it('should not emit target on native button rendering', () => {
+ const el = doc.createElement('bq-button');
+ el.setAttribute('target', '_blank');
+ doc.body.appendChild(el);
+ const btn = el.shadowRoot?.querySelector('button');
+ expect(btn?.hasAttribute('target')).toBe(false);
+ expect(btn?.hasAttribute('rel')).toBe(false);
+ });
+
it('should describe loading state without changing the button accessible name', () => {
const el = doc.createElement('bq-button');
el.setAttribute('label', 'Save changes');