Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/components/radio.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ The component creates a hidden input proxy for native `<form>` submission. The p

- Each radio renders a native `<input type="radio">` inside the shadow DOM for full keyboard support.
- The label is associated with the input via a wrapping `<label>` element.
- Use the `required` prop to mark mandatory radio fields; the required indicator is rendered visually and via `aria-required`.
- Use the `required` prop to mark mandatory radio fields; the required indicator is rendered visually and conveyed through the native `required` attribute on the internal radio input.
7 changes: 7 additions & 0 deletions src/components/slider/BqSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ type BqSliderProps = {
'show-value': boolean;
};

function syncInputAccessibility(input: HTMLInputElement, value: number): void {
input.setAttribute('aria-valuenow', String(value));
input.setAttribute('aria-valuetext', t('slider.valueText', { value }));
}

const definition: ComponentDefinition<BqSliderProps> = {
props: {
value: { type: Number, default: 50 },
Expand Down Expand Up @@ -69,6 +74,7 @@ const definition: ComponentDefinition<BqSliderProps> = {
const v = Number(input.value);
// Update form proxy and value display directly without re-rendering (prevents jank during drag)
proxy.setValue(String(v));
syncInputAccessibility(input, v);
const valueSpan = self.shadowRoot?.querySelector('.value');
if (valueSpan) valueSpan.textContent = String(v);
self.dispatchEvent(
Expand All @@ -86,6 +92,7 @@ const definition: ComponentDefinition<BqSliderProps> = {
// Commit value to attribute on change (drag end) — triggers one clean re-render
self.setAttribute('value', String(v));
proxy.setValue(String(v));
syncInputAccessibility(input, v);
self.dispatchEvent(
new CustomEvent('bq-change', {
detail: { value: v },
Expand Down
58 changes: 41 additions & 17 deletions src/components/tabs/BqTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,19 @@ import { getBaseStyles } from '../../utils/styles.js';

type BqTabsProps = { 'active-tab': string; variant: string };

function getPanelId(tabId: string): string {
return `panel-${tabId}`;
function getAriaIdPart(tabId: string): string | null {
const trimmed = tabId.trim();
return trimmed ? encodeURIComponent(trimmed) : null;
}

function getTabButtonId(tabId: string): string | null {
const ariaIdPart = getAriaIdPart(tabId);
return ariaIdPart ? `tab-${ariaIdPart}` : null;
}

function getPanelId(tabId: string): string | null {
const ariaIdPart = getAriaIdPart(tabId);
return ariaIdPart ? `panel-${ariaIdPart}` : null;
}

const definition: ComponentDefinition<BqTabsProps> = {
Expand Down Expand Up @@ -155,19 +166,30 @@ const definition: ComponentDefinition<BqTabsProps> = {
const tabId = panel.getAttribute('data-tab') ?? '';
const isActive = tabId === active;
const panelId = panel.id || getPanelId(tabId);
const button = Array.from(
this.shadowRoot?.querySelectorAll<HTMLElement>('.tab[data-tab-id]') ?? []
).find((candidate) => candidate.getAttribute('data-tab-id') === tabId);
const labelledBy = button?.id || (button ? getTabButtonId(tabId) : null);

panel.id = panelId;
if (panelId) {
panel.id = panelId;
}
panel.hidden = !isActive;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('tabindex', isActive ? '0' : '-1');
panel.setAttribute('aria-labelledby', `tab-${tabId}`);
if (labelledBy) {
panel.setAttribute('aria-labelledby', labelledBy);
} else {
panel.removeAttribute('aria-labelledby');
}
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');

const button = Array.from(
this.shadowRoot?.querySelectorAll<HTMLElement>('.tab[data-tab-id]') ??
[]
).find((candidate) => candidate.getAttribute('data-tab-id') === tabId);
button?.setAttribute('aria-controls', panelId);
if (button) {
if (panelId) {
button.setAttribute('aria-controls', panelId);
} else {
button.removeAttribute('aria-controls');
}
}
});
},
render({ props, state }) {
Expand All @@ -177,17 +199,19 @@ const definition: ComponentDefinition<BqTabsProps> = {
| undefined) ?? [];
const active = props['active-tab'] || items[0]?.id || '';
const tabsHtml = items
.map(
(tab) => `
<button part="tab" class="tab" data-variant="${escapeHtml(props.variant)}"
role="tab" id="tab-${escapeHtml(tab.id)}" data-tab-id="${escapeHtml(tab.id)}"
.map((tab) => {
const buttonId = getTabButtonId(tab.id);
const panelId = getPanelId(tab.id);
return `
<button part="tab" class="tab" data-variant="${escapeHtml(props.variant)}"
role="tab" ${buttonId ? `id="${escapeHtml(buttonId)}"` : ''} data-tab-id="${escapeHtml(tab.id)}"
aria-selected="${tab.id === active ? 'true' : 'false'}"
aria-controls="${escapeHtml(getPanelId(tab.id))}"
${panelId ? `aria-controls="${escapeHtml(panelId)}"` : ''}
tabindex="${tab.id === active ? '0' : '-1'}"
${tab.disabled ? 'disabled aria-disabled="true"' : ''}
>${escapeHtml(tab.label)}</button>
`
)
`;
})
.join('');
return html`
<div class="tabs" part="tabs">
Expand Down
18 changes: 18 additions & 0 deletions tests/slider-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ describe('BqSlider', () => {
expect(input?.getAttribute('aria-valuetext')).toBe('Value: 50');
});

it('should update live value and ARIA state during input without re-rendering', () => {
const el = doc.createElement('bq-slider');
el.setAttribute('show-value', '');
el.setAttribute('value', '40');
doc.body.appendChild(el);

const input = el.shadowRoot?.querySelector('input') as HTMLInputElement;
const valueEl = el.shadowRoot?.querySelector('.value');

input.value = '65';
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));

expect(el.getAttribute('value')).toBe('40');
expect(valueEl?.textContent).toBe('65');
expect(input.getAttribute('aria-valuenow')).toBe('65');
expect(input.getAttribute('aria-valuetext')).toBe('Value: 65');
});

it('should create form proxy hidden input', () => {
const el = doc.createElement('bq-slider');
el.setAttribute('name', 'volume');
Expand Down
38 changes: 38 additions & 0 deletions tests/tabs-tooltip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,44 @@ describe('BqTabs', () => {
expect(panelTwo?.getAttribute('tabindex')).toBe('0');
expect(panelTwo?.getAttribute('aria-hidden')).toBe('false');
});

it('should avoid invalid generated aria ids when a tab item has no id', async () => {
const el = doc.createElement('bq-tabs');
el.innerHTML = `
<div data-tab-item label="One"></div>
<div data-tab="one">Panel one</div>
`;
doc.body.appendChild(el);

await waitForFrame();

const tab = el.shadowRoot?.querySelector('[role="tab"]');
const panel = el.querySelector('[data-tab="one"]');

expect(tab?.getAttribute('id')).toBeNull();
expect(tab?.getAttribute('aria-controls')).toBeNull();
expect(panel?.getAttribute('aria-labelledby')).toBeNull();
});

it('should generate encoded aria ids for tab values that include spaces', async () => {
const el = doc.createElement('bq-tabs');
el.setAttribute('active-tab', 'needs space');
el.innerHTML = `
<div data-tab-item id="needs space" label="Needs Space"></div>
<div data-tab="needs space">Panel</div>
`;
doc.body.appendChild(el);

await waitForFrame();

const tab = el.shadowRoot?.querySelector('[role="tab"]');
const panel = el.querySelector('[data-tab="needs space"]');

expect(tab?.getAttribute('id')).toBe('tab-needs%20space');
expect(tab?.getAttribute('aria-controls')).toBe('panel-needs%20space');
expect(panel?.id).toBe('panel-needs%20space');
expect(panel?.getAttribute('aria-labelledby')).toBe('tab-needs%20space');
});
});

describe('BqTooltip', () => {
Expand Down
Loading