Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c11603b
feat!: migrate to TailwindCSS v4 (CSS-first) — v2.0.0
jaieds Jul 1, 2026
bc8df02
docs: add complete Theme Tokens Reference to README
jaieds Jul 1, 2026
acd94ae
fix: v4 follow-ups from consumer integration testing
jaieds Jul 2, 2026
5dd168e
docs: add 2.0.0 changelog entry
jaieds Jul 2, 2026
31499bf
Update version.json
jaieds Jul 2, 2026
8d9708e
fix: resolve stylelint errors in theme.css
jaieds Jul 16, 2026
541490f
Merge branch 'staging' into fix/SUR-397-tailwindcss-v4-migration
jaieds Jul 22, 2026
5e8cd34
Merge pull request #470 from brainstormforce/fix/SUR-397-tailwindcss-…
jaieds Jul 22, 2026
f76013a
Revert TailwindCSS v4 migration (PR #470)
jaieds Aug 3, 2026
c6a992e
feat: support custom mention trigger regex in EditorInput
jaieds Aug 3, 2026
0e83488
chore: bump version to 1.8.1 and add changelog entry
jaieds Aug 3, 2026
fdf581b
test: close mention menu before story settles to keep axe scan clean
jaieds Aug 3, 2026
ed8e167
fix: keep multi-select dropdown open across selections
jaieds Aug 3, 2026
dd13910
fix: normalize select value when multiple mode is toggled
jaieds Aug 6, 2026
5ed154c
Merge pull request #489 from brainstormforce/component-improvements/e…
ravindrakele Aug 11, 2026
73564a8
fix: address review — inlineSearch refocus, stale selectedIndex, types
jaieds Aug 11, 2026
4aca2c3
Merge branch 'staging' into fix/select-multiselect-keep-open
jaieds Aug 11, 2026
cd2adf5
Update the changelog
jaieds Aug 11, 2026
787c892
Merge pull request #491 from brainstormforce/fix/select-multiselect-k…
jaieds Aug 11, 2026
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand All @@ -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
```

<br />
Expand Down
4 changes: 4 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bsf/force-ui",
Comment thread
jaieds marked this conversation as resolved.
"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",
Expand Down
81 changes: 81 additions & 0 deletions src/components/editor-input/editor-input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jaieds marked this conversation as resolved.
// mounts the editor in a web component: a SEPARATE React root created on the
Comment thread
jaieds marked this conversation as resolved.
Expand Down Expand Up @@ -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<HTMLElement>(
'[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<HTMLElement>(
'[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 ) => (
Expand Down
11 changes: 11 additions & 0 deletions src/components/editor-input/editor-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ interface EditorInputProps<T = TOptionItem> {
by?: T extends Record<string, unknown> ? 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. */
Expand Down Expand Up @@ -109,6 +118,7 @@ const EditorInput = forwardRef<LexicalEditor, EditorInputProps>(
options,
by = 'name',
trigger = '@',
triggerRegex,
menuComponent,
menuItemComponent,
className,
Expand Down Expand Up @@ -185,6 +195,7 @@ const EditorInput = forwardRef<LexicalEditor, EditorInputProps>(
by={ by }
optionsArray={ options }
trigger={ trigger }
triggerRegex={ triggerRegex }
autoSpace={ autoSpaceAfterMention }
/>
<OnChangePlugin
Expand Down
23 changes: 22 additions & 1 deletion src/components/editor-input/mention-plugin/mention-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface MentionPluginProps<T = OptionsArray> {
menuComponent?: TMenuComponent;
menuItemComponent?: TMenuItemComponent;
autoSpace: boolean;
triggerRegex?: RegExp;
}

const MentionPlugin = ( {
Expand All @@ -68,6 +69,7 @@ const MentionPlugin = ( {
menuComponent: MenuComponent = EditorCombobox,
menuItemComponent: MenuItemComponent = EditorCombobox.Item,
autoSpace = true,
triggerRegex,
}: MentionPluginProps ) => {
const { y, refs, strategy } = useFloating( {
placement: 'bottom',
Expand All @@ -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 =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';

Expand Down Expand Up @@ -135,7 +156,7 @@ const MentionPlugin = ( {
}
return null;
};
}, [ trigger ] );
}, [ trigger, triggerRegex ] );

const [ editor ] = useLexicalComposerContext();
const [ queryString, setQueryString ] = useState<string | null>( null );
Expand Down
125 changes: 110 additions & 15 deletions src/components/select/select-atom.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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( {} );
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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 (
<div style={ { width: '300px' } }>
<button type="button" onClick={ () => setMultiple( ( v ) => ! v ) }>
{ multiple ? 'Disable multiple' : 'Enable multiple' }
</button>
<Select
size={ size }
multiple={ multiple }
disabled={ disabled }
onChange={ ( value ) => value }
>
<Select.Button
label="Select a Color"
placeholder="Select an option"
render={ ( selected ) =>
( selected as Record<string, string> )?.name
}
/>
<Select.Portal>
<Select.Options>
{ options.map( ( option ) => (
<Select.Option key={ option.id } value={ option }>
{ option.name }
</Select.Option>
) ) }
</Select.Options>
</Select.Portal>
</Select>
</div>
);
};
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,
Expand Down
2 changes: 0 additions & 2 deletions src/components/select/select-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ export type SelectOnClose = ( event: React.MouseEvent ) => void;

export type SelectFunctionChildren = ( {
value,
onClose,
}: {
value: SelectOptionValue;
onClose?: ( event: React.MouseEvent<HTMLElement> ) => void;
} ) => React.JSX.Element;

export type MultiTypeChildren =
Expand Down
Loading
Loading