diff --git a/package-lock.json b/package-lock.json
index 1dd29cd7e..8160ca358 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@lob/ui-components",
- "version": "2.0.89",
+ "version": "2.0.90",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@lob/ui-components",
- "version": "2.0.89",
+ "version": "2.0.90",
"dependencies": {
"date-fns": "^2.29.3",
"date-fns-holiday-us": "^0.3.1",
diff --git a/package.json b/package.json
index f46077ebc..317bf249d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lob/ui-components",
- "version": "2.0.89",
+ "version": "2.0.90",
"engines": {
"node": ">=20.2.0",
"npm": ">=10.2.0"
diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx b/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx
new file mode 100644
index 000000000..9bcd145af
--- /dev/null
+++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.mdx
@@ -0,0 +1,37 @@
+import { Canvas, ArgTypes, PRIMARY_STORY } from '@storybook/addon-docs';
+import { Primary } from './AdvancedSearchBar.stories';
+
+# AdvancedSearchBar
+
+This Advanced Search Bar component is designed to render an input for the user to enter their search term and then return multi tabled search from Recipients, Campaigns and Templates.
+
+
+
+## How to Use
+
+To use this component, you must provide a `searchFunction` to fetch search results. The function must return an array. The array must contain objects for each result group with nested results within it.
+
+The component also has 3 optional props. Count an option to display the a number of search results that differs from the number rendered. Link a link to see all the search results. Footer an option to display the footer for the search results.
+
+The table has a 2 slots, Header slot for you to define the header for each result group and body how to render a single result row which it will use to iterate and render each result. You can provide whatever markup you want to be rendered within the slots.
+
+To use this component, here is an example
+
+```html
+
+
+
+
+ {{ result.title }}
+
+
+
+ {{ result.name }}
+
+
+
+```
+
+## Props
+
+
diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js b/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js
new file mode 100644
index 000000000..dd555f468
--- /dev/null
+++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.stories.js
@@ -0,0 +1,129 @@
+import { AdvancedSearchBar } from '@/components';
+import mdx from './AdvancedSearchBar.mdx';
+import routeDecorator, {
+ routeTemplate
+} from '../../../.storybook/routeDecorator';
+import { Icon, IconName } from '../Icon';
+
+export default {
+ title: 'Components/Advanced Search Bar',
+ component: AdvancedSearchBar,
+ decorators: [
+ routeDecorator('/', [
+ {
+ path: '/advanced-search',
+ component: {
+ template: routeTemplate('advanced-search')
+ }
+ }
+ ])
+ ],
+ parameters: {
+ docs: {
+ page: mdx
+ }
+ }
+};
+
+const PrimaryTemplate = (args, { argTypes }) => ({
+ props: Object.keys(argTypes),
+ components: { Icon, AdvancedSearchBar },
+ setup: () => ({ args }),
+ template: `
+
+
+
+
+ {{ result.title }}
+
+
+
+
+
+ {{ result.name }}
+
+
+
+
+ `
+});
+
+export const Primary = PrimaryTemplate.bind({});
+Primary.args = {
+ searchFunction: (searchTerm) => {
+ const allResults = [
+ {
+ title: 'Recipients',
+ icon: IconName.USER,
+ results: [
+ {
+ name: 'John Doe',
+ description: 'A postcard to John Doe for Texas',
+ type: 'postcard'
+ },
+ {
+ name: 'Jane Doe',
+ description: 'A postcard to Jane Doe for California',
+ type: 'postcard'
+ },
+ {
+ name: 'John Smith',
+ description: 'soccer postcard going to Texas',
+ type: 'postcard'
+ }
+ ]
+ },
+ {
+ title: 'Campaigns',
+ icon: IconName.BULLHORN,
+ results: [
+ {
+ name: 'Marketing Campaign for Texas',
+ description: '5000 recipients'
+ },
+ {
+ name: 'Marketing Campaign for California',
+ description: '10000 recipients'
+ }
+ ]
+ },
+ {
+ title: 'Templates',
+ icon: IconName.CREATIVE,
+ results: [
+ {
+ name: 'Template with John Doe to be sent to Texas',
+ description: 'A template to create postcard for John Doe'
+ },
+ {
+ name: 'Template with Jane Doe to be sent to California',
+ description: 'A template to create postcard for Jane Doe'
+ }
+ ]
+ }
+ ];
+ const results = allResults.map((result) => {
+ return {
+ title: result.title,
+ icon: result.icon,
+ items: result.results.filter(
+ (eachResult) =>
+ eachResult.description.includes(searchTerm) ||
+ eachResult.name.includes(searchTerm)
+ )
+ };
+ });
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(results);
+ }, 1500); // waits for 1500ms before returning results, so it's more 'realistic'
+ });
+ },
+ link: '/advanced-search',
+ count: 10,
+ footer: true
+};
diff --git a/src/components/AdvancedSearchBar/AdvancedSearchBar.vue b/src/components/AdvancedSearchBar/AdvancedSearchBar.vue
new file mode 100644
index 000000000..7a2f6a68a
--- /dev/null
+++ b/src/components/AdvancedSearchBar/AdvancedSearchBar.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('search.loading') }}
+
+
+
+
+
+
+
+
+
+ {{ t('search.noResults') }}
+
+
+
+
+
diff --git a/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts b/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts
new file mode 100644
index 000000000..4e996c0f2
--- /dev/null
+++ b/src/components/AdvancedSearchBar/__tests__/AdvancedSearchBar.spec.ts
@@ -0,0 +1,185 @@
+import '@testing-library/jest-dom';
+import AdvancedSearchBar from '../AdvancedSearchBar.vue';
+import { translate } from '@/mixins';
+import { IconName } from '../../Icon';
+import { RenderOptions, render, waitFor } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+
+const mixins = [translate];
+
+const initialProps = {
+ searchFunction: (searchTerm: string) => {
+ const allResults = [
+ {
+ title: 'Recipients',
+ icon: IconName.USER,
+ results: [
+ {
+ name: 'John Doe',
+ description: 'A postcard to John Doe',
+ type: 'postcard'
+ },
+ {
+ name: 'Jane Doe',
+ description: 'A postcard to Jane Doe',
+ type: 'postcard'
+ },
+ {
+ name: 'John Smith',
+ description: 'soccer postcard',
+ type: 'postcard'
+ }
+ ]
+ },
+ {
+ title: 'Campaigns',
+ icon: IconName.BULLHORN,
+ results: [
+ {
+ name: 'Marketing Campaign for Texas',
+ description: '5000 recipients'
+ },
+ {
+ name: 'Marketing Campaign for California',
+ description: '10000 recipients'
+ }
+ ]
+ },
+ {
+ title: 'Templates',
+ icon: IconName.CREATIVE,
+ results: [
+ {
+ name: 'Template with John Doe',
+ description: 'A template to create postcard for John Doe'
+ },
+ {
+ name: 'Template with Jane Doe',
+ description: 'A template to create postcard for Jane Doe'
+ }
+ ]
+ }
+ ];
+
+ const results = allResults.map((result) => {
+ return {
+ title: result.title,
+ icon: result.icon,
+ results: result.results.filter(
+ (eachResult) =>
+ eachResult.description.includes(searchTerm) ||
+ eachResult.name.includes(searchTerm)
+ )
+ };
+ });
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(results);
+ }, 0);
+ });
+ }
+};
+
+// Helper function to render the component
+const renderComponent = (options: RenderOptions) =>
+ render(AdvancedSearchBar, { ...options, global: { mixins } });
+
+describe('AdvancedSearchBar', () => {
+ it('clears entered searchTerm when x button is clicked', async () => {
+ const searchTerm = 'something';
+ const props = {
+ ...initialProps
+ };
+
+ const { getByLabelText, getByTestId } = renderComponent({ props });
+
+ const input = getByLabelText('Search term') as HTMLInputElement;
+ await userEvent.type(input, searchTerm);
+ expect(input.value).toBe(searchTerm);
+
+ const button = getByTestId('clearSearchButton') as HTMLElement;
+ await userEvent.click(button);
+ expect(input.value).toBe('');
+ });
+
+ it('does not show the clear button if there is no search term present', async () => {
+ const props = {
+ ...initialProps
+ };
+
+ const { queryByTestId } = renderComponent({ props });
+
+ const button = queryByTestId('clearSearchButton');
+ expect(button).toHaveClass('opacity-0');
+ expect(button).toBeDisabled();
+ });
+
+ it('executes the search function when the user types', async () => {
+ const searchTerm = 'soccer';
+ const props = {
+ ...initialProps,
+ data: await initialProps.searchFunction(searchTerm),
+ count: 1,
+ footer: true
+ };
+
+ const { getByLabelText, getByText } = renderComponent({ props });
+
+ const input = getByLabelText('Search term') as HTMLInputElement;
+ await userEvent.type(input, searchTerm);
+ expect(input.value).toBe(searchTerm);
+
+ await waitFor(() => {
+ expect(getByText('1 matching results')).toBeInTheDocument();
+ });
+ });
+
+ it('hides the search results when the user clicks outside the search bar', async () => {
+ const searchTerm = 'soccer';
+ const props = {
+ ...initialProps,
+ data: await initialProps.searchFunction(searchTerm),
+ count: 1
+ };
+
+ const { queryByRole, getByLabelText, getByText } = renderComponent({
+ props
+ });
+
+ const input = getByLabelText('Search term') as HTMLInputElement;
+ await userEvent.type(input, searchTerm);
+ expect(input.value).toBe(searchTerm);
+
+ let searchResults = queryByRole('results');
+ await waitFor(() => {
+ expect(getByText('1 matching results')).toBeInTheDocument();
+ });
+ await userEvent.click(document.body);
+ searchResults = queryByRole('results');
+ await waitFor(() => {
+ expect(searchResults).not.toBeInTheDocument();
+ });
+ });
+
+ it('does not clear the input when the user clicks outside the search bar', async () => {
+ const searchTerm = 'soccer';
+ const props = {
+ ...initialProps,
+ data: await initialProps.searchFunction(searchTerm),
+ count: 1
+ };
+
+ const { getByLabelText, getByText } = renderComponent({ props });
+
+ const input = getByLabelText('Search term') as HTMLInputElement;
+ await userEvent.type(input, searchTerm);
+ expect(input.value).toBe(searchTerm);
+
+ await waitFor(() => {
+ expect(getByText('1 matching results')).toBeInTheDocument();
+ });
+ await userEvent.click(document.body);
+ expect(input.value).toBe(searchTerm);
+ });
+});
diff --git a/src/components/AdvancedSearchBar/index.ts b/src/components/AdvancedSearchBar/index.ts
new file mode 100644
index 000000000..e95c5c565
--- /dev/null
+++ b/src/components/AdvancedSearchBar/index.ts
@@ -0,0 +1 @@
+export { default as AdvancedSearchBar } from './AdvancedSearchBar.vue';
diff --git a/src/components/Icon/svgs/MagnifyingGlass.svg b/src/components/Icon/svgs/MagnifyingGlass.svg
new file mode 100644
index 000000000..6da8880bd
--- /dev/null
+++ b/src/components/Icon/svgs/MagnifyingGlass.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts
index 6d2dbf0a0..500433bb2 100644
--- a/src/components/Icon/types.ts
+++ b/src/components/Icon/types.ts
@@ -74,6 +74,7 @@ export const IconName = {
LAYERS: 'Layers',
LIGHTNING: 'Lightning',
LOCATION_PIN: 'LocationPin',
+ MAGNIFYING_GLASS: 'MagnifyingGlass',
MONEY_BILL: 'MoneyBill',
MINUS: 'Minus',
NAV_ARROW_LEFT: 'NavArrowLeft',
diff --git a/src/components/index.js b/src/components/index.js
index 762dd4545..9a105b14c 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1,4 +1,5 @@
export { default as Accordion } from './Accordion/Accordion';
+export { default as AdvancedSearchBar } from './AdvancedSearchBar/AdvancedSearchBar';
export { default as Alert } from './Alert/Alert';
export { default as Breadcrumb } from './Breadcrumb/Breadcrumb';
export { default as LobButton } from './Button/Button';
diff --git a/src/mixins/en.js b/src/mixins/en.js
index 416ff5cde..d2bbca937 100644
--- a/src/mixins/en.js
+++ b/src/mixins/en.js
@@ -46,7 +46,9 @@ export default {
loading: 'Loading, please wait...',
resultsPrefix: 'View all',
resultsSuffix: 'results...',
- noResults: 'No results found'
+ noResults: 'No results found',
+ matchingResults: 'matching results',
+ seeAllResults: 'See all results'
},
dropzone: {
yourFile: 'your file',