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 `]; + }); + + render(SelectButton, { + props: { + label: 'Select a fruit', + options: FRUITS, + getOptionId: 'id', + getOptionName: 'name', + }, + slots: { button: slotSpy }, + }); + + expect(slotSpy).toHaveBeenCalled(); + const slotProps = slotSpy.mock.calls[0][0]; + expect(slotProps).toHaveProperty('buttonProps'); + expect(slotProps.buttonProps).toHaveProperty('role', 'combobox'); + expect(slotProps).toHaveProperty('label', 'Select a fruit'); + // Default labelDisplayMode is 'show-selection'; no value selected → children fall back to label + expect(slotProps).toHaveProperty('children', 'Select a fruit'); + }); + + it('should set children to null when labelDisplayMode is "show-tooltip"', () => { + const slotSpy = vi.fn((slotProps: any) => [ + ) as JSXElement; + } + + // Custom button render + return slots.button({ buttonProps, label: props.label, children }) as JSXElement; + }, + ); + + const isMultiple = computed(() => props.selectionType === 'multiple'); + + const handleSelect = (selectedOption: { value: string }) => { + const next = toggleSelection( + props.options, + props.getOptionId, + props.value, + selectedOption?.value, + isMultiple.value, + ); + // Preserve previous behaviour of normalizing falsy single-mode results to `undefined`. + emit('change', next || undefined); + }; + + /* + * Check if a 'load-more' listener is registered on this component + * (EmitsToProps mapping for the 'load-more' event produces 'onLoad-more'). + */ + const hasLoadMoreListener = useHasEventListener('onLoadMore') || useHasEventListener('onLoad-more'); + const onLoadMore = () => emit('load-more'); + + return () => { + const listStatus = props.listStatus ?? 'idle'; + + return UI( + { + options: props.options, + getOptionId: props.getOptionId as any, + getOptionName: props.getOptionName as any, + getOptionDescription: props.getOptionDescription as any, + renderOption: renderOption.value as any, + getSectionId: props.getSectionId as any, + renderSectionTitle: renderSectionTitle.value as any, + value: props.value, + isMultiselectable: isMultiple.value, + label: props.label, + labelDisplayMode: props.labelDisplayMode, + buttonProps: { + ...attrs, + ...disabledStateProps.value, + className: className.value, + renderButton: renderButton.value, + }, + popoverProps: props.popoverProps, + listProps: { ref: listRef }, + handleSelect, + listStatus, + onOpen: (isOpen: boolean) => emit('open', isOpen), + translations: props.translations, + onLoadMore: hasLoadMoreListener ? onLoadMore : undefined, + infiniteScrollOptions: { root: listRef.value?.$el ?? null, rootMargin: '100px' }, + }, + { Combobox, InfiniteScroll }, + ); + }; + }, + { + name: 'LumxSelectButton', + inheritAttrs: false, + slots: Object as SlotsType<{ + /** + * Replaces the default `