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..b9ba2ffaf1 100644 --- a/examples/nuxt-app/test/features/search-listing/filters.feature +++ b/examples/nuxt-app/test/features/search-listing/filters.feature @@ -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,11 +59,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&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" @@ -69,6 +71,9 @@ Feature: Search listing - Filter | 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 @@ -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-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 87c62cf485..985f876545 100644 --- a/packages/ripple-tide-webform/mapping/webforms-mapping.ts +++ b/packages/ripple-tide-webform/mapping/webforms-mapping.ts @@ -196,6 +196,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..3c32579d7f 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,23 +21,36 @@ } } +.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 { @@ -56,8 +62,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 +96,38 @@ 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; } .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 +144,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 +156,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 +168,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 +192,17 @@ 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); +} + +.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..cc33c4f7f1 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,32 +26,32 @@ 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') }) }) @@ -51,45 +59,45 @@ describe('RplFormDropDown', () => { 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 +109,318 @@ 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('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}') + cy.focused().type('{enter}') + cy.focused().type('{downarrow}') + cy.focused().type('{enter}') + + // Return to search + cy.focused().type('{uparrow}{uparrow}{uparrow}') + cy.get(search).should('have.focus') + + // Manage tags + cy.focused().type('{leftarrow}') + cy.focused().contains('Orange') + cy.focused().type('{leftarrow}') + cy.focused().contains('Banana').type('{del}') + cy.focused().contains('Apple') + cy.focused().type('{leftarrow}') + cy.focused().contains('Apple') + cy.focused().type('{rightarrow}') + cy.focused().contains('Orange') + cy.focused().type('{rightarrow}') + cy.get(search).should('have.focus') + }) }) 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..de6d117a2a 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 {