diff --git a/README.md b/README.md index 3c85c599..5e06b083 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Using Force UI as a dependency in package.json - ```json "dependencies": { - "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.8.0" + "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.8.1" } ``` @@ -28,7 +28,7 @@ npm install Or you can directly run the following command to install the package - ```bash -npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.8.0 +npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.8.1 ```
diff --git a/changelog.txt b/changelog.txt index 28ed3f08..3f675bff 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +Version 1.8.1 - 11th August, 2026 +- Improvement: Organism - Editor Input: Added optional `triggerRegex` prop that overrides the built-in mention-suggestion matcher, so consumers can allow the trigger without a leading blank space (e.g. directly after a double quote inside JSON string values). The custom regex must expose the same capture groups as the built-in matcher: 1 = leading boundary, 2 = replaceable string (trigger + query), 3 = query string used to filter options. When omitted, the existing trigger-based matcher is used unchanged. +- Fix: Atom - Select: In `multiple` mode the dropdown now stays open while selecting or deselecting options, closing only on Escape or outside click. + Version 1.8.0 - 17th July, 2026 - New: Atom - File Picker: Added a standalone file upload component that displays the selected file name and supports a controlled value and an optional clear action. - New: Atom - Select: Added `inlineSearch` prop that renders the search input inside the trigger instead of the dropdown. Selected items render as badges (multiple) or as the input value (single). Mutually exclusive with `combobox`; `inlineSearch` wins when both are passed. diff --git a/package-lock.json b/package-lock.json index a172425f..813b5954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsf/force-ui", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsf/force-ui", - "version": "1.8.0", + "version": "1.8.1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index cb48168d..39ac5140 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bsf/force-ui", - "version": "1.8.0", + "version": "1.8.1", "description": "Library of components for the BSF project", "main": "./dist/force-ui.cjs.js", "module": "./dist/force-ui.es.js", diff --git a/src/components/editor-input/editor-input.stories.tsx b/src/components/editor-input/editor-input.stories.tsx index a51536c8..c51bb12c 100644 --- a/src/components/editor-input/editor-input.stories.tsx +++ b/src/components/editor-input/editor-input.stories.tsx @@ -2,6 +2,7 @@ import EditorInput from './editor-input'; import type { Meta, StoryFn } from '@storybook/react-vite'; import { useEffect, useRef, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; // Renders its children inside an open Shadow DOM root, the way a real consumer // mounts the editor in a web component: a SEPARATE React root created on the @@ -112,6 +113,86 @@ Large.args = { onChange: ( editorState ) => editorState.toJSON(), }; +// Overrides the suggestion trigger with a custom regex so the menu opens on +// "{{" (e.g. template-variable syntax) instead of the default "@" trigger. +export const CustomTriggerRegex: Story = Template.bind( {} ); +CustomTriggerRegex.args = { + size: 'md', + placeholder: 'Type {{ to view variable suggestions', + options, + triggerRegex: /(^|\s|\()(\{\{(\w{0,30}))$/, + onChange: ( editorState ) => editorState.toJSON(), +}; +CustomTriggerRegex.play = async ( { canvasElement } ) => { + const editor = canvasElement.querySelector( + '[contenteditable="true"]' + ); + await expect( editor ).not.toBeNull(); + await userEvent.click( editor! ); + + // The menu portals to document.body, not the story canvas. + const body = within( canvasElement.ownerDocument.body ); + + // The default "@" trigger must NOT open the menu when a custom regex is + // provided. The lookup service debounces by 500ms, so give it time to + // (not) fire before asserting. + await userEvent.keyboard( '@Re' ); + await new Promise( ( resolve ) => setTimeout( resolve, 700 ) ); + await expect( body.queryByText( 'Red' ) ).not.toBeInTheDocument(); + + // The custom "{{" trigger opens the menu. In `userEvent.keyboard`, "{{" + // escapes a literal "{", so four braces type two. + await userEvent.keyboard( ' {{{{Re' ); + await expect( + await body.findByText( 'Red', {}, { timeout: 3000 } ) + ).toBeVisible(); + + // Close the menu before the story settles: the test-runner's axe scan + // runs on the final story state, and the open combobox popup has known + // aria-required-parent/children violations unrelated to this feature. + await userEvent.keyboard( '{Escape}' ); + await waitFor( () => + expect( body.queryByText( 'Red' ) ).not.toBeInTheDocument() + ); +}; + +// SureRank PR #2776: smart tags must be insertable WITHOUT a blank space +// before the trigger (e.g. "text@" right after a word). The default matcher +// requires `(^|\s|\()` before the trigger; this custom regex drops that +// boundary so "@" opens the menu anywhere, including mid-word. +export const MentionWithoutLeadingSpace: Story = Template.bind( {} ); +MentionWithoutLeadingSpace.args = { + size: 'md', + options, + triggerRegex: /()(@(\w{0,75}))$/, + onChange: ( editorState ) => editorState.toJSON(), +}; +MentionWithoutLeadingSpace.play = async ( { canvasElement } ) => { + const editor = canvasElement.querySelector( + '[contenteditable="true"]' + ); + await expect( editor ).not.toBeNull(); + await userEvent.click( editor! ); + + // The menu portals to document.body, not the story canvas. + const body = within( canvasElement.ownerDocument.body ); + + // No space before "@" — the default matcher would ignore this; the + // custom regex must open the menu. + await userEvent.keyboard( 'Hello@Re' ); + await expect( + await body.findByText( 'Red', {}, { timeout: 3000 } ) + ).toBeVisible(); + + // Close the menu before the story settles: the test-runner's axe scan + // runs on the final story state, and the open combobox popup has known + // aria-required-parent/children violations unrelated to this feature. + await userEvent.keyboard( '{Escape}' ); + await waitFor( () => + expect( body.queryByText( 'Red' ) ).not.toBeInTheDocument() + ); +}; + // Renders the editor inside a Shadow DOM to verify it works across the shadow // boundary (fix from facebook/lexical PR #7790). export const InsideShadowDom: Story = ( args ) => ( diff --git a/src/components/editor-input/editor-input.tsx b/src/components/editor-input/editor-input.tsx index 89571dc8..e568d8af 100644 --- a/src/components/editor-input/editor-input.tsx +++ b/src/components/editor-input/editor-input.tsx @@ -72,6 +72,15 @@ interface EditorInputProps { by?: T extends Record ? keyof T : string; /** The trigger to be used to show the mention options. */ trigger?: string; + /** + * Custom regex that overrides the default mention-suggestion matcher. + * When provided, the `trigger` prop is ignored for matching. The regex + * must expose the same capture groups as the default matcher: + * 1 = leading whitespace/boundary, 2 = replaceable string (trigger + + * query), 3 = query string used to filter the options. + * When omitted, the default trigger-based matcher is used. + */ + triggerRegex?: RegExp; /** The component to be used for the mention menu. */ menuComponent?: TMenuComponent; /** The component to be used for the mention menu items. */ @@ -109,6 +118,7 @@ const EditorInput = forwardRef( options, by = 'name', trigger = '@', + triggerRegex, menuComponent, menuItemComponent, className, @@ -185,6 +195,7 @@ const EditorInput = forwardRef( by={ by } optionsArray={ options } trigger={ trigger } + triggerRegex={ triggerRegex } autoSpace={ autoSpaceAfterMention } /> { menuComponent?: TMenuComponent; menuItemComponent?: TMenuItemComponent; autoSpace: boolean; + triggerRegex?: RegExp; } const MentionPlugin = ( { @@ -68,6 +69,7 @@ const MentionPlugin = ( { menuComponent: MenuComponent = EditorCombobox, menuItemComponent: MenuItemComponent = EditorCombobox.Item, autoSpace = true, + triggerRegex, }: MentionPluginProps ) => { const { y, refs, strategy } = useFloating( { placement: 'bottom', @@ -83,6 +85,25 @@ const MentionPlugin = ( { // matcher) once per trigger instead of on every render — this runs on the // editor's per-keystroke render path. const checkForAtSignMentions = useMemo( () => { + // A custom trigger regex replaces the built-in matcher entirely. It + // must expose the same capture groups the built-in regex does: + // 1 = leading whitespace/boundary, 2 = replaceable string (trigger + + // query), 3 = query string used to filter the options. + if ( triggerRegex ) { + return ( text: string ) => { + const match = triggerRegex.exec( text ); + if ( match === null ) { + return null; + } + const maybeLeadingWhitespace = match[ 1 ] ?? ''; + return { + leadOffset: match.index + maybeLeadingWhitespace.length, + matchingString: match[ 3 ] ?? '', + replaceableString: match[ 2 ] ?? '', + }; + }; + } + const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; @@ -135,7 +156,7 @@ const MentionPlugin = ( { } return null; }; - }, [ trigger ] ); + }, [ trigger, triggerRegex ] ); const [ editor ] = useLexicalComposerContext(); const [ queryString, setQueryString ] = useState( null ); diff --git a/src/components/select/select-atom.stories.tsx b/src/components/select/select-atom.stories.tsx index 5220ed97..e2a826bf 100644 --- a/src/components/select/select-atom.stories.tsx +++ b/src/components/select/select-atom.stories.tsx @@ -248,17 +248,29 @@ MultiSelect.play = async ( { canvasElement } ) => { const listBox = await screen.findByRole( 'listbox' ); expect( listBox ).toHaveTextContent( 'Red' ); - // Click on the first option - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); - - // Check if the listbox contains the option 'Orange' - await userEvent.click( selectButton ); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); + // Select two options — the dropdown stays open in multiple mode. + // Options are re-queried after each click since the list re-renders. + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); // Check if the button text is updated expect( selectButton ).toHaveTextContent( /Red.*Orange/ ); + + // Clicking an already-selected option deselects it without closing + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); // Orange (toggle off) + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); + expect( selectButton ).toHaveTextContent( 'Red' ); + expect( selectButton ).not.toHaveTextContent( 'Orange' ); + + // Escape still closes the dropdown + await userEvent.keyboard( '{Escape}' ); + expect( screen.queryByRole( 'listbox' ) ).toBeNull(); }; export const MultiSelectWithoutPortal = SelectWithoutPortalTemplate.bind( {} ); @@ -498,15 +510,23 @@ InlineSearchMulti.play = async ( { canvasElement } ) => { expect( listbox ).toHaveTextContent( 'Orange' ); expect( listbox ).not.toHaveTextContent( 'Cyan' ); - // Clear and select two options + // Clear and select two options — dropdown stays open in multiple mode + await userEvent.clear( input ); + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); + + // Focus must return to the input after a pick — typing (no re-click) + // keeps filtering, proving type-to-filter survives the selection. + await userEvent.keyboard( 'oran' ); + const listboxAfterPick = await screen.findByRole( 'listbox' ); + expect( listboxAfterPick ).toHaveTextContent( 'Orange' ); + expect( listboxAfterPick ).not.toHaveTextContent( 'Cyan' ); await userEvent.clear( input ); - const allOptions = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions[ 0 ] ); // Red - // Re-open and select Orange - await userEvent.click( triggerWrapper ); - const allOptions2 = await screen.findAllByRole( 'option' ); - await userEvent.click( allOptions2[ 1 ] ); // Orange + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); + expect( screen.queryByRole( 'listbox' ) ).not.toBeNull(); // Two badges should be visible inside trigger const redBadge = await canvas.findByText( 'Red' ); @@ -546,6 +566,81 @@ InlineSearchMulti.play = async ( { canvasElement } ) => { expect( screen.queryByRole( 'listbox' ) ).toBeNull(); }; +// Toggling `multiple` at runtime while a selection already exists. +export const ToggleMultipleAtRuntime: Story = ( { size, disabled } ) => { + const [ multiple, setMultiple ] = useState( false ); + return ( +
+ + +
+ ); +}; +ToggleMultipleAtRuntime.args = { + size: 'md', + disabled: false, +}; +ToggleMultipleAtRuntime.parameters = { + docs: { + description: { + story: 'The `multiple` prop can be flipped after a selection exists. The existing single value is treated as a one-item list instead of throwing.', + }, + }, +}; +ToggleMultipleAtRuntime.play = async ( { canvasElement } ) => { + const canvas = within( canvasElement ); + + // Single mode: pick Red. + const trigger = await canvas.findByRole( 'combobox' ); + await userEvent.click( trigger ); + await userEvent.click( await screen.findByRole( 'option', { name: 'Red' } ) ); + expect( trigger ).toHaveTextContent( 'Red' ); + + // Flip to multiple mode — previous single value survives as a badge. + await userEvent.click( + await canvas.findByRole( 'button', { name: 'Enable multiple' } ) + ); + expect( trigger ).toHaveTextContent( 'Red' ); + + // Selecting another option must not crash and must append. + await userEvent.click( trigger ); + await userEvent.click( + await screen.findByRole( 'option', { name: 'Orange' } ) + ); + expect( trigger ).toHaveTextContent( /Red.*Orange/ ); + + // Flip back to single mode — first value is shown, still no crash. + await userEvent.keyboard( '{Escape}' ); + await userEvent.click( + await canvas.findByRole( 'button', { name: 'Disable multiple' } ) + ); + expect( trigger ).toHaveTextContent( 'Red' ); +}; + const GroupedSelectTemplate: Story = ( { size, multiple, diff --git a/src/components/select/select-types.ts b/src/components/select/select-types.ts index f4311c71..865f14f4 100644 --- a/src/components/select/select-types.ts +++ b/src/components/select/select-types.ts @@ -12,10 +12,8 @@ export type SelectOnClose = ( event: React.MouseEvent ) => void; export type SelectFunctionChildren = ( { value, - onClose, }: { value: SelectOptionValue; - onClose?: ( event: React.MouseEvent ) => void; } ) => React.JSX.Element; export type MultiTypeChildren = diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index a568101f..a89de0b1 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -56,7 +56,7 @@ import type { SelectSizes, SelectOptionGroupProps, } from './select-types'; -import { getTextContent } from './utils'; +import { getTextContent, toValuesArray } from './utils'; import { useDebouncedCallback } from '@/utilities/hooks'; // Context to manage the state of the select component. @@ -156,20 +156,27 @@ export const SelectButton = forwardRef( }, [ icon ] ); const renderSelected = useCallback( () => { - const selectedValue = getValues(); + const currentValue = getValues(); - if ( ! selectedValue ) { + if ( ! currentValue ) { return null; } if ( multiple ) { - return ( selectedValue as SelectOptionValue[] ).map( - ( valueItem: SelectOptionValue, index: number ) => ( + return toValuesArray( currentValue ).map( + ( valueItem: SelectOptionValue ) => ( )[ + by + ] + : valueItem + ) } size={ badgeSize as SelectSizes } onMouseDown={ handleOnCloseItem( valueItem ) } label={ @@ -184,6 +191,15 @@ export const SelectButton = forwardRef( ); } + // Single mode can still hold an array when `multiple` is toggled at + // runtime — fall back to the first entry so render()/children get a + // single value. An empty array is passed through untouched to keep + // the value render()/children already received. + const selectedValue = + Array.isArray( currentValue ) && currentValue.length + ? currentValue[ 0 ] + : currentValue; + let renderValue: ReactNode = typeof selectedValue === 'string' ? selectedValue : ''; @@ -197,13 +213,6 @@ export const SelectButton = forwardRef( ) { const childProps = { value: selectedValue as SelectOptionValue, - ...( multiple - ? { - onClose: handleOnCloseItem( - selectedValue as SelectOptionValue - ), - } - : {} ), }; renderValue = children( childProps ); } @@ -227,7 +236,7 @@ export const SelectButton = forwardRef( { renderValue as React.ReactNode } ); - }, [ getValues, disabled ] ); + }, [ getValues, disabled, multiple, render, children, by ] ); const handleOnCloseItem = ( value: SelectOptionValue ) => @@ -235,9 +244,7 @@ export const SelectButton = forwardRef( event?.preventDefault(); event?.stopPropagation(); - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; + const selectedValues = [ ...toValuesArray( getValues() ) ]; const selectedIndex = selectedValues.findIndex( ( val ) => { if ( val !== null && @@ -276,7 +283,7 @@ export const SelectButton = forwardRef( } const showPlaceholder = multiple - ? ! ( getValues() as SelectOptionValue[] )?.length + ? ! toValuesArray( getValues() ).length : ! getValues() && ! searchKeyword; return ( @@ -384,9 +391,9 @@ export const SelectButton = forwardRef( multiple ) { e.preventDefault(); - const arr = - ( getValues() as SelectOptionValue[] ) ?? - []; + const arr = toValuesArray( + getValues() + ); if ( arr.length ) { handleOnCloseItem( arr[ arr.length - 1 ] @@ -456,7 +463,7 @@ export const SelectButton = forwardRef( { /* Placeholder */ } { ( multiple - ? ! ( getValues() as SelectOptionValue[] )?.length + ? ! toValuesArray( getValues() ).length : ! getValues() ) && (
{ + return toValuesArray( currentValue ).some( ( val ) => { if ( val !== null && value !== null && typeof val === 'object' ) { return ( ( val as Record )[ by ] === @@ -1056,7 +1063,7 @@ export function SelectItem( { } return val === value; } ); - }, [ value, getValues ] ); + }, [ value, getValues, multiple, by ] ); const isChecked = useMemo( () => { if ( typeof selected === 'boolean' ) { @@ -1068,7 +1075,7 @@ export function SelectItem( { } return indx === selectedIndex; - }, [ multipleChecked, selectedIndex, selected ] ); + }, [ multipleChecked, selectedIndex, selected, multiple, indx ] ); let itemTabIndex: number | undefined; if ( ! inlineSearch ) { @@ -1234,9 +1241,10 @@ const SelectComponent = ( { ] ); const handleMultiSelect: OnClick = ( index, newValue ) => { - const selectedValues = [ - ...( ( getValues() as SelectOptionValue[] ) ?? [] ), - ]; + // getValues() can return a single value here — e.g. `multiple` flipped + // to true while a single selection was already made — so normalize + // before treating it as a list. + const selectedValues = [ ...toValuesArray( getValues() ) ]; const valueIndex = selectedValues.findIndex( ( selectedValue ) => { if ( selectedValue !== null && @@ -1251,20 +1259,31 @@ const SelectComponent = ( { return selectedValue === newValue; } ); + // Toggle: deselect if already selected, otherwise select. The + // dropdown stays open so multiple options can be picked in one go; + // Escape and outside click (useDismiss) still close it. if ( valueIndex !== -1 ) { - return; + selectedValues.splice( valueIndex, 1 ); + if ( selectedIndex === index ) { + setSelectedIndex( null ); + } + } else { + selectedValues.push( newValue ); + setSelectedIndex( index ); } - selectedValues.push( newValue ); if ( ! isControlled ) { setSelected( selectedValues ); } - setSelectedIndex( index ); - ( - ( refs.domReference.current ?? - refs.reference.current ) as HTMLElement | null - )?.focus(); - setIsOpen( false ); + // inlineSearch options carry no tabIndex and render without a + // FloatingFocusManager, so a click would drop focus to document.body + // and kill type-to-filter, arrow nav and Backspace-removes-badge. + if ( inlineSearch ) { + ( + ( refs.domReference.current ?? + refs.reference.current ) as HTMLElement | null + )?.focus(); + } setSearchKeyword( '' ); if ( typeof onChange === 'function' ) { onChange( selectedValues ); diff --git a/src/components/select/utils.ts b/src/components/select/utils.ts index 2b18d816..639aa844 100644 --- a/src/components/select/utils.ts +++ b/src/components/select/utils.ts @@ -1,4 +1,29 @@ import { type ReactNode, isValidElement } from 'react'; +import type { SelectOptionValue } from './select-types'; + +/** + * Normalize a select value into an array of selected values. + * + * Multi-select code paths assume an array, but the value can be a single + * value when `multiple` is toggled at runtime or when a single value is + * passed while `multiple` is true. Wrapping instead of assuming keeps those + * paths from throwing on non-iterable values. + * + * @param {SelectOptionValue | SelectOptionValue[] | null | undefined} value - Current select value. + * @return {SelectOptionValue[]} Array of selected values. + * @since x.x.x + */ +export const toValuesArray = ( + value: SelectOptionValue | SelectOptionValue[] | null | undefined +): SelectOptionValue[] => { + if ( Array.isArray( value ) ) { + return value; + } + if ( value === null || typeof value === 'undefined' || value === '' ) { + return []; + } + return [ value ]; +}; /** * Get text content of a node diff --git a/version.json b/version.json index 849418d2..b4050a71 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "force-ui": "1.8.0" + "force-ui": "1.8.1" }