diff --git a/cspell.config.cjs b/cspell.config.cjs index 6c94db0f..f839cb78 100644 --- a/cspell.config.cjs +++ b/cspell.config.cjs @@ -8,5 +8,6 @@ module.exports = { words: [ ...baseConfig.words, 'observavir', + 'listbox', ], }; diff --git a/package-lock.json b/package-lock.json index 47a08bda..792f54ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@electrovir/element-vir-mono-repo", - "version": "22.1.3", + "version": "22.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@electrovir/element-vir-mono-repo", - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "workspaces": [ "packages/*" @@ -15147,7 +15147,7 @@ } }, "packages/element-book": { - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/browser": "^27.0.0", @@ -15156,7 +15156,7 @@ "lit-css-vars": "^3.0.9", "spa-router-vir": "^4.0.3", "typed-event-target": "^3.4.0", - "vira": "22.1.3" + "vira": "22.2.0" }, "devDependencies": { "@augment-vir/browser-testing": "^27.0.0", @@ -15182,7 +15182,7 @@ }, "packages/element-book-example": { "name": "@electrovir/element-book-example", - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/browser": "^27.0.0", @@ -15216,8 +15216,31 @@ "type-fest": "^4.12.0" } }, + "packages/element-book/node_modules/vira/node_modules/@augment-vir/browser": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@augment-vir/browser/-/browser-26.4.0.tgz", + "integrity": "sha512-EvUJ93iIf9uXTdMfPP/tZIKoNThUCenbpncwV4KzPhZL7mlJMmgszCpuyk2bZZUFc/stbqis54Cli8aaINNbUw==", + "dependencies": { + "@augment-vir/common": "^26.4.0", + "html-spec-tags": "^2.2.0", + "run-time-assertions": "^1.0.0" + }, + "peerDependencies": { + "element-vir": ">=17" + } + }, + "packages/element-book/node_modules/vira/node_modules/@augment-vir/common": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@augment-vir/common/-/common-26.4.0.tgz", + "integrity": "sha512-rQoLA+t3bbvs269KvZYKZ76WO7Ofp468mqahsI4RAvPjJaOJ/6lCo49HgvUjnFlv7DoEVbTMfHT3G3+VztNnsA==", + "dependencies": { + "browser-or-node": "^2.1.1", + "run-time-assertions": "^1.0.0", + "type-fest": "^4.12.0" + } + }, "packages/element-vir": { - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/common": "^27.0.0", @@ -15255,7 +15278,7 @@ }, "packages/element-vir-example": { "name": "@electrovir/element-vir-example", - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "element-book": "*", @@ -15285,7 +15308,7 @@ }, "packages/scripts": { "name": "@electrovir/scripts", - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/common": "^27.0.0", @@ -15310,7 +15333,7 @@ } }, "packages/theme-vir": { - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/common": "^27.0.0" @@ -15348,7 +15371,7 @@ } }, "packages/vira": { - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/browser": "^27.0.0", @@ -15389,7 +15412,7 @@ }, "packages/vira-book": { "name": "@electrovir/vira-book", - "version": "22.1.3", + "version": "22.2.0", "license": "(MIT or CC0 1.0)", "dependencies": { "@augment-vir/common": "^27.0.0", diff --git a/package.json b/package.json index 26d02dda..fe0f9024 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@electrovir/element-vir-mono-repo", - "version": "22.1.3", + "version": "22.2.0", "private": true, "license": "(MIT or CC0 1.0)", "author": { @@ -17,8 +17,7 @@ "format": "virmator format", "publish": "virmator publish \"npm run compile && npm run --workspace @electrovir/scripts update:deps && npm i && npm run test:all\"", "test": "mono-vir for-each npm test", - "test:all": "npm run compile && concurrently -c auto -m 90% --kill-others-on-fail --colors --names tests,spelling,format,docs,build \"npm run test:coverage\" \"npm run test:spelling\" \"npm run test:format\" \"npm run test:docs\" \"npm run build:pages\"", - "test:coverage": "npm run test coverage", + "test:all": "npm run compile && concurrently -c auto -m 90% --kill-others-on-fail --colors --names tests,spelling,format,docs,build \"npm run test\" \"npm run test:spelling\" \"npm run test:format\" \"npm run test:docs\" \"npm run build:pages\"", "test:deps": "virmator deps check", "test:docs": "mono-vir for-each-async npm run --if-present test:docs", "test:format": "virmator format check", diff --git a/packages/element-book-example/package.json b/packages/element-book-example/package.json index 8d5c3ba4..a6bf9c4a 100644 --- a/packages/element-book-example/package.json +++ b/packages/element-book-example/package.json @@ -1,6 +1,6 @@ { "name": "@electrovir/element-book-example", - "version": "22.1.3", + "version": "22.2.0", "private": true, "license": "(MIT or CC0 1.0)", "scripts": { diff --git a/packages/element-book/package.json b/packages/element-book/package.json index 54382ba5..924ecb68 100644 --- a/packages/element-book/package.json +++ b/packages/element-book/package.json @@ -1,6 +1,6 @@ { "name": "element-book", - "version": "22.1.3", + "version": "22.2.0", "keywords": [ "book", "design system", @@ -36,7 +36,6 @@ "docs": "virmator docs", "start": "cd ../element-book-example && npm start", "test": "virmator test-web", - "test:coverage": "npm run test coverage", "test:docs": "virmator docs check", "test:types": "npm run compile", "test:watch": "web-test-runner --color --watch --config configs/web-test-runner.config.mjs" @@ -48,7 +47,7 @@ "lit-css-vars": "^3.0.9", "spa-router-vir": "^4.0.3", "typed-event-target": "^3.4.0", - "vira": "22.1.3" + "vira": "22.2.0" }, "devDependencies": { "@augment-vir/browser-testing": "^27.0.0", diff --git a/packages/element-vir-example/package.json b/packages/element-vir-example/package.json index 8128d717..dacba43b 100644 --- a/packages/element-vir-example/package.json +++ b/packages/element-vir-example/package.json @@ -1,6 +1,6 @@ { "name": "@electrovir/element-vir-example", - "version": "22.1.3", + "version": "22.2.0", "private": true, "license": "(MIT or CC0 1.0)", "author": { diff --git a/packages/element-vir/package.json b/packages/element-vir/package.json index e45d91fc..751f389c 100644 --- a/packages/element-vir/package.json +++ b/packages/element-vir/package.json @@ -1,6 +1,6 @@ { "name": "element-vir", - "version": "22.1.3", + "version": "22.2.0", "keywords": [ "custom", "web", @@ -33,7 +33,6 @@ "docs": "virmator docs", "start": "cd ../element-vir-example && npm start", "test": "virmator test-web", - "test:coverage": "npm run test coverage", "test:docs": "virmator docs check", "test:types": "tsc --pretty --noEmit" }, diff --git a/packages/scripts/package.json b/packages/scripts/package.json index ca13d1e5..fb978899 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@electrovir/scripts", - "version": "22.1.3", + "version": "22.2.0", "private": true, "license": "(MIT or CC0 1.0)", "author": { diff --git a/packages/theme-vir/package.json b/packages/theme-vir/package.json index fa34dafc..dbbd0aed 100644 --- a/packages/theme-vir/package.json +++ b/packages/theme-vir/package.json @@ -1,6 +1,6 @@ { "name": "theme-vir", - "version": "22.1.3", + "version": "22.2.0", "private": true, "description": "Create an entire web theme.", "keywords": [ diff --git a/packages/vira-book/package.json b/packages/vira-book/package.json index 9c11860d..e98ac9ca 100644 --- a/packages/vira-book/package.json +++ b/packages/vira-book/package.json @@ -1,6 +1,6 @@ { "name": "@electrovir/vira-book", - "version": "22.1.3", + "version": "22.2.0", "private": true, "license": "(MIT or CC0 1.0)", "author": { diff --git a/packages/vira-book/src/element-book/all-element-book-entries.ts b/packages/vira-book/src/element-book/all-element-book-entries.ts index 81fe4ffc..3ccbb141 100644 --- a/packages/vira-book/src/element-book/all-element-book-entries.ts +++ b/packages/vira-book/src/element-book/all-element-book-entries.ts @@ -1,4 +1,7 @@ import {elementsBookPage} from './elements.book'; +import {ViraDropdownItemPage} from './entries/dropdown/vira-dropdown-item.element.book'; +import {dropdownPage} from './entries/dropdown/vira-dropdown.book'; +import {viraDropdownPage} from './entries/dropdown/vira-dropdown.element.book'; import {iconsBookPage} from './entries/icons.book'; import {viraButtonBookPage} from './entries/vira-button.element.book'; import {viraCollapsibleBookPage} from './entries/vira-collapsible-wrapper.element.book'; @@ -9,12 +12,15 @@ import {viraLinkBookPage} from './entries/vira-link.element.book'; export const allElementBookEntries = [ elementsBookPage, - iconsBookPage, + dropdownPage, + viraButtonBookPage, viraCollapsibleBookPage, + ViraDropdownItemPage, + viraDropdownPage, viraIconBookPage, viraImageBookPage, viraInputBookPage, viraLinkBookPage, -]; +].sort((a, b) => a.title.localeCompare(b.title)); diff --git a/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown-item.element.book.ts b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown-item.element.book.ts new file mode 100644 index 00000000..1473d44e --- /dev/null +++ b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown-item.element.book.ts @@ -0,0 +1,111 @@ +import {BookPageControlTypeEnum, defineBookPage, definePageControl} from 'element-book'; +import {CSSResult, HTMLTemplateResult, css, html} from 'element-vir'; +import {ViraDropdownItem} from 'vira'; +import {dropdownPage} from './vira-dropdown.book'; + +const examples: ReadonlyArray<{ + title: string; + inputs: typeof ViraDropdownItem.inputsType; + customStyle?: CSSResult; + customTemplate?: HTMLTemplateResult; +}> = [ + { + title: 'unselected', + inputs: { + label: 'my label', + selected: false, + }, + }, + { + title: 'selected', + inputs: { + label: 'my label', + selected: true, + }, + }, + { + title: 'with custom child', + inputs: { + label: 'custom child', + selected: true, + }, + customTemplate: html` + This is custom + `, + }, + { + title: 'constrained width', + customStyle: css` + :host { + max-width: 100px; + } + `, + inputs: { + label: 'has more text than is possible to fit', + selected: true, + }, + }, + { + title: 'stretched width', + customStyle: css` + ${ViraDropdownItem} { + width: 400px; + } + `, + inputs: { + label: 'wide', + selected: true, + }, + }, +]; + +export const ViraDropdownItemPage = defineBookPage({ + title: ViraDropdownItem.tagName, + parent: dropdownPage, + controls: { + Selected: definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + initValue: '', + options: [ + '', + 'all', + 'none', + ], + }), + Label: definePageControl({ + controlType: BookPageControlTypeEnum.Text, + initValue: '', + }), + }, + elementExamplesCallback({defineExample}) { + examples.forEach((example) => { + defineExample({ + title: example.title, + stateInitStatic: { + selected: example.inputs?.selected || [], + }, + styles: example.customStyle, + renderCallback({controls}) { + const finalInputs: typeof ViraDropdownItem.inputsType = { + label: controls.Label || example.inputs.label, + selected: controls.Selected + ? controls.Selected === 'all' + : example.inputs.selected, + }; + + if (example.customTemplate) { + return html` + <${ViraDropdownItem.assign(finalInputs)}> + ${example.customTemplate} + + `; + } else { + return html` + <${ViraDropdownItem.assign(finalInputs)}> + `; + } + }, + }); + }); + }, +}); diff --git a/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.book.ts b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.book.ts new file mode 100644 index 00000000..545ade5d --- /dev/null +++ b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.book.ts @@ -0,0 +1,7 @@ +import {defineBookPage} from 'element-book'; +import {elementsBookPage} from '../../elements.book'; + +export const dropdownPage = defineBookPage({ + parent: elementsBookPage, + title: 'Dropdown', +}); diff --git a/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.element.book.ts b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.element.book.ts new file mode 100644 index 00000000..4ebe0bb5 --- /dev/null +++ b/packages/vira-book/src/element-book/entries/dropdown/vira-dropdown.element.book.ts @@ -0,0 +1,243 @@ +import {isTruthy} from '@augment-vir/common'; +import {BookPageControlTypeEnum, defineBookPage, definePageControl} from 'element-book'; +import {CSSResult, css, html, listen} from 'element-vir'; +import {Element24Icon, ViraDropdown, ViraDropdownOption, allIconsByName} from 'vira'; +import {dropdownPage} from './vira-dropdown.book'; + +const exampleDropdownOptions: ReadonlyArray> = [ + { + label: 'Option 1', + id: 1, + }, + { + label: 'Option 2', + id: 2, + }, + { + label: 'Option 3', + id: 3, + }, + { + label: 'Really really super duper long option', + id: 4, + }, + { + label: 'Really really super duper long option', + id: 5, + }, + { + label: 'Really really super duper long option', + id: 6, + }, + { + label: 'Really really super duper long option', + id: 7, + }, + { + label: "Really really super duper long it just keeps going because it's so long option", + id: 8, + }, +]; + +const examples: ReadonlyArray<{ + title: string; + inputs?: Partial; + customStyle?: CSSResult; +}> = [ + { + title: 'default', + }, + { + title: 'disabled', + inputs: { + isDisabled: true, + }, + }, + { + title: 'multi select', + inputs: { + isMultiSelect: true, + }, + }, + { + title: 'long selection', + inputs: { + selected: [8], + }, + }, + { + title: 'with custom template', + inputs: { + selected: [], + options: [ + ...exampleDropdownOptions, + { + id: 42, + label: 'custom template', + template: html` + + `, + }, + ], + }, + }, + { + title: 'with disabled item', + inputs: { + selected: [], + options: [ + ...exampleDropdownOptions, + { + id: 42, + label: 'this is disabled', + disabled: true, + }, + ], + }, + }, + { + title: 'constrained width', + customStyle: css` + :host { + max-width: 150px; + } + `, + }, + { + title: 'stretched width', + customStyle: css` + ${ViraDropdown} { + width: 400px; + } + `, + }, + { + title: 'without a placeholder', + inputs: { + placeholder: undefined, + }, + }, + { + title: 'with a prefix', + inputs: { + selectionPrefix: 'Pre:', + selected: [1], + }, + }, + { + title: 'with an icon', + inputs: { + icon: Element24Icon, + }, + }, +]; + +export const viraDropdownPage = defineBookPage({ + title: ViraDropdown.tagName, + parent: dropdownPage, + controls: { + Selected: definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + initValue: '', + options: [ + '', + ...exampleDropdownOptions.map((option) => option.label), + ], + }), + Prefix: definePageControl({ + controlType: BookPageControlTypeEnum.Text, + initValue: '', + }), + 'Force State': definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + options: [ + '', + 'force open', + 'force closed', + ], + initValue: '', + }), + 'Multi Select': definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + options: [ + '', + 'all', + 'none', + ], + initValue: '', + }), + Icon: definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + initValue: '', + options: [ + '', + ...Object.keys(allIconsByName), + ], + }), + Disabled: definePageControl({ + controlType: BookPageControlTypeEnum.Dropdown, + options: [ + '', + 'all', + 'none', + ], + initValue: '', + }), + Placeholder: definePageControl({ + controlType: BookPageControlTypeEnum.Text, + initValue: 'Select something', + }), + }, + elementExamplesCallback({defineExample}) { + examples.forEach((example) => { + defineExample({ + title: example.title, + stateInitStatic: { + selected: example.inputs?.selected || [], + }, + styles: example.customStyle, + renderCallback({state, updateState, controls}) { + const finalInputs: typeof ViraDropdown.inputsType = { + placeholder: + example.inputs && 'placeholder' in example.inputs + ? example.inputs.placeholder + : controls.Placeholder, + options: example.inputs?.options || exampleDropdownOptions, + selected: controls.Selected + ? [ + exampleDropdownOptions.find( + (option) => option.label === controls.Selected, + )?.id, + ].filter(isTruthy) + : state.selected, + selectionPrefix: controls.Prefix || example.inputs?.selectionPrefix, + isDisabled: controls.Disabled + ? controls.Disabled === 'all' + : example.inputs?.isDisabled, + icon: controls.Icon + ? allIconsByName[controls.Icon as keyof typeof allIconsByName] + : example.inputs?.icon, + isMultiSelect: controls['Multi Select'] + ? controls['Multi Select'] === 'all' + : example.inputs?.isMultiSelect, + z_debug_forceOpenState: controls['Force State'] + ? controls['Force State'] === 'force open' + : example.inputs?.z_debug_forceOpenState, + }; + + return html` + <${ViraDropdown.assign(finalInputs)} + ${listen(ViraDropdown.events.selectedChange, (event) => { + updateState({selected: event.detail}); + })} + > + `; + }, + }); + }); + }, +}); diff --git a/packages/vira/configs/web-test-runner.config.mjs b/packages/vira/configs/web-test-runner.config.mjs index fc476afe..85d10945 100644 --- a/packages/vira/configs/web-test-runner.config.mjs +++ b/packages/vira/configs/web-test-runner.config.mjs @@ -12,6 +12,7 @@ const baseConfig = getWebTestRunnerConfigWithCoveragePercent({ const webTestRunnerConfig = { ...baseConfig, port: 8103, + concurrency: 1, }; export default webTestRunnerConfig; diff --git a/packages/vira/package.json b/packages/vira/package.json index d10172ef..a555cc98 100644 --- a/packages/vira/package.json +++ b/packages/vira/package.json @@ -1,6 +1,6 @@ { "name": "vira", - "version": "22.1.3", + "version": "22.2.0", "description": "A simple and highly versatile design system using element-vir.", "keywords": [ "design", diff --git a/packages/vira/src/elements/dropdown/dropdown-helpers.test.ts b/packages/vira/src/elements/dropdown/dropdown-helpers.test.ts new file mode 100644 index 00000000..f13f3dbc --- /dev/null +++ b/packages/vira/src/elements/dropdown/dropdown-helpers.test.ts @@ -0,0 +1,66 @@ +import {itCases} from '@augment-vir/browser-testing'; +import {filterToSelectedOptions} from './dropdown-helpers'; +import {mockOptions} from './dropdown.mock'; + +describe(filterToSelectedOptions.name, () => { + itCases(filterToSelectedOptions, [ + { + it: 'returns nothing when nothing is selected', + input: { + selected: [], + options: mockOptions, + }, + expect: [], + }, + { + it: 'returns nothing for no options', + input: { + selected: [ + 1, + 2, + 3, + ], + options: [], + }, + expect: [], + }, + { + it: 'returns the selected option', + input: { + selected: [3], + options: mockOptions, + }, + expect: [ + mockOptions[3], + ], + }, + { + it: 'truncates the selection without isMultiSelect', + input: { + selected: [ + 2, + 3, + ], + options: mockOptions, + }, + expect: [ + mockOptions[2], + ], + }, + { + it: 'supports isMultiSelect', + input: { + selected: [ + 2, + 3, + ], + isMultiSelect: true, + options: mockOptions, + }, + expect: [ + mockOptions[2], + mockOptions[3], + ], + }, + ]); +}); diff --git a/packages/vira/src/elements/dropdown/dropdown-helpers.ts b/packages/vira/src/elements/dropdown/dropdown-helpers.ts new file mode 100644 index 00000000..feb08036 --- /dev/null +++ b/packages/vira/src/elements/dropdown/dropdown-helpers.ts @@ -0,0 +1,89 @@ +import {joinWithFinalConjunction} from '@augment-vir/common'; +import {PopUpManager, ShowPopUpResult} from '../../util/pop-up-manager'; +import {ViraDropdownOption} from './vira-dropdown-item.element'; +import {ViraDropdown} from './vira-dropdown.element'; + +export function filterToSelectedOptions({ + selected, + options, + isMultiSelect, +}: Readonly<{ + selected: ReadonlyArray; + isMultiSelect?: boolean | undefined; + options: ReadonlyArray>; +}>): ViraDropdownOption[] { + if (selected.length && options.length) { + const selectedOptions = options.filter((option) => selected.includes(option.id)); + + if (selectedOptions.length > 1 && !isMultiSelect) { + console.error( + `${ViraDropdown.tagName} has multiple selections but \`isMultiSelect\` is not \`true\`. Truncating to the first selection.`, + ); + return selectedOptions.slice(0, 1); + } else { + return selectedOptions; + } + } else { + return []; + } +} + +export function assertUniqueIdProps(options: ReadonlyArray>) { + const usedIds = new Set(); + const duplicateIds: PropertyKey[] = []; + options.forEach((option) => { + if (usedIds.has(option.id)) { + duplicateIds.push(option.id); + } else { + usedIds.add(option.id); + } + }); + + if (duplicateIds.length) { + throw new Error( + `Duplicate option ids were given to ViraDropdown: ${joinWithFinalConjunction(duplicateIds)}`, + ); + } +} + +export function createNewSelection( + id: PropertyKey, + currentSelection: ReadonlyArray, + isMultiSelect: boolean, +): PropertyKey[] { + if (isMultiSelect) { + return currentSelection.includes(id) + ? currentSelection.filter((entry) => entry !== id) + : [ + ...currentSelection, + id, + ]; + } else { + return [id]; + } +} + +export function triggerPopUpState( + {open, emitEvent}: {open: boolean; emitEvent: boolean}, + { + updateState, + popUpManager, + dispatch, + host, + }: { + updateState: (params: {showPopUpResult: ShowPopUpResult | undefined}) => void; + popUpManager: PopUpManager; + dispatch: (open: boolean) => void; + host: HTMLElement; + }, +) { + if (open) { + updateState({showPopUpResult: popUpManager.showPopUp(host)}); + } else { + popUpManager.removePopUp(); + } + + if (emitEvent) { + dispatch(open); + } +} diff --git a/packages/vira/src/elements/dropdown/dropdown.mock.ts b/packages/vira/src/elements/dropdown/dropdown.mock.ts new file mode 100644 index 00000000..cda46721 --- /dev/null +++ b/packages/vira/src/elements/dropdown/dropdown.mock.ts @@ -0,0 +1,20 @@ +import {ViraDropdownOption} from './vira-dropdown-item.element'; + +export const mockOptions = [ + { + label: 'Option 0', + id: 0, + }, + { + label: 'Option 1', + id: 1, + }, + { + label: 'Option 2', + id: 2, + }, + { + label: 'Option 3', + id: 3, + }, +] as const satisfies ReadonlyArray>; diff --git a/packages/vira/src/elements/dropdown/vira-dropdown-item.element.ts b/packages/vira/src/elements/dropdown/vira-dropdown-item.element.ts new file mode 100644 index 00000000..dab55192 --- /dev/null +++ b/packages/vira/src/elements/dropdown/vira-dropdown-item.element.ts @@ -0,0 +1,79 @@ +import {PartialAndUndefined} from '@augment-vir/common'; +import {css, html, HTMLTemplateResult} from 'element-vir'; +import {Check24Icon} from '../../icons/icon-svgs/check-24.icon'; +import {noUserSelect, viraAnimationDurations} from '../../styles'; +import {viraBorders} from '../../styles/border'; +import {defineViraElement} from '../define-vira-element'; +import {ViraIcon} from '../vira-icon.element'; + +export type ViraDropdownOption = { + /** Each `id` must be unique across all options. */ + id: PropertyKey; + label: string; +} & PartialAndUndefined<{ + disabled?: boolean | undefined; + hoverText?: string | undefined; + /** An optional custom template for this option. */ + template?: HTMLTemplateResult | undefined; +}>; + +export const ViraDropdownItem = defineViraElement<{ + label: string; + selected: boolean; +}>()({ + tagName: 'vira-dropdown-item', + hostClasses: { + 'vira-dropdown-item-selected': ({inputs}) => inputs.selected, + }, + styles: ({hostClasses}) => css` + :host { + display: flex; + ${noUserSelect}; + } + + .option { + pointer-events: none; + min-height: 24px; + display: flex; + align-items: center; + padding: 8px; + padding-left: 0; + text-align: left; + } + + ${hostClasses['vira-dropdown-item-selected'].selector} ${ViraIcon} { + opacity: 1; + } + + /* + The check icon looks centered when it has a border. + However, it does not have a border here. + */ + ${ViraIcon} { + transition: opacity + ${viraAnimationDurations['vira-interaction-animation-duration'].value}; + opacity: 0; + margin-top: -4px; + margin-right: -2px; + margin-left: 2px; + } + + .dropdown-wrapper:not(.reverse-direction) .option:last-of-type { + border-radius: 0 0 ${viraBorders['vira-form-input-radius'].value} + ${viraBorders['vira-form-input-radius'].value}; + } + + .dropdown-wrapper.reverse-direction .option:first-of-type { + border-radius: ${viraBorders['vira-form-input-radius'].value} + ${viraBorders['vira-form-input-radius'].value} 0 0; + } + `, + renderCallback({inputs}) { + return html` +
+ <${ViraIcon.assign({icon: Check24Icon})}> + ${inputs.label} +
+ `; + }, +}); diff --git a/packages/vira/src/elements/dropdown/vira-dropdown-options.element.ts b/packages/vira/src/elements/dropdown/vira-dropdown-options.element.ts new file mode 100644 index 00000000..de0cc46c --- /dev/null +++ b/packages/vira/src/elements/dropdown/vira-dropdown-options.element.ts @@ -0,0 +1,126 @@ +import {nav, navSelector} from 'device-navigation'; +import { + classMap, + css, + defineElementEvent, + html, + ifDefined, + listen, + nothing, + testId, +} from 'element-vir'; +import {viraDisabledStyles} from '../../styles'; +import {viraBorders} from '../../styles/border'; +import {viraFormCssVars} from '../../styles/form-themes'; +import {viraShadows} from '../../styles/shadows'; +import {defineViraElement} from '../define-vira-element'; +import {ViraDropdownItem, ViraDropdownOption} from './vira-dropdown-item.element'; + +export const viraDropdownOptionsTestIds = { + option: 'dropdown-option', +}; + +export const ViraDropdownOptions = defineViraElement< + Readonly<{ + /** All dropdown options to show to the user. */ + options: ReadonlyArray>; + /** + * The currently selected dropdown options. Note that this must be a reference subset of the + * options input. Meaning, entries in this array must be the exact same objects (by + * reference) as entries in the `options` input array for them to be marked as selected. + */ + selectedOptions: ReadonlyArray>; + }> +>()({ + tagName: 'vira-dropdown-options', + events: { + selectionChange: defineElementEvent>(), + }, + styles: css` + :host { + display: flex; + flex-direction: column; + + pointer-events: auto; + width: 100%; + max-height: 100%; + overflow-y: auto; + z-index: 99; + border-radius: ${viraBorders['vira-form-input-radius'].value}; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: ${viraFormCssVars['vira-form-background-color'].value}; + border: 1px solid ${viraFormCssVars['vira-form-border-color'].value}; + color: ${viraFormCssVars['vira-form-foreground-color'].value}; + ${viraShadows.menuShadow} + } + + .dropdown-item { + background-color: white; + outline: none; + } + + ${navSelector.css.selected('.dropdown-item:not(.disabled)')} { + background-color: ${viraFormCssVars['vira-form-selection-hover-background-color'] + .value}; + outline: none; + } + + ${ViraDropdownItem} { + pointer-events: none; + } + + .dropdown-item.disabled { + ${viraDisabledStyles}; + pointer-events: auto; + } + `, + renderCallback({inputs, dispatch, events}) { + const optionTemplates = inputs.options.map((option) => { + const innerTemplate = + option.template || + html` + <${ViraDropdownItem.assign({ + label: option.label, + selected: inputs.selectedOptions.includes(option), + })}> + `; + + return html` + + `; + }); + + return html` + ${optionTemplates} + `; + }, +}); diff --git a/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts b/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts new file mode 100644 index 00000000..3cfe5e00 --- /dev/null +++ b/packages/vira/src/elements/dropdown/vira-dropdown.element.test.ts @@ -0,0 +1,174 @@ +import {queryThroughShadow, waitForAnimationFrame} from '@augment-vir/browser'; +import {clickElement, extractText} from '@augment-vir/browser-testing'; +import {mapObjectValues, randomString, waitUntilTruthy} from '@augment-vir/common'; +import {assert, fixture, waitUntil} from '@open-wc/testing'; +import {html, listen, testIdBy} from 'element-vir'; +import {assertDefined, assertInstanceOf} from 'run-time-assertions'; +import {Element24Icon} from '../../icons/index'; +import {mockOptions} from './dropdown.mock'; +import {viraDropdownOptionsTestIds} from './vira-dropdown-options.element'; +import {ViraDropdown, viraDropdownTestIds} from './vira-dropdown.element'; + +async function setupDropdownTest(inputs?: Partial<(typeof ViraDropdown)['inputsType']>) { + const events: { + [EventKey in keyof typeof ViraDropdown.events]: InstanceType< + (typeof ViraDropdown.events)[EventKey] + >['detail'][]; + } = mapObjectValues(ViraDropdown.events, () => []); + const instance = await fixture(html` + <${ViraDropdown.assign({ + options: mockOptions, + selected: [], + ...inputs, + })} + ${listen(ViraDropdown.events.openChange, (event) => { + events.openChange.push(event.detail); + })} + ${listen(ViraDropdown.events.selectedChange, (event) => { + events.selectedChange.push(event.detail); + })} + > + `); + + assertInstanceOf(instance, ViraDropdown); + + const triggerElement = instance.shadowRoot.querySelector(testIdBy(viraDropdownTestIds.trigger)); + assertInstanceOf(triggerElement, HTMLElement); + + assert.isNull(instance.shadowRoot.querySelector(testIdBy(viraDropdownTestIds.options))); + assert.isEmpty(events.openChange); + assert.isEmpty(events.selectedChange); + + return { + events, + instance, + triggerElement, + queryByTestId: mapObjectValues(viraDropdownTestIds, (testIdKey, testId) => { + return () => { + return instance.shadowRoot.querySelector(testIdBy(testId)); + }; + }), + async toggle() { + const optionsExisted: boolean = !!instance.shadowRoot.querySelector( + testIdBy(viraDropdownTestIds.options), + ); + + await clickElement(triggerElement); + + await waitUntilTruthy( + async () => { + const optionsExistNow = !!instance.shadowRoot.querySelector( + testIdBy(viraDropdownTestIds.options), + ); + + return optionsExisted !== optionsExistNow; + }, + 'the options never popped up', + {timeout: {milliseconds: 1000}}, + ); + }, + }; +} + +describe(ViraDropdown.tagName, () => { + it('opens on a click', async () => { + const {toggle, events} = await setupDropdownTest(); + + await toggle(); + assert.deepStrictEqual(events.openChange, [true]); + }); + + it('closes on a click', async () => { + const {toggle, events, queryByTestId} = await setupDropdownTest(); + + await toggle(); + assert.deepStrictEqual(events.openChange, [true]); + await toggle(); + assert.deepStrictEqual(events.openChange, [ + true, + false, + ]); + await waitUntil(() => { + return !queryByTestId.options(); + }); + }); + + it('selects an option on click', async () => { + const {instance, toggle, events, queryByTestId} = await setupDropdownTest(); + + await toggle(); + const options = queryThroughShadow({ + element: instance, + query: testIdBy(viraDropdownOptionsTestIds.option), + all: true, + }); + + assert.lengthOf(options, mockOptions.length); + assertDefined(options[1]); + await clickElement(options[1]); + + await waitUntil(() => { + return !queryByTestId.options(); + }); + assert.deepStrictEqual(events.openChange, [ + true, + false, + ]); + assert.deepStrictEqual(events.selectedChange, [ + [1], + ]); + }); + + it('does not render prefix if nothing is selected', async () => { + const {queryByTestId} = await setupDropdownTest({ + selectionPrefix: randomString(), + }); + await waitForAnimationFrame(5); + assert.isNull(queryByTestId.prefix()); + }); + + it('renders a prefix', async () => { + const prefix = randomString(); + const {queryByTestId} = await setupDropdownTest({ + selectionPrefix: prefix, + selected: [1], + }); + const prefixElement = await waitUntilTruthy( + () => { + return queryByTestId.prefix(); + }, + 'prefix element never showed up', + {timeout: {milliseconds: 1000}}, + ); + + assert.strictEqual(extractText(prefixElement), prefix); + }); + + it('renders an icon', async () => { + const {queryByTestId} = await setupDropdownTest({ + icon: Element24Icon, + }); + await waitUntilTruthy( + () => { + return queryByTestId.icon(); + }, + 'icon element never showed up', + {timeout: {milliseconds: 1000}}, + ); + }); + + it('does not render an icon if not assigned', async () => { + const {queryByTestId} = await setupDropdownTest(); + await waitForAnimationFrame(5); + assert.isNull(queryByTestId.icon()); + }); + + it('renders a placeholder', async () => { + const placeholder = randomString(); + const {triggerElement} = await setupDropdownTest({ + placeholder, + }); + + assert.strictEqual(extractText(triggerElement), placeholder); + }); +}); diff --git a/packages/vira/src/elements/dropdown/vira-dropdown.element.ts b/packages/vira/src/elements/dropdown/vira-dropdown.element.ts new file mode 100644 index 00000000..819aafab --- /dev/null +++ b/packages/vira/src/elements/dropdown/vira-dropdown.element.ts @@ -0,0 +1,396 @@ +import {PartialAndUndefined} from '@augment-vir/common'; +import {NavController} from 'device-navigation'; +import { + classMap, + css, + defineElementEvent, + html, + ifDefined, + listen, + perInstance, + renderIf, + testId, +} from 'element-vir'; +import {assertInstanceOf} from 'run-time-assertions'; +import {ViraIconSvg} from '../../icons/icon-svg'; +import {ChevronUp24Icon} from '../../icons/index'; +import { + noNativeFormStyles, + noUserSelect, + viraAnimationDurations, + viraDisabledStyles, +} from '../../styles'; +import {viraBorders} from '../../styles/border'; +import {createFocusStyles, viraFocusCssVars} from '../../styles/focus'; +import {viraFormCssVars} from '../../styles/form-themes'; +import {viraShadows} from '../../styles/shadows'; +import { + HidePopUpEvent, + NavSelectEvent, + PopUpManager, + ShowPopUpResult, +} from '../../util/pop-up-manager'; +import {defineViraElement} from '../define-vira-element'; +import {ViraIcon} from '../vira-icon.element'; +import { + assertUniqueIdProps, + createNewSelection, + filterToSelectedOptions, + triggerPopUpState, +} from './dropdown-helpers'; +import {ViraDropdownOption} from './vira-dropdown-item.element'; +import {ViraDropdownOptions} from './vira-dropdown-options.element'; + +export const viraDropdownTestIds = { + trigger: 'dropdown-trigger', + icon: 'dropdown-icon', + prefix: 'dropdown-prefix', + options: 'dropdown-options', +}; + +export const ViraDropdown = defineViraElement< + { + options: ReadonlyArray>; + /** The selected id from the given options. */ + selected: ReadonlyArray; + } & PartialAndUndefined<{ + /** Text to show if nothing is selected. */ + placeholder: string; + /** + * If false, this will behave like a single select dropdown, otherwise you can select + * multiple. + */ + isMultiSelect: boolean; + icon: ViraIconSvg; + selectionPrefix: string; + isDisabled: boolean; + /** For debugging purposes only. Very bad for actual production code use. */ + z_debug_forceOpenState: boolean; + }> +>()({ + tagName: 'vira-dropdown', + hostClasses: { + 'vira-dropdown-disabled': ({inputs}) => !!inputs.isDisabled, + }, + styles: ({hostClasses}) => css` + :host { + display: inline-flex; + vertical-align: middle; + width: 256px; + ${viraFocusCssVars['vira-focus-outline-color'].name}: ${viraFormCssVars[ + 'vira-form-focus-color' + ].value}; + position: relative; + max-width: 100%; + } + + .dropdown-wrapper { + ${noNativeFormStyles}; + max-width: 100%; + align-self: stretch; + flex-grow: 1; + position: relative; + border-radius: ${viraBorders['vira-form-input-radius'].value}; + transition: border-radius + ${viraAnimationDurations['vira-interaction-animation-duration'].value}; + outline: none; + } + + ${createFocusStyles({ + selector: '.dropdown-wrapper:focus', + elementBorderSize: 1, + })} + + .selection-display { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .trigger-icon { + transform: rotate(0); + transition: ${viraAnimationDurations['vira-interaction-animation-duration'].value} + linear transform; + align-self: flex-start; + } + + .trigger-icon-wrapper { + flex-grow: 1; + display: flex; + justify-content: flex-end; + } + + .dropdown-wrapper.open .trigger-icon { + transform: rotate(180deg); + } + + .dropdown-wrapper.open:not(.open-upwards) { + border-bottom-left-radius: 0; + } + + .open-upwards.dropdown-wrapper.open { + border-top-left-radius: 0; + } + + .dropdown-trigger { + border: 1px solid ${viraFormCssVars['vira-form-border-color'].value}; + height: 100%; + width: 100%; + transition: inherit; + box-sizing: border-box; + display: flex; + gap: 8px; + text-align: left; + align-items: center; + padding: 3px; + padding-left: 10px; + ${noUserSelect}; + border-radius: inherit; + background-color: ${viraFormCssVars['vira-form-background-color'].value}; + color: ${viraFormCssVars['vira-form-foreground-color'].value}; + } + + .open-upwards ${ViraDropdownOptions} { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + ${viraShadows.menuShadowReversed} + } + + ${hostClasses['vira-dropdown-disabled'].selector} { + ${viraDisabledStyles} + pointer-events: auto; + } + + ${hostClasses['vira-dropdown-disabled'].selector} .dropdown-wrapper { + pointer-events: none; + } + + .pop-up-positioner { + position: absolute; + pointer-events: none; + display: flex; + flex-direction: column; + + /* highest possible z-index */ + z-index: 2147483647; + /* space for the caret icon */ + right: 28px; + /* minus the border width */ + top: calc(100% - 1px); + } + + .using-placeholder { + opacity: 0.4; + } + + .open-upwards .pop-up-positioner { + flex-direction: column-reverse; + /* minus the border width */ + bottom: calc(100% - 1px); + } + `, + events: { + selectedChange: defineElementEvent(), + openChange: defineElementEvent(), + }, + stateInitStatic: { + /** `undefined` means the pop up is not currently showing. */ + showPopUpResult: undefined as ShowPopUpResult | undefined, + popUpManager: perInstance(() => new PopUpManager()), + navController: undefined as NavController | undefined, + }, + cleanupCallback({state, updateState}) { + updateState({showPopUpResult: undefined}); + state.popUpManager.destroy(); + }, + initCallback({state, updateState, host, inputs, dispatch, events}) { + state.popUpManager.listen(HidePopUpEvent, () => { + updateState({showPopUpResult: undefined}); + if (!inputs.isDisabled) { + const dropdownWrapper = host.shadowRoot.querySelector('.dropdown-wrapper'); + + assertInstanceOf( + dropdownWrapper, + HTMLButtonElement, + 'failed to find dropdown wrapper child', + ); + + dropdownWrapper.focus(); + } + }); + state.popUpManager.listen(NavSelectEvent, (event) => { + const optionIndex = event.detail.x; + const option = inputs.options[optionIndex]; + if (!option) { + throw new Error(`Found no dropdown option at index '${optionIndex}'`); + } + /** Only close upon option selection if the dropdown is not multi select. */ + if (!inputs.isMultiSelect) { + triggerPopUpState( + {emitEvent: true, open: false}, + { + dispatch: (openState) => { + dispatch(new events.openChange(openState)); + }, + host, + popUpManager: state.popUpManager, + updateState, + }, + ); + } + + dispatch( + new events.selectedChange( + createNewSelection(option.id, inputs.selected, !!inputs.isMultiSelect), + ), + ); + }); + updateState({navController: new NavController(host)}); + }, + renderCallback({dispatch, events, state, inputs, updateState, host}) { + assertUniqueIdProps(inputs.options); + + function triggerPopUp(param: Parameters[0]) { + triggerPopUpState(param, { + dispatch: (openState) => { + dispatch(new events.openChange(openState)); + }, + host, + popUpManager: state.popUpManager, + updateState, + }); + } + + if (inputs.isDisabled) { + triggerPopUp({open: false, emitEvent: false}); + } else if (inputs.z_debug_forceOpenState != undefined) { + if (!inputs.z_debug_forceOpenState && state.showPopUpResult) { + triggerPopUp({emitEvent: false, open: false}); + } else if (inputs.z_debug_forceOpenState && !state.showPopUpResult) { + triggerPopUp({emitEvent: false, open: true}); + } + } + + const selectedOptions: ReadonlyArray> = + filterToSelectedOptions(inputs); + + const leadingIconTemplate = inputs.icon + ? html` + <${ViraIcon.assign({ + icon: inputs.icon, + })} + ${testId(viraDropdownTestIds.icon)} + > + ` + : ''; + + const positionerStyles = state.showPopUpResult + ? state.showPopUpResult.popDown + ? /** Dropdown going down position. */ + css` + bottom: -${state.showPopUpResult.positions.diff.bottom}px; + ` + : /** Dropdown going up position. */ + css` + top: -${state.showPopUpResult.positions.diff.top}px; + ` + : undefined; + + function respondToClick() { + triggerPopUp({emitEvent: true, open: !state.showPopUpResult}); + } + + const shouldUsePlaceholder: boolean = !selectedOptions.length; + + const prefixTemplate = + inputs.selectionPrefix && !shouldUsePlaceholder + ? html` + + ${inputs.selectionPrefix} + + ` + : ''; + + const selectionDisplay: string = shouldUsePlaceholder + ? inputs.placeholder || '' + : selectedOptions.map((item) => item.label).join(', '); + + return html` + + `; + }, +}); diff --git a/packages/vira/src/elements/index.ts b/packages/vira/src/elements/index.ts index d9aa9d0b..f255ee43 100644 --- a/packages/vira/src/elements/index.ts +++ b/packages/vira/src/elements/index.ts @@ -1,6 +1,9 @@ /** This file is automatically updated by update-index-exports.ts */ export * from './define-vira-element'; +export * from './dropdown/vira-dropdown-item.element'; +export * from './dropdown/vira-dropdown-options.element'; +export * from './dropdown/vira-dropdown.element'; export * from './vira-button.element'; export * from './vira-collapsible-wrapper.element'; export * from './vira-icon.element'; diff --git a/packages/vira/src/styles/form-themes.ts b/packages/vira/src/styles/form-themes.ts new file mode 100644 index 00000000..0226568f --- /dev/null +++ b/packages/vira/src/styles/form-themes.ts @@ -0,0 +1,12 @@ +import {defineCssVars} from 'lit-css-vars'; +import {viraFocusCssVars} from './focus'; + +export const viraFormCssVars = defineCssVars({ + 'vira-form-border-color': '#cccccc', + 'vira-form-background-color': 'white', + 'vira-form-foreground-color': 'black', + 'vira-form-focus-color': viraFocusCssVars['vira-focus-outline-color'].value, + + 'vira-form-selection-hover-background-color': '#d2eaff', + 'vira-form-selection-hover-foreground-color': 'black', +}); diff --git a/packages/vira/src/styles/index.ts b/packages/vira/src/styles/index.ts index ea4c6d65..aca9240c 100644 --- a/packages/vira/src/styles/index.ts +++ b/packages/vira/src/styles/index.ts @@ -5,6 +5,7 @@ export * from './color'; export * from './disabled'; export * from './durations'; export * from './focus'; +export * from './form-themes'; export * from './native-styles'; export * from './scrollbar'; export * from './shadows'; diff --git a/packages/vira/src/util/pop-up-manager.ts b/packages/vira/src/util/pop-up-manager.ts index 64221183..ed1954af 100644 --- a/packages/vira/src/util/pop-up-manager.ts +++ b/packages/vira/src/util/pop-up-manager.ts @@ -87,6 +87,7 @@ export class PopUpManager { supportNavigation: true, }; private cleanupCallbacks: (() => void)[] = []; + private lastRootElement: HTMLElement | undefined; constructor(options?: Partial | undefined) { this.options = {...this.options, ...options}; @@ -103,7 +104,14 @@ export class PopUpManager { }), listenToGlobal( 'mousedown', - () => { + (event) => { + if ( + this.lastRootElement && + event.composedPath().includes(this.lastRootElement) + ) { + /** Ignore clicks that came from the pop up host itself. */ + return; + } this.removePopUp(); }, {passive: true}, @@ -185,6 +193,7 @@ export class PopUpManager { rootElement: HTMLElement, options?: Partial | undefined, ): ShowPopUpResult { + this.lastRootElement = rootElement; const currentOptions = {...this.options, ...options}; const container = findOverflowParent(rootElement); assertInstanceOf(container, HTMLElement);