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
21 changes: 20 additions & 1 deletion docs/components/button.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Button

The `bq-button` component is a versatile, accessible button element supporting multiple variants, sizes, loading states, and icon slots.
The `bq-button` component is a versatile, accessible button element supporting multiple variants, sizes, loading states, accessible naming overrides, and icon slots.

## Basic Usage

Expand All @@ -27,6 +27,8 @@ The `bq-button` component is a versatile, accessible button element supporting m
<bq-button loading>Loading…</bq-button>
```

When `loading` is enabled, the inner control exposes `aria-busy="true"` and adds a localized screen-reader-only loading announcement.

## Disabled State

```html
Expand All @@ -41,6 +43,8 @@ The `bq-button` component is a versatile, accessible button element supporting m
</bq-button>
```

Links opened in a new tab automatically receive `rel="noopener noreferrer"`.

## With Icons (Slots)

```html
Expand All @@ -55,6 +59,14 @@ The `bq-button` component is a versatile, accessible button element supporting m
</bq-button>
```

If you need an icon-only action, prefer [`bq-icon-button`](/components/icon-button). If you intentionally render `bq-button` without visible text, provide a `label` so assistive technologies still announce a meaningful name.

```html
<bq-button label="Refresh results">
<span slot="prefix-icon" aria-hidden="true">↻</span>
</bq-button>
```

## Properties

| Property | Type | Default | Description |
Expand All @@ -66,6 +78,7 @@ The `bq-button` component is a versatile, accessible button element supporting m
| `type` | `button \| submit \| reset` | `button` | Form submission type |
| `href` | `string` | — | Renders as `<a>` when set |
| `target` | `string` | — | Link target (used with `href`) |
| `label` | `string` | — | Optional accessible label override, especially useful for icon-only usage |

## Events

Expand All @@ -86,3 +99,9 @@ The `bq-button` component is a versatile, accessible button element supporting m
| Part | Description |
|------|-------------|
| `button` | The inner `<button>` or `<a>` element |

## Accessibility Notes

- Use the default slot for the visible button text whenever possible.
- Provide `label` when the control has no visible text content.
- Loading buttons keep their current content visible, set `aria-busy`, and expose a localized loading announcement for assistive technologies.
80 changes: 80 additions & 0 deletions docs/components/icon-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Icon Button

Use `bq-icon-button` for compact icon-only actions such as refresh, close, open-menu, favorite, or toolbar controls.

## Basic Usage

```html
<bq-icon-button label="Open filters">
<span aria-hidden="true">☰</span>
</bq-icon-button>
```

## Title Fallback

If you already need a tooltip-like title, you can use `title` as the accessible-name fallback when `label` is omitted. If both are provided, `label` remains the primary accessible name.

```html
<bq-icon-button title="Refresh results">
<span aria-hidden="true">↻</span>
</bq-icon-button>
```

## Loading State

```html
<bq-icon-button label="Sync data" loading>
<span aria-hidden="true">⟳</span>
</bq-icon-button>
```

Loading icon buttons expose `aria-busy="true"` and attach a localized status description without changing the control's accessible name.

## As a Link

```html
<bq-icon-button
href="https://example.com/settings"
label="Open settings"
title="Settings"
>
<span aria-hidden="true">⚙</span>
</bq-icon-button>
```

## Properties

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `variant` | `primary \| secondary \| outline \| ghost \| danger` | `ghost` | Visual style |
| `size` | `sm \| md \| lg` | `md` | Button size |
| `disabled` | `boolean` | `false` | Disables interaction |
| `loading` | `boolean` | `false` | Shows spinner and disables interaction |
| `label` | `string` | — | Preferred accessible label for the control |
| `title` | `string` | — | Optional tooltip and accessible-name fallback |
| `href` | `string` | — | Renders as `<a>` when set |

## Events

| Event | Detail | Description |
|-------|--------|-------------|
| `bq-click` | `{ originalEvent: MouseEvent }` | Fired on click when not disabled/loading |

## Slots

| Slot | Description |
|------|-------------|
| *(default)* | The icon content |

## CSS Parts

| Part | Description |
|------|-------------|
| `button` | The inner `<button>` or `<a>` element |

## Accessibility Notes

- 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.
6 changes: 3 additions & 3 deletions docs/components/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Across the component library you will find the same professional capabilities th

| Component | Use it for | Highlights |
| --- | --- | --- |
| `bq-button` | Primary and secondary actions | Variants, sizes, loading state, link rendering, icon slots |
| `bq-icon-button` | Compact icon-only actions | Accessible label, multiple variants, loading state, link rendering |
| `bq-button` | Primary and secondary actions | Variants, sizes, loading state, link rendering, icon slots, accessible label override |
| `bq-icon-button` | Compact icon-only actions | Accessible label or title fallback, multiple variants, loading state, link rendering |

## Forms

Expand Down Expand Up @@ -103,4 +103,4 @@ If you are evaluating the library for a product team, start with:
3. [Theming](/guide/theming)
4. [Accessibility](/guide/accessibility)

For component-level API examples, see the dedicated references for [Button](/components/button), [Input](/components/input), and [Card](/components/card).
For component-level API examples, see the dedicated references for [Button](/components/button), [Icon Button](/components/icon-button), [Input](/components/input), and [Card](/components/card).
52 changes: 39 additions & 13 deletions src/components/button/BqButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @prop {string} type - button | submit | reset
* @prop {string} href
* @prop {string} target
* @prop {string} label - Optional accessible label override
* @slot - Button content
* @slot prefix-icon - Icon before content
* @slot suffix-icon - Icon after content
Expand All @@ -16,6 +17,8 @@
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 = {
Expand All @@ -26,9 +29,11 @@ type BqButtonProps = {
type: string;
href: string;
target: string;
label: string;
};
type BqButtonState = { statusId: string };

const definition: ComponentDefinition<BqButtonProps> = {
const definition: ComponentDefinition<BqButtonProps, BqButtonState> = {
props: {
variant: { type: String, default: 'primary' },
size: { type: String, default: 'md' },
Expand All @@ -37,6 +42,10 @@ const definition: ComponentDefinition<BqButtonProps> = {
type: { type: String, default: 'button' },
href: { type: String, default: '' },
target: { type: String, default: '' },
label: { type: String, default: '' },
},
state: {
statusId: '',
},
styles: `
${getBaseStyles()}
Expand Down Expand Up @@ -65,25 +74,31 @@ const definition: ComponentDefinition<BqButtonProps> = {
.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); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
/* Loading spinner */
.spinner { width: 1em; height: 1em; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.btn, .spinner { transition: none; animation: none; }
}
`,
connected() {
const self = this;
type BqButtonElement = HTMLElement & { setState(k: 'statusId', v: string): void; getState<T>(k: string): T };
const self = this as unknown as BqButtonElement;
if (!self.getState<string>('statusId')) self.setState('statusId', uniqueId('bq-button-status'));
const handler = (e: Event) => {
if (self.hasAttribute('disabled') || self.hasAttribute('loading')) {
e.preventDefault(); e.stopPropagation(); return;
Expand All @@ -98,29 +113,40 @@ const definition: ComponentDefinition<BqButtonProps> = {
const handler = (self as unknown as Record<string, unknown>)['_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';
const normalizedTarget = props.target.trim();
const safeRel = normalizedTarget.toLowerCase() === '_blank' ? 'noopener noreferrer' : '';
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 && normalizedTarget ? `target="${escapeHtml(normalizedTarget)}"` : ''}
${isLink && safeRel ? `rel="${escapeHtml(safeRel)}"` : ''}
${!isLink && disabled ? 'disabled' : ''}
${disabled ? 'aria-disabled="true"' : ''}
${isLink && disabled ? 'tabindex="-1"' : ''}
${props.loading ? 'aria-busy="true"' : ''}
${tag === 'a' ? 'role="button"' : ''}
${props.loading ? `aria-describedby="${escapeHtml(statusId)}"` : ''}
${accessibleLabel ? `aria-label="${escapeHtml(accessibleLabel)}"` : ''}
>
<slot name="prefix-icon"></slot>
${props.loading ? '<span class="spinner" aria-hidden="true"></span>' : ''}
<slot></slot>
<slot name="suffix-icon"></slot>
</${tag}>
${props.loading ? `<span class="sr-only" id="${escapeHtml(statusId)}" role="status" aria-live="polite">${escapeHtml(loadingLabel)}</span>` : ''}
`;
},
};

