From 381f9c111954e5f10c16a3c6dd64e4dbb955a69a Mon Sep 17 00:00:00 2001 From: Guillaume Cornut Date: Thu, 7 May 2026 17:45:19 +0200 Subject: [PATCH 1/3] chore(vue): fix ref forwarding on IconButton and ListItemAction --- .../src/components/button/IconButton.test.ts | 7 +++---- .../src/components/button/IconButton.tsx | 15 +++++++++++---- .../combobox/ComboboxOptionMoreInfo.tsx | 18 ++++-------------- .../components/list/ListItemAction.test.ts | 19 ++++++++++++++++++- .../src/components/list/ListItemAction.tsx | 8 ++------ 5 files changed, 38 insertions(+), 29 deletions(-) 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( { const mergedClassName = useClassName(() => props.class); - // Ref to the IconButton component instance (with exposed `$el`). + // Ref to the IconButton component instance. const iconButtonRef = ref(null); - // Ref to the resolved DOM element (} ); }; 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/combobox/ComboboxButton.tsx b/packages/lumx-vue/src/components/combobox/ComboboxButton.tsx index 90bebf0b60..f6ebd68702 100644 --- a/packages/lumx-vue/src/components/combobox/ComboboxButton.tsx +++ b/packages/lumx-vue/src/components/combobox/ComboboxButton.tsx @@ -23,6 +23,10 @@ export type ComboboxButtonProps = VueToJSXProps 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 `