From 96f59e1d5d20600ac12e2a6a824137cc8f7c092c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:44:11 +0000 Subject: [PATCH 01/11] feat: add segmented control and menu typeahead Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/7d7afef3-07c2-4fd0-af32-83cb01c37c1e --- docs/.vitepress/config.ts | 6 +- docs/components/dropdown-menu.md | 2 + docs/components/index.md | 3 +- docs/components/segmented-control.md | 133 +++++ docs/guide/architecture-roadmap.md | 102 ++++ .../dropdown-menu/BqDropdownMenu.ts | 57 ++- src/components/index.ts | 1 + .../segmented-control/BqSegmentedControl.ts | 473 ++++++++++++++++++ src/components/segmented-control/index.ts | 1 + src/index.ts | 2 + stories/segmented-control.stories.ts | 38 ++ tests/dropdown-menu.test.ts | 24 + tests/segmented-control.test.ts | 121 +++++ 13 files changed, 959 insertions(+), 4 deletions(-) create mode 100644 docs/components/segmented-control.md create mode 100644 docs/guide/architecture-roadmap.md create mode 100644 src/components/segmented-control/BqSegmentedControl.ts create mode 100644 src/components/segmented-control/index.ts create mode 100644 stories/segmented-control.stories.ts create mode 100644 tests/segmented-control.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 90e8134..6467786 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -9,7 +9,7 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/getting-started' }, { text: 'Components', link: '/components/' }, - { text: 'GitHub', link: 'https://github.com/bquery/component-library' }, + { text: 'GitHub', link: 'https://github.com/bQuery/ui' }, ], sidebar: [ { @@ -22,6 +22,7 @@ export default defineConfig({ { text: 'Dark Mode', link: '/guide/dark-mode' }, { text: 'Accessibility', link: '/guide/accessibility' }, { text: 'Internationalization', link: '/guide/i18n' }, + { text: 'Architecture & Roadmap', link: '/guide/architecture-roadmap' }, ], }, { @@ -48,6 +49,7 @@ export default defineConfig({ { text: 'Progress', link: '/components/progress' }, { text: 'Radio', link: '/components/radio' }, { text: 'Select', link: '/components/select' }, + { text: 'Segmented Control', link: '/components/segmented-control' }, { text: 'Skeleton', link: '/components/skeleton' }, { text: 'Slider', link: '/components/slider' }, { text: 'Spinner', link: '/components/spinner' }, @@ -61,7 +63,7 @@ export default defineConfig({ }, ], socialLinks: [ - { icon: 'github', link: 'https://github.com/bquery/component-library' }, + { icon: 'github', link: 'https://github.com/bQuery/ui' }, ], footer: { message: 'Released under the MIT License.', diff --git a/docs/components/dropdown-menu.md b/docs/components/dropdown-menu.md index a56c6d3..514f641 100644 --- a/docs/components/dropdown-menu.md +++ b/docs/components/dropdown-menu.md @@ -120,6 +120,7 @@ The `value` in `bq-select` comes from the item's `data-value` attribute, or fall | `End` | Moves focus to the last item | | `Escape` | Closes the menu and returns focus to the trigger | | `Tab` | Closes the menu and moves focus according to the browser's tab order (does not return focus to the trigger) | +| Printable characters | Typeahead focuses the next matching enabled item by label | ## Accessibility @@ -129,6 +130,7 @@ The `value` in `bq-select` comes from the item's `data-value` attribute, or fall - Focus is managed: opening moves focus to first item; closing via Escape, outside click, or item selection returns focus to the trigger, while `Tab` closes the menu without restoring focus - Click-outside closes the menu - Disabled items are skipped during keyboard navigation +- Typeahead matching uses the item label or `data-value` text, which helps large menus stay efficient without custom scripting ## Localization diff --git a/docs/components/index.md b/docs/components/index.md index eb68bed..805cd3c 100644 --- a/docs/components/index.md +++ b/docs/components/index.md @@ -9,7 +9,7 @@ This catalog gives you a complete overview of the currently available building b | Category | Components | | --- | --- | | **Actions** | `bq-button`, `bq-icon-button` | -| **Forms** | `bq-input`, `bq-textarea`, `bq-select`, `bq-checkbox`, `bq-radio`, `bq-switch`, `bq-slider`, `bq-chip` | +| **Forms** | `bq-input`, `bq-textarea`, `bq-select`, `bq-segmented-control`, `bq-checkbox`, `bq-radio`, `bq-switch`, `bq-slider`, `bq-chip` | | **Navigation & Disclosure** | `bq-tabs`, `bq-accordion`, `bq-breadcrumbs`, `bq-pagination` | | **Data Display** | `bq-card`, `bq-badge`, `bq-avatar`, `bq-table`, `bq-divider`, `bq-empty-state` | | **Feedback & Loading** | `bq-alert`, `bq-progress`, `bq-spinner`, `bq-skeleton`, `bq-tooltip`, `bq-toast` | @@ -40,6 +40,7 @@ Across the component library you will find the same professional capabilities th | `bq-input` | Single-line text input | Labels, hints, validation, prefix/suffix slots, size variants | | `bq-textarea` | Multi-line text input | Rows, max length, validation, hints | | `bq-select` | Native dropdown selection | Placeholder, form-friendly behavior, error state | +| `bq-segmented-control` | Compact single-choice view and filter toggles | Radio-group semantics, form participation, keyboard navigation, mobile-friendly full-width mode | | `bq-checkbox` | Boolean or multi-select forms | Checked and indeterminate states, hint text | | `bq-radio` | Exclusive option groups | Shared name support, hint text | | `bq-switch` | Toggle settings and preferences | Size variants, switch semantics, animated thumb | diff --git a/docs/components/segmented-control.md b/docs/components/segmented-control.md new file mode 100644 index 0000000..efa9ad8 --- /dev/null +++ b/docs/components/segmented-control.md @@ -0,0 +1,133 @@ +# Segmented Control + +The `bq-segmented-control` component provides a compact, touch-friendly single-choice control for toggling between views, density modes, filters, or app states. + +It fills the gap between a full tab set and a radio group by keeping the interaction inline while still exposing form participation and radio-group accessibility semantics. + +## Import + +```ts +import '@bquery/ui/components/segmented-control'; +``` + +## Basic Usage + +```html + + + + + +``` + +## Sizes + +```html + + + + + + + + + + + + + + +``` + +## Full-Width Layout + +Use `full-width` when each option should share the available width, which is especially useful on mobile layouts and settings screens. + +```html + + + + + +``` + +## Disabled States + +The host can disable the entire control, and individual segment buttons can also be disabled. + +```html + + + + + +``` + +## Properties + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `label` | `string` | `''` | Visible group label. Also used to label the radiogroup. | +| `hint` | `string` | `''` | Supporting copy rendered below the control and linked with `aria-describedby`. | +| `name` | `string` | `''` | Form field name used for the hidden proxy input. | +| `value` | `string` | `''` | Selected segment value. Falls back to the first enabled segment when omitted. | +| `size` | `sm \| md \| lg` | `md` | Controls the segment button sizing. | +| `full-width` | `boolean` | `false` | Expands the control and evenly distributes each segment. | +| `disabled` | `boolean` | `false` | Disables the entire control. | +| `aria-label` | `string` | `''` | Accessible label when you do not render a visible `label`. | + +## Events + +| Event | Detail | Description | +| --- | --- | --- | +| `bq-change` | `{ value: string }` | Fired when the selected segment changes. | + +## Slots + +| Slot | Description | +| --- | --- | +| *(default)* | Segment buttons. Use native `` elements for the best accessibility and keyboard behavior. | + +## CSS Parts + +| Part | Description | +| --- | --- | +| `field` | Wrapper around label, control, and hint | +| `label` | Visible group label | +| `control` | Segmented control track | +| `hint` | Supporting text | + +## Keyboard Interactions + +| Key | Action | +| --- | --- | +| `ArrowRight` / `ArrowDown` | Moves to the next enabled segment and selects it | +| `ArrowLeft` / `ArrowUp` | Moves to the previous enabled segment and selects it | +| `Home` | Selects the first enabled segment | +| `End` | Selects the last enabled segment | +| `Enter` / `Space` | Selects the focused segment | + +## Accessibility Notes + +- The control uses `role="radiogroup"` and each slotted button receives `role="radio"` with `aria-checked`. +- A visible `label` is preferred. If the UI should stay label-free, pass a localizable `aria-label`. +- The selected segment keeps `tabindex="0"` while inactive or disabled items move to `tabindex="-1"` to support roving focus. +- Arrow-key behavior follows the WAI-ARIA radio-group pattern and respects RTL layouts for horizontal navigation. + +## Localization Notes + +- Keep the `label`, `hint`, and each button’s text content fully localizable in the host application. +- Segment `value` attributes should stay stable across locales so application state and forms do not depend on translated text. + +## Theming Notes + +- The track and selected state use the shared token system (`--bq-bg-*`, `--bq-border-*`, `--bq-color-primary-*`, and focus-ring tokens). +- Use `::part(control)` for layout adjustments and host-level token overrides for bespoke themes without forking the component. diff --git a/docs/guide/architecture-roadmap.md b/docs/guide/architecture-roadmap.md new file mode 100644 index 0000000..21a456e --- /dev/null +++ b/docs/guide/architecture-roadmap.md @@ -0,0 +1,102 @@ +# Architecture, Gap Analysis, and Roadmap + +This page summarizes the current state of `@bquery/ui`, the research-informed gap analysis for the library’s next phase, and the first implementation batch landed in this update. + +## Current State Analysis + +`@bquery/ui` already ships a strong cross-framework base: + +- a broad core set of actions, forms, overlays, navigation, and feedback components, +- a reusable token and theme system with dark mode support, +- localized defaults through `src/i18n`, +- and focused accessibility utilities for focus management, live regions, and overlay behavior. + +### Current strengths + +- **Accessible foundation**: overlays, tabs, tables, and form controls already expose meaningful keyboard and ARIA behavior. +- **Consistent design primitives**: tokens and theme helpers provide a shared visual language instead of one-off component colors. +- **Framework portability**: the custom-element architecture keeps the library usable in Angular, React, Vue, Svelte, and plain HTML. +- **Good documentation/testing baseline**: VitePress, Storybook, and Bun-based component tests are already in place. + +### Current constraints + +- Components are mostly **single-file implementations**, so new patterns should stay simple and composable instead of adding deep abstraction layers prematurely. +- The library is strong in core controls but still lighter in **composite app patterns** such as segmented controls, advanced selects, command bars, and richer data views. +- The current docs explain individual APIs well, but the library benefits from more pages that explain **architecture and product direction**. + +## Research-Informed Gap Analysis + +Reviewing mature systems such as Radix UI, shadcn/ui, Chakra UI, Mantine, Material UI, Ant Design, Headless UI, and React Aria highlights a few recurring expectations: + +### Essential next additions + +- **Compact single-choice controls** like segmented controls and toggle groups +- **Richer menu behaviors** such as typeahead and tighter keyboard ergonomics +- **More composable app-shell and settings primitives** +- **Higher-level data controls** like richer tables, column controls, and filtering patterns + +### High-value improvements + +- Better **keyboard parity** across all navigation and overlay components +- More **documentation pages for architecture, roadmap, theming, and usage patterns** +- Continued refinement of **mobile-friendly sizing, focus behavior, and RTL-aware navigation** + +### Advanced future opportunities + +- Date and time primitives +- Command palette and workspace navigation patterns +- Multi-select and richer combobox behavior +- Split layouts, resizable panels, and app-shell primitives + +## Prioritized Roadmap + +### 1. Strengthen navigation and selection primitives + +Add compact view/filter controls and improve keyboard ergonomics for menu-like components. + +### 2. Expand composable form building blocks + +Prioritize segmented control, richer select patterns, and advanced field composition before heavier enterprise widgets. + +### 3. Grow app-scale patterns + +Add settings-page primitives, command surfaces, responsive navigation helpers, and more structured documentation recipes. + +### 4. Deepen data-heavy capabilities + +Iterate on the table with more controls and patterns instead of jumping straight to a full enterprise grid. + +## First High-Value Batch Implemented Here + +This update focuses on a coherent, maintainable first batch: + +1. **New `bq-segmented-control` component** + - compact single-selection control, + - radio-group accessibility semantics, + - keyboard support, + - form participation, + - sizing and full-width options for mobile-friendly layouts. + +2. **Improved `bq-dropdown-menu` keyboard UX** + - adds **typeahead navigation** for faster action discovery in larger menus. + +3. **Documentation improvements** + - new segmented-control reference page, + - this architecture and roadmap guide, + - updated catalog/sidebar coverage, + - updated dropdown-menu keyboard guidance. + +## Why this batch matters + +This batch improves the library in a way that mirrors mature UI ecosystems without overextending the architecture: + +- it adds a missing **foundational choice primitive**, +- strengthens an existing **overlay/navigation component**, +- and improves documentation so the library feels more like an intentional platform than a loose collection of widgets. + +## Recommended Next Steps + +- Add a composable **combobox / rich select** next. +- Expand **table recipes** and column-control patterns. +- Add more **layout and workspace primitives** for responsive app shells and settings pages. +- Continue auditing older components for shared size/variant/state consistency. diff --git a/src/components/dropdown-menu/BqDropdownMenu.ts b/src/components/dropdown-menu/BqDropdownMenu.ts index 29a3b0f..1427cc5 100644 --- a/src/components/dropdown-menu/BqDropdownMenu.ts +++ b/src/components/dropdown-menu/BqDropdownMenu.ts @@ -96,6 +96,11 @@ const definition: ComponentDefinition< const isDisabledItem = (el: HTMLElement): boolean => el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true'; + const getItemLabel = (el: HTMLElement): string => + (el.getAttribute('data-value') || el.textContent || '') + .trim() + .toLowerCase(); + const getMenuRoots = (): HTMLElement[] => { const slot = self.shadowRoot?.querySelector( 'slot:not([name])' @@ -158,9 +163,12 @@ const definition: ComponentDefinition< if (items.length > 0) items[0]!.focus(); }); }; - const close = ({ restoreFocus = false }: { restoreFocus?: boolean } = {}) => { + const close = ( + { restoreFocus = false }: { restoreFocus?: boolean } = {} + ) => { if (!self.hasAttribute('open')) return; self.removeAttribute('open'); + clearTypeaheadBuffer(); syncTriggerA11y(); self.dispatchEvent( new CustomEvent('bq-close', { bubbles: true, composed: true }) @@ -172,6 +180,32 @@ const definition: ComponentDefinition< else open(); }; + let typeaheadBuffer = ''; + let typeaheadTimeout = 0; + + const clearTypeaheadBuffer = () => { + typeaheadBuffer = ''; + if (typeaheadTimeout) { + clearTimeout(typeaheadTimeout); + typeaheadTimeout = 0; + } + }; + + const focusTypeaheadMatch = (search: string, currentIndex: number) => { + const items = getMenuItems(); + if (items.length === 0) return; + + const orderedItems = + currentIndex >= 0 + ? [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)] + : items; + + const match = orderedItems.find((item) => + getItemLabel(item).startsWith(search) + ); + match?.focus(); + }; + const getMenuItems = (): HTMLElement[] => { return getMenuRoots().filter( (el): el is HTMLElement => @@ -296,6 +330,22 @@ const definition: ComponentDefinition< close(); break; } + default: { + if ( + ke.key.length === 1 && + !ke.altKey && + !ke.ctrlKey && + !ke.metaKey + ) { + typeaheadBuffer = `${typeaheadBuffer}${ke.key.toLowerCase()}`; + if (typeaheadTimeout) clearTimeout(typeaheadTimeout); + typeaheadTimeout = window.setTimeout(() => { + typeaheadBuffer = ''; + typeaheadTimeout = 0; + }, 500); + focusTypeaheadMatch(typeaheadBuffer, currentIndex); + } + } } }; @@ -331,6 +381,7 @@ const definition: ComponentDefinition< s['_outsideHandler'] = outsideHandler; s['_syncOutsideListener'] = syncOutsideListener; s['_slotChangeHandler'] = slotChangeHandler; + s['_clearTypeaheadBuffer'] = clearTypeaheadBuffer; self.addEventListener('click', triggerHandler); self.addEventListener('click', menuClickHandler); @@ -357,10 +408,14 @@ const definition: ComponentDefinition< const slotChangeHandler = s['_slotChangeHandler'] as | EventListener | undefined; + const clearTypeaheadBuffer = s['_clearTypeaheadBuffer'] as + | (() => void) + | undefined; if (triggerHandler) this.removeEventListener('click', triggerHandler); if (menuClickHandler) this.removeEventListener('click', menuClickHandler); if (keyHandler) this.removeEventListener('keydown', keyHandler); if (outsideHandler) document.removeEventListener('click', outsideHandler); + clearTypeaheadBuffer?.(); if (slotChangeHandler) { this.shadowRoot ?.querySelector('slot[name="trigger"]') diff --git a/src/components/index.ts b/src/components/index.ts index 657da3a..88a7289 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -21,6 +21,7 @@ import './pagination/BqPagination.js'; import './progress/BqProgress.js'; import './radio/BqRadio.js'; import './select/BqSelect.js'; +import './segmented-control/BqSegmentedControl.js'; import './skeleton/BqSkeleton.js'; import './slider/BqSlider.js'; import './spinner/BqSpinner.js'; diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts new file mode 100644 index 0000000..72166b6 --- /dev/null +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -0,0 +1,473 @@ +/** + * Segmented control for single-choice view and filter toggles. + * @element bq-segmented-control + * @prop {string} label + * @prop {string} hint + * @prop {string} name + * @prop {string} value + * @prop {string} size - sm | md | lg + * @prop {boolean} full-width + * @prop {boolean} disabled + * @prop {string} aria-label - Accessible label when no visible label is rendered + * @slot - Segment buttons (use ) + * @fires bq-change - { value: string } + */ +import type { ComponentDefinition } from '@bquery/bquery/component'; +import { component, html } from '@bquery/bquery/component'; +import { escapeHtml } from '@bquery/bquery/security'; +import { uniqueId } from '../../utils/dom.js'; +import { createFormProxy, type FormProxy } from '../../utils/form.js'; +import { getBaseStyles } from '../../utils/styles.js'; + +type BqSegmentedControlProps = { + label: string; + hint: string; + name: string; + value: string; + size: string; + 'full-width': boolean; + disabled: boolean; + 'aria-label': string; +}; + +type BqSegmentedControlState = { + uid: string; +}; + +const definition: ComponentDefinition< + BqSegmentedControlProps, + BqSegmentedControlState +> = { + props: { + label: { type: String, default: '' }, + hint: { type: String, default: '' }, + name: { type: String, default: '' }, + value: { type: String, default: '' }, + size: { type: String, default: 'md' }, + 'full-width': { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + 'aria-label': { type: String, default: '' }, + }, + state: { + uid: '', + }, + styles: ` + ${getBaseStyles()} + *, *::before, *::after { box-sizing: border-box; } + :host { display: block; } + .field { display: flex; flex-direction: column; gap: 0.375rem; } + .label { + font-family: var(--bq-font-family-sans); + font-size: var(--bq-font-size-sm,0.875rem); + font-weight: var(--bq-font-weight-medium,500); + color: var(--bq-text-base,#0f172a); + line-height: 1.5; + } + .control { + display: inline-flex; + align-items: stretch; + gap: 0.25rem; + width: fit-content; + min-width: 0; + padding: 0.25rem; + border: 1px solid var(--bq-border-base,#e2e8f0); + border-radius: var(--bq-radius-xl,0.75rem); + background: var(--bq-bg-subtle,#f8fafc); + min-height: 2.75rem; + } + :host([full-width]) .control { + display: flex; + width: 100%; + } + :host([disabled]) .control { + opacity: 0.7; + } + .hint { + font-family: var(--bq-font-family-sans); + font-size: var(--bq-font-size-sm,0.875rem); + color: var(--bq-text-muted,#475569); + line-height: 1.5; + } + ::slotted(button) { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + min-height: 2.25rem; + min-width: 2.75rem; + padding: 0.5rem 0.875rem; + border: none; + border-radius: var(--bq-radius-lg,0.5rem); + background: transparent; + color: var(--bq-text-muted,#475569); + font-family: var(--bq-font-family-sans); + font-size: var(--bq-font-size-sm,0.875rem); + font-weight: var(--bq-font-weight-medium,500); + line-height: 1.25; + text-align: center; + white-space: nowrap; + cursor: pointer; + transition: + background var(--bq-duration-fast,150ms) var(--bq-easing-standard), + color var(--bq-duration-fast,150ms) var(--bq-easing-standard), + box-shadow var(--bq-duration-fast,150ms) var(--bq-easing-standard); + } + :host([size="sm"]) ::slotted(button) { + min-height: 2rem; + padding: 0.375rem 0.75rem; + font-size: var(--bq-font-size-xs,0.75rem); + } + :host([size="lg"]) ::slotted(button) { + min-height: 2.75rem; + padding: 0.625rem 1rem; + font-size: var(--bq-font-size-md,1rem); + } + :host([full-width]) ::slotted(button) { + flex: 1 1 0; + min-width: 0; + } + ::slotted(button:hover:not(:disabled):not([aria-disabled="true"]):not([data-selected="true"])) { + color: var(--bq-text-base,#0f172a); + background: var(--bq-bg-muted,#f1f5f9); + } + ::slotted(button[data-selected="true"]) { + background: var(--bq-bg-base,#ffffff); + color: var(--bq-color-primary-600,#2563eb); + box-shadow: var(--bq-shadow-sm); + } + ::slotted(button:focus-visible) { + outline: 2px solid transparent; + box-shadow: var(--bq-focus-ring); + } + ::slotted(button:disabled), + ::slotted(button[aria-disabled="true"]) { + opacity: 0.5; + cursor: not-allowed; + } + @media (prefers-reduced-motion: reduce) { + ::slotted(button) { transition: none; } + } + `, + connected() { + type BQEl = HTMLElement & { + setState(key: 'uid', value: string): void; + getState(key: string): T; + }; + + const self = this as unknown as BQEl & Record; + + if (!self.getState('uid')) { + self.setState('uid', uniqueId('bq-segmented')); + } + + const proxy = createFormProxy( + self, + self.getAttribute('name') ?? '', + self.getAttribute('value') ?? '', + self.hasAttribute('disabled') + ); + self['_formProxy'] = proxy; + + const getButtons = (): HTMLButtonElement[] => { + const slot = self.shadowRoot?.querySelector( + 'slot:not([name])' + ) as HTMLSlotElement | null; + if (!slot) return []; + return slot + .assignedElements({ flatten: true }) + .filter( + (element): element is HTMLButtonElement => + element instanceof HTMLElement && element.tagName === 'BUTTON' + ); + }; + + const getButtonValue = (button: HTMLButtonElement): string => + button.getAttribute('value') || + button.getAttribute('data-value') || + button.textContent?.trim() || + ''; + + const isButtonDisabled = (button: HTMLButtonElement): boolean => + self.hasAttribute('disabled') || + button.disabled || + button.getAttribute('aria-disabled') === 'true'; + + const ensureSelectedValue = (): string => { + const buttons = getButtons(); + const currentValue = self.getAttribute('value') ?? ''; + const selectedButton = buttons.find( + (button) => + !isButtonDisabled(button) && getButtonValue(button) === currentValue + ); + if (selectedButton) return currentValue; + + const fallbackButton = buttons.find((button) => !isButtonDisabled(button)); + const fallbackValue = fallbackButton ? getButtonValue(fallbackButton) : ''; + + if (fallbackValue && fallbackValue !== currentValue) { + self.setAttribute('value', fallbackValue); + } else if (!fallbackValue && currentValue) { + self.removeAttribute('value'); + } + + return fallbackValue; + }; + + const syncButtons = () => { + const buttons = getButtons(); + const selectedValue = ensureSelectedValue(); + const enabledButtons = buttons.filter((button) => !isButtonDisabled(button)); + const focusValue = + selectedValue || (enabledButtons[0] ? getButtonValue(enabledButtons[0]) : ''); + + buttons.forEach((button) => { + const value = getButtonValue(button); + const disabled = isButtonDisabled(button); + const selected = value !== '' && value === selectedValue; + + if (!button.hasAttribute('type')) { + button.setAttribute('type', 'button'); + } + + button.setAttribute('role', 'radio'); + button.setAttribute('aria-checked', selected ? 'true' : 'false'); + button.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + + if (selected) { + button.setAttribute('data-selected', 'true'); + } else { + button.removeAttribute('data-selected'); + } + + button.setAttribute( + 'tabindex', + !disabled && value === focusValue ? '0' : '-1' + ); + + button.removeEventListener('keydown', keyHandler); + button.addEventListener('keydown', keyHandler); + }); + + proxy.setName(self.getAttribute('name') ?? ''); + proxy.setValue(selectedValue); + proxy.setDisabled(self.hasAttribute('disabled')); + }; + + const selectButton = ( + button: HTMLButtonElement, + options: { focus?: boolean; emit?: boolean } = {} + ) => { + if (isButtonDisabled(button)) return; + + const nextValue = getButtonValue(button); + if (!nextValue) return; + + const previousValue = self.getAttribute('value') ?? ''; + if (nextValue !== previousValue) { + self.setAttribute('value', nextValue); + syncButtons(); + + if (options.emit !== false) { + self.dispatchEvent( + new CustomEvent('bq-change', { + detail: { value: nextValue }, + bubbles: true, + composed: true, + }) + ); + } + } else { + syncButtons(); + } + + if (options.focus) { + button.focus(); + } + }; + + const moveSelection = ( + currentButton: HTMLButtonElement, + direction: 'next' | 'prev' | 'first' | 'last' + ) => { + const enabledButtons = getButtons().filter( + (button) => !isButtonDisabled(button) + ); + if (enabledButtons.length === 0) return; + + let nextButton: HTMLButtonElement | null = enabledButtons[0] ?? null; + + if (direction === 'first') { + nextButton = enabledButtons[0] ?? null; + } else if (direction === 'last') { + nextButton = enabledButtons[enabledButtons.length - 1] ?? null; + } else { + const currentIndex = enabledButtons.indexOf(currentButton); + if (currentIndex === -1) { + nextButton = enabledButtons[0] ?? null; + } else if (direction === 'next') { + nextButton = + enabledButtons[(currentIndex + 1) % enabledButtons.length] ?? null; + } else { + nextButton = + enabledButtons[ + (currentIndex - 1 + enabledButtons.length) % enabledButtons.length + ] ?? null; + } + } + + if (!nextButton) return; + selectButton(nextButton, { focus: true }); + }; + + const clickHandler = (event: Event) => { + const target = event.target as Element | null; + const button = target?.closest('button') as HTMLButtonElement | null; + if (!button || !getButtons().includes(button)) return; + + selectButton(button); + }; + + const keyHandler = (event: Event) => { + const keyboardEvent = event as KeyboardEvent; + const target = event.target as Element | null; + const button = target?.closest('button') as HTMLButtonElement | null; + if (!button || !getButtons().includes(button)) return; + + const isRtl = getComputedStyle(self).direction === 'rtl'; + + switch (keyboardEvent.key) { + case 'ArrowRight': + keyboardEvent.preventDefault(); + moveSelection(button, isRtl ? 'prev' : 'next'); + break; + case 'ArrowLeft': + keyboardEvent.preventDefault(); + moveSelection(button, isRtl ? 'next' : 'prev'); + break; + case 'ArrowDown': + keyboardEvent.preventDefault(); + moveSelection(button, 'next'); + break; + case 'ArrowUp': + keyboardEvent.preventDefault(); + moveSelection(button, 'prev'); + break; + case 'Home': + keyboardEvent.preventDefault(); + moveSelection(button, 'first'); + break; + case 'End': + keyboardEvent.preventDefault(); + moveSelection(button, 'last'); + break; + case 'Enter': + case ' ': + keyboardEvent.preventDefault(); + selectButton(button, { focus: true }); + break; + } + }; + + const slotChangeHandler = () => { + syncButtons(); + }; + + const observer = new MutationObserver(() => { + syncButtons(); + }); + observer.observe(self, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['disabled', 'value', 'data-value'], + }); + + self['_syncButtons'] = syncButtons; + self['_clickHandler'] = clickHandler; + self['_keyHandler'] = keyHandler; + self['_slotChangeHandler'] = slotChangeHandler; + self['_observer'] = observer; + + self.addEventListener('click', clickHandler); + self.addEventListener('keydown', keyHandler, true); + self.shadowRoot + ?.querySelector('slot:not([name])') + ?.addEventListener('slotchange', slotChangeHandler); + + requestAnimationFrame(syncButtons); + }, + disconnected() { + const self = this as unknown as Record; + + const clickHandler = self['_clickHandler'] as EventListener | undefined; + const keyHandler = self['_keyHandler'] as EventListener | undefined; + const slotChangeHandler = self['_slotChangeHandler'] as + | EventListener + | undefined; + const observer = self['_observer'] as MutationObserver | undefined; + const proxy = self['_formProxy'] as FormProxy | undefined; + + if (clickHandler) this.removeEventListener('click', clickHandler); + if (keyHandler) { + this.removeEventListener('keydown', keyHandler, true); + const buttons = this.querySelectorAll('button'); + buttons.forEach((button) => button.removeEventListener('keydown', keyHandler)); + } + if (slotChangeHandler) { + this.shadowRoot + ?.querySelector('slot:not([name])') + ?.removeEventListener('slotchange', slotChangeHandler); + } + + observer?.disconnect(); + proxy?.cleanup(); + }, + updated() { + const syncButtons = (this as unknown as Record)['_syncButtons'] as + | (() => void) + | undefined; + syncButtons?.(); + }, + render({ props, state }) { + const uid = state.uid || 'bq-segmented'; + const labelId = `${uid}-label`; + const hintId = `${uid}-hint`; + + const labelAttribute = props.label + ? `aria-labelledby="${labelId}"` + : props['aria-label'] + ? `aria-label="${escapeHtml(props['aria-label'])}"` + : ''; + + const descriptionAttribute = props.hint + ? `aria-describedby="${hintId}"` + : ''; + + return html` +
+ ${props.label + ? `
${escapeHtml(props.label)}
` + : ''} +
+ +
+ ${props.hint + ? `
${escapeHtml(props.hint)}
` + : ''} +
+ `; + }, +}; + +component( + 'bq-segmented-control', + definition +); diff --git a/src/components/segmented-control/index.ts b/src/components/segmented-control/index.ts new file mode 100644 index 0000000..5371788 --- /dev/null +++ b/src/components/segmented-control/index.ts @@ -0,0 +1 @@ +export * as __bqComponentEntry from './BqSegmentedControl.js'; diff --git a/src/index.ts b/src/index.ts index 40a9532..d2bab1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import { __bqComponentEntry as paginationComponentEntry } from './components/pag import { __bqComponentEntry as progressComponentEntry } from './components/progress/index.js'; import { __bqComponentEntry as radioComponentEntry } from './components/radio/index.js'; import { __bqComponentEntry as selectComponentEntry } from './components/select/index.js'; +import { __bqComponentEntry as segmentedControlComponentEntry } from './components/segmented-control/index.js'; import { __bqComponentEntry as skeletonComponentEntry } from './components/skeleton/index.js'; import { __bqComponentEntry as sliderComponentEntry } from './components/slider/index.js'; import { __bqComponentEntry as spinnerComponentEntry } from './components/spinner/index.js'; @@ -76,6 +77,7 @@ Object.defineProperty(registerAll, COMPONENT_ENTRY_MAP_KEY, { 'progress': progressComponentEntry, 'radio': radioComponentEntry, 'select': selectComponentEntry, + 'segmented-control': segmentedControlComponentEntry, 'skeleton': skeletonComponentEntry, 'slider': sliderComponentEntry, 'spinner': spinnerComponentEntry, diff --git a/stories/segmented-control.stories.ts b/stories/segmented-control.stories.ts new file mode 100644 index 0000000..3e8c88a --- /dev/null +++ b/stories/segmented-control.stories.ts @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { storyHtml } from '@bquery/bquery/storybook'; + +const meta: Meta = { + title: 'Forms/Segmented Control', + tags: ['autodocs'], + render: (args) => storyHtml` + + + + + + `, + argTypes: { + value: { control: 'select', options: ['overview', 'board', 'activity'] }, + size: { control: 'select', options: ['sm', 'md', 'lg'] }, + fullWidth: { control: 'boolean' }, + }, + args: { + value: 'overview', + size: 'md', + fullWidth: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const Large: Story = { args: { size: 'lg' } }; +export const FullWidth: Story = { args: { fullWidth: true } }; diff --git a/tests/dropdown-menu.test.ts b/tests/dropdown-menu.test.ts index 90b6af9..ef081b9 100644 --- a/tests/dropdown-menu.test.ts +++ b/tests/dropdown-menu.test.ts @@ -337,6 +337,30 @@ describe('BqDropdownMenu', () => { expect(el.hasAttribute('open')).toBe(false); }); + it('should focus the next matching item when typing a printable character', async () => { + const el = doc.createElement('bq-dropdown-menu'); + const trigger = doc.createElement('button'); + trigger.setAttribute('slot', 'trigger'); + trigger.textContent = 'Trigger'; + const archive = doc.createElement('button'); + archive.textContent = 'Archive'; + const duplicate = doc.createElement('button'); + duplicate.textContent = 'Duplicate'; + const deleteItem = doc.createElement('button'); + deleteItem.textContent = 'Delete'; + el.append(trigger, archive, duplicate, deleteItem); + doc.body.appendChild(el); + + trigger.click(); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + archive.focus(); + archive.dispatchEvent( + new win.KeyboardEvent('keydown', { key: 'd', bubbles: true }) + ); + + expect(doc.activeElement).toBe(duplicate); + }); + it('should ignore disabled anchor activation', async () => { const el = doc.createElement('bq-dropdown-menu'); const trigger = doc.createElement('button'); diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts new file mode 100644 index 0000000..758c661 --- /dev/null +++ b/tests/segmented-control.test.ts @@ -0,0 +1,121 @@ +// DOM environment is provided by tests/setup.ts (preloaded via bunfig.toml) +import { afterEach, beforeAll, describe, expect, it } from 'bun:test'; +import { waitForFrame } from './helpers.ts'; + +const win = (globalThis as unknown as Record)['window'] as + Window & typeof globalThis; +const doc = win.document as unknown as Document; + +describe('BqSegmentedControl', () => { + beforeAll(async () => { + await import('../src/components/segmented-control/index.js'); + }); + + afterEach(() => { + doc.body.innerHTML = ''; + }); + + const createControl = () => { + const el = doc.createElement('bq-segmented-control'); + el.setAttribute('name', 'view'); + el.setAttribute('label', 'View mode'); + el.setAttribute('hint', 'Choose a preferred layout'); + + const overview = doc.createElement('button'); + overview.setAttribute('value', 'overview'); + overview.textContent = 'Overview'; + + const board = doc.createElement('button'); + board.setAttribute('value', 'board'); + board.textContent = 'Board'; + + const activity = doc.createElement('button'); + activity.setAttribute('value', 'activity'); + activity.textContent = 'Activity'; + + el.append(overview, board, activity); + doc.body.appendChild(el); + + return { el, overview, board, activity }; + }; + + it('should define bq-segmented-control as a custom element', () => { + expect(win.customElements.get('bq-segmented-control')).toBeDefined(); + }); + + it('should link the radiogroup to its label and hint', async () => { + const { el } = createControl(); + await waitForFrame(); + + const group = el.shadowRoot?.querySelector('[role="radiogroup"]'); + const label = el.shadowRoot?.querySelector('[part="label"]'); + const hint = el.shadowRoot?.querySelector('[part="hint"]'); + + expect(group?.getAttribute('aria-labelledby')).toBe(label?.id); + expect(group?.getAttribute('aria-describedby')).toBe(hint?.id); + }); + + it('should default to the first enabled segment and sync a form proxy', async () => { + const { el, overview, board } = createControl(); + board.setAttribute('disabled', ''); + await waitForFrame(); + + const proxy = el.nextElementSibling as HTMLInputElement | null; + + expect(el.getAttribute('value')).toBe('overview'); + expect(overview.getAttribute('data-selected')).toBe('true'); + expect(overview.getAttribute('aria-checked')).toBe('true'); + expect(overview.getAttribute('tabindex')).toBe('0'); + expect(board.getAttribute('aria-disabled')).toBe('true'); + expect(proxy?.name).toBe('view'); + expect(proxy?.value).toBe('overview'); + }); + + it('should dispatch bq-change and update selection on click', async () => { + const { el, board } = createControl(); + await waitForFrame(); + + let detailValue = ''; + el.addEventListener('bq-change', (event: Event) => { + detailValue = (event as CustomEvent<{ value: string }>).detail.value; + }); + + board.click(); + + expect(el.getAttribute('value')).toBe('board'); + expect(board.getAttribute('data-selected')).toBe('true'); + expect(detailValue).toBe('board'); + }); + + it('should keep only the selected segment in the tab order', async () => { + const { el, overview, board } = createControl(); + await waitForFrame(); + + board.click(); + + expect(el.getAttribute('value')).toBe('board'); + expect(overview.getAttribute('tabindex')).toBe('-1'); + expect(board.getAttribute('tabindex')).toBe('0'); + }); + + it('should allow aria-label when no visible label is rendered', async () => { + const el = doc.createElement('bq-segmented-control'); + el.setAttribute('aria-label', 'Display density'); + + const compact = doc.createElement('button'); + compact.setAttribute('value', 'compact'); + compact.textContent = 'Compact'; + + const comfortable = doc.createElement('button'); + comfortable.setAttribute('value', 'comfortable'); + comfortable.textContent = 'Comfortable'; + + el.append(compact, comfortable); + doc.body.appendChild(el); + + await waitForFrame(); + + const group = el.shadowRoot?.querySelector('[role="radiogroup"]'); + expect(group?.getAttribute('aria-label')).toBe('Display density'); + }); +}); From ef98d71b7831222365e2cc6564e165c2a0baf29b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:25:23 +0000 Subject: [PATCH 02/11] fix: remove duplicate segmented control keydown path Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- .../segmented-control/BqSegmentedControl.ts | 2 -- tests/segmented-control.test.ts | 20 ++++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts index 72166b6..e5cbb06 100644 --- a/src/components/segmented-control/BqSegmentedControl.ts +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -391,7 +391,6 @@ const definition: ComponentDefinition< self['_observer'] = observer; self.addEventListener('click', clickHandler); - self.addEventListener('keydown', keyHandler, true); self.shadowRoot ?.querySelector('slot:not([name])') ?.addEventListener('slotchange', slotChangeHandler); @@ -411,7 +410,6 @@ const definition: ComponentDefinition< if (clickHandler) this.removeEventListener('click', clickHandler); if (keyHandler) { - this.removeEventListener('keydown', keyHandler, true); const buttons = this.querySelectorAll('button'); buttons.forEach((button) => button.removeEventListener('keydown', keyHandler)); } diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index 758c661..95de06d 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -87,15 +87,21 @@ describe('BqSegmentedControl', () => { expect(detailValue).toBe('board'); }); - it('should keep only the selected segment in the tab order', async () => { - const { el, overview, board } = createControl(); - await waitForFrame(); + it('should not register a host keydown listener', async () => { + const { el } = createControl(); - board.click(); + let hostKeydownRegistrations = 0; - expect(el.getAttribute('value')).toBe('board'); - expect(overview.getAttribute('tabindex')).toBe('-1'); - expect(board.getAttribute('tabindex')).toBe('0'); + const originalHostAddEventListener = el.addEventListener.bind(el); + + el.addEventListener = ((type, listener, options) => { + if (type === 'keydown') hostKeydownRegistrations += 1; + return originalHostAddEventListener(type, listener, options); + }) as HTMLElement['addEventListener']; + + await waitForFrame(); + + expect(hostKeydownRegistrations).toBe(0); }); it('should allow aria-label when no visible label is rendered', async () => { From f95d30b0579be05373649adc97418b61e899273a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:27:01 +0000 Subject: [PATCH 03/11] test: fix segmented control listener assertion timing Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- tests/segmented-control.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index 95de06d..d6ee14e 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -15,7 +15,14 @@ describe('BqSegmentedControl', () => { doc.body.innerHTML = ''; }); - const createControl = () => { + const createControl = ( + beforeAppend?: (elements: { + el: HTMLElement; + overview: HTMLButtonElement; + board: HTMLButtonElement; + activity: HTMLButtonElement; + }) => void + ) => { const el = doc.createElement('bq-segmented-control'); el.setAttribute('name', 'view'); el.setAttribute('label', 'View mode'); @@ -34,6 +41,7 @@ describe('BqSegmentedControl', () => { activity.textContent = 'Activity'; el.append(overview, board, activity); + beforeAppend?.({ el, overview, board, activity }); doc.body.appendChild(el); return { el, overview, board, activity }; @@ -88,16 +96,14 @@ describe('BqSegmentedControl', () => { }); it('should not register a host keydown listener', async () => { - const { el } = createControl(); - let hostKeydownRegistrations = 0; - - const originalHostAddEventListener = el.addEventListener.bind(el); - - el.addEventListener = ((type, listener, options) => { - if (type === 'keydown') hostKeydownRegistrations += 1; - return originalHostAddEventListener(type, listener, options); - }) as HTMLElement['addEventListener']; + const { el } = createControl(({ el: control }) => { + const originalHostAddEventListener = control.addEventListener.bind(control); + control.addEventListener = ((type, listener, options) => { + if (type === 'keydown') hostKeydownRegistrations += 1; + return originalHostAddEventListener(type, listener, options); + }) as HTMLElement['addEventListener']; + }); await waitForFrame(); From d90a4700329a6bf7a92c93b77ec38d5aa0134e71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:28:18 +0000 Subject: [PATCH 04/11] test: rename segmented control setup hook Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- tests/segmented-control.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index d6ee14e..125646d 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -16,7 +16,7 @@ describe('BqSegmentedControl', () => { }); const createControl = ( - beforeAppend?: (elements: { + beforeAttach?: (elements: { el: HTMLElement; overview: HTMLButtonElement; board: HTMLButtonElement; @@ -41,7 +41,7 @@ describe('BqSegmentedControl', () => { activity.textContent = 'Activity'; el.append(overview, board, activity); - beforeAppend?.({ el, overview, board, activity }); + beforeAttach?.({ el, overview, board, activity }); doc.body.appendChild(el); return { el, overview, board, activity }; From dbc53ad7393c43a0dc2ae9588fc0ebd47cd3a6f9 Mon Sep 17 00:00:00 2001 From: Jonas Pfalzgraf Date: Tue, 24 Mar 2026 15:29:08 +0100 Subject: [PATCH 05/11] Potential fix for pull request finding 'Useless assignment to local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/components/segmented-control/BqSegmentedControl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts index e5cbb06..4010795 100644 --- a/src/components/segmented-control/BqSegmentedControl.ts +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -295,7 +295,7 @@ const definition: ComponentDefinition< ); if (enabledButtons.length === 0) return; - let nextButton: HTMLButtonElement | null = enabledButtons[0] ?? null; + let nextButton: HTMLButtonElement | null = null; if (direction === 'first') { nextButton = enabledButtons[0] ?? null; From f9ac91fd94ab35045d38fbfb39f4e75d0486c9d3 Mon Sep 17 00:00:00 2001 From: Jonas Pfalzgraf Date: Tue, 24 Mar 2026 15:29:46 +0100 Subject: [PATCH 06/11] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/segmented-control.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index 125646d..1b56d5e 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -97,7 +97,7 @@ describe('BqSegmentedControl', () => { it('should not register a host keydown listener', async () => { let hostKeydownRegistrations = 0; - const { el } = createControl(({ el: control }) => { + createControl(({ el: control }) => { const originalHostAddEventListener = control.addEventListener.bind(control); control.addEventListener = ((type, listener, options) => { if (type === 'keydown') hostKeydownRegistrations += 1; From 1b7f3f2bbf611affc772865e9d00b02c2e7a3b68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:31:54 +0000 Subject: [PATCH 07/11] test: cover segmented control keyboard handler Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- tests/segmented-control.test.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index 1b56d5e..ce51eda 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -110,6 +110,48 @@ describe('BqSegmentedControl', () => { expect(hostKeydownRegistrations).toBe(0); }); + it('should still move selection when the segment key handler runs', async () => { + const { el, overview, board } = createControl(); + await waitForFrame(); + + let changes = 0; + el.addEventListener('bq-change', () => { + changes += 1; + }); + + const keyHandler = (el as unknown as Record)['_keyHandler'] as + | ((event: KeyboardEvent) => void) + | undefined; + + expect(keyHandler).toBeDefined(); + + const event = new win.KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + }); + + Object.defineProperty(event, 'target', { + value: overview, + configurable: true, + }); + + const globalScope = globalThis as typeof globalThis & { + getComputedStyle?: typeof win.getComputedStyle; + }; + const originalGetComputedStyle = globalScope.getComputedStyle; + globalScope.getComputedStyle = win.getComputedStyle.bind(win); + + try { + keyHandler?.(event); + } finally { + globalScope.getComputedStyle = originalGetComputedStyle; + } + + expect(el.getAttribute('value')).toBe('board'); + expect(board.getAttribute('data-selected')).toBe('true'); + expect(changes).toBe(1); + }); + it('should allow aria-label when no visible label is rendered', async () => { const el = doc.createElement('bq-segmented-control'); el.setAttribute('aria-label', 'Display density'); From 6c71faf88b784aa818db82d3b3b7a6d850503136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:33:33 +0000 Subject: [PATCH 08/11] test: use public keyboard path in segmented control test Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- tests/segmented-control.test.ts | 31 +++++-------------------------- tests/setup.ts | 1 + 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index ce51eda..48874f3 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -110,7 +110,7 @@ describe('BqSegmentedControl', () => { expect(hostKeydownRegistrations).toBe(0); }); - it('should still move selection when the segment key handler runs', async () => { + it('should still move selection when a segment receives ArrowRight', async () => { const { el, overview, board } = createControl(); await waitForFrame(); @@ -119,33 +119,12 @@ describe('BqSegmentedControl', () => { changes += 1; }); - const keyHandler = (el as unknown as Record)['_keyHandler'] as - | ((event: KeyboardEvent) => void) - | undefined; - - expect(keyHandler).toBeDefined(); - - const event = new win.KeyboardEvent('keydown', { + overview.dispatchEvent( + new win.KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, - }); - - Object.defineProperty(event, 'target', { - value: overview, - configurable: true, - }); - - const globalScope = globalThis as typeof globalThis & { - getComputedStyle?: typeof win.getComputedStyle; - }; - const originalGetComputedStyle = globalScope.getComputedStyle; - globalScope.getComputedStyle = win.getComputedStyle.bind(win); - - try { - keyHandler?.(event); - } finally { - globalScope.getComputedStyle = originalGetComputedStyle; - } + }) + ); expect(el.getAttribute('value')).toBe('board'); expect(board.getAttribute('data-selected')).toBe('true'); diff --git a/tests/setup.ts b/tests/setup.ts index ae5cfee..499c616 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -43,6 +43,7 @@ g['NodeList'] = win.NodeList; g['customElements'] = win.customElements; g['DOMParser'] = win.DOMParser; g['SyntaxError'] = SyntaxError; +g['getComputedStyle'] = win.getComputedStyle.bind(win); // requestAnimationFrame / cancelAnimationFrame polyfill g['requestAnimationFrame'] = (cb: FrameRequestCallback): number => From e4eff318ad82ae6f61972f6ba6dfec77f2921d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:35:01 +0000 Subject: [PATCH 09/11] test: tighten segmented control keyboard regression Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- src/components/segmented-control/BqSegmentedControl.ts | 2 +- tests/segmented-control.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts index 4010795..0e5866a 100644 --- a/src/components/segmented-control/BqSegmentedControl.ts +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -295,7 +295,7 @@ const definition: ComponentDefinition< ); if (enabledButtons.length === 0) return; - let nextButton: HTMLButtonElement | null = null; + let nextButton: HTMLButtonElement | null; if (direction === 'first') { nextButton = enabledButtons[0] ?? null; diff --git a/tests/segmented-control.test.ts b/tests/segmented-control.test.ts index 48874f3..300f7d7 100644 --- a/tests/segmented-control.test.ts +++ b/tests/segmented-control.test.ts @@ -121,8 +121,9 @@ describe('BqSegmentedControl', () => { overview.dispatchEvent( new win.KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, + key: 'ArrowRight', + bubbles: true, + cancelable: true, }) ); From f477e1e3e7c11f124815867f95d3c4eccb9512c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:35:50 +0000 Subject: [PATCH 10/11] refactor: simplify segmented control next-button fallback Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/770510b0-8b29-4fa8-97eb-87baea7559ee --- src/components/segmented-control/BqSegmentedControl.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts index 0e5866a..be89383 100644 --- a/src/components/segmented-control/BqSegmentedControl.ts +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -295,13 +295,11 @@ const definition: ComponentDefinition< ); if (enabledButtons.length === 0) return; - let nextButton: HTMLButtonElement | null; + let nextButton: HTMLButtonElement | null = enabledButtons[0] ?? null; - if (direction === 'first') { - nextButton = enabledButtons[0] ?? null; - } else if (direction === 'last') { + if (direction === 'last') { nextButton = enabledButtons[enabledButtons.length - 1] ?? null; - } else { + } else if (direction === 'next' || direction === 'prev') { const currentIndex = enabledButtons.indexOf(currentButton); if (currentIndex === -1) { nextButton = enabledButtons[0] ?? null; From 334874fcb67eeef9c7215f4203d7678e44793826 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:46:51 +0000 Subject: [PATCH 11/11] fix: align segmented control style lookup and docs repo link Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> Agent-Logs-Url: https://github.com/bQuery/ui/sessions/e380cf01-78f7-4f17-b67c-f6d334126a56 --- docs/index.md | 2 +- src/components/segmented-control/BqSegmentedControl.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 64de497..2be3928 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ hero: link: /components/ - theme: alt text: View on GitHub - link: https://github.com/bquery/component-library + link: https://github.com/bQuery/ui features: - icon: 🧩 diff --git a/src/components/segmented-control/BqSegmentedControl.ts b/src/components/segmented-control/BqSegmentedControl.ts index be89383..9fd3905 100644 --- a/src/components/segmented-control/BqSegmentedControl.ts +++ b/src/components/segmented-control/BqSegmentedControl.ts @@ -332,7 +332,9 @@ const definition: ComponentDefinition< const button = target?.closest('button') as HTMLButtonElement | null; if (!button || !getButtons().includes(button)) return; - const isRtl = getComputedStyle(self).direction === 'rtl'; + const docView = self.ownerDocument?.defaultView; + const computedStyle = docView?.getComputedStyle?.(self); + const isRtl = computedStyle?.direction === 'rtl'; switch (keyboardEvent.key) { case 'ArrowRight':