Skip to content

Commit

Permalink
feat(atomic): added textarea search box (#3118)
Browse files Browse the repository at this point in the history
* feat(atomic): added text area

* feat(atomic): added search text area

* feat(atomic): added new buttons

* feat(atomic): absolute positioning

* feat(atomic): layout

* feat(atomic): removed need for fixed height

* feat(atomic): spacer in own function

* feat(atomic): added full searchbox test suite

* feat(atomic): added tsdoc for new prop

* feat(atomic): udpated prop in test

* Add generated files

* feat(atomic): moved z-index out

* feat(atomic): calling handlers

* feat(atomic): apply new styles only when textarea prop

* feat(atomic): feedback implementation

* feat(atomic): feedback implementation

* feat(atomic): fixed undefined parent error

---------

Co-authored-by: GitHub Actions Bot <>
  • Loading branch information
nathanlb authored Aug 23, 2023
1 parent 47b3f6a commit a436d52
Show file tree
Hide file tree
Showing 18 changed files with 596 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1490,13 +1490,13 @@ Example:

@ProxyCmp({
defineCustomElementFn: undefined,
inputs: ['clearFilters', 'disableSearch', 'enableQuerySyntax', 'minimumQueryLength', 'numberOfQueries', 'redirectionUrl', 'suggestionTimeout']
inputs: ['clearFilters', 'disableSearch', 'enableQuerySyntax', 'minimumQueryLength', 'numberOfQueries', 'redirectionUrl', 'suggestionTimeout', 'textarea']
})
@Component({
selector: 'atomic-search-box',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
inputs: ['clearFilters', 'disableSearch', 'enableQuerySyntax', 'minimumQueryLength', 'numberOfQueries', 'redirectionUrl', 'suggestionTimeout']
inputs: ['clearFilters', 'disableSearch', 'enableQuerySyntax', 'minimumQueryLength', 'numberOfQueries', 'redirectionUrl', 'suggestionTimeout', 'textarea']
})
export class AtomicSearchBox {
protected el: HTMLElement;
Expand Down
9 changes: 9 additions & 0 deletions packages/atomic/cypress/e2e/search-box/search-box-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,12 @@ export function typeSearchInput(query: string, verifyInput = '') {
SearchBoxSelectors.inputBox().type(`${query}{enter}`, {force: true});
SearchBoxAssertions.assertHasText(verifyInput || query);
}

export function typeSearchTextArea(query: string, verifyInput = '') {
SearchBoxSelectors.textArea().click();
SearchBoxSelectors.textArea().type(`${query}{enter}`, {force: true});
SearchBoxAssertions.assertHasText(
verifyInput || query,
SearchBoxSelectors.textArea
);
}
13 changes: 9 additions & 4 deletions packages/atomic/cypress/e2e/search-box/search-box-assertions.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import {SearchBoxSelectors} from './search-box-selectors';

export function assertFocusSearchBox() {
export function assertFocusSearchBox(
searchBoxSelector = SearchBoxSelectors.inputBox
) {
it('should focus on the search box', () => {
SearchBoxSelectors.inputBox().should('be.focused');
searchBoxSelector().should('be.focused');
});
}

export function assertHasText(text: string) {
export function assertHasText(
text: string,
searchBoxSelector = SearchBoxSelectors.inputBox
) {
it(`should contain "${text}"`, () => {
SearchBoxSelectors.inputBox().should('have.value', text);
searchBoxSelector().should('have.value', text);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const SearchBoxSelectors = {
host: () => cy.get(searchBoxComponent),
shadow: () => cy.get(searchBoxComponent).shadow(),
inputBox: () => SearchBoxSelectors.shadow().find('[part="input"]'),
textArea: () => SearchBoxSelectors.shadow().find('[part="textarea"]'),
submitButton: () =>
SearchBoxSelectors.shadow().find('[part="submit-button"]'),
querySuggestionsWrapper: () =>
Expand Down
159 changes: 159 additions & 0 deletions packages/atomic/cypress/e2e/search-box/text-area-search-box.cypress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
SafeStorage,
StorageItems,
} from '../../../src/utils/local-storage-utils';
import {RouteAlias} from '../../fixtures/fixture-common';
import {TestFixture} from '../../fixtures/test-fixture';
import * as CommonAssertions from '../common-assertions';
import {selectIdleCheckboxValueAt} from '../facets/facet-common-actions';
import * as FacetCommonAssertions from '../facets/facet-common-assertions';
import {addFacet, field} from '../facets/facet/facet-actions';
import {FacetSelectors} from '../facets/facet/facet-selectors';
import {addQuerySummary} from '../query-summary-actions';
import * as QuerySummaryAssertions from '../query-summary-assertions';
import {
addSearchBox,
AddSearchBoxOptions,
typeSearchTextArea,
} from './search-box-actions';
import * as SearchBoxAssertions from './search-box-assertions';
import {searchBoxComponent, SearchBoxSelectors} from './search-box-selectors';

const setSuggestions = (count: number) => () => {
cy.intercept(
{method: 'POST', path: '**/rest/search/v2/querySuggest?*'},
(request) => {
request.reply((response) => {
const newResponse = response.body;
newResponse.completions = Array.from({length: count}, (_, i) => ({
expression: `query-suggestion-${i}`,
executableConfidence: 0,
highlighted: `Suggestion ${i}`,
score: 0,
}));
response.send(200, newResponse);
});
}
).as(TestFixture.interceptAliases.QuerySuggestions.substring(1));
};

const setRecentQueries = (count: number) => () => {
new SafeStorage().setJSON(
StorageItems.RECENT_QUERIES,
Array.from({length: count}, (_, i) => `Recent query ${i}`)
);
};

const addTextAreaSearchBox = (options: AddSearchBoxOptions = {}) => {
const textAreaProp = {textarea: 'true'};
const props = options?.props
? {...options.props, ...textAreaProp}
: textAreaProp;
return addSearchBox(options ? {...options, props} : {props});
};

describe('TextArea Search Box Test Suites', () => {
describe('with no suggestions nor recentQueries', () => {
beforeEach(() => {
new TestFixture()
.with(setSuggestions(0))
.with(setRecentQueries(0))
.with(addTextAreaSearchBox())
.init();
});

it('should be accessible', () => {
SearchBoxSelectors.textArea().click();
CommonAssertions.assertAriaLiveMessage(
SearchBoxSelectors.searchBoxAriaLive,
' no '
);
CommonAssertions.assertAccessibility(searchBoxComponent);
});
});

describe('with default textarea search box', () => {
beforeEach(() => {
new TestFixture()
.with(addTextAreaSearchBox())
.with(addQuerySummary())
.withoutFirstAutomaticSearch()
.init();
SearchBoxSelectors.textArea().click();
SearchBoxSelectors.textArea().type('test{enter}', {force: true});
cy.wait(RouteAlias.UA);
});

it('search button is enabled to start with', () => {
SearchBoxSelectors.textArea().should('be.empty');
SearchBoxSelectors.submitButton().should('be.enabled');
});

CommonAssertions.assertConsoleError(false);
});

describe('with disableSearch set to true', () => {
beforeEach(() => {
new TestFixture()
.with(
addTextAreaSearchBox({
props: {
'disable-search': 'true',
'minimum-query-length': 1, // disable-search should override this setting
},
})
)
.with(addQuerySummary())
.withoutFirstAutomaticSearch()
.init();
});

it('should be accessible', () => {
CommonAssertions.assertAccessibility(searchBoxComponent);
});

it('there are no search suggestions or errors on query input', () => {
typeSearchTextArea('test');
SearchBoxSelectors.submitButton().should('be.disabled');
SearchBoxAssertions.assertNoSuggestionGenerated();
QuerySummaryAssertions.assertHasPlaceholder();
CommonAssertions.assertConsoleError(false);
});
});

describe('with a facet & clear-filters set to false', () => {
beforeEach(() => {
new TestFixture()
.with(addTextAreaSearchBox({props: {'clear-filters': 'false'}}))
.with(addFacet({field}))
.init();
selectIdleCheckboxValueAt(FacetSelectors, 0);
cy.wait(TestFixture.interceptAliases.Search);
SearchBoxSelectors.submitButton().click({force: true});
cy.wait(TestFixture.interceptAliases.Search);
});

FacetCommonAssertions.assertNumberOfSelectedCheckboxValues(
FacetSelectors,
1
);
});

describe('with a facet & clear-filters set to true', () => {
beforeEach(() => {
new TestFixture()
.with(addTextAreaSearchBox({props: {'clear-filters': 'true'}}))
.with(addFacet({field}))
.init();
selectIdleCheckboxValueAt(FacetSelectors, 0);
cy.wait(TestFixture.interceptAliases.Search);
SearchBoxSelectors.submitButton().click({force: true});
cy.wait(TestFixture.interceptAliases.Search);
});

FacetCommonAssertions.assertNumberOfSelectedCheckboxValues(
FacetSelectors,
0
);
});
});
8 changes: 8 additions & 0 deletions packages/atomic/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,10 @@ export namespace Components {
* The timeout for suggestion queries, in milliseconds. If a suggestion query times out, the suggestions from that particular query won't be shown.
*/
"suggestionTimeout": number;
/**
* Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css <style> atomic-search-box::part(textarea), atomic-search-box::part(textarea)::after, atomic-search-box::part(textarea-spacer) { font-size: x-large; } atomic-search-box::part(submit-button-wrapper), atomic-search-box::part(clear-button-wrapper) { padding-top: 0.75rem; } </style> ```
*/
"textarea": boolean;
}
interface AtomicSearchBoxInstantResults {
/**
Expand Down Expand Up @@ -4583,6 +4587,10 @@ declare namespace LocalJSX {
* The timeout for suggestion queries, in milliseconds. If a suggestion query times out, the suggestions from that particular query won't be shown.
*/
"suggestionTimeout"?: number;
/**
* Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css <style> atomic-search-box::part(textarea), atomic-search-box::part(textarea)::after, atomic-search-box::part(textarea-spacer) { font-size: x-large; } atomic-search-box::part(submit-button-wrapper), atomic-search-box::part(clear-button-wrapper) { padding-top: 0.75rem; } </style> ```
*/
"textarea"?: boolean;
}
interface AtomicSearchBoxInstantResults {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {AnyBindings} from '../interface/bindings';

interface Props extends Partial<ButtonProps> {
bindings: AnyBindings;
inputRef: HTMLInputElement | null;
inputRef: HTMLInputElement | HTMLTextAreaElement | null;
}

export const ClearButton: FunctionalComponent<Props> = ({
Expand All @@ -17,7 +17,7 @@ export const ClearButton: FunctionalComponent<Props> = ({
<Button
style="text-transparent"
part="clear-button"
class="w-8 h-8 mr-1.5 text-neutral-dark"
class="w-8 h-8 mr-1.5 text-neutral-dark shrink-0"
onClick={() => {
onClick?.();
inputRef?.focus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,29 @@ import {FunctionalComponent, h} from '@stencil/core';

interface Props {
disabled: boolean;
textArea?: boolean;
}

export const SearchBoxWrapper: FunctionalComponent<Props> = (
props,
children
) => (
<div
part="wrapper"
class={`relative flex bg-background h-full w-full border border-neutral rounded-md focus-within:ring ${
props.disabled
? 'focus-within:border-disabled focus-within:ring-neutral'
: 'focus-within:border-primary focus-within:ring-ring-primary'
}`}
>
{children}
</div>
);
) => {
const getClasses = () => {
const baseClasses =
'flex bg-background w-full border border-neutral rounded-md focus-within:ring';
const focusClasses = props.disabled
? 'focus-within:border-disabled focus-within:ring-neutral'
: 'focus-within:border-primary focus-within:ring-ring-primary';
const inputTypeClasses = props.textArea
? 'absolute top-0 left-0'
: 'relative h-full';

return [baseClasses, focusClasses, inputTypeClasses].join(' ');
};

return (
<div part="wrapper" class={getClasses()}>
{children}
</div>
);
};
23 changes: 23 additions & 0 deletions packages/atomic/src/components/common/search-box/search-box.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,26 @@
content: '';
@apply absolute w-5/6 h-5/6 bg-background rounded-full;
}

.grow-wrap {
&::after {
/* Space needed to prevent jumpy behavior */
content: attr(data-replicated-value) ' ';
visibility: hidden;
}

> [part='textarea'],
&::after {
@apply whitespace-nowrap z-10 h-full outline-none resize-none bg-transparent text-neutral-dark text-lg grow p-3.5 px-4 overflow-x-hidden overflow-y-auto;
/* Place on top of each other */
grid-area: 1 / 1 / 2 / 2;
}

&.expanded {
> [part='textarea'],
&::after {
@apply whitespace-pre-wrap;
max-height: 8em;
}
}
}
Loading

0 comments on commit a436d52

Please sign in to comment.