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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 12 additions & 13 deletions packages/lumx-core/src/js/components/Combobox/ComboboxButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip
Expand All @@ -93,18 +103,7 @@ export const ComboboxButton = (props: ComboboxButtonProps, { Button, Tooltip }:
closeMode="hide"
ariaLinkMode="aria-labelledby"
>
<Component
ref={ref}
{...forwardedProps}
className={classNames.join(className, CLASSNAME)}
role="combobox"
aria-controls={listboxId}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-activedescendant=""
>
{content}
</Component>
{renderButton ? renderButton(componentProps) : <Button {...componentProps}>{content}</Button>}
</Tooltip>
);
};
32 changes: 15 additions & 17 deletions packages/lumx-core/src/js/components/SelectButton/Stories.tsx
Original file line number Diff line number Diff line change
@@ -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).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -94,6 +125,7 @@ export function setup({
return {
meta,
ClickOutsideCloses,
SelectionUpdates,
WithInfiniteScroll,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fruit>();

Expand All @@ -46,15 +50,20 @@ export const CustomRender = () => {
options={FRUITS}
getOptionId="id"
getOptionName="name"
getSectionId="category"
value={value}
onChange={setValue}
isClickable
isSelected={!!value}
after={<Icon icon={mdiMenuDown} />}
renderOption={(fruit) => (
<SelectButton.Option value={fruit.id}>
<strong>{fruit.name}</strong>
</SelectButton.Option>
renderSectionTitle={(sectionId: string, options: Fruit[]) => (
<>
<Icon icon={options[0].categoryIcon} size="xs" />
{sectionId}
</>
)}
renderOption={(fruit: Fruit) => (
<SelectButton.Option value={fruit.id} before={<Icon icon={fruit.icon} size="xs" />} />
)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any>(undefined);
return template({ value, onChange: setValue });
};
return <Wrapper />;
}

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)

Expand Down
13 changes: 8 additions & 5 deletions packages/lumx-vue/src/components/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <span>{children}</span>;
return <span>{visibleChildren}</span>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What can be the children here? Can we wrap them always in a span?

Copy link
Copy Markdown
Member Author

@gcornut gcornut May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not really support anything else than text in button content for now

};

return () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/lumx-vue/src/components/button/IconButton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('<IconButton />', () => {
expect(tooltip).toBeInTheDocument();
});

it('should forward ref to the underlying button element', () => {
it('should expose the underlying button element via $el', () => {
const iconButtonRef = ref<HTMLElement>();
render(
defineComponent({
Expand All @@ -67,9 +67,8 @@ describe('<IconButton />', () => {
template: `<IconButton ref="iconButtonRef" label="Icon" />`,
}),
);
// 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 () => {
Expand Down
15 changes: 11 additions & 4 deletions packages/lumx-vue/src/components/button/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,24 @@ const IconButton = defineComponent(
emit('click', event);
};

// Ref to the underlying button DOM element, exposed so template refs resolve to the button.
const buttonRef = ref<HTMLElement>();
expose({ $el: buttonRef });
// Ref to the underlying button DOM element.
const buttonRef = ref<HTMLElement | null>(null);
const setButtonRef = (el: any) => {
buttonRef.value = (el?.$el ?? el) as HTMLElement | null;
};
expose({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrzej-augustyniak I have no idea what this does... is it reactive written this way? And in general do we really care if this is reactive or not?

get $el() {
return buttonRef.value;
},
});

return () => {
const { linkAs, tooltipProps, hideTooltip, ...rest } = otherProps.value;
return (
<Tooltip label={hideTooltip ? '' : props.label} {...tooltipProps}>
<IconButtonUI
{...rest}
ref={buttonRef}
ref={setButtonRef}
linkAs={toRaw(linkAs)}
{...disabledStateProps.value}
className={className.value}
Expand Down
15 changes: 14 additions & 1 deletion packages/lumx-vue/src/components/combobox/ComboboxButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export type ComboboxButtonProps = VueToJSXProps<UIProps, 'label' | 'renderButton
value?: string;
/** Controls how the label/value is displayed. @default 'show-selection' */
labelDisplayMode?: ComboboxButtonLabelDisplayMode;
/** Called when an option is selected. */
onSelect?: (option: { value: string }) => void;
/** Custom render function replacing the default `<Button>`. See `ComboboxButtonProps.renderButton` in core. */
renderButton?: UIProps['renderButton'];
};

/**
Expand Down Expand Up @@ -65,14 +69,22 @@ const ComboboxButton = defineComponent(
});

return () => {
const { label, value, labelDisplayMode = 'show-selection', class: _class, ...forwardedProps } = props;
const {
label,
value,
labelDisplayMode = 'show-selection',
renderButton,
class: _class,
...forwardedProps
} = props;
return UI(
{
...attrs,
...forwardedProps,
label,
value,
labelDisplayMode,
renderButton,
listboxId,
isOpen: isOpen.value,
ref: (el: any) => {
Expand All @@ -94,6 +106,7 @@ const ComboboxButton = defineComponent(
'label',
'value',
'labelDisplayMode',
'renderButton',
'listboxId',
'isOpen',
'onSelect',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineComponent, isRef, ref, watchEffect } from 'vue';
import { computed, defineComponent, ref } from 'vue';

import { useClassName } from '../../composables/useClassName';

Expand Down Expand Up @@ -40,17 +40,11 @@ const ComboboxOptionMoreInfo = defineComponent(
(props: ComboboxOptionMoreInfoProps, { slots }) => {
const mergedClassName = useClassName(() => props.class);

// Ref to the IconButton component instance (with exposed `$el`).
// Ref to the IconButton component instance.
const iconButtonRef = ref<any>(null);

// Ref to the resolved DOM element (<button>) used as the popover anchor.
// IconButton uses `expose({ $el: buttonRef })` where `buttonRef` is a Vue `Ref<HTMLElement>`,
// so we need to unwrap it to get the raw DOM element.
const anchorEl = ref<HTMLElement>();
watchEffect(() => {
const raw = iconButtonRef.value?.$el;
anchorEl.value = (isRef(raw) ? raw.value : raw) ?? undefined;
});
// Resolved DOM element (<button>) used as the popover anchor.
const anchorEl = computed<HTMLElement | undefined>(() => iconButtonRef.value?.$el ?? undefined);

const isHovered = ref(false);

Expand All @@ -65,10 +59,6 @@ const ComboboxOptionMoreInfo = defineComponent(
/**
* IconButton adapter (closure) that maps core `onMouseEnter`/`onMouseLeave`
* → Vue `onMouseenter`/`onMouseleave`
*
* The ref resolves to the IconButton's exposed proxy (with `$el` pointing to the
* actual `<button>` DOM element), which is then used by `watchEffect` to extract
* the anchor element for popover positioning.
*/
function IconButtonAdapter(coreProps: any) {
const { onMouseEnter, onMouseLeave, ...rest } = coreProps;
Expand Down
Loading
Loading