component<BqButtonProps>('bq-button', definition);
component<BqButtonProps, BqButtonState>('bq-button', definition);
11 changes: 7 additions & 4 deletions src/components/empty-state/BqEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 { getBaseStyles } from '../../utils/styles.js';

type BqEmptyStateProps = { title: string; description: string; icon: string };
Expand All @@ -34,12 +35,14 @@ const definition: ComponentDefinition<BqEmptyStateProps> = {
.actions { display: flex; gap: var(--bq-space-3,0.75rem); flex-wrap: wrap; justify-content: center; }
`,
render({ props }) {
const title = props.title.trim() || t('emptyState.title');
const description = props.description.trim() || t('emptyState.description');
return html`
<div part="empty-state" class="empty">
<div part="empty-state" class="empty" role="status" aria-live="polite">
${props.icon ? `<span class="icon" part="icon" aria-hidden="true">${escapeHtml(props.icon)}</span>` : ''}
${props.title ? `<h3 class="title" part="title">${escapeHtml(props.title)}</h3>` : ''}
${props.description ? `<p class="description" part="description">${escapeHtml(props.description)}</p>` : ''}
<div class="actions"><slot></slot></div>
${title ? `<h3 class="title" part="title">${escapeHtml(title)}</h3>` : ''}
${description ? `<p class="description" part="description">${escapeHtml(description)}</p>` : ''}
<div class="actions" part="actions"><slot></slot></div>
</div>
`;
},
Expand Down
Loading
Loading