Skip to content

Commit a14accf

Browse files
committed
feat(pagination): add first/last buttons and update properties
- Introduced `show-first-last` property to `bq-pagination` for rendering boundary jump buttons. - Updated documentation to reflect new property and examples. - Enhanced pagination component to conditionally display first and last page buttons. feat(toast): enhance showToast API with position options - Added `position` option to `showToast` for customizable toast anchoring. - Updated documentation to include new position options and their descriptions. fix(breadcrumbs): improve accessibility and CSS handling - Updated breadcrumbs to use CSS pseudo-elements for separators, improving accessibility. - Adjusted aria attributes for better screen reader support. fix(checkbox, switch): standardize event detail structure - Changed `bq-change` event detail to include `value` first, followed by `checked` for consistency across components. fix(form proxy): implement live value retrieval for form elements - Enhanced form proxy to support live value updates during form submission, ensuring accurate data capture. test(pagination): add tests for first/last button functionality - Implemented tests to verify rendering and disabling of first/last buttons in pagination component.
1 parent de95b7e commit a14accf

21 files changed

Lines changed: 627 additions & 292 deletions

File tree

bun.lock

Lines changed: 79 additions & 183 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/components/breadcrumbs.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ The `bq-breadcrumbs` component renders a navigation trail with auto-inserted sep
4141
| `nav` | The `<nav>` wrapper |
4242
| `list` | The breadcrumb list container |
4343

44+
## CSS Custom Properties
45+
46+
| Property | Default | Description |
47+
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
48+
| `--bq-breadcrumb-separator` | `"/"` | CSS `content` value used between items. Override directly when not setting the `separator` attribute. |
49+
50+
```css
51+
bq-breadcrumbs {
52+
--bq-breadcrumb-separator: "";
53+
}
54+
```
55+
4456
## Accessibility
4557

46-
The last breadcrumb item is automatically marked with `aria-current="page"`. The component renders inside a `<nav>` with an appropriate `aria-label`.
58+
The last breadcrumb item is automatically marked with `aria-current="page"`. The component renders inside a `<nav>` with an appropriate `aria-label`. Separators are produced via a CSS `::before` pseudo-element so they are visible but **not part of the DOM** — keeping them out of copy/paste output and assistive-technology readings.

docs/components/pagination.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ The `bq-pagination` component provides page navigation with previous/next button
1414
<bq-pagination page="5" total="20" sibling-count="2"></bq-pagination>
1515
```
1616

17+
## With First / Last Buttons
18+
19+
Add boundary jump buttons (« and ») on either side of prev/next:
20+
21+
```html
22+
<bq-pagination page="5" total="20" show-first-last></bq-pagination>
23+
```
24+
1725
## Disabled
1826

1927
```html
@@ -22,12 +30,13 @@ The `bq-pagination` component provides page navigation with previous/next button
2230

2331
## Properties
2432

25-
| Property | Type | Default | Description |
26-
| --------------- | --------- | ------- | -------------------------------------------- |
27-
| `page` | `number` | `1` | Current page (1-based) |
28-
| `total` | `number` | `1` | Total number of pages |
29-
| `sibling-count` | `number` | `1` | Number of visible sibling pages on each side |
30-
| `disabled` | `boolean` | `false` | Disables all interactions |
33+
| Property | Type | Default | Description |
34+
| ----------------- | --------- | ------- | ---------------------------------------------- |
35+
| `page` | `number` | `1` | Current page (1-based) |
36+
| `total` | `number` | `1` | Total number of pages |
37+
| `sibling-count` | `number` | `1` | Number of visible sibling pages on each side |
38+
| `show-first-last` | `boolean` | `false` | Render boundary jump buttons (« first, » last) |
39+
| `disabled` | `boolean` | `false` | Disables all interactions |
3140

3241
## Events
3342

