diff --git a/examples/nuxt-app/test/features/landingpage/forms.feature b/examples/nuxt-app/test/features/landingpage/forms.feature index 55e63b0822..25e009eb81 100644 --- a/examples/nuxt-app/test/features/landingpage/forms.feature +++ b/examples/nuxt-app/test/features/landingpage/forms.feature @@ -23,6 +23,8 @@ Feature: Forms | required | | true | Then a select field with the label "Term select" should exist + Then a select field with the label "Searchable single select" should exist + Then a select field with the label "Searchable multi select" should exist Then a radio group field with the label "Type of person" should exist with the following options | label | | Dog person | @@ -58,11 +60,12 @@ Feature: Forms Then the error summary should not display When I submit the form with ID "full_form" Then the error summary should display with the following errors - | text | url | - | You must enter your first name | /kitchen-sink#first_name | - | The message field is required | /kitchen-sink#message | - | Must choose a favourite colour | /kitchen-sink#favourite_colour | - | You must accept the terms | /kitchen-sink#i_accept_the_terms | + | text | url | + | You must enter your first name | /kitchen-sink#first_name | + | The message field is required | /kitchen-sink#message | + | Must choose a favourite colour | /kitchen-sink#favourite_colour | + | The searchable single select is required | /kitchen-sink#searchable_single_select | + | You must accept the terms | /kitchen-sink#i_accept_the_terms | Then clicking on an error summary link with text "Must choose a favourite colour" should focus on the input with ID "favourite_colour" And the dataLayer should include the following events @@ -79,13 +82,16 @@ Feature: Forms And the form with ID "full_form" should exist Then the input with the label "First name" should be valid + And the select with the label "Searchable single select" should be valid When I submit the form with ID "full_form" Then the input with the label "First name" should be invalid with message "You must enter your first name" + And the select with the label "Searchable single select" should be invalid with message "The searchable single select is required" When I type "Cat" into the input with the label "First name" - Then the input with the label "First name" should be invalid with message "You must enter your first name" + And I select "Orange" by searching the select field with label "Searchable single select" When I submit the form with ID "full_form" Then the input with the label "First name" should be valid + Then the select with the label "Searchable single select" should be valid @mockserver Scenario: Form submission - Error @@ -101,6 +107,7 @@ Feature: Forms And I type "Here is some text to go in the textarea field" into the textarea with the label "Message" And I click "Green" from the select field with label "Favourite colour" And I toggle the checkbox with label "Terms and conditions" + And I click "Orange" from the select field with label "Searchable single select" When I submit the form with ID "full_form" @@ -126,6 +133,10 @@ Feature: Forms And I type "0400 000 000" into the input with the label "Mobile phone" And I type "Here is some text to go in the textarea field" into the textarea with the label "Message" And I click "Green" from the select field with label "Favourite colour" + And I select "Mango" by searching the select field with label "Searchable single select" + And I select the following options by searching for "Ap" the select field with label "Searchable multi select" + | Aprium | + | Apricot | And I click "Free admission" from the select field with label "Term select" And I click "Seniors" from the select field with label "Term select" And I click "Dog person" from the radio group with label "Type of person" @@ -140,24 +151,29 @@ Feature: Forms | success | Server success | Test success message | And the dataLayer should include the following events - | event | label | form_id | field_id | type | value | component | - | update_form_field | First name | full_form | first_name | text | [redacted] | rpl-form-input | - | update_form_field | Last name | full_form | last_name | text | [redacted] | rpl-form-input | - | update_form_field | Email | full_form | email | email | [redacted] | rpl-form-input | - | update_form_field | Quantity | full_form | quantity | number | [redacted] | rpl-form-number | - | update_form_field | Website | full_form | website | url | [redacted] | rpl-form-input | - | update_form_field | Mobile phone | full_form | mobile_phone | tel | [redacted] | rpl-form-input | - | update_form_field | Message | full_form | message | textarea | [redacted] | rpl-form-textarea | - | open_form_field | Favourite colour | full_form | favourite_colour | select | | rpl-form-dropdown | - | update_form_field | Favourite colour | full_form | favourite_colour | select | Green | rpl-form-dropdown | - | open_form_field | Term select | full_form | term_select | select | | rpl-form-dropdown | - | update_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | - | open_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | - | update_form_field | Term select | full_form | term_select | select | Free admission,Seniors | rpl-form-dropdown | - | update_form_field | Type of person | full_form | person_type | radio | Dog person | rpl-form-radio-group | - | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London | rpl-form-checkbox-group | - | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London,Tokyo | rpl-form-checkbox-group | - | update_form_field | I accept the terms | full_form | i_accept_the_terms__checkbox | checkbox | true | rpl-form-option | + | event | label | form_id | field_id | type | value | component | + | update_form_field | First name | full_form | first_name | text | [redacted] | rpl-form-input | + | update_form_field | Last name | full_form | last_name | text | [redacted] | rpl-form-input | + | update_form_field | Email | full_form | email | email | [redacted] | rpl-form-input | + | update_form_field | Quantity | full_form | quantity | number | [redacted] | rpl-form-number | + | update_form_field | Website | full_form | website | url | [redacted] | rpl-form-input | + | update_form_field | Mobile phone | full_form | mobile_phone | tel | [redacted] | rpl-form-input | + | update_form_field | Message | full_form | message | textarea | [redacted] | rpl-form-textarea | + | open_form_field | Favourite colour | full_form | favourite_colour | select | | rpl-form-dropdown | + | update_form_field | Favourite colour | full_form | favourite_colour | select | Green | rpl-form-dropdown | + | open_form_field | Searchable single select | full_form | searchable_single_select | select | | rpl-form-dropdown | + | update_form_field | Searchable single select | full_form | searchable_single_select | select | Mango | rpl-form-dropdown | + | open_form_field | Searchable multi select | full_form | searchable_multi_select | select | | rpl-form-dropdown | + | update_form_field | Searchable multi select | full_form | searchable_multi_select | select | Aprium | rpl-form-dropdown | + | update_form_field | Searchable multi select | full_form | searchable_multi_select | select | Apricot | rpl-form-dropdown | + | open_form_field | Term select | full_form | term_select | select | | rpl-form-dropdown | + | update_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | + | open_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | + | update_form_field | Term select | full_form | term_select | select | Free admission,Seniors | rpl-form-dropdown | + | update_form_field | Type of person | full_form | person_type | radio | Dog person | rpl-form-radio-group | + | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London | rpl-form-checkbox-group | + | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London,Tokyo | rpl-form-checkbox-group | + | update_form_field | I accept the terms | full_form | i_accept_the_terms__checkbox | checkbox | true | rpl-form-option | And the dataLayer should include the following events | event | form_id | form_valid | element_text | component | @@ -165,19 +181,20 @@ Feature: Forms | form_complete | full_form | | Submit | rpl-form | And the dataLayer form data for "form_complete" should include the following values - | key | value | - | first_name | [redacted] | - | last_name | [redacted] | - | role | [redacted] | - | email | [redacted] | - | quantity | [redacted] | - | website | [redacted] | - | mobile_phone | [redacted] | - | dob | [redacted] | - | message | [redacted] | - | favourite_colour | Green | - | term_select | Free admission,Seniors | - | person_type | Dog person | - | favourite_locations | London,Tokyo | - | i_accept_the_terms | true | - | site_section | DPC | + | key | value | + | first_name | [redacted] | + | last_name | [redacted] | + | role | [redacted] | + | email | [redacted] | + | quantity | [redacted] | + | website | [redacted] | + | mobile_phone | [redacted] | + | dob | [redacted] | + | message | [redacted] | + | favourite_colour | Green | + | searchable_single_select | Mango | + | term_select | Free admission,Seniors | + | person_type | Dog person | + | favourite_locations | London,Tokyo | + | i_accept_the_terms | true | + | site_section | DPC | diff --git a/examples/nuxt-app/test/features/search-listing/filters.feature b/examples/nuxt-app/test/features/search-listing/filters.feature index 3f87344d96..0866a0d89a 100644 --- a/examples/nuxt-app/test/features/search-listing/filters.feature +++ b/examples/nuxt-app/test/features/search-listing/filters.feature @@ -29,7 +29,7 @@ Feature: Search listing - Filter Then the filters toggle should show 1 applied filters When I toggle the search listing filters section - Then the search listing dropdown field labelled "Raw filter example" should have the value "Dogs, Birds" + Then the search listing dropdown field labelled "Raw filter example" should have the value "Birds, Dogs" @mockserver Example: Term filter - Should reflect a single value from the URL @@ -37,11 +37,11 @@ Feature: Search listing - Filter And the search network request is stubbed with fixture "/search-listing/filters/response" and status 200 And the current date is "Fri, 02 Feb 2050 03:04:05 GMT" - When I visit the page "/filters?termFilter=Green&singleTermFilter=Aqua&checkboxFilter=Archived&checkboxFilterGroup=Weekdays" + When I visit the page "/filters?termFilter=Green&singleTermFilter=Aqua&checkboxFilter=Archived&checkboxFilterGroup=Weekdays&singleSearchDropdownFilter=Banana" Then the search listing page should have 2 results And the search network request should be called with the "/search-listing/filters/request-term-single" fixture - Then the filters toggle should show 4 applied filters + Then the filters toggle should show 5 applied filters When I toggle the search listing filters section Then the search listing dropdown field labelled "Term filter example" should have the value "Green" @@ -50,6 +50,8 @@ Feature: Search listing - Filter And the search listing checkbox group labelled "Checkbox group" should have the following options checked | label | | Weekdays | + Then the search listing dropdown field labelled "Single search dropdown filter" should have the value "Banana" + And the search listing dropdown field labelled "Single search dropdown filter" should have the search text "Banana" when opened @mockserver Example: Term filter - Should reflect an array from the URL @@ -57,18 +59,21 @@ Feature: Search listing - Filter And the search network request is stubbed with fixture "/search-listing/filters/response" and status 200 And the current date is "Fri, 02 Feb 2050 03:04:05 GMT" - When I visit the page "/filters?termFilter=Green&termFilter=Red&checkboxFilterGroup=Weekdays&checkboxFilterGroup=Weekends" + When I visit the page "/filters?termFilter=Green&termFilter=Red&checkboxFilterGroup=Weekdays&checkboxFilterGroup=Weekends&multiSearchDropdownFilter=Potato&multiSearchDropdownFilter=Onion" Then the search listing page should have 2 results And the search network request should be called with the "/search-listing/filters/request-term-array" fixture - Then the filters toggle should show 2 applied filters + Then the filters toggle should show 3 applied filters When I toggle the search listing filters section - Then the search listing dropdown field labelled "Term filter example" should have the value "Red, Green" + Then the search listing dropdown field labelled "Term filter example" should have the value "Green, Red" And the search listing checkbox group labelled "Checkbox group" should have the following options checked | label | | Weekdays | | Weekends | + Then the select field labelled "Multi search dropdown filter" should have the following tags + | Potato | + | Onion | @mockserver Example: Terms (with an 's') - Should reflect a single value from the URL @@ -98,7 +103,7 @@ Feature: Search listing - Filter Then the filters toggle should show 1 applied filters When I toggle the search listing filters section - Then the search listing dropdown field labelled "Terms filter example" should have the value "Orange, Purple" + Then the search listing dropdown field labelled "Terms filter example" should have the value "Purple, Orange" @mockserver Example: Date range filter (single query value) - Should reflect the range value from the URL @@ -162,7 +167,7 @@ Feature: Search listing - Filter And the search network request is stubbed with fixture "/search-listing/filters/response" and status 200 And the current date is "Fri, 02 Feb 2050 03:04:05 GMT" - When I visit the page "/filters?functionFilter=closed&functionFilter=open" + When I visit the page "/filters?functionFilter=open&functionFilter=closed" Then the search listing page should have 2 results And the search network request should be called with the "/search-listing/filters/request-function-filter" fixture @@ -266,13 +271,32 @@ Feature: Search listing - Filter | Yellow | Then I click the option labelled "Purple" in the selected dropdown And I click the search listing checkbox field labelled "Show archived content" + And I select "Mango" by searching the select field with label "Single search dropdown filter" + And I select the following options by searching for "Ca" the select field with label "Multi search dropdown filter" + | Carrot | + | Cabbage | + And I submit the search filters Then the URL should reflect that the current active filters are as follows: - | id | value | - | q | the | - | termFilter | Blue | - | termsFilter | Orange | - | checkboxFilter | Archived | + | id | value | + | q | the | + | termFilter | Blue | + | termsFilter | Orange | + | checkboxFilter | Archived | + | singleSearchDropdownFilter | Mango | + Then the URL should reflect that the current active filters are as follows: + | multiSearchDropdownFilter | Carrot | + | multiSearchDropdownFilter | Cabbage | + + When I delete the text for the select field with label "Single search dropdown filter" + And I delete the following tags for the select field with label "Multi search dropdown filter" + | Carrot | + | Cabbage | + + Then I submit the search filters + Then the URL should reflect that the current active filters are as follows: + | singleSearchDropdownFilter | + | multiSearchDropdownFilter | @mockserver Example: Dependent filter - Should reflect values from the URL diff --git a/examples/nuxt-app/test/fixtures/landingpage/full-form.json b/examples/nuxt-app/test/fixtures/landingpage/full-form.json index 8e06127c6b..356c25b422 100644 --- a/examples/nuxt-app/test/fixtures/landingpage/full-form.json +++ b/examples/nuxt-app/test/fixtures/landingpage/full-form.json @@ -228,6 +228,44 @@ }, "pii": false }, + { + "$formkit": "RplFormDropdown", + "key": "searchable_single_select", + "id": "searchable_single_select", + "name": "searchable_single_select", + "label": "Searchable single select", + "multiple": false, + "searchable": true, + "options": [ + { "id": "apple", "value": "apple", "label": "Apple" }, + { "id": "banana", "value": "banana", "label": "Banana" }, + { "id": "orange", "value": "orange", "label": "Orange" }, + { "id": "mango", "value": "mango", "label": "Mango" }, + { "id": "grape", "value": "grape", "label": "Grape" } + ], + "validation": "required", + "validationMessages": { + "required": "The searchable single select is required" + }, + "pii": false + }, + { + "$formkit": "RplFormDropdown", + "key": "searchable_multi_select", + "id": "searchable_multi_select", + "name": "searchable_multi_select", + "label": "Searchable multi select", + "multiple": true, + "searchable": true, + "options": [ + { "id": "cherry", "value": "cherry", "label": "Cherry" }, + { "id": "aprium", "value": "aprium", "label": "Aprium" }, + { "id": "pear", "value": "pear", "label": "Pear" }, + { "id": "plum", "value": "plum", "label": "Plum" }, + { "id": "apricot", "value": "apricot", "label": "Apricot" } + ], + "pii": false + }, { "$formkit": "RplFormRadioGroup", "id": "person_type", diff --git a/examples/nuxt-app/test/fixtures/search-listing/filters/page.json b/examples/nuxt-app/test/fixtures/search-listing/filters/page.json index 4c552b05ea..1b73953a0b 100644 --- a/examples/nuxt-app/test/fixtures/search-listing/filters/page.json +++ b/examples/nuxt-app/test/fixtures/search-listing/filters/page.json @@ -261,6 +261,58 @@ "min": "2024-07-01", "max": "2084-06-30" } + }, + { + "id": "singleSearchDropdownFilter", + "component": "TideSearchFilterDropdown", + "filter": { + "type": "term", + "value": "singleSearchDropdownFilter.keyword", + "multiple": false + }, + "aggregations": { + "field": "singleSearchDropdownFilter", + "source": "taxonomy" + }, + "props": { + "id": "singleSearchDropdownFilter", + "label": "Single search dropdown filter", + "placeholder": "Select a fruit", + "multiple": false, + "searchable": true, + "options": [ + { "id": "Apple", "value": "Apple", "label": "Apple" }, + { "id": "Banana", "value": "Banana", "label": "Banana" }, + { "id": "Orange", "value": "Orange", "label": "Orange" }, + { "id": "Mango", "value": "Mango", "label": "Mango" }, + { "id": "Pineapple", "value": "Pineapple", "label": "Pineapple" } + ] + } + }, { + "id": "multiSearchDropdownFilter", + "component": "TideSearchFilterDropdown", + "filter": { + "type": "term", + "value": "multiSearchDropdownFilter.keyword" + }, + "aggregations": { + "field": "multiSearchDropdownFilter", + "source": "taxonomy" + }, + "props": { + "id": "multiSearchDropdownFilter", + "label": "Multi search dropdown filter", + "placeholder": "Select a vegetable", + "multiple": true, + "searchable": true, + "options": [ + { "id": "Carrot", "value": "Carrot", "label": "Carrot" }, + { "id": "Potato", "value": "Potato", "label": "Potato" }, + { "id": "Tomato", "value": "Tomato", "label": "Tomato" }, + { "id": "Onion", "value": "Onion", "label": "Onion" }, + { "id": "Cabbage", "value": "Cabbage", "label": "Cabbage" } + ] + } } ] } diff --git a/examples/nuxt-app/test/fixtures/search-listing/filters/request-function-filter.json b/examples/nuxt-app/test/fixtures/search-listing/filters/request-function-filter.json index b04b60802c..370724c3b8 100644 --- a/examples/nuxt-app/test/fixtures/search-listing/filters/request-function-filter.json +++ b/examples/nuxt-app/test/fixtures/search-listing/filters/request-function-filter.json @@ -44,7 +44,7 @@ ] } }, - "providedValues": ["closed", "open"] + "providedValues": ["open", "closed"] } ] } diff --git a/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-array.json b/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-array.json index a0b8a262c3..0921e33cde 100644 --- a/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-array.json +++ b/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-array.json @@ -26,6 +26,14 @@ "terms": { "checkboxFilterGroup.keyword": ["Weekdays", "Weekends"] } + }, + { + "terms": { + "multiSearchDropdownFilter.keyword": [ + "Potato", + "Onion" + ] + } } ] } diff --git a/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-single.json b/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-single.json index f358db7f71..de82106617 100644 --- a/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-single.json +++ b/examples/nuxt-app/test/fixtures/search-listing/filters/request-term-single.json @@ -36,6 +36,13 @@ "terms": { "checkboxFilterGroup.keyword": ["Weekdays"] } + }, + { + "terms": { + "singleSearchDropdownFilter.keyword": [ + "Banana" + ] + } } ] } diff --git a/packages/ripple-test-utils/step_definitions/components/forms.ts b/packages/ripple-test-utils/step_definitions/components/forms.ts index 1d71778699..8b5b012b3e 100644 --- a/packages/ripple-test-utils/step_definitions/components/forms.ts +++ b/packages/ripple-test-utils/step_definitions/components/forms.ts @@ -116,6 +116,107 @@ When( } ) +When( + 'I select {string} by searching the select field with label {string}', + (option: string, label: string) => { + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).click() + cy.focused().type(option) + cy.get(`#${dropdownId}__menu`) + .find(`.rpl-form-dropdown-option`) + .as('availableOptions') + cy.get('@availableOptions').should('have.length', 1) + cy.get('@availableOptions').eq(0).click() + }) + } +) + +When( + 'I delete the text for the select field with label {string}', + (label: string) => { + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).as('dropdown') + cy.get('@dropdown').click() + + cy.focused().as('searchInput') + cy.get('@searchInput').invoke('val').should('not.be.empty') + cy.get('@searchInput').clear() + }) + } +) + +Then( + `the select field labelled {string} should have the following tags`, + (label: string, dataTable: DataTable) => { + const table = dataTable.raw() + + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).find('[data-tag-id]').as('selectedTags') + }) + + table.forEach((row, i: number) => { + cy.get('@selectedTags') + .eq(i) + .then((item) => { + cy.wrap(item).as('item') + cy.get('@item').should('contain', row[0]) + }) + }) + } +) + +When( + 'I delete the following tags for the select field with label {string}', + (label: string, dataTable: DataTable) => { + const table = dataTable.raw() + + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).find('[data-tag-id]').as('selectedTags') + }) + + table.forEach((row) => { + cy.get('@selectedTags').contains(row[0]).click() + }) + } +) + +When( + 'I select the following options by searching for {string} the select field with label {string}', + (search: string, label: string, dataTable: DataTable) => { + const table = dataTable.raw() + + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).click() + cy.focused().type(search) + + cy.get(`#${dropdownId}__menu`) + .find(`.rpl-form-dropdown-option`) + .as('availableOptions') + + cy.get('@availableOptions').should('have.length', 2) + + table.forEach((row, i: number) => { + cy.get('@availableOptions') + .eq(i) + .then((item) => { + cy.wrap(item).as('item') + cy.get('@item').contains(row[0]).click() + }) + }) + }) + } +) + Then( 'a select field with the label {string} should exist', (label: string, dataTable: DataTable) => { @@ -140,6 +241,37 @@ Then( } ) +Then('the select with the label {string} should be valid', (label: string) => { + cy.get('label.rpl-form-label') + .contains(label) + .closest('.rpl-form__outer') + .as('field') + + cy.get('@field').should('exist') + cy.get('@field') + .find('.rpl-form-dropdown-input') + .should('have.attr', 'aria-invalid', 'false') + cy.get('@field').find('.rpl-form-validation-error').should('not.exist') +}) + +Then( + 'the select with the label {string} should be invalid with message {string}', + (label: string, errorMsg: string) => { + cy.get('label.rpl-form-label') + .contains(label) + .closest('.rpl-form__outer') + .as('field') + + cy.get('@field').should('exist') + cy.get('@field') + .find('.rpl-form-dropdown-input') + .should('have.attr', 'aria-invalid', 'true') + cy.get('@field') + .find('.rpl-form-validation-error') + .should('contain', errorMsg) + } +) + Then( 'a radio group field with the label {string} should exist with the following options', (label: string, dataTable: DataTable) => { diff --git a/packages/ripple-test-utils/step_definitions/content-types/listing.ts b/packages/ripple-test-utils/step_definitions/content-types/listing.ts index 920157ec48..f9b389e00c 100644 --- a/packages/ripple-test-utils/step_definitions/content-types/listing.ts +++ b/packages/ripple-test-utils/step_definitions/content-types/listing.ts @@ -227,6 +227,21 @@ Then( } ) +Then( + `the search listing dropdown field labelled {string} should have the search text {string} when opened`, + (label: string, value: string) => { + cy.contains('label', label) + .invoke('attr', 'for') + .then((dropdownId) => { + cy.get(`#${dropdownId}`).as('selectedDropdown') + cy.get('@selectedDropdown').click() + cy.get('@selectedDropdown') + .find('.rpl-form-dropdown-search__input') + .should('have.value', value) + }) + } +) + Then( `the selected dropdown field should allow {string} selection`, (type: string) => { diff --git a/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue b/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue index 57bd6bbc9e..05bdc2debb 100644 --- a/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue +++ b/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue @@ -7,6 +7,8 @@ interface Props { options?: any[] timestamp?: string | number variant: 'default' | 'reverse' + searchable?: boolean + noResultsLabel?: string } defineProps() @@ -23,5 +25,7 @@ defineProps() :placeholder="placeholder" :options="options" :pii="false" + :searchable="searchable" + :no-results-label="noResultsLabel" /> diff --git a/packages/ripple-tide-webform/mapping/webforms-mapping.ts b/packages/ripple-tide-webform/mapping/webforms-mapping.ts index e17b7c5d83..272ea4064d 100644 --- a/packages/ripple-tide-webform/mapping/webforms-mapping.ts +++ b/packages/ripple-tide-webform/mapping/webforms-mapping.ts @@ -197,6 +197,7 @@ export const getFormSchemaFromMapping = async ( label: field['#title'], help: field['#description'], multiple: !!field['#multiple'], + searchable: !!field['#searchable'], options: Object.entries(field['#options'] || {}).map( ([value, label]) => { return { diff --git a/packages/ripple-ui-forms/cypress/support/component.ts b/packages/ripple-ui-forms/cypress/support/component.ts index b74abeec08..3ff0cab955 100644 --- a/packages/ripple-ui-forms/cypress/support/component.ts +++ b/packages/ripple-ui-forms/cypress/support/component.ts @@ -28,6 +28,13 @@ import { mount } from 'cypress/vue' import { h } from 'vue' import RplFauxForm from './components/RplFauxForm.vue' +Cypress.on('uncaught:exception', (err) => { + // https://stackoverflow.com/a/50387233 Ignore Resize observer loop + if (err.message.includes('ResizeObserver loop')) { + return false + } +}) + Cypress.Commands.add('mount', (component, options = {}) => { return mount( () => { diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.css b/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.css index 810713998b..d27c1a2766 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.css +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.css @@ -6,7 +6,6 @@ .rpl-form-dropdown__multi-value-label { position: relative; - flex-grow: 1; text-overflow: ellipsis; white-space: nowrap; @@ -26,4 +25,5 @@ flex-shrink: 0; text-align: right; white-space: nowrap; + align-self: center; } diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.vue b/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.vue index 4a046b351e..e5fc5ce7b1 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.vue +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/MultiValueLabel.vue @@ -1,13 +1,10 @@ + + + + diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.css b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.css index 76bd5f11d1..e4032f9907 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.css +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.css @@ -1,21 +1,14 @@ .rpl-form-dropdown { --local-item-height: 48px; + --local-background-color: var(--rpl-clr-neutral-100); + --local-background-strip-color: var(--rpl-clr-light); position: relative; } .rpl-form-dropdown--reverse { - .rpl-form-dropdown-input { - background: var(--rpl-clr-light); - } - - .rpl-form-dropdown-menu { - background: var(--rpl-clr-neutral-100); - } - - .rpl-form-dropdown-option:nth-child(2n) { - background: var(--rpl-clr-light); - } + --local-background-color: var(--rpl-clr-light); + --local-background-strip-color: var(--rpl-clr-neutral-100); } .rpl-form-dropdown--invalid { @@ -28,26 +21,40 @@ } } +.rpl-form-dropdown--multi-search { + .rpl-form-dropdown-input { + padding-block: calc(var(--rpl-sp-2) - var(--rpl-border-1)); + } +} + .rpl-form-dropdown-input { - background: var(--rpl-clr-neutral-100); - border: 0; + --local-input-padding-right: calc(var(--rpl-sp-2) + var(--rpl-sp-4) + var(--rpl-sp-5)); + + background: var(--local-background-color); outline: 0; width: 100%; height: 100%; + min-height: var(--local-item-height); max-height: var(--local-item-height); padding-top: var(--rpl-sp-3); padding-bottom: var(--rpl-sp-3); padding-left: var(--rpl-sp-5); - padding-right: calc(var(--rpl-sp-2) + var(--rpl-sp-4) + var(--rpl-sp-5)); + padding-right: var(--local-input-padding-right); border: var(--rpl-border-1) solid var(--rpl-clr-neutral-600); border-radius: var(--rpl-border-radius-2); display: flex; justify-content: space-between; align-items: center; cursor: pointer; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } &:hover, - &:focus { + &:focus, + &.rpl-u-focusable--force-on { border-color: var(--rpl-clr-dark); } @@ -56,8 +63,12 @@ border-radius: 0; } - &[aria-expanded='true'] .rpl-form-dropdown__chevron { - transform: rotate(-180deg); + &[aria-expanded='true'] { + overflow-x: auto; + + .rpl-form-dropdown__chevron { + transform: rotate(-180deg); + } } &[aria-disabled='true'] { @@ -86,23 +97,39 @@ overflow: hidden; } +.rpl-form-dropdown-input__toggle { + --local-toogle-inset: var(--rpl-border-2); + + position: absolute; + top: var(--local-toogle-inset); + right: var(--local-toogle-inset); + bottom: var(--local-toogle-inset); + width: var(--local-input-padding-right); + background: linear-gradient( + to right, + transparent, + var(--local-background-color) 10% + ); +} + .rpl-form-dropdown__chevron { + pointer-events: none; color: var(--rpl-clr-link); position: absolute; top: 50%; - right: var(--rpl-sp-5); + right: calc(var(--rpl-sp-5) - var(--local-toogle-inset)); margin-top: -8px; + transition: transform var(--rpl-motion-speed-7) linear; } .rpl-form-dropdown-menu { z-index: var(--rpl-layer-2); border: var(--rpl-border-2) solid var(--rpl-clr-dark); - background: var(--rpl-clr-light); - max-height: calc(var(--local-max-items) * var(--local-item-height)); + background: var(--local-background-strip-color); + max-height: calc(var(--local-max-items) * var(--local-item-height) + (var(--rpl-border-2) * 2)); overflow-y: auto; scroll-behavior: auto; overscroll-behavior: contain; - position: absolute; width: 100%; margin-top: -2px; @@ -119,10 +146,10 @@ padding: var(--rpl-sp-3) var(--rpl-sp-5); &:nth-child(2n) { - background: var(--rpl-clr-neutral-100); + background: var(--local-background-color); } - &:hover { + &:is(:hover, &.rpl-form-dropdown-option--highlight) { background: var(--rpl-clr-neutral-300); } @@ -131,12 +158,6 @@ } } -.rpl-form-dropdown--reverse .rpl-form-dropdown-option { - &:hover { - background: var(--rpl-clr-neutral-300); - } -} - .rpl-form-dropdown-option__tick { display: block; width: var(--rpl-sp-4); @@ -149,7 +170,7 @@ color: var(--rpl-clr-dark); width: var(--rpl-sp-6); height: var(--rpl-sp-6); - background: var(--rpl-clr-neutral-100); + background: var(--local-background-color); border: var(--rpl-border-1) solid var(--rpl-clr-neutral-600); border-radius: var(--rpl-border-radius-1); display: flex; @@ -173,3 +194,18 @@ right: auto; left: var(--rpl-sp-5); } + +.rpl-form-dropdown-search__input { + padding: 0; + outline: none; + border: none; + background: none; + width: 100%; + min-width: var(--rpl-sp-9); + color: inherit; +} + +.rpl-form-dropdown-search__no-results { + text-align: center; + padding: var(--rpl-sp-3) var(--rpl-sp-5); +} diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.cy.ts b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.cy.ts index 2cbf0fee62..ce70633ea5 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.cy.ts +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.cy.ts @@ -8,6 +8,14 @@ const props = { options: RplFormDropdownOptions } +const input = '.rpl-form-dropdown-input' +const menu = '.rpl-form-dropdown-menu' +const search = '.rpl-form-dropdown-search__input' +const option = '.rpl-form-dropdown-option' +const toggle = '.rpl-form-dropdown-input__toggle' +const moreLabel = '.rpl-form-dropdown__more-label' +const tagItem = '.rpl-form-dropdown__multi-value-tag-item:not([aria-hidden])' + describe('RplFormDropDown', () => { it('mounts', () => { cy.mount(RplFormDropDown, { props }) @@ -18,78 +26,92 @@ describe('RplFormDropDown', () => { it('can be toggled open and closed', () => { cy.mount(RplFormDropDown, { props }) - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-menu').should('be.visible') - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-menu').should('not.exist') + cy.get(input).click() + cy.get(menu).should('be.visible') + cy.get(input).click() + cy.get(menu).should('not.exist') }) it('allows for single options to be selected', () => { cy.mount(RplFormDropDown, { props }) - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-option').contains('Apple').click() - cy.get('.rpl-form-dropdown-input').should('contain', 'Apple') + cy.get(input).click() + cy.get(option).contains('Apple').click() + cy.get(input).should('contain', 'Apple') - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-option').contains('Orange').click() - cy.get('.rpl-form-dropdown-input').should('contain', 'Orange') + cy.get(input).click() + cy.get(option).contains('Orange').click() + cy.get(input).should('contain', 'Orange') }) it('allows for multiple options to be selected', () => { cy.mount(RplFormDropDown, { props: { ...props, multiple: true } }) - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-option').contains('Apple').click() - cy.get('.rpl-form-dropdown-option').contains('Orange').click() + cy.get(input).click() + cy.get(option).contains('Apple').click() + cy.get(option).contains('Orange').click() - cy.get('.rpl-form-dropdown-input').should(($div) => { + cy.get(input).should(($div) => { expect($div.get(0).innerText).to.eq('Apple, Orange') }) }) + it('selected options are displayed in the order which they were selected', () => { + cy.mount(RplFormDropDown, { props: { ...props, multiple: true } }) + + cy.get(input).click() + cy.get(option).contains('Apple').click() + cy.get(option).contains('Grapes').click() + cy.get(option).contains('Apricots').click() + cy.get(option).contains('Orange').click() + + cy.get(input).should(($div) => { + expect($div.get(0).innerText).to.eq('Apple, Grapes, Apricots, Orange') + }) + }) + it('correctly displays the number of hidden selected options', () => { cy.viewport(960, 680) cy.mount(RplFormDropDown, { props: { ...props, multiple: true } }) - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-option').click({ multiple: true }) - cy.get('.rpl-form-dropdown__more-label').contains('+2 more') + cy.get(input).click() + cy.get(option).click({ multiple: true }) + cy.get(moreLabel).contains('+2 more') cy.viewport(746, 680) - cy.get('.rpl-form-dropdown__more-label').contains('+5 more') + cy.get(moreLabel).contains('+5 more') cy.viewport(480, 680) - cy.get('.rpl-form-dropdown__more-label').contains('+8 more') + cy.get(moreLabel).contains('+8 more') cy.viewport(370, 680) - cy.get('.rpl-form-dropdown__more-label').contains('+10 more') + cy.get(moreLabel).contains('+10 more') }) it('can be "searched" by typing from the input', () => { cy.mount(RplFormDropDown, { props }) - cy.get('.rpl-form-dropdown-input').type('b') + cy.get(input).type('b') cy.focused().contains('Banana') - cy.get('.rpl-form-dropdown-input').type('bl') + cy.get(input).type('bl') cy.focused().contains('Blueberries') }) it('can be "searched" by typing from an option', () => { cy.mount(RplFormDropDown, { props }) - cy.get('.rpl-form-dropdown-input').click() - cy.get('.rpl-form-dropdown-option').first().type('apr') + cy.get(input).click() + cy.get(option).first().type('apr') cy.focused().contains('Apricots') - cy.get('.rpl-form-dropdown-option').first().type('l') + cy.get(option).first().type('l') cy.focused().contains('Lemon') }) it('can be "traversed" by cycling through a single key stroke', () => { cy.mount(RplFormDropDown, { props }) - cy.get('.rpl-form-dropdown-input').type('a') + cy.get(input).type('a') cy.focused().contains('Apple') cy.focused().type('a') @@ -101,4 +123,336 @@ describe('RplFormDropDown', () => { cy.focused().type('a') cy.focused().contains('Apple') }) + + it('can be navigated using the keyboard', () => { + cy.mount(RplFormDropDown, { props }) + + cy.get(input).focus() + cy.focused().type('{downarrow}') + cy.get(menu).should('be.visible') + cy.focused().contains('Select') + + // Options list can be cycled through + cy.focused().type('{downarrow}{downarrow}') + cy.focused().contains('Banana') + cy.focused().type('{uparrow}') + cy.focused().contains('Apple') + cy.focused().type('{uparrow}') + cy.focused().contains('Select') + cy.focused().type('{uparrow}') + cy.focused().contains('Select') + cy.focused().type('{esc}') + cy.get(menu).should('not.exist') + }) + + /* Searchable dropdowns (single) */ + it('single select can be searched', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).click() + + cy.get(search).should('have.focus') + cy.get(menu).should('be.visible') + cy.get(option).should('have.length', 13) + + cy.get(search).type('ap') + cy.get(option).should('have.length', 4) + cy.get(option).each(($el) => { + expect($el.text().toLowerCase()).to.contain('ap') + }) + }) + + it('selecting a single option populates the search input', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).click() + cy.get(option).contains('Orange').click() + cy.get(input).contains('Orange') + cy.get(input).click() + cy.get(search).should('have.value', 'Orange') + cy.get(option).contains('Peach').click() + cy.get(input).contains('Peach') + cy.get(input).click() + cy.get(search).should('have.value', 'Peach') + }) + + it('a partially cleared selected input with be restored when dropdown is closed', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).click() + cy.get(option).contains('Lemon').click() + + cy.get(input).click() + cy.get(option).should('have.length', 13) + cy.get(search).type('{backspace}{backspace}{backspace}') + cy.get(search).should('have.value', 'Le') + + cy.get(option).should('have.length', 3) + cy.get(option).each(($el) => { + expect($el.text().toLowerCase()).to.contain('le') + }) + + cy.get(toggle).click() + cy.get(input).contains('Lemon') + cy.get(toggle).click() + cy.get(search).should('have.value', 'Lemon') + }) + + it('a completely cleared selected input will remove the selected value', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).click() + cy.get(option).contains('Lemon').click() + cy.get(input).contains('Lemon') + + cy.get(input).click() + cy.get(search).clear() + cy.get(option).should('have.length', 13) + cy.get(toggle).click() + cy.get(input).contains('Select') + }) + + it('a no results message is displayed', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).focus() + cy.focused().type('...') + cy.get(menu).contains('No results found') + }) + + it('a single matching result will be auto selected on enter', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).click() + cy.focused().type('pea{enter}') + cy.get(input).contains('Peach') + }) + + it('single select can be navigated using the keyboard', () => { + cy.mount(RplFormDropDown, { props: { ...props, searchable: true } }) + + cy.get(input).focus() + cy.focused().type('{downarrow}') + cy.get(search).should('have.focus') + cy.focused().type('{downarrow}{downarrow}') + cy.focused().contains('Banana') + cy.focused().type('{uparrow}') + cy.focused().contains('Apple') + cy.focused().type('{uparrow}') + cy.get(search).should('have.focus') + cy.focused().type('{esc}') + cy.get(menu).should('not.exist') + }) + + /* Searchable dropdowns (multi) */ + it('multi select can be searched', () => { + cy.mount(RplFormDropDown, { + props: { ...props, multiple: true, searchable: true } + }) + + cy.get(input).click() + + cy.get(search).should('have.focus') + cy.get(menu).should('be.visible') + cy.get(option).should('have.length', 13) + + cy.get(search).type('be') + cy.get(option).should('have.length', 2) + cy.get(option).each(($el) => { + expect($el.text().toLowerCase()).to.contain('be') + }) + cy.get(toggle).click() + + cy.get(toggle).click() + cy.get(option).should('have.length', 13) + }) + + it('selecting multiple options populates the the tag list', () => { + cy.viewport(480, 680) + cy.mount(RplFormDropDown, { + props: { ...props, multiple: true, searchable: true } + }) + + const selection = [ + 'Apple', + 'Banana', + 'Orange', + 'Blueberries', + 'Peach', + 'Lemon' + ] + + cy.get(input).click() + + selection.forEach((item) => { + cy.get(option).contains(item).click() + }) + + // The search input should remain visible + cy.get(search).then(($el) => { + const rect = $el[0].getBoundingClientRect() + const windowHeight = Cypress.config('viewportHeight') + const windowWidth = Cypress.config('viewportWidth') + + const isVisible = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= windowHeight && + rect.right <= windowWidth + + expect(isVisible).to.be.true + }) + + // The full tag list is displayed while the dropdown is open + selection.forEach((item) => { + cy.get(tagItem).contains(item) + }) + }) + + it('selecting the only matching option clears the search input', () => { + cy.mount(RplFormDropDown, { + props: { + ...props, + multiple: true, + searchable: true + } + }) + + cy.get(input).click() + cy.focused().type('Gra{enter}') + cy.get(tagItem).contains('Grapes') + cy.get(search).should('have.value', '') + }) + + it('displays tags with the number of hidden selected options when closed', () => { + cy.viewport(960, 680) + cy.mount(RplFormDropDown, { + props: { + ...props, + multiple: true, + searchable: true, + options: [ + { + id: 'bullace', + value: 'bullace', + label: 'Bullace damson plum' + }, + ...RplFormDropdownOptions + ] + } + }) + + cy.get(input).click() + cy.get(option).click({ multiple: true }) + cy.get(toggle).click() + + cy.get(moreLabel).contains('+7 more') + + cy.viewport(746, 680) + cy.get(moreLabel).contains('+9 more') + + cy.viewport(480, 680) + cy.get(moreLabel).contains('+12 more') + + cy.viewport(370, 680) + cy.get(moreLabel).contains('14 items') + }) + + it('options can be managed via the tag list', () => { + cy.mount(RplFormDropDown, { + props: { + ...props, + multiple: true, + searchable: true + } + }) + + // Select some options + const selection = ['Apple', 'Banana', 'Orange', 'Peach'] + + cy.get(input).click() + + selection.forEach((item) => { + cy.get(option).contains(item).click() + }) + + cy.get(toggle).click() + + cy.get(tagItem).should('have.length', 4) + + selection.forEach((item) => { + cy.get(tagItem).contains(item) + }) + + // Remove some options + cy.get(tagItem).contains('Banana').click() + cy.get(tagItem).contains('Orange').click() + + cy.get(tagItem).should('not.contain', 'Banana') + cy.get(tagItem).should('not.contain', 'Orange') + cy.get(tagItem).should('contain', 'Apple') + cy.get(tagItem).should('contain', 'Peach') + + cy.get(tagItem).contains('Apple').click() + cy.get(tagItem).contains('Peach').click() + + cy.get(input).contains('Select') + }) + + it('pressing delete on the multi select will auto select the last tag for deletion', () => { + cy.mount(RplFormDropDown, { + props: { ...props, searchable: true, multiple: true } + }) + + cy.get(input).click() + cy.get(option).eq(0).click() + cy.get(toggle).click() + + // Pressing delete on an empty input focuses tags + cy.get(input).focus() + cy.focused().type('{del}') + cy.focused().contains('Apple') + + // Focus returns to input when all tags are removed + cy.focused().type('{del}') + cy.get(search).should('have.focus') + }) + + it('multi select can be navigated using the keyboard', () => { + cy.mount(RplFormDropDown, { + props: { ...props, searchable: true, multiple: true } + }) + + cy.get(input).focus() + cy.focused().type('{downarrow}') + cy.get(search).should('have.focus') + + // Select options + cy.focused().type('{downarrow}') + cy.focused().type('{enter}') + cy.focused().type('{downarrow}{downarrow}') + cy.focused().type('{enter}') + cy.focused().type('{uparrow}') + cy.focused().type('{enter}') + + // Return to search + cy.focused().type('{uparrow}{uparrow}') + cy.get(search).should('have.focus') + + // Manage tags + cy.focused().type('{leftarrow}') + cy.focused().contains('Banana') + cy.focused().type('{leftarrow}') + cy.focused().contains('Orange').type('{del}') + cy.focused().contains('Apple') + cy.focused().type('{leftarrow}') + cy.focused().contains('Apple') + cy.focused().type('{rightarrow}') + cy.focused().contains('Banana') + cy.focused().type('{rightarrow}') + cy.get(search).should('have.focus') + + cy.focused().type('ap{leftarrow}{leftarrow}{leftarrow}') + cy.focused().contains('Banana') + }) }) diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.stories.mdx b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.stories.mdx index 17409ee3ff..74f0739e43 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.stories.mdx +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.stories.mdx @@ -202,3 +202,137 @@ export const SingleTemplate = (args) => ({ {SingleTemplate.bind()} + + + + {SingleTemplate.bind()} + + + + + + {SingleTemplate.bind()} + + diff --git a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.vue b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.vue index c6b049391a..b9c1081b5a 100644 --- a/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.vue +++ b/packages/ripple-ui-forms/src/components/RplFormDropdown/RplFormDropdown.vue @@ -5,14 +5,21 @@ export default {