diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10e8fe7b9d..48c263f1c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- `@lumx/vue`:
+ - Create the `SelectButton` component
+
## [4.13.0][] - 2026-05-11
### Added
diff --git a/packages/lumx-core/src/js/components/Combobox/ComboboxButton.tsx b/packages/lumx-core/src/js/components/Combobox/ComboboxButton.tsx
index c7b0d292e1..ce4b7319f2 100644
--- a/packages/lumx-core/src/js/components/Combobox/ComboboxButton.tsx
+++ b/packages/lumx-core/src/js/components/Combobox/ComboboxButton.tsx
@@ -84,7 +84,17 @@ export const ComboboxButton = (props: ComboboxButtonProps, { Button, Tooltip }:
// Hide tooltip if the displayed content equals the label or when open
const hideTooltip = label === content || isOpen;
- const Component = renderButton || Button;
+ const componentProps = {
+ ref,
+ ...forwardedProps,
+ className: classNames.join(className, CLASSNAME),
+ role: 'combobox',
+ 'aria-controls': listboxId,
+ 'aria-haspopup': 'listbox',
+ 'aria-expanded': isOpen,
+ 'aria-activedescendant': '',
+ children: content,
+ };
return (
-
- {content}
-
+ {renderButton ? renderButton(componentProps) : }
);
};
diff --git a/packages/lumx-core/src/js/components/SelectButton/Stories.tsx b/packages/lumx-core/src/js/components/SelectButton/Stories.tsx
index 9e8bfc535d..bb3d2b5709 100644
--- a/packages/lumx-core/src/js/components/SelectButton/Stories.tsx
+++ b/packages/lumx-core/src/js/components/SelectButton/Stories.tsx
@@ -1,28 +1,26 @@
import { userEvent } from 'storybook/test';
+import { mdiFruitCherries, mdiFruitCitrus, mdiFruitGrapes, mdiFruitWatermelon } from '@lumx/icons';
import type { SetupStoriesOptions } from '@lumx/core/stories/types';
import { TRANSLATIONS } from './Tests';
-export interface Fruit {
- id: string;
- name: string;
- category: string;
- description?: string;
-}
+const CAT_STONE = { category: 'Stone', categoryIcon: mdiFruitCherries };
+const CAT_BERRY = { category: 'Berry', categoryIcon: mdiFruitGrapes };
+const CAT_CITRUS = { category: 'Citrus', categoryIcon: mdiFruitCitrus };
-export const FRUITS: Fruit[] = [
- { id: 'apple', name: 'Apple', category: 'Pome', description: 'A sweet red fruit' },
- { id: 'apricot', name: 'Apricot', category: 'Stone', description: 'A soft orange fruit' },
- { id: 'banana', name: 'Banana', category: 'Tropical', description: 'A long yellow fruit' },
- { id: 'blueberry', name: 'Blueberry', category: 'Berry', description: 'A small blue fruit' },
- { id: 'cherry', name: 'Cherry', category: 'Stone', description: 'A small red fruit' },
- { id: 'grape', name: 'Grape', category: 'Berry', description: 'A small purple fruit' },
- { id: 'lemon', name: 'Lemon', category: 'Citrus', description: 'A sour yellow fruit' },
- { id: 'orange', name: 'Orange', category: 'Citrus', description: 'A citrus fruit' },
- { id: 'peach', name: 'Peach', category: 'Stone', description: 'A soft fuzzy fruit' },
- { id: 'strawberry', name: 'Strawberry', category: 'Berry', description: 'A sweet red berry' },
+export const FRUITS = [
+ { id: '0', name: 'Apricot', icon: mdiFruitCherries, description: 'A soft orange fruit', ...CAT_STONE },
+ { id: '1', name: 'Blueberry', icon: mdiFruitGrapes, description: 'A small blue fruit', ...CAT_BERRY },
+ { id: '2', name: 'Cherry', icon: mdiFruitCherries, description: 'A small red fruit', ...CAT_STONE },
+ { id: '3', name: 'Grape', icon: mdiFruitGrapes, description: 'A small purple fruit', ...CAT_BERRY },
+ { id: '4', name: 'Lemon', icon: mdiFruitCitrus, description: 'A sour yellow fruit', ...CAT_CITRUS },
+ { id: '5', name: 'Orange', icon: mdiFruitCitrus, description: 'A citrus fruit', ...CAT_CITRUS },
+ { id: '6', name: 'Peach', icon: mdiFruitCherries, description: 'A soft fuzzy fruit', ...CAT_STONE },
+ { id: '7', name: 'Strawberry', icon: mdiFruitWatermelon, description: 'A sweet red berry', ...CAT_BERRY },
];
+export type Fruit = (typeof FRUITS)[number];
+
/**
* Setup SelectButton stories for a specific framework (React or Vue).
*/
diff --git a/packages/lumx-core/src/js/components/SelectButton/TestStories.tsx b/packages/lumx-core/src/js/components/SelectButton/TestStories.tsx
index dd860e95c4..6770343e40 100644
--- a/packages/lumx-core/src/js/components/SelectButton/TestStories.tsx
+++ b/packages/lumx-core/src/js/components/SelectButton/TestStories.tsx
@@ -7,9 +7,13 @@ import { createTemplates } from './Tests';
*/
export function setup({
components: { SelectButton },
+ renderWithState,
}: SetupStoriesOptions<{
components: { SelectButton: any };
-}>) {
+ decorators: 'withValueOnChange';
+}> & {
+ renderWithState: (template: (props: any) => any) => any;
+}) {
const { defaultTemplate } = createTemplates(SelectButton);
const meta = {
@@ -47,6 +51,33 @@ export function setup({
},
};
+ // ─── Selection updates button display ────────────────────────
+
+ const SelectionUpdates = {
+ render: () => renderWithState(defaultTemplate),
+ play: async ({ canvasElement }: any) => {
+ const button = within(canvasElement).getByRole('combobox');
+
+ expect(button.textContent).toContain('Select a fruit');
+
+ await userEvent.click(button);
+ await waitFor(() => {
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ const options = screen.getAllByRole('option');
+ await userEvent.click(options[2]); // Banana
+
+ await waitFor(() => {
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ await waitFor(() => {
+ expect(button.textContent).toContain('Banana');
+ });
+ },
+ };
+
/**
* Test story for WithInfiniteScroll.
* Opens the dropdown and verifies that the infinite scroll sentinel triggers
@@ -94,6 +125,7 @@ export function setup({
return {
meta,
ClickOutsideCloses,
+ SelectionUpdates,
WithInfiniteScroll,
};
}
diff --git a/packages/lumx-react/src/components/select-button/SelectButton.stories.tsx b/packages/lumx-react/src/components/select-button/SelectButton.stories.tsx
index f385786e92..25e7f2e068 100644
--- a/packages/lumx-react/src/components/select-button/SelectButton.stories.tsx
+++ b/packages/lumx-react/src/components/select-button/SelectButton.stories.tsx
@@ -35,7 +35,11 @@ export const LabelDisplayModes = { ...stories.LabelDisplayModes };
// ── Framework-specific stories (use React hooks for stateful behavior) ──
-/** SelectButton with custom option rendering via the `renderOption` prop */
+/**
+ * SelectButton with a custom button trigger (`as={Chip}`) and custom option/section
+ * rendering via the `renderOption` and `renderSectionTitle` props (icons added on
+ * the section title and each option).
+ */
export const CustomRender = () => {
const [value, setValue] = useState();
@@ -46,15 +50,20 @@ export const CustomRender = () => {
options={FRUITS}
getOptionId="id"
getOptionName="name"
+ getSectionId="category"
value={value}
onChange={setValue}
isClickable
isSelected={!!value}
after={}
- renderOption={(fruit) => (
-
- {fruit.name}
-
+ renderSectionTitle={(sectionId: string, options: Fruit[]) => (
+ <>
+
+ {sectionId}
+ >
+ )}
+ renderOption={(fruit: Fruit) => (
+ } />
)}
/>
);
diff --git a/packages/lumx-react/src/components/select-button/SelectButton.test.stories.tsx b/packages/lumx-react/src/components/select-button/SelectButton.test.stories.tsx
index 87e0f29e93..286e9e7f16 100644
--- a/packages/lumx-react/src/components/select-button/SelectButton.test.stories.tsx
+++ b/packages/lumx-react/src/components/select-button/SelectButton.test.stories.tsx
@@ -1,18 +1,30 @@
/* eslint-disable react-hooks/rules-of-hooks */
-import { useCallback, useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { setup } from '@lumx/core/js/components/SelectButton/TestStories';
import { FRUITS, Fruit } from '@lumx/core/js/components/SelectButton/Stories';
+import { withValueOnChange } from '@lumx/react/stories/decorators/withValueOnChange';
import { SelectButton } from '.';
+function renderWithState(template: (props: any) => React.JSX.Element) {
+ const Wrapper = () => {
+ const [value, setValue] = useState(undefined);
+ return template({ value, onChange: setValue });
+ };
+ return ;
+}
+
const { meta, ...testStories } = setup({
components: { SelectButton },
+ decorators: { withValueOnChange },
+ renderWithState,
});
export default { ...meta, title: 'LumX components/select-button/SelectButton/Tests' };
export const ClickOutsideCloses = { ...testStories.ClickOutsideCloses };
+export const SelectionUpdates = { ...testStories.SelectionUpdates };
// React-specific test stories (use React hooks for stateful rendering)
diff --git a/packages/lumx-vue/src/components/button/Button.tsx b/packages/lumx-vue/src/components/button/Button.tsx
index 2b29fad8e1..089149181c 100644
--- a/packages/lumx-vue/src/components/button/Button.tsx
+++ b/packages/lumx-vue/src/components/button/Button.tsx
@@ -1,5 +1,5 @@
import isEmpty from 'lodash/isEmpty';
-import { computed, defineComponent, toRaw, useAttrs, useSlots } from 'vue';
+import { Comment, computed, defineComponent, toRaw, useAttrs, useSlots } from 'vue';
import {
Button as ButtonUI,
@@ -59,15 +59,18 @@ const Button = defineComponent(
*/
const renderContent = () => {
const children = slots.default?.();
- if (!children || children.length === 0) return null;
+
+ // Filter vnodes (Matches React behavior where `{null}` renders nothing — avoids an empty wrapping)
+ const visibleChildren = children?.filter((vnode) => vnode != null && vnode.type !== Comment);
+ if (!visibleChildren?.length) return null;
// If single Text component, render directly
- if (children.length === 1 && children[0].type === Text) {
- return children[0];
+ if (visibleChildren.length === 1 && visibleChildren[0].type === Text) {
+ return visibleChildren[0];
}
// Otherwise wrap in span
- return {children};
+ return {visibleChildren};
};
return () => {
diff --git a/packages/lumx-vue/src/components/button/IconButton.test.ts b/packages/lumx-vue/src/components/button/IconButton.test.ts
index cbfd3fc11d..985492e487 100644
--- a/packages/lumx-vue/src/components/button/IconButton.test.ts
+++ b/packages/lumx-vue/src/components/button/IconButton.test.ts
@@ -56,7 +56,7 @@ describe('', () => {
expect(tooltip).toBeInTheDocument();
});
- it('should forward ref to the underlying button element', () => {
+ it('should expose the underlying button element via $el', () => {
const iconButtonRef = ref();
render(
defineComponent({
@@ -67,9 +67,8 @@ describe('', () => {
template: ``,
}),
);
- // The ref exposes { $el } pointing to the underlying button element,
- // so that @floating-ui/vue can resolve it correctly as an anchor.
- expect((iconButtonRef.value as any)?.$el).toBe(screen.getByRole('button', { name: 'Icon' }));
+ const button = screen.getByRole('button', { name: 'Icon' });
+ expect((iconButtonRef.value as any)?.$el).toBe(button);
});
it('should hide tooltip when hideTooltip is true', async () => {
diff --git a/packages/lumx-vue/src/components/button/IconButton.tsx b/packages/lumx-vue/src/components/button/IconButton.tsx
index f800ba3711..bdfcd20852 100644
--- a/packages/lumx-vue/src/components/button/IconButton.tsx
+++ b/packages/lumx-vue/src/components/button/IconButton.tsx
@@ -53,9 +53,16 @@ const IconButton = defineComponent(
emit('click', event);
};
- // Ref to the underlying button DOM element, exposed so template refs resolve to the button.
- const buttonRef = ref();
- expose({ $el: buttonRef });
+ // Ref to the underlying button DOM element.
+ const buttonRef = ref(null);
+ const setButtonRef = (el: any) => {
+ buttonRef.value = (el?.$el ?? el) as HTMLElement | null;
+ };
+ expose({
+ get $el() {
+ return buttonRef.value;
+ },
+ });
return () => {
const { linkAs, tooltipProps, hideTooltip, ...rest } = otherProps.value;
@@ -63,7 +70,7 @@ const IconButton = defineComponent(
void;
+ /** Custom render function replacing the default `