docs/components/toast.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,25 @@ showToast({
5656

5757
// Info toast
5858
showToast({ variant: 'info', message: 'Update available.' });
59+
60+
// Choose where the toast stack is anchored. Each position uses its own
61+
// container, lazily created on first use.
62+
showToast({
63+
variant: 'success',
64+
message: 'Saved!',
65+
position: 'top-center',
66+
});
5967
```
6068

69+
### `showToast(options)` reference
70+
71+
| Option | Type | Default | Description |
72+
| ---------- | ------------------------------------------------------------------------------------------------- | ------------- | --------------------------------- |
73+
| `message` | `string` || Toast body text |
74+
| `variant` | `'success' \| 'error' \| 'warning' \| 'info'` | `'info'` | Visual style |
75+
| `duration` | `number` | `4000` | Auto-dismiss delay (`0` = sticky) |
76+
| `position` | `'top-left' \| 'top-right' \| 'top-center' \| 'bottom-left' \| 'bottom-right' \| 'bottom-center'` | `'top-right'` | Anchor for the toast container |
77+
6178
## Properties
6279

6380
| Property | Type | Default | Description |

mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
bun = "1"

package.json

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bquery/ui",
3-
"version": "1.10.0",
3+
"version": "1.11.0",
44
"description": "Production-grade web component library for the bQuery project",
55
"type": "module",
66
"main": "./dist/index.cjs",
@@ -85,23 +85,23 @@
8585
"build:storybook": "storybook build --output-dir storybook-static"
8686
},
8787
"peerDependencies": {
88-
"@bquery/bquery": "^1.10.0"
88+
"@bquery/bquery": "^1.11.0"
8989
},
9090
"devDependencies": {
91-
"@bquery/bquery": "^1.10.0",
92-
"@storybook/addon-docs": "^10.3.4",
93-
"@storybook/web-components-vite": "^10.3.4",
94-
"@tailwindcss/postcss": "^4.2.2",
95-
"@types/node": "^25.5.2",
96-
"autoprefixer": "^10.4.27",
97-
"bun-types": "^1.3.11",
98-
"happy-dom": "^20.8.9",
99-
"postcss": "^8.5.8",
100-
"storybook": "^10.3.4",
101-
"tailwindcss": "^4.2.2",
102-
"typescript": "^5.9.3",
103-
"vite": "^8.0.3",
104-
"vite-plugin-dts": "^4.5.4",
91+
"@bquery/bquery": "^1.11.0",
92+
"@storybook/addon-docs": "^10.3.6",
93+
"@storybook/web-components-vite": "^10.3.6",
94+
"@tailwindcss/postcss": "^4.2.4",
95+
"@types/node": "^25.6.0",
96+
"autoprefixer": "^10.5.0",
97+
"bun-types": "^1.3.13",
98+
"happy-dom": "^20.9.0",
99+
"postcss": "^8.5.12",
100+
"storybook": "^10.3.6",
101+
"tailwindcss": "^4.2.4",
102+
"typescript": "^6.0.3",
103+
"vite": "^8.0.10",
104+
"vite-plugin-dts": "^5.0.0",
105105
"vitepress": "^1.6.4"
106106
}
107107
}

src/components/breadcrumbs/BqBreadcrumbs.ts

Lines changed: 35 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@
22
* Breadcrumbs navigation.
33
* @element bq-breadcrumbs
44
* @prop {string} separator - Separator character or text (default: /)
5-
* @slot - <a> or other link elements
5+
* @slot - <a> or other link elements; the last item is automatically marked as `aria-current="page"`
6+
*
7+
* @cssprop --bq-breadcrumb-separator - Override the separator string ("/" by default)
68
*/
79
import type { ComponentDefinition } from '@bquery/bquery/component';
810
import { component, html } from '@bquery/bquery/component';
911
import { t } from '../../i18n/index.js';
1012
import { getBaseStyles } from '../../utils/styles.js';
1113

1214
type BqBreadcrumbsProps = { separator: string };
13-
const SEPARATOR_ATTR = 'data-bq-breadcrumb-separator';
1415

15-
const isSeparatorElement = (element: Element): boolean =>
16-
element.hasAttribute(SEPARATOR_ATTR);
17-
18-
const getBreadcrumbSignature = (items: Element[], separator: string): string =>
19-
`${separator}::${items.map((item) => `${item.tagName}:${item.getAttribute('href') ?? ''}:${item.textContent?.trim() ?? ''}`).join('|')}`;
16+
/**
17+
* Escape a string for safe use as a CSS `content` value.
18+
* Wraps in quotes and backslash-escapes embedded backslashes / quotes.
19+
*/
20+
const cssQuote = (raw: string): string =>
21+
`"${raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
2022

2123
const definition: ComponentDefinition<BqBreadcrumbsProps> = {
2224
props: {
@@ -27,10 +29,18 @@ const definition: ComponentDefinition<BqBreadcrumbsProps> = {
2729
*, *::before, *::after { box-sizing: border-box; }
2830
:host { display: block; }
2931
.breadcrumbs { display: flex; align-items: center; flex-wrap: wrap; list-style: none; margin: 0; padding: 0; font-family: var(--bq-font-family-sans); font-size: var(--bq-font-size-sm,0.875rem); gap: 0; }
32+
::slotted(*) { display: inline-flex; align-items: center; }
33+
::slotted(*:not(:first-child))::before {
34+
content: var(--bq-breadcrumb-separator, "/");
35+
color: var(--bq-text-subtle,#94a3b8);
36+
margin: 0 var(--bq-space-2,0.5rem);
37+
user-select: none;
38+
pointer-events: none;
39+
speak: never;
40+
}
3041
::slotted(a) { color: var(--bq-text-muted,#475569); text-decoration: none; transition: color var(--bq-duration-fast); }
3142
::slotted(a:hover) { color: var(--bq-color-primary-600,#2563eb); text-decoration: underline; }
3243
::slotted([aria-current="page"]) { color: var(--bq-text-base,#0f172a); font-weight: var(--bq-font-weight-medium,500); pointer-events: none; }
33-
::slotted([data-bq-breadcrumb-separator]) { color: var(--bq-text-subtle,#94a3b8); margin: 0 var(--bq-space-2,0.5rem); user-select: none; }
3444
@media (prefers-reduced-motion: reduce) {
3545
::slotted(a) { transition: none; }
3646
}
@@ -42,49 +52,27 @@ const definition: ComponentDefinition<BqBreadcrumbsProps> = {
4252
'slot'
4353
) as HTMLSlotElement | null;
4454
if (!slot) return;
45-
const items = slot
46-
.assignedElements({ flatten: true })
47-
.filter((item) => !isSeparatorElement(item));
48-
const separatorText = self.getAttribute('separator') || '/';
49-
const state = self as unknown as Record<string, unknown>;
50-
if (state['_syncingSeparators']) return;
51-
const signature = getBreadcrumbSignature(items, separatorText);
52-
55+
const items = slot.assignedElements({ flatten: true });
5356
items.forEach((item, i) => {
5457
if (i === items.length - 1) item.setAttribute('aria-current', 'page');
5558
else item.removeAttribute('aria-current');
5659
});
57-
58-
const separatorCount = self.querySelectorAll<HTMLElement>(
59-
`[${SEPARATOR_ATTR}]`
60-
).length;
61-
if (
62-
state['_separatorSignature'] === signature &&
63-
separatorCount === Math.max(0, items.length - 1)
64-
) {
60+
};
61+
const applySeparatorVar = () => {
62+
// Only set an inline override when the host explicitly carries a
63+
// `separator` attribute. This keeps the `--bq-breadcrumb-separator`
64+
// CSS custom property externally overridable when the prop is omitted.
65+
const attr = self.getAttribute('separator');
66+
if (attr === null) {
67+
self.style.removeProperty('--bq-breadcrumb-separator');
6568
return;
6669
}
67-
68-
state['_syncingSeparators'] = true;
69-
state['_separatorSignature'] = signature;
70-
try {
71-
self
72-
.querySelectorAll<HTMLElement>(`[${SEPARATOR_ATTR}]`)
73-
.forEach((separator) => separator.remove());
74-
items.forEach((item, i) => {
75-
if (i < items.length - 1) {
76-
const separator = document.createElement('span');
77-
separator.setAttribute(SEPARATOR_ATTR, '');
78-
separator.setAttribute('aria-hidden', 'true');
79-
separator.textContent = separatorText;
80-
item.after(separator);
81-
}
82-
});
83-
} finally {
84-
state['_syncingSeparators'] = false;
85-
}
70+
self.style.setProperty('--bq-breadcrumb-separator', cssQuote(attr));
8671
};
8772
(self as unknown as Record<string, unknown>)['_applyAttrs'] = applyAttrs;
73+
(self as unknown as Record<string, unknown>)['_applySeparatorVar'] =
74+
applySeparatorVar;
75+
applySeparatorVar();
8876
queueMicrotask(() => {
8977
const slot = self.shadowRoot?.querySelector(
9078
'slot'
@@ -102,12 +90,11 @@ const definition: ComponentDefinition<BqBreadcrumbsProps> = {
10290
if (slot && applyAttrs) slot.removeEventListener('slotchange', applyAttrs);
10391
},
10492
updated() {
105-
const applyAttrs = (this as unknown as Record<string, unknown>)[
106-
'_applyAttrs'
107-
] as (() => void) | undefined;
108-
applyAttrs?.();
93+
const s = this as unknown as Record<string, unknown>;
94+
(s['_applySeparatorVar'] as (() => void) | undefined)?.();
95+
(s['_applyAttrs'] as (() => void) | undefined)?.();
10996
},
110-
render({ props }) {
97+
render() {
11198
return html`
11299
<nav aria-label="${t('breadcrumbs.nav')}" part="nav">
113100
<ol class="breadcrumbs" part="list">

src/components/checkbox/BqCheckbox.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @prop {boolean} disabled
1010
* @prop {boolean} required
1111
* @prop {string} hint
12-
* @fires bq-change - { checked: boolean, value: string }
12+
* @fires bq-change - { value: string, checked: boolean }
1313
*/
1414
import type { ComponentDefinition } from '@bquery/bquery/component';
1515
import { bool, component, html } from '@bquery/bquery/component';
@@ -79,8 +79,8 @@ const definition: ComponentDefinition<BqCheckboxProps> = {
7979
self.dispatchEvent(
8080
new CustomEvent('bq-change', {
8181
detail: {
82-
checked: input.checked,
8382
value: self.getAttribute('value') ?? '',
83+
checked: input.checked,
8484
},
8585
bubbles: true,
8686
composed: true,

src/components/dropdown-menu/BqDropdownMenu.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,10 @@ const definition: ComponentDefinition<
205205
if (timeoutMs <= 0) {
206206
finalize();
207207
} else {
208-
record['_closeTimer'] = setTimeout(finalize, Math.ceil(timeoutMs) + 20);
208+
record['_closeTimer'] = setTimeout(
209+
finalize,
210+
Math.ceil(timeoutMs) + 20
211+
);
209212
}
210213
}
211214
};
@@ -371,6 +374,10 @@ const definition: ComponentDefinition<
371374
break;
372375
}
373376
default: {
377+
// Skip typeahead while an IME composition is active — the keys
378+
// are part of the candidate selection, not menu navigation.
379+
// Also ignore the synthetic key fired on composition end (229).
380+
if (ke.isComposing || ke.keyCode === 229) break;
374381
if (ke.key.length === 1 && !ke.altKey && !ke.ctrlKey && !ke.metaKey) {
375382
typeaheadBuffer = `${typeaheadBuffer}${ke.key.toLowerCase()}`;
376383
if (typeaheadTimeout) clearTimeout(typeaheadTimeout);
@@ -448,7 +455,9 @@ const definition: ComponentDefinition<
448455
const clearTypeaheadBuffer = s['_clearTypeaheadBuffer'] as
449456
| (() => void)
450457
| undefined;
451-
const closeTimer = s['_closeTimer'] as ReturnType<typeof setTimeout> | undefined;
458+
const closeTimer = s['_closeTimer'] as
459+
| ReturnType<typeof setTimeout>
460+
| undefined;
452461
if (triggerHandler) this.removeEventListener('click', triggerHandler);
453462
if (menuClickHandler) this.removeEventListener('click', menuClickHandler);
454463
if (keyHandler) this.removeEventListener('keydown', keyHandler);

src/components/input/BqInput.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,19 @@ const definition: ComponentDefinition<BqInputProps, BqInputState> = {
106106
.counter[data-over="true"] { color: var(--bq-color-danger-600,#dc2626); }
107107
.password-toggle {
108108
display: inline-flex; align-items: center; justify-content: center;
109-
background: none; border: none; cursor: pointer; padding: 0 0.25rem;
109+
background: none; border: none; cursor: pointer;
110+
padding: 0 0.5rem; margin-inline-start: 0.25rem;
111+
border-inline-start: 1px solid var(--bq-border-base,#e2e8f0);
110112
color: var(--bq-text-muted,#475569); font-size: 0.875rem; line-height: 1;
113+
align-self: stretch;
114+
transition: color var(--bq-duration-fast) var(--bq-easing-standard), background var(--bq-duration-fast) var(--bq-easing-standard);
111115
}
112-
.password-toggle:hover { color: var(--bq-text-base,#0f172a); }
116+
.password-toggle:hover { color: var(--bq-text-base,#0f172a); background: var(--bq-bg-subtle,#f8fafc); }
117+
.password-toggle:disabled { cursor: not-allowed; opacity: 0.5; }
113118
.password-toggle:focus-visible { outline: 2px solid transparent; box-shadow: var(--bq-focus-ring); border-radius: var(--bq-radius-sm,0.25rem); }
114119
.pw-icon { font-size: 1.125rem; line-height: 1; }
115120
@media (prefers-reduced-motion: reduce) {
116-
input, .input-wrap { transition: none; }
121+
input, .input-wrap, .password-toggle { transition: none; }
117122
}
118123
`,
119124
connected() {
@@ -125,7 +130,14 @@ const definition: ComponentDefinition<BqInputProps, BqInputState> = {
125130
const name = self.getAttribute('name') ?? '';
126131
const value = self.getAttribute('value') ?? '';
127132
const disabled = self.hasAttribute('disabled');
128-
const proxy = createFormProxy(self, name, value, disabled);
133+
const proxy = createFormProxy(self, name, value, disabled, {
134+
getLiveValue: () => {
135+
const input = self.shadowRoot?.querySelector(
136+
'input'
137+
) as HTMLInputElement | null;
138+
return input ? input.value : (self.getAttribute('value') ?? '');
139+
},
140+
});
129141
(self as unknown as Record<string, unknown>)['_formProxy'] = proxy;
130142

131143
const ih = (e: Event) => {
@@ -258,6 +270,7 @@ const definition: ComponentDefinition<BqInputProps, BqInputState> = {
258270
aria-invalid="${hasError ? 'true' : 'false'}"
259271
${props.required ? 'aria-required="true"' : ''}
260272
${describedBy ? `aria-describedby="${describedBy}"` : ''}
273+
${hasError ? `aria-errormessage="${uid}-err"` : ''}
261274
/>
262275
${isPassword
263276
? `<button type="button" class="password-toggle" part="password-toggle" aria-label="${escapeHtml(state.passwordVisible ? t('input.hidePassword') : t('input.showPassword'))}" aria-pressed="${state.passwordVisible ? 'true' : 'false'}" ${props.disabled || props.readonly ? 'disabled aria-disabled="true"' : ''}>

0 commit comments

Comments
 (0)