diff --git a/.stylelintrc.json b/.stylelintrc.json index 28670f05ca..f75f5f4652 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -6,6 +6,7 @@ "color-hex-length": null, "color-named": "never", "hue-degree-notation": "number", + "no-descending-specificity": null, "scss/at-mixin-argumentless-call-parentheses": null, "scss/at-rule-conditional-no-parentheses": null, "scss/double-slash-comment-empty-line-before": null, diff --git a/packages/ods/package.json b/packages/ods/package.json index 4eec55d1c8..92fc3674df 100644 --- a/packages/ods/package.json +++ b/packages/ods/package.json @@ -26,7 +26,8 @@ }, "dependencies": { "@floating-ui/dom": "1.6.3", - "@stencil/core": "4.12.2" + "@stencil/core": "4.12.2", + "tom-select": "2.3.1" }, "devDependencies": { "@jest/types": "29.6.3", diff --git a/packages/ods/react/tests/_app/src/components.ts b/packages/ods/react/tests/_app/src/components.ts index b4c0d02115..68b34cc619 100644 --- a/packages/ods/react/tests/_app/src/components.ts +++ b/packages/ods/react/tests/_app/src/components.ts @@ -33,6 +33,7 @@ const componentNames = [ 'message', 'radio', 'checkbox', + 'select', //--generator-anchor-- ]; diff --git a/packages/ods/react/tests/_app/src/components/ods-select.tsx b/packages/ods/react/tests/_app/src/components/ods-select.tsx new file mode 100644 index 0000000000..e724b2946a --- /dev/null +++ b/packages/ods/react/tests/_app/src/components/ods-select.tsx @@ -0,0 +1,35 @@ +import React from 'react-dom/client'; +import { OdsSelect } from 'ods-components-react'; + +const Select = () => { + function onOdsChange() { + console.log('React select odsChange'); + } + + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; + +export default Select; diff --git a/packages/ods/react/tests/e2e/ods-select.e2e.ts b/packages/ods/react/tests/e2e/ods-select.e2e.ts new file mode 100644 index 0000000000..e16bf21e6a --- /dev/null +++ b/packages/ods/react/tests/e2e/ods-select.e2e.ts @@ -0,0 +1,50 @@ +import type { Page } from 'puppeteer'; +import { goToComponentPage, setupBrowser } from '../setup'; + +describe('ods-select react', () => { + const setup = setupBrowser(); + let page: Page; + + beforeAll(async () => { + page = setup().page; + }); + + beforeEach(async () => { + await goToComponentPage(page, 'ods-select'); + }); + + it('render the component correctly', async () => { + const elem = await page.$('ods-select'); + const boundingBox = await elem?.boundingBox(); + + expect(boundingBox?.height).toBeGreaterThan(0); + expect(boundingBox?.width).toBeGreaterThan(0); + }); + + it('trigger the odsChange handler on option selection', async () => { + const control = await page.$('ods-select:not([is-disabled]) >>> .ts-control'); + let consoleLog = ''; + page.on('console', (consoleObj) => { + consoleLog = consoleObj.text(); + }); + + await control?.click(); + + const option = await page.$('ods-select:not([is-disabled]) >>> .option'); + await option?.click(); + + // Small delay to ensure page console event has been resolved + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(consoleLog).toBe('React select odsChange'); + }); + + it('does not open the option list if disabled', async () => { + const control = await page.$('ods-select[is-disabled] >>> .ts-control'); + + await control?.click(); + const option = await page.$('ods-select:not([is-disabled]) >>> .option'); + + expect(option).toBeNull(); + }); +}); diff --git a/packages/ods/src/components/index.ts b/packages/ods/src/components/index.ts index 71f715f4aa..5832923cad 100644 --- a/packages/ods/src/components/index.ts +++ b/packages/ods/src/components/index.ts @@ -32,3 +32,4 @@ export * from './form-field/src'; export * from './message/src'; export * from './radio/src'; export * from './checkbox/src'; +export * from './select/src'; diff --git a/packages/ods/src/components/input/src/components/ods-input/ods-input.scss b/packages/ods/src/components/input/src/components/ods-input/ods-input.scss index 4953ace5c4..5b75d36cfb 100644 --- a/packages/ods/src/components/input/src/components/ods-input/ods-input.scss +++ b/packages/ods/src/components/input/src/components/ods-input/ods-input.scss @@ -42,7 +42,6 @@ } } - /* stylelint-disable no-descending-specificity */ &__clearable { color: var(--ods-color-neutral-600); @@ -72,7 +71,6 @@ } } } - /* stylelint-enable no-descending-specificity */ &__spinner { padding-right: input.$ods-input-actions-padding-right; diff --git a/packages/ods/src/components/popover/src/components/ods-popover/ods-popover.tsx b/packages/ods/src/components/popover/src/components/ods-popover/ods-popover.tsx index 45d1bda81d..958b55d3d7 100644 --- a/packages/ods/src/components/popover/src/components/ods-popover/ods-popover.tsx +++ b/packages/ods/src/components/popover/src/components/ods-popover/ods-popover.tsx @@ -67,6 +67,9 @@ export class OdsPopover { arrow: this.arrowElement, popper: this.el, trigger: this.triggerElement, + }, { + offset: 8, + shift: { padding: 5 }, }); this.isOpen = true; diff --git a/packages/ods/src/components/select/.gitignore b/packages/ods/src/components/select/.gitignore new file mode 100644 index 0000000000..7b15d7273d --- /dev/null +++ b/packages/ods/src/components/select/.gitignore @@ -0,0 +1,5 @@ +# Local Stencil command generates external ods component build at the root of the project +# Excluding them is a temporary solution to avoid pushing generated files +# But the issue may cause main build (ods-component package) to fails, as it detects multiples occurences +# of the same component and thus you have to delete all those generated dir manually +*/src/ diff --git a/packages/ods/src/components/select/package.json b/packages/ods/src/components/select/package.json new file mode 100644 index 0000000000..b5cd9d1df3 --- /dev/null +++ b/packages/ods/src/components/select/package.json @@ -0,0 +1,19 @@ +{ + "name": "@ovhcloud/ods-component-select", + "version": "17.1.0", + "private": true, + "description": "ODS Select component", + "main": "dist/index.cjs.js", + "collection": "dist/collection/collection-manifest.json", + "scripts": { + "clean": "rimraf .stencil coverage dist docs-api www", + "doc": "typedoc --pretty --plugin ../../../scripts/typedoc-plugin-decorator.js && node ../../../scripts/generate-typedoc-md.js", + "lint:scss": "stylelint 'src/components/**/*.scss'", + "lint:ts": "eslint '{src,tests}/**/*.{js,ts,tsx}'", + "start": "stencil build --dev --watch --serve", + "test:e2e": "stencil test --e2e --config stencil.config.ts", + "test:e2e:ci": "tsc --noEmit && stencil test --e2e --ci --runInBand --config stencil.config.ts", + "test:spec": "stencil test --spec --config stencil.config.ts --coverage", + "test:spec:ci": "tsc --noEmit && stencil test --config stencil.config.ts --spec --ci --coverage" + } +} diff --git a/packages/ods/src/components/select/src/components/ods-select/ods-select.scss b/packages/ods/src/components/select/src/components/ods-select/ods-select.scss new file mode 100644 index 0000000000..0710261250 --- /dev/null +++ b/packages/ods/src/components/select/src/components/ods-select/ods-select.scss @@ -0,0 +1,212 @@ +@use '../../../../../style/checkbox'; +@use '../../../../../style/focus'; +@use '../../../../../style/icon'; +@use '../../../../../style/input'; +@use '../../../../../style/overlay'; +@import '~tom-select/dist/css/tom-select.bootstrap4.min.css'; + +$select-background-color: #fff; +$select-caret-size: 24px; +$select-base-padding: 8px; +$select-padding-right: $select-caret-size + ($select-base-padding * 2); + +@mixin ods-select-item() { + color: var(--ods-color-text); +} + +@mixin ods-select-placeholder() { + color: var(--ods-color-neutral-600); +} + +:host(.ods-select) { + .ts-control { + border: solid 1px var(--ods-color-neutral-300); + border-radius: var(--ods-border-radius-sm); + background-color: $select-background-color; + cursor: pointer !important; + padding: 0 $select-padding-right 0 $select-base-padding !important; + height: input.$ods-input-input-height; + min-height: auto; + + .item { + @include ods-select-item(); + } + } + + .disabled { + .ts-control { + @include input.ods-input-disabled(); + + opacity: 1; + cursor: not-allowed !important; + + * { + cursor: not-allowed !important; + color: inherit !important; + } + } + } + + .dropdown-active.focus { + .ts-control { + box-shadow: none; + } + } + + :not(.dropdown-active).focus { + .ts-control { + @include focus.ods-focus(); + + box-shadow: none; + } + } + + .ts-dropdown { + z-index: overlay.$ods-overlay-z-index - 1; + margin: 0; + box-shadow: none; + + &.ods-select__dropdown--bottom { + border-radius: 0 0 var(--ods-border-radius-sm) var(--ods-border-radius-sm); + } + + &.ods-select__dropdown--top { + border-radius: var(--ods-border-radius-sm) var(--ods-border-radius-sm) 0 0; + } + } + + .ts-dropdown-content { + padding: 0; + + .option { + display: flex; + align-items: center; + padding: 0 8px; + min-height: input.$ods-input-input-height; + color: var(--ods-color-text); + + &:not([data-selectable]) { + @include input.ods-input-disabled(); + + opacity: 1; + } + + &.active, + &:hover { + background-color: var(--ods-color-primary-100); + } + } + + .optgroup { + &::before { + margin: 0; + } + + &[data-disabled] { + @include input.ods-input-disabled(); + + opacity: 1; + } + + .optgroup-header { + display: flex; + align-items: center; + background-color: inherit; + padding: 0 8px; + min-height: input.$ods-input-input-height; + color: var(--ods-color-heading); + font-size: 1rem; + font-weight: 600; + } + + .option { + padding-left: 24px; + } + } + + .tomselect-checkbox { + @include checkbox.ods-checkbox(); + + margin-right: 8px; + } + } + + .ts-placeholder { + @include ods-select-placeholder(); + } + + .plugin-merge_selected_items { + .ts-merged-items { + @include ods-select-item(); + + margin: 0; + background: $select-background-color; + cursor: pointer; + padding: 0; + } + + .ts-merged-items-placeholder { + @include ods-select-placeholder(); + } + } + + .ts-wrapper { + &.multi, + &.single { + .ts-control { + &::after { + @include icon.ods-icon(); + + position: absolute; + top: 50%; + right: $select-base-padding; + transform: translateY(-50%); + margin: 0; + border: none; + background-color: inherit; + width: auto; + height: auto; + color: var(--ods-color-primary-500); + font-size: $select-caret-size; + content: '\e916'; + } + } + + &.dropdown-active { + .ts-control { + &::after { + content: '\e919'; + } + } + } + + &.disabled { + .ts-control { + &::after { + color: var(--ods-color-neutral-600); + } + } + + .ts-merged-items { + @include input.ods-input-disabled(); + } + } + } + + &.multi { + &.plugin-placeholder { + .ts-placeholder { + // As we use the merge plugin, we enforce the placeholder from other plugin to be hidden + display: none !important; + } + } + } + } +} + +// Double class to give more weight than base override +:host(.ods-select.ods-select--error) { + .ts-control { + @include input.ods-input-error(); + } +} diff --git a/packages/ods/src/components/select/src/components/ods-select/ods-select.tsx b/packages/ods/src/components/select/src/components/ods-select/ods-select.tsx new file mode 100644 index 0000000000..86ecb07b66 --- /dev/null +++ b/packages/ods/src/components/select/src/components/ods-select/ods-select.tsx @@ -0,0 +1,259 @@ +import { AttachInternals, Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, Watch, h } from '@stencil/core'; +import TomSelect from 'tom-select'; +import { getElementPosition } from '../../../../../utils/overlay'; +import { mergeSelectedItemPlugin, placeholderPlugin } from '../../../../../utils/select'; +import { type CustomRenderer, getSelectConfig, inlineValue, moveSlottedElements, setFormValue, setSelectValue } from '../../controller/ods-select'; +import { type OdsSelectEventChangeDetail } from '../../interfaces/events'; + +TomSelect.define('merge_selected_items', mergeSelectedItemPlugin); +TomSelect.define('placeholder', placeholderPlugin); + +@Component({ + formAssociated: true, + shadow: true, + styleUrl: 'ods-select.scss', + tag: 'ods-select', +}) +export class OdsSelect { + private hasMovedNodes: boolean = false; + private isSelectSync: boolean = false; + private select?: TomSelect; + private selectElement?: HTMLSelectElement; + + @Element() el!: HTMLElement; + + @AttachInternals() private internals!: ElementInternals; + + @Prop({ reflect: true }) public allowMultiple: boolean = false; + @Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null; + @Prop({ reflect: true }) public ariaLabelledby?: string; + @Prop({ reflect: false }) public customRenderer?: CustomRenderer; + @Prop({ reflect: true }) public defaultValue?: string | string []; + @Prop({ reflect: true }) public hasError: boolean = false; + @Prop({ reflect: true }) public isDisabled: boolean = false; + @Prop({ reflect: true }) public isRequired: boolean = false; + @Prop({ reflect: true }) public multipleSelectionLabel: string = 'Selected item'; + @Prop({ reflect: true }) public name!: string; + @Prop({ reflect: true }) public placeholder?: string; + @Prop({ mutable: true, reflect: true }) public value: string | string [] | null = null; + + @Event() odsBlur!: EventEmitter; + @Event() odsChange!: EventEmitter; + @Event() odsClear!: EventEmitter; + @Event() odsFocus!: EventEmitter; + @Event() odsReset!: EventEmitter; + + @Method() + async clear(): Promise { + this.value = null; + this.odsClear.emit(); + } + + @Method() + async close(): Promise { + this.select?.close(); + } + + @Method() + async getValidity(): Promise { + return this.selectElement?.validity; + } + + @Method() + async open(): Promise { + this.select?.open(); + } + + @Method() + async reset(): Promise { + this.updateValue(this.defaultValue ?? null); + this.odsReset.emit(); + } + + @Watch('isDisabled') + onIsDisabledChange(newValue: boolean): void { + newValue ? this.select?.disable() : this.select?.enable(); + } + + @Watch('multipleSelectionLabel') + onMultipleSelectionLabelChange(newValue: string): void { + this.select?.control.dispatchEvent(new CustomEvent('ods-select-multiple-selection-label-change', { + detail: newValue, + })); + } + + @Watch('placeholder') + onPlaceholderChange(newValue: string): void { + this.select?.control.dispatchEvent(new CustomEvent('ods-select-placeholder-change', { + detail: newValue, + })); + } + + @Watch('value') + onValueChange(value: string | string[], oldValue?: string | string[]): void { + // Value change can be triggered from either value attribute change or select change + // For the latter, we don't want to trigger a new change (as it may causes loop) + if (!this.isSelectSync) { + setSelectValue(this.select, value); + } + this.isSelectSync = false; + + setFormValue(this.internals, value); + + this.odsChange.emit({ + name: this.name, + oldValue: inlineValue(oldValue), + validity: this.selectElement?.validity, + value: inlineValue(value), + }); + } + + componentWillLoad(): void { + if (!this.value) { + this.value = this.defaultValue ?? null; + } + setFormValue(this.internals, this.value); + } + + async formResetCallback(): Promise { + await this.reset(); + } + + private bindSelectControl(): void { + // By setting the lib "openOnFocus" to false, the dropdown doesn't open anymore on click + // So we need to manually add our own open handler + this.select?.control.addEventListener('click', () => { + if (this.isDisabled) { + return; + } + + // BUT the dropdown still get closed on click if open, and this close click is triggered before + // this listener, which means we can't use the select "isOpen" state here as it is always false + // + // "ignoreFocus" is only set in the lib between the node focus() and the focus event trigger call + // so we can use this moment to know that our click needs to open the dropdown + // and it will be ignored on close clicks + if (this.select?.ignoreFocus) { + this.select?.open(); + } + }); + + this.select?.control.addEventListener('keydown', (event: KeyboardEvent) => { + // This prevents Space key to scroll the window down + if (event.key === ' ') { + event.preventDefault(); + } + }); + + this.select?.control.addEventListener('keyup', (event: KeyboardEvent) => { + if (!this.isDisabled && event.key === ' ') { + this.select?.open(); + } + }); + } + + private onSlotChange(event: Event): void { + // The initial slot nodes move will trigger this callback again + // but we want to avoid a second select initialisation + if (this.hasMovedNodes) { + this.hasMovedNodes = false; + return; + } + + if (this.selectElement) { + moveSlottedElements(this.selectElement, (event.currentTarget as HTMLSlotElement).assignedElements()); + this.hasMovedNodes = true; + + const { plugin, template } = getSelectConfig(this.allowMultiple, this.multipleSelectionLabel, this.customRenderer); + + this.select?.destroy(); + this.select = new TomSelect(this.selectElement, { + allowEmptyOption: true, + closeAfterSelect: !this.allowMultiple, + controlInput: undefined, + create: false, + onBlur: (): void => { + this.odsBlur.emit(); + }, + onChange: (value: string | string[]): void => { + this.isSelectSync = true; + this.updateValue(value); + }, + onDropdownClose: (dropdown: HTMLDivElement): void => { + dropdown.classList.remove('ods-select__dropdown--bottom', 'ods-select__dropdown--top'); + + this.select!.control.style.removeProperty('border-top-right-radius'); + this.select!.control.style.removeProperty('border-top-left-radius'); + this.select!.control.style.removeProperty('border-bottom-right-radius'); + this.select!.control.style.removeProperty('border-bottom-left-radius'); + }, + onDropdownOpen: async(dropdown: HTMLDivElement): Promise => { + // Delay the position computing at the end of the stack to ensure floating element has its final height + setTimeout(async() => { + const { placement, y } = await getElementPosition('bottom', { + popper: dropdown, + trigger: this.select?.control, + }, { + offset: -1, // offset the border-width size as we want it merged with the trigger. + }); + + Object.assign(dropdown.style, { + left: '0', + top: `${y}px`, + }); + + dropdown.classList.add(`ods-select__dropdown--${placement}`); + + if (placement === 'top') { + this.select!.control.style.borderTopRightRadius = '0'; + this.select!.control.style.borderTopLeftRadius = '0'; + } else { + this.select!.control.style.borderBottomRightRadius = '0'; + this.select!.control.style.borderBottomLeftRadius = '0'; + } + }, 0); + }, + onFocus: (): void => { + this.odsFocus.emit(); + }, + openOnFocus: false, + placeholder: this.placeholder, + plugins: plugin, + render: template, + selectOnTab: true, + }); + this.bindSelectControl(); + + setSelectValue(this.select, this.value, this.defaultValue, true); + } + } + + private updateValue(newValue: string | string[] | null): void { + if (Array.isArray(newValue)) { + this.value = [...newValue]; // to enforce Stencil @Watch trigger + } else { + this.value = newValue; + } + } + + render(): FunctionalComponent { + return ( + + + + this.onSlotChange(e) }> + + ); + } +} diff --git a/packages/ods/src/components/select/src/controller/ods-select.ts b/packages/ods/src/components/select/src/controller/ods-select.ts new file mode 100644 index 0000000000..b1243f2b17 --- /dev/null +++ b/packages/ods/src/components/select/src/controller/ods-select.ts @@ -0,0 +1,70 @@ +import type TomSelect from 'tom-select'; + +type RendererData = { + text: string, + value: string, +} +type CustomRenderer = { + item?: (data: RendererData) => string, + option?: (data: RendererData) => string, +}; +type SelectConfigItem = Record; +type SelectConfig = { plugin: SelectConfigItem, template: SelectConfigItem }; + +function getSelectConfig(allowMultiple: boolean, multipleSelectionLabel: string, renderer?: CustomRenderer): SelectConfig { + const plugin: SelectConfigItem = { 'placeholder': {} }; + const template: SelectConfigItem = renderer || {}; + + if (allowMultiple) { + plugin['checkbox_options'] = { + 'checkedClassNames': ['ts-checked'], + 'uncheckedClassNames': ['ts-unchecked'], + }; + plugin['merge_selected_items'] = { + label: multipleSelectionLabel, + }; + + template['item'] = (): string => '
'; + } + + return { plugin, template }; +} + +function inlineValue(value: string | string[] | null | undefined): string { + if (Array.isArray(value)) { + return value.join(','); + } + return value || ''; +} + +function moveSlottedElements(targetElement: HTMLSelectElement, slottedElements: Element[]): void { + // clean-up target + targetElement.replaceChildren(); + + slottedElements.forEach((element) => { + targetElement.appendChild(element); + }); +} + +function setFormValue(internals: ElementInternals, value: string | string [] | null | undefined): void { + internals.setFormValue(inlineValue(value)); +} + +function setSelectValue(select?: TomSelect, value?: string | string[] | null, defaultValue?: string | string [], isSilent: boolean = false): void { + if (select) { + if (value || defaultValue) { + select.setValue(value || defaultValue || '', isSilent); + } else { + select.clear(isSilent); + } + } +} + +export { + type CustomRenderer, + getSelectConfig, + inlineValue, + moveSlottedElements, + setFormValue, + setSelectValue, +}; diff --git a/packages/ods/src/components/select/src/globals.ts b/packages/ods/src/components/select/src/globals.ts new file mode 100644 index 0000000000..049ced9fc3 --- /dev/null +++ b/packages/ods/src/components/select/src/globals.ts @@ -0,0 +1,8 @@ +/** + * Import here all the external ODS component that you need to run the current component + * when running dev server (yarn start) or e2e tests + * + * ex: + * import '../../text/src'; + */ +import '../../icon/src'; diff --git a/packages/ods/src/components/select/src/index.html b/packages/ods/src/components/select/src/index.html new file mode 100644 index 0000000000..060b55b731 --- /dev/null +++ b/packages/ods/src/components/select/src/index.html @@ -0,0 +1,333 @@ + + + + + + Dev ods-select + + + + + + + + + +

Default

+ + + + + + + + + +

Disabled

+ + + + + + + + + +

Has error

+ + + + + + + + + +

Default value

+ + + + + + + + + +

Value

+ + + + + + + + + + + +

Placeholder

+ + + + + + + + + + + +

Multiple

+ + + + + + + + + + + +

Optgroup

+ + + + + + + + + + + + + +

Option disabled

+ + + + + + + + + +

Optgroup disabled

+ + + + + + + + + + + + + + + + + + + + + + + +

Form

+
+ + + + + + + + + + + + +
+ + +

Methods & Events

+ + + + + + + + + + + + + + + +

Custom renderer

+ + + + + + + + +

Custom renderer multiple

+ + + + + + + + +
+ + diff --git a/packages/ods/src/components/select/src/index.ts b/packages/ods/src/components/select/src/index.ts new file mode 100644 index 0000000000..784ab77758 --- /dev/null +++ b/packages/ods/src/components/select/src/index.ts @@ -0,0 +1,2 @@ +export { OdsSelect } from './components/ods-select/ods-select'; +export { type OdsSelectEventChange, type OdsSelectEventChangeDetail } from './interfaces/events'; diff --git a/packages/ods/src/components/select/src/interfaces/events.ts b/packages/ods/src/components/select/src/interfaces/events.ts new file mode 100644 index 0000000000..b30e337b9a --- /dev/null +++ b/packages/ods/src/components/select/src/interfaces/events.ts @@ -0,0 +1,13 @@ +interface OdsSelectEventChangeDetail { + name: string; + oldValue?: string; + validity?: ValidityState; + value: string; +} + +type OdsSelectEventChange = CustomEvent; + +export { + type OdsSelectEventChange, + type OdsSelectEventChangeDetail, +}; diff --git a/packages/ods/src/components/select/stencil.config.ts b/packages/ods/src/components/select/stencil.config.ts new file mode 100644 index 0000000000..d42a85166e --- /dev/null +++ b/packages/ods/src/components/select/stencil.config.ts @@ -0,0 +1,7 @@ +import { getStencilConfig } from '../../config/stencil'; + +export const config = getStencilConfig({ + args: process.argv.slice(2), + componentCorePackage: '@ovhcloud/ods-component-select', + namespace: 'ods-select', +}); diff --git a/packages/ods/src/components/select/tests/accessibility/ods-select.e2e.ts b/packages/ods/src/components/select/tests/accessibility/ods-select.e2e.ts new file mode 100644 index 0000000000..59282a0113 --- /dev/null +++ b/packages/ods/src/components/select/tests/accessibility/ods-select.e2e.ts @@ -0,0 +1,34 @@ +import { type E2EElement, type E2EPage, newE2EPage } from '@stencil/core/testing'; + +describe('ods-select accessibility', () => { + let el: E2EElement; + let page: E2EPage; + + async function setup(content: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + + el = await page.find('ods-select'); + } + + it('should render the web component with the right default attributes', async() => { + await setup(''); + + expect(el.shadowRoot).not.toBeNull(); + expect(el.getAttribute('aria-label')).toBeNull(); + expect(el.getAttribute('aria-labelledby')).toBeNull(); + }); + + it('should render the web component with the right aria attributes', async() => { + const dummyAriaLabel = 'dummy aria label'; + const dummyAriaLabelledby = 'dummy element'; + + await setup(``); + + expect(el.shadowRoot).not.toBeNull(); + expect(el.getAttribute('aria-label')).toBe(dummyAriaLabel); + expect(el.getAttribute('aria-labelledby')).toBe(dummyAriaLabelledby); + }); +}); diff --git a/packages/ods/src/components/select/tests/behaviour/ods-select.e2e.ts b/packages/ods/src/components/select/tests/behaviour/ods-select.e2e.ts new file mode 100644 index 0000000000..58f0949308 --- /dev/null +++ b/packages/ods/src/components/select/tests/behaviour/ods-select.e2e.ts @@ -0,0 +1,164 @@ +import { type E2EElement, type E2EPage, newE2EPage } from '@stencil/core/testing'; + +describe('ods-select behaviour', () => { + let el: E2EElement; + let page: E2EPage; + + async function isSelectOpen(): Promise { + return page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper')?.classList.contains('dropdown-active') || false; + }); + } + + async function setup(content: string, customStyle?: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + + if (customStyle) { + await page.addStyleTag({ content: customStyle }); + } + + el = await page.find('ods-select'); + } + + beforeEach(jest.clearAllMocks); + + describe('form', () => { + it('should get form data when submit button is triggered', async() => { + await setup(`
+ + +
`); + const submitButton = await page.find('button[type="submit"]'); + + await submitButton.click(); + await page.waitForNetworkIdle(); + + const url = new URL(page.url()); + expect(url.searchParams.get('odsSelect')).toBe('1'); + }); + + it('should reset form when reset button is triggered', async() => { + await setup(`
+ + + +
`); + const resetButton = await page.find('button[type="reset"]'); + const submitButton = await page.find('button[type="submit"]'); + + await resetButton.click(); + await submitButton.click(); + await page.waitForNetworkIdle(); + + const url = new URL(page.url()); + expect(url.searchParams.get('odsSelect')).toBe(''); + }); + }); + + describe('methods', () => { + describe('clear', () => { + it('should emit an odsClear event', async() => { + await setup(''); + const odsClearSpy = await page.spyOnEvent('odsClear'); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.getProperty('value')).toBeNull(); + expect(odsClearSpy).toHaveReceivedEventTimes(1); + }); + + it('should emit an odsClear event even if disabled', async() => { + await setup(''); + const odsClearSpy = await page.spyOnEvent('odsClear'); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.getProperty('value')).toBeNull(); + expect(odsClearSpy).toHaveReceivedEventTimes(1); + }); + }); + + describe('close', () => { + it('should close the select dropdown', async() => { + await setup(''); + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await isSelectOpen()).toBe(true); + + await el.callMethod('close'); + await page.waitForChanges(); + + expect(await isSelectOpen()).toBe(false); + }); + }); + + describe('open', () => { + it('should open the select dropdown', async() => { + await setup(''); + + expect(await isSelectOpen()).toBe(false); + + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await isSelectOpen()).toBe(true); + }); + }); + + describe('reset', () => { + it('should emit an odsReset event', async() => { + const dummyDefaultValue = 'dummy defaultValue'; + await setup(``); + const odsResetSpy = await page.spyOnEvent('odsReset'); + + await el.callMethod('reset'); + await page.waitForChanges(); + + expect(await el.getProperty('value')).toBe(dummyDefaultValue); + expect(odsResetSpy).toHaveReceivedEventTimes(1); + }); + + it('should emit an odsReset event even if disabled', async() => { + const dummyDefaultValue = 'dummy defaultValue'; + await setup(``); + const odsResetSpy = await page.spyOnEvent('odsReset'); + + await el.callMethod('reset'); + await page.waitForChanges(); + + expect(await el.getProperty('value')).toBe(dummyDefaultValue); + expect(odsResetSpy).toHaveReceivedEventTimes(1); + }); + }); + }); + + describe('watchers', () => { + describe('on value change', () => { + it('should emit an odsChange event', async() => { + const dummyValue = 'dummy value'; + await setup(``); + const odsValueChangeSpy = await page.spyOnEvent('odsChange'); + + await el.callMethod('open'); + await page.evaluate(() => { + document.querySelector('ods-select')?.shadowRoot?.querySelector('[role="option"]')?.click(); + }); + await page.waitForChanges(); + + expect(await el.getProperty('value')).toBe(dummyValue); + expect(odsValueChangeSpy).toHaveReceivedEventTimes(1); + expect(odsValueChangeSpy).toHaveReceivedEventDetail({ + oldValue: '', + validity: {}, + value: dummyValue, + }); + }); + }); + }); +}); diff --git a/packages/ods/src/components/select/tests/behaviour/ods-select.spec.ts b/packages/ods/src/components/select/tests/behaviour/ods-select.spec.ts new file mode 100644 index 0000000000..6ef6481e3f --- /dev/null +++ b/packages/ods/src/components/select/tests/behaviour/ods-select.spec.ts @@ -0,0 +1,72 @@ +jest.mock('../../src/controller/ods-select'); + +import type { SpecPage } from '@stencil/core/testing'; +import { newSpecPage } from '@stencil/core/testing'; +import { OdsSelect } from '../../src'; + +describe('ods-select behaviour', () => { + let page: SpecPage; + let root: HTMLElement | undefined; + let rootInstance: OdsSelect | undefined; + + async function setup(html: string): Promise { + page = await newSpecPage({ + components: [OdsSelect], + html, + }); + + root = page.root; + rootInstance = page.rootInstance; + } + + describe('methods', () => { + describe('clear', () => { + it('should clear the select', async() => { + await setup(''); + + await rootInstance?.clear(); + await page.waitForChanges(); + + expect(root?.getAttribute('value')).toBeNull(); + }); + + it('should clear even if disabled', async() => { + const dummyValue = 'dummy value'; + await setup(``); + + await rootInstance?.clear(); + await page.waitForChanges(); + + expect(root?.getAttribute('value')).toBeNull(); + }); + }); + + describe('reset', () => { + it('should reset the select with the default value', async() => { + const dummyDefaultValue = 'dummy defaultValue'; + const dummyValue = 'dummy value'; + await setup(``); + + expect(root?.getAttribute('value')).toBe(dummyValue); + + await rootInstance?.reset(); + await page.waitForChanges(); + + expect(root?.getAttribute('value')).toBe(dummyDefaultValue); + }); + + it('should reset the select with the default value even if disabled', async() => { + const dummyDefaultValue = 'dummy defaultValue'; + const dummyValue = 'dummy value'; + await setup(``); + + expect(root?.getAttribute('value')).toBe(dummyValue); + + await rootInstance?.reset(); + await page.waitForChanges(); + + expect(root?.getAttribute('value')).toBe(dummyDefaultValue); + }); + }); + }); +}); diff --git a/packages/ods/src/components/select/tests/controller/ods-select.spec.ts b/packages/ods/src/components/select/tests/controller/ods-select.spec.ts new file mode 100644 index 0000000000..adf9e8d73d --- /dev/null +++ b/packages/ods/src/components/select/tests/controller/ods-select.spec.ts @@ -0,0 +1,157 @@ +import type TomSelect from 'tom-select'; +import { getSelectConfig, inlineValue, moveSlottedElements, setFormValue, setSelectValue } from '../../src/controller/ods-select'; + +describe('ods-select controller', () => { + beforeEach(jest.clearAllMocks); + + describe('getSelectConfig', () => { + it('should return single selection config', () => { + expect(getSelectConfig(false, '')).toEqual({ + plugin: { + placeholder: {}, + }, + template: {}, + }); + }); + + it('should return multiple selection config', () => { + const dummyLabel = 'dummy label'; + + expect(getSelectConfig(true, dummyLabel)).toEqual({ + plugin: { + checkbox_options: { + checkedClassNames: ['ts-checked'], + uncheckedClassNames: ['ts-unchecked'], + }, + merge_selected_items: { + label: dummyLabel, + }, + placeholder: {}, + }, + template: { + item: expect.any(Function), + }, + }); + }); + }); + + describe('inlineValue', () => { + it('should return empty string if no value', () => { + // @ts-ignore for test purpose + expect(inlineValue()).toBe(''); + expect(inlineValue(undefined)).toBe(''); + expect(inlineValue(null)).toBe(''); + }); + + it('should return same string if value is not an array', () => { + const dummyValue = 'dummy value'; + + expect(inlineValue(dummyValue)).toBe(dummyValue); + }); + + it('should return joined string if value is an array', () => { + expect(inlineValue([ + 'dummy value 1', + 'dummy value 2', + ])).toBe('dummy value 1,dummy value 2'); + }); + }); + + describe('moveSlottedElements', () => { + it('should clean and move nodes', () => { + const dummySlotted = ['node 1', 'node 2']; + const dummyTarget = { + appendChild: jest.fn(), + replaceChildren: jest.fn(), + }; + + // @ts-ignore for test purpose + moveSlottedElements(dummyTarget, dummySlotted); + + expect(dummyTarget.replaceChildren).toHaveBeenCalled(); + expect(dummyTarget.appendChild).toHaveBeenCalledTimes(dummySlotted.length); + }); + }); + + describe('setFormValue', () => { + const dummyInternal = { + setFormValue: jest.fn(), + } as unknown as ElementInternals; + + it('should set internal value with empty string', () => { + // @ts-ignore for test purpose + setFormValue(dummyInternal); + expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); + + setFormValue(dummyInternal, undefined); + expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); + + setFormValue(dummyInternal, null); + expect(dummyInternal.setFormValue).toHaveBeenCalledWith(''); + }); + + it('should set internal value with string value', () => { + const dummyValue = 'dummy value'; + + setFormValue(dummyInternal, dummyValue); + + expect(dummyInternal.setFormValue).toHaveBeenCalledWith(dummyValue); + }); + + it('should set internal value with strings joined value', () => { + setFormValue(dummyInternal, [ + 'dummy value 1', + 'dummy value 2', + ]); + + expect(dummyInternal.setFormValue).toHaveBeenCalledWith('dummy value 1,dummy value 2'); + }); + }); + + describe('setSelectValue', () => { + const dummySelect = { + clear: jest.fn(), + setValue: jest.fn(), + } as unknown as TomSelect; + + it('should clear the select if no value', () => { + setSelectValue(dummySelect); + + expect(dummySelect.clear).toHaveBeenCalledTimes(1); + expect(dummySelect.clear).toHaveBeenCalledWith(false); + + setSelectValue(dummySelect, undefined, undefined, true); + + expect(dummySelect.clear).toHaveBeenCalledTimes(2); + expect(dummySelect.clear).toHaveBeenCalledWith(true); + }); + + it('should set select value with given value if defined', () => { + const dummyValue = 'dummy value'; + + setSelectValue(dummySelect, dummyValue); + + expect(dummySelect.setValue).toHaveBeenCalledTimes(1); + expect(dummySelect.setValue).toHaveBeenCalledWith(dummyValue, false); + + setSelectValue(dummySelect, dummyValue, undefined, true); + + expect(dummySelect.setValue).toHaveBeenCalledTimes(2); + expect(dummySelect.setValue).toHaveBeenCalledWith(dummyValue, true); + }); + + it('should set select value with given default value if defined and no value passed', () => { + const dummyDefaultValue = 'dummy default value'; + + setSelectValue(dummySelect, undefined, dummyDefaultValue); + + expect(dummySelect.setValue).toHaveBeenCalledTimes(1); + expect(dummySelect.setValue).toHaveBeenCalledWith(dummyDefaultValue, false); + + setSelectValue(dummySelect, undefined, dummyDefaultValue, true); + + expect(dummySelect.setValue).toHaveBeenCalledTimes(2); + expect(dummySelect.setValue).toHaveBeenCalledWith(dummyDefaultValue, true); + }); + }); +}); diff --git a/packages/ods/src/components/select/tests/navigation/ods-select.e2e.ts b/packages/ods/src/components/select/tests/navigation/ods-select.e2e.ts new file mode 100644 index 0000000000..e2894b11ca --- /dev/null +++ b/packages/ods/src/components/select/tests/navigation/ods-select.e2e.ts @@ -0,0 +1,252 @@ +import { type E2EPage, newE2EPage } from '@stencil/core/testing'; + +describe('ods-select navigation', () => { + let page: E2EPage; + + async function isOpen(): Promise { + return await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-dropdown')?.style.display !== 'none'; + }); + } + + async function isFocused(): Promise { + return await page.evaluate(() => { + const element = document.querySelector('ods-select'); + return document.activeElement === element; + }); + } + + async function setup(content: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + } + + it('should be focused on tabulation', async() => { + await setup(''); + + expect(await isFocused()).toBe(false); + + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isFocused()).toBe(true); + }); + + it('should not be focusable if disabled', async() => { + await setup(''); + + expect(await isFocused()).toBe(false); + + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isFocused()).toBe(false); + }); + + it('should open the option list on select click', async() => { + await setup(''); + + expect(await isOpen()).toBe(false); + + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + }); + + it('should not open the option list on select click if disabled', async() => { + await setup(''); + + expect(await isOpen()).toBe(false); + + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(false); + }); + + it('should open the option list on arrow down press when select is focused', async() => { + await setup(''); + + expect(await isOpen()).toBe(false); + + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + }); + + it('should close the option list on escape press', async() => { + await setup(''); + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await page.keyboard.press('Escape'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(false); + }); + + describe('single selection', () => { + it('should select an option on click', async() => { + await setup(''); + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await (await page.find('ods-select >>> .option')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(false); + expect((await page.find('ods-select')).getAttribute('value')).toBe('1'); + }); + + it('should not select a disabled option on click', async() => { + await setup(''); + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await (await page.find('ods-select >>> .option')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect((await page.find('ods-select')).getAttribute('value')).toBeNull(); + }); + + it('should select an option on Enter press', async() => { + await setup(''); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(false); + expect((await page.find('ods-select')).getAttribute('value')).toBe('1'); + }); + + it('should select an option on Tab press', async() => { + await setup(''); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(false); + expect((await page.find('ods-select')).getAttribute('value')).toBe('1'); + }); + }); + + describe('multiple selection', () => { + it('should toggle options on click', async() => { + await setup(''); + const odsChangeSpy = await page.spyOnEvent('odsChange'); + await (await page.find('ods-select >>> .ts-control')).click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + const options = await page.findAll('ods-select >>> .option'); + expect(options.length).toBe(2); + + await options[0].click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '', validity: {}, value: '1' }); + + await options[1].click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1', validity: {}, value: '1,2' }); + + await options[0].click(); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1,2', validity: {}, value: '2' }); + }); + + it('should toggle options on Enter press', async() => { + await setup(''); + const odsChangeSpy = await page.spyOnEvent('odsChange'); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '', validity: {}, value: '1' }); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1', validity: {}, value: '1,2' }); + + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1,2', validity: {}, value: '2' }); + }); + + it('should toggle options on Tab press', async() => { + await setup(''); + const odsChangeSpy = await page.spyOnEvent('odsChange'); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '', validity: {}, value: '1' }); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1', validity: {}, value: '1,2' }); + + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Tab'); + await page.waitForChanges(); + + expect(await isOpen()).toBe(true); + expect(odsChangeSpy).toHaveReceivedEventDetail({ oldValue: '1,2', validity: {}, value: '2' }); + }); + }); +}); diff --git a/packages/ods/src/components/select/tests/rendering/ods-select.e2e.ts b/packages/ods/src/components/select/tests/rendering/ods-select.e2e.ts new file mode 100644 index 0000000000..ed63d63ed9 --- /dev/null +++ b/packages/ods/src/components/select/tests/rendering/ods-select.e2e.ts @@ -0,0 +1,131 @@ +import { type E2EElement, type E2EPage, newE2EPage } from '@stencil/core/testing'; +import { type OdsSelect } from '../../src/'; + +describe('ods-select rendering', () => { + let el: E2EElement; + let innerSelect: HTMLSelectElement; + let page: E2EPage; + let selectComponent: HTMLElement; + + async function setup(content: string, customStyle?: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + + if (customStyle) { + await page.addStyleTag({ content: customStyle }); + } + + el = await page.find('ods-select'); + innerSelect = el.shadowRoot!.querySelector('select')!; + selectComponent = el.shadowRoot!.querySelector('.ts-wrapper')!; + } + + it('should render the web component', async() => { + await setup(''); + + expect(el.shadowRoot).not.toBeNull(); + expect(innerSelect).not.toBeNull(); + expect(selectComponent).not.toBeNull(); + }); + + it('should move the slot content to the inner select', async() => { + await setup(''); + + expect(innerSelect.innerHTML).toBe(''); + }); + + it('should render a placeholder', async() => { + const dummyPlaceholder = 'dummy placeholder'; + await setup(``); + + expect(await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-control')?.innerText; + })).toBe(dummyPlaceholder); + }); + + it('should render with an option disabled', async() => { + await setup(''); + await el.callMethod('open'); + await page.waitForChanges(); + + const disabledStates = await page.evaluate(() => { + const options = document.querySelector('ods-select')?.shadowRoot?.querySelectorAll('.ts-wrapper .option'); + return (Array.from(options || [])).map((option) => option.getAttribute('aria-disabled')); + }) || []; + + expect(disabledStates[0]).toBeNull(); + expect(disabledStates[1]).toBe('true'); + }); + + it('should render with an optgroup', async() => { + const dummyLabel = 'dummy optgroup'; + await setup(``); + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await page.evaluate(() => { + return !!document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper .optgroup'); + })).toBe(true); + expect(await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper .optgroup-header')?.innerText; + })).toBe(dummyLabel); + }); + + it('should render with a whole optgroup disabled', async() => { + await setup(''); + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper .optgroup')?.hasAttribute('data-disabled'); + })).toBe(true); + }); + + it('should render with checkboxes if multiple', async() => { + await setup(''); + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await page.evaluate(() => { + return !!document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper .option [type="checkbox"]'); + })).toBe(true); + }); + + it('should render with a custom renderer', async() => { + await setup(''); + await page.evaluate(() => { + const select = document.querySelector('ods-select'); + select!.customRenderer = { + option: ({ text }: { text: string }): string => { + return `
>>> ${text} <<<
`; + }, + }; + select!.innerHTML = ''; + }); + await el.callMethod('open'); + await page.waitForChanges(); + + expect(await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper .option')?.innerText; + })).toBe('>>> 1 <<<'); + }); + + describe('watchers', () => { + describe('isDisabled', () => { + it('should disable the select component', async() => { + await setup(''); + + expect(selectComponent.classList.contains('disabled')).toBe(false); + + el.setAttribute('is-disabled', true); + await page.waitForChanges(); + + expect(await page.evaluate(() => { + return document.querySelector('ods-select')?.shadowRoot?.querySelector('.ts-wrapper')?.classList.contains('disabled') || false; + })).toBe(true); + }); + }); + }); +}); diff --git a/packages/ods/src/components/select/tests/rendering/ods-select.spec.ts b/packages/ods/src/components/select/tests/rendering/ods-select.spec.ts new file mode 100644 index 0000000000..8fb716c7c6 --- /dev/null +++ b/packages/ods/src/components/select/tests/rendering/ods-select.spec.ts @@ -0,0 +1,201 @@ +jest.mock('../../src/controller/ods-select'); + +import { type SpecPage, newSpecPage } from '@stencil/core/testing'; +import { OdsSelect } from '../../src'; + +describe('ods-select rendering', () => { + let page: SpecPage; + let root: HTMLElement | undefined; + + async function setup(html: string): Promise { + page = await newSpecPage({ + components: [OdsSelect], + html, + }); + + root = page.root; + } + + describe('allowMultiple', () => { + it('should be reflected', async() => { + await setup(''); + + expect(root?.getAttribute('allow-multiple')).toBe(''); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('allow-multiple')).toBeNull(); + }); + }); + + describe('ariaLabel', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('aria-label')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('aria-label')).toBeNull(); + }); + }); + + describe('ariaLabelledby', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('aria-labelledby')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('aria-labelledby')).toBeNull(); + }); + }); + + describe('customRenderer', () => { + it('should not be reflected', async() => { + await setup(''); + (root as unknown as OdsSelect).customRenderer = { item: (): string => '' }; + + expect(root?.getAttribute('custom-renderer')).toBeNull(); + }); + }); + + describe('defaultValue', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('default-value')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('default-value')).toBeNull(); + }); + }); + + describe('hasError', () => { + it('should be reflected', async() => { + await setup(''); + + expect(root?.getAttribute('has-error')).toBe(''); + }); + + it('should render with expected default value', async() => { + await setup(''); + + expect(root?.getAttribute('has-error')).toBeNull(); + }); + + it('should add correct class if set', async() => { + await setup(''); + + expect(root?.classList.contains('ods-select--error')).toBe(true); + }); + }); + + describe('isDisabled', () => { + it('should be reflected', async() => { + await setup(''); + + expect(root?.getAttribute('is-disabled')).toBe(''); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('is-disabled')).toBeNull(); + }); + }); + + describe('isRequired', () => { + it('should be reflected', async() => { + await setup(''); + + expect(root?.getAttribute('is-required')).toBe(''); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('is-required')).toBeNull(); + }); + }); + + describe('multipleSelectionLabel', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('multiple-selection-label')).toBe(dummyValue); + }); + + it('should render with expected default value', async() => { + await setup(''); + + expect(root?.getAttribute('multiple-selection-label')).toBe('Selected item'); + }); + }); + + describe('name', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('name')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('name')).toBeNull(); + }); + }); + + describe('placeholder', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('placeholder')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('placeholder')).toBeNull(); + }); + }); + + describe('value', () => { + it('should be reflected', async() => { + const dummyValue = 'dummy value'; + + await setup(``); + + expect(root?.getAttribute('value')).toBe(dummyValue); + }); + + it('should not be set by default', async() => { + await setup(''); + + expect(root?.getAttribute('value')).toBeNull(); + }); + }); +}); diff --git a/packages/ods/src/components/select/tsconfig.json b/packages/ods/src/components/select/tsconfig.json new file mode 100644 index 0000000000..e242da5e2f --- /dev/null +++ b/packages/ods/src/components/select/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "src", + "tests" + ] +} diff --git a/packages/ods/src/components/select/typedoc.json b/packages/ods/src/components/select/typedoc.json new file mode 100644 index 0000000000..74d6700e67 --- /dev/null +++ b/packages/ods/src/components/select/typedoc.json @@ -0,0 +1,10 @@ +{ + "entryPoints": ["src/index.ts"], + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "hideGenerator": true, + "json": "dist/docs-api/typedoc.json", + "out": "dist/docs-api/", + "tsconfig":"tsconfig.json" +} diff --git a/packages/ods/src/components/tooltip/src/components/ods-tooltip/ods-tooltip.tsx b/packages/ods/src/components/tooltip/src/components/ods-tooltip/ods-tooltip.tsx index a549ecb7e2..75d778c3f2 100644 --- a/packages/ods/src/components/tooltip/src/components/ods-tooltip/ods-tooltip.tsx +++ b/packages/ods/src/components/tooltip/src/components/ods-tooltip/ods-tooltip.tsx @@ -37,6 +37,9 @@ export class OdsTooltip { arrow: this.arrowElement, popper: this.el, trigger: this.triggerElement, + }, { + offset: 8, + shift: { padding: 5 }, }); this.odsTooltipShow.emit(); diff --git a/packages/ods/src/style/_input.scss b/packages/ods/src/style/_input.scss index b4c7873464..78d051c42b 100644 --- a/packages/ods/src/style/_input.scss +++ b/packages/ods/src/style/_input.scss @@ -19,9 +19,7 @@ $ods-input-input-padding-right: calc($ods-input-actions-button-width + $ods-inpu } &:disabled { - background-color: var(--ods-color-neutral-100); - cursor: not-allowed; - color: var(--ods-form-element-foreground-color-disabled); + @include ods-input-disabled(); } &:read-only { @@ -29,6 +27,12 @@ $ods-input-input-padding-right: calc($ods-input-actions-button-width + $ods-inpu } } +@mixin ods-input-disabled() { + background-color: var(--ods-color-neutral-100); + cursor: not-allowed; + color: var(--ods-form-element-foreground-color-disabled); +} + @mixin ods-input-error() { border-color: var(--ods-form-element-border-color-critical); } diff --git a/packages/ods/src/utils/overlay.ts b/packages/ods/src/utils/overlay.ts index e61f5e4209..68160f5be1 100644 --- a/packages/ods/src/utils/overlay.ts +++ b/packages/ods/src/utils/overlay.ts @@ -1,10 +1,14 @@ -import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; +import { type ComputePositionReturn, type OffsetOptions, type ShiftOptions, arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; type DomElement = { arrow?: HTMLElement, popper: HTMLElement, trigger: HTMLElement | null | undefined, } +type MiddlewareOption = { + offset?: OffsetOptions, + shift?: ShiftOptions, +} enum ODS_OVERLAY_POSITION { bottom = 'bottom', @@ -47,6 +51,27 @@ function findTriggerElement(triggerId: string, shadowDomTriggerId?: string): HTM return hostElement; } +async function getElementPosition(position: OdsOverlayPosition, domElement: DomElement, option?: MiddlewareOption): Promise { + if (!domElement.trigger) { + throw new Error('No trigger element passed, unable to compute the position'); + } + + const middlewares = [ + flip(), + offset(option?.offset || 0), + shift(option?.shift), + ]; + + if (domElement.arrow) { + middlewares.push(arrow({ element: domElement.arrow })); + } + + return computePosition(domElement.trigger, domElement.popper, { + middleware: middlewares, + placement: position, + }); +} + function hideOverlay(popperElement: HTMLElement, cleanUpCallback?: () => void): void { popperElement.style.display = 'none'; @@ -55,7 +80,7 @@ function hideOverlay(popperElement: HTMLElement, cleanUpCallback?: () => void): } } -function showOverlay(position: OdsOverlayPosition, domElement: DomElement): () => void { +function showOverlay(position: OdsOverlayPosition, domElement: DomElement, option?: MiddlewareOption): () => void { if (!domElement.trigger) { return () => {}; } @@ -65,57 +90,41 @@ function showOverlay(position: OdsOverlayPosition, domElement: DomElement): () = return autoUpdate( domElement.trigger, domElement.popper, - () => update(position, domElement), + () => update(position, domElement, option), ); } -async function update(position: OdsOverlayPosition, domElement: DomElement): Promise { - if (!domElement.trigger) { - return; - } +async function update(position: OdsOverlayPosition, domElement: DomElement, option?: MiddlewareOption): Promise { + const { x, y, placement, middlewareData } = await getElementPosition(position, domElement, option); - const middlewares = [ - flip(), - offset(8), - shift({ padding: 5 }), - ]; - - if (domElement.arrow) { - middlewares.push(arrow({ element: domElement.arrow })); - } + Object.assign(domElement.popper.style, { + left: `${x}px`, + top: `${y}px`, + }); - return computePosition(domElement.trigger, domElement.popper, { - middleware: middlewares, - placement: position, - }).then(({ x, y, placement, middlewareData }) => { - Object.assign(domElement.popper.style, { - left: `${x}px`, - top: `${y}px`, + if (!!domElement.arrow && middlewareData.arrow) { + const arrowData = middlewareData.arrow; + const staticSide = { + bottom: 'top', + left: 'right', + right: 'left', + top: 'bottom', + }[placement.split('-')[0]] || ''; + + Object.assign(domElement.arrow.style, { + bottom: '', + left: arrowData?.x ? `${arrowData.x}px` : '' , + right: '', + top: arrowData?.y ? `${arrowData.y}px` : '', + // eslint-disable-next-line sort-keys + [staticSide]: '-4px', // half of arrow css width/height }); - - if (!!domElement.arrow && middlewareData.arrow) { - const arrowData = middlewareData.arrow; - const staticSide = { - bottom: 'top', - left: 'right', - right: 'left', - top: 'bottom', - }[placement.split('-')[0]] || ''; - - Object.assign(domElement.arrow.style, { - bottom: '', - left: arrowData?.x ? `${arrowData.x}px` : '' , - right: '', - top: arrowData?.y ? `${arrowData.y}px` : '', - // eslint-disable-next-line sort-keys - [staticSide]: '-4px', // half of arrow css width/height - }); - } - }); + } } export { findTriggerElement, + getElementPosition, hideOverlay, ODS_OVERLAY_POSITION, ODS_OVERLAY_POSITIONS, diff --git a/packages/ods/src/utils/select.ts b/packages/ods/src/utils/select.ts new file mode 100644 index 0000000000..ce41fdeef2 --- /dev/null +++ b/packages/ods/src/utils/select.ts @@ -0,0 +1,91 @@ +/** + * Tom-select plugin to handle multiple selection as one string instead of x elements + * (see https://tom-select.js.org/docs/plugins) + * @param label: the custom label to use before the selection count + */ +function mergeSelectedItemPlugin({ label }: Record): void { + // @ts-ignore "this" is the TomSelect instance but is set as any by the lib + const self = this; // eslint-disable-line @typescript-eslint/no-this-alias + const mergeItemsClassName = 'ts-merged-items'; + const placeholderClassName = 'ts-merged-items-placeholder'; + let currentLabel = label; + let currentPlaceholder = self.settings.placeholder || ''; + let divElement: HTMLDivElement; + + function updateCount(): void { + if (self.items.length > 0) { + divElement.classList.remove(placeholderClassName); + divElement.innerText = `${currentLabel} (${self.items.length})`; + } else { + divElement.classList.add(placeholderClassName); + divElement.innerText = currentPlaceholder; + } + } + + self.on('initialize', () => { + divElement = document.createElement('div'); + divElement.className = mergeItemsClassName; + self.control.append(divElement); + updateCount(); + }); + + self.on('item_add', updateCount); + self.on('item_remove', updateCount); + + self.hook('after', 'setup', function() { + self.control.addEventListener('ods-select-multiple-selection-label-change', (event: CustomEvent) => { + event.stopPropagation(); + currentLabel = event.detail; + updateCount(); + }); + self.control.addEventListener('ods-select-placeholder-change', (event: CustomEvent) => { + event.stopPropagation(); + currentPlaceholder = event.detail; + updateCount(); + }); + }); +} + +/** + * Tom-select plugin to display placeholder on all type of Select + * The lib placeholder attribute will not work when we enforce an empty control input to prevent typing + * (see https://tom-select.js.org/docs/plugins) + */ +function placeholderPlugin(): void { + // @ts-ignore "this" is the TomSelect instance but is set as any by the lib + const self = this; // eslint-disable-line @typescript-eslint/no-this-alias + const placeholderElement = document.createElement('div'); + let currentPlaceholder = ''; + + function updateVisibility(): void { + if (self.items.length > 0) { + placeholderElement.style.display = 'none'; + } else { + placeholderElement.style.display = 'block'; + } + } + + self.on('initialize', () => { + currentPlaceholder = self.settings.placeholder || ''; + placeholderElement.className = 'ts-placeholder'; + placeholderElement.innerText = currentPlaceholder; + self.control.append(placeholderElement); + updateVisibility(); + }); + + self.on('item_add', updateVisibility); + self.on('item_remove', updateVisibility); + + self.hook('after', 'setup', function() { + self.control.addEventListener('ods-select-placeholder-change', (event: CustomEvent) => { + event.stopPropagation(); + currentPlaceholder = event.detail; + placeholderElement.innerText = currentPlaceholder; + }); + }); +} + +export { + mergeSelectedItemPlugin, + placeholderPlugin, +}; diff --git a/packages/ods/tests/utils/overlay.spec.ts b/packages/ods/tests/utils/overlay.spec.ts index cc6f786d1d..67da5e2dac 100644 --- a/packages/ods/tests/utils/overlay.spec.ts +++ b/packages/ods/tests/utils/overlay.spec.ts @@ -8,7 +8,7 @@ jest.mock('@floating-ui/dom', () => ({ })); import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; -import { findTriggerElement, hideOverlay, showOverlay } from '../../src/utils/overlay'; +import { findTriggerElement, getElementPosition, hideOverlay, showOverlay } from '../../src/utils/overlay'; describe('utils overlay', () => { beforeEach(jest.clearAllMocks); @@ -61,6 +61,37 @@ describe('utils overlay', () => { }); }); + describe('getElementPosition', () => { + it('should throw if no trigger element', () => { + expect(async() => { + await getElementPosition('top', { + popper: document.createElement('div'), + trigger: undefined, + }); + }).rejects.toThrow(); + }); + + it('should call computePosition with expected arguments', async() => { + const dummyDomElement = { + popper: document.createElement('div'), + trigger: document.createElement('div'), + }; + const dummyPosition = 'top'; + + await getElementPosition(dummyPosition, dummyDomElement); + + expect(computePosition).toHaveBeenCalledTimes(1); + expect(computePosition).toHaveBeenCalledWith(dummyDomElement.trigger, dummyDomElement.popper, { + middleware: [ + 'flip middleware', + 'offset middleware', + 'shift middleware', + ], + placement: dummyPosition, + }); + }); + }); + describe('hideOverlay', () => { it('should set popper display style to none', async() => { const dummyPopperElement = document.createElement('div'); @@ -107,6 +138,10 @@ describe('utils overlay', () => { }); describe('update', () => { + const dummyOption = { + offset: 8, + shift: { padding: 5 }, + }; const dummyPosition = 'top'; const expectedPopperX = 33; const expectedPopperY = 42; @@ -127,7 +162,7 @@ describe('utils overlay', () => { y: expectedPopperY, }); - showOverlay(dummyPosition, dummyDom); + showOverlay(dummyPosition, dummyDom, dummyOption); expect(autoUpdate).toHaveBeenCalledWith(dummyTrigger, dummyPopper, expect.any(Function)); expect(computePosition).toHaveBeenCalledWith(dummyTrigger, dummyPopper, { @@ -139,8 +174,8 @@ describe('utils overlay', () => { placement: dummyPosition, }); expect(flip).toHaveBeenCalled(); - expect(offset).toHaveBeenCalledWith(expect.any(Number)); - expect(shift).toHaveBeenCalledWith({ padding: expect.any(Number) }); + expect(offset).toHaveBeenCalledWith(dummyOption.offset); + expect(shift).toHaveBeenCalledWith(dummyOption.shift); // Need to wait for the autoUpdate async callback to end await new Promise(process.nextTick); @@ -173,7 +208,7 @@ describe('utils overlay', () => { y: expectedPopperY, }); - showOverlay(dummyPosition, dummyDom); + showOverlay(dummyPosition, dummyDom, dummyOption); expect(autoUpdate).toHaveBeenCalledWith(dummyTrigger, dummyPopper, expect.any(Function)); expect(computePosition).toHaveBeenCalledWith(dummyTrigger, dummyPopper, { @@ -187,8 +222,8 @@ describe('utils overlay', () => { }); expect(arrow).toHaveBeenCalledWith({ element: dummyArrow }); expect(flip).toHaveBeenCalled(); - expect(offset).toHaveBeenCalledWith(expect.any(Number)); - expect(shift).toHaveBeenCalledWith({ padding: expect.any(Number) }); + expect(offset).toHaveBeenCalledWith(dummyOption.offset); + expect(shift).toHaveBeenCalledWith(dummyOption.shift); // Need to wait for the autoUpdate async callback to end await new Promise(process.nextTick); diff --git a/packages/ods/tests/utils/select.spec.ts b/packages/ods/tests/utils/select.spec.ts new file mode 100644 index 0000000000..434661b75c --- /dev/null +++ b/packages/ods/tests/utils/select.spec.ts @@ -0,0 +1,131 @@ +import { mergeSelectedItemPlugin, placeholderPlugin } from '../../src/utils/select'; + +describe('utils select', () => { + let dummyThis: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + beforeEach(jest.clearAllMocks); + + beforeEach(() => { + dummyThis = { + control: { + addEventListener: jest.fn(), + append: jest.fn(), + }, + hook: jest.fn(), + items: [] as string[], + on: jest.fn(), + settings: { + placeholder: 'dummy placeholder', + }, + }; + }); + + describe('mergeSelectedItemPlugin', () => { + const dummyLabel = 'dummy label'; + + it('should set correct listeners', () => { + mergeSelectedItemPlugin.bind(dummyThis)({ label: dummyLabel }); + + expect(dummyThis.on).toHaveBeenNthCalledWith(1, 'initialize', expect.any(Function)); + expect(dummyThis.on).toHaveBeenNthCalledWith(2, 'item_add', expect.any(Function)); + expect(dummyThis.on).toHaveBeenNthCalledWith(3, 'item_remove', expect.any(Function)); + expect(dummyThis.hook).toHaveBeenCalledWith('after', 'setup', expect.any(Function)); + }); + + it('should append an element on initialize with placeholder text if no items', () => { + mergeSelectedItemPlugin.bind(dummyThis)({ label: dummyLabel }); + + const initializeCallback = dummyThis.on.mock.calls[0][1]; + initializeCallback(); + + expect(dummyThis.control.append).toHaveBeenCalledTimes(1); + + const mergeElement = dummyThis.control.append.mock.calls[0][0]; + + expect(mergeElement.classList.contains('ts-merged-items')).toBe(true); + expect(mergeElement.classList.contains('ts-merged-items-placeholder')).toBe(true); + expect(mergeElement.innerText).toBe(dummyThis.settings.placeholder); + }); + + it('should append an element on initialize with merge label if some items', () => { + dummyThis.items = ['dummy', 'items']; + + mergeSelectedItemPlugin.bind(dummyThis)({ label: dummyLabel }); + + const initializeCallback = dummyThis.on.mock.calls[0][1]; + initializeCallback(); + + expect(dummyThis.control.append).toHaveBeenCalledTimes(1); + + const mergeElement = dummyThis.control.append.mock.calls[0][0]; + + expect(mergeElement.classList.contains('ts-merged-items')).toBe(true); + expect(mergeElement.classList.contains('ts-merged-items-placeholder')).toBe(false); + expect(mergeElement.innerText).toBe(`${dummyLabel} (${dummyThis.items.length})`); + }); + + it('should set event listeners after setup for label and placeholder change', () => { + mergeSelectedItemPlugin.bind(dummyThis)({ label: dummyLabel }); + + const hookCallback = dummyThis.hook.mock.calls[0][2]; + hookCallback(); + + expect(dummyThis.control.addEventListener).toHaveBeenCalledTimes(2); + expect(dummyThis.control.addEventListener).toHaveBeenNthCalledWith(1, 'ods-select-multiple-selection-label-change', expect.any(Function)); + expect(dummyThis.control.addEventListener).toHaveBeenNthCalledWith(2, 'ods-select-placeholder-change', expect.any(Function)); + }); + }); + + describe('placeholderPlugin', () => { + it('should set correct listeners', () => { + placeholderPlugin.bind(dummyThis)(); + + expect(dummyThis.on).toHaveBeenNthCalledWith(1, 'initialize', expect.any(Function)); + expect(dummyThis.on).toHaveBeenNthCalledWith(2, 'item_add', expect.any(Function)); + expect(dummyThis.on).toHaveBeenNthCalledWith(3, 'item_remove', expect.any(Function)); + expect(dummyThis.hook).toHaveBeenCalledWith('after', 'setup', expect.any(Function)); + }); + + it('should append a placeholder element on initialize with visible display if no items', () => { + placeholderPlugin.bind(dummyThis)(); + + const initializeCallback = dummyThis.on.mock.calls[0][1]; + initializeCallback(); + + expect(dummyThis.control.append).toHaveBeenCalledTimes(1); + + const placeholderElement = dummyThis.control.append.mock.calls[0][0]; + + expect(placeholderElement.classList.contains('ts-placeholder')).toBe(true); + expect(placeholderElement.innerText).toBe(dummyThis.settings.placeholder); + expect(placeholderElement.style.display).toBe('block'); + }); + + it('should append a placeholder element on initialize with hidden display if some items', () => { + dummyThis.items = ['dummy', 'items']; + + placeholderPlugin.bind(dummyThis)(); + + const initializeCallback = dummyThis.on.mock.calls[0][1]; + initializeCallback(); + + expect(dummyThis.control.append).toHaveBeenCalledTimes(1); + + const placeholderElement = dummyThis.control.append.mock.calls[0][0]; + + expect(placeholderElement.classList.contains('ts-placeholder')).toBe(true); + expect(placeholderElement.innerText).toBe(dummyThis.settings.placeholder); + expect(placeholderElement.style.display).toBe('none'); + }); + + it('should set an event listener after setup for placeholder change', () => { + placeholderPlugin.bind(dummyThis)(); + + const hookCallback = dummyThis.hook.mock.calls[0][2]; + hookCallback(); + + expect(dummyThis.control.addEventListener).toHaveBeenCalledTimes(1); + expect(dummyThis.control.addEventListener).toHaveBeenCalledWith('ods-select-placeholder-change', expect.any(Function)); + }); + }); +}); diff --git a/packages/ods/vue/tests/_app/src/components.ts b/packages/ods/vue/tests/_app/src/components.ts index b4c0d02115..68b34cc619 100644 --- a/packages/ods/vue/tests/_app/src/components.ts +++ b/packages/ods/vue/tests/_app/src/components.ts @@ -33,6 +33,7 @@ const componentNames = [ 'message', 'radio', 'checkbox', + 'select', //--generator-anchor-- ]; diff --git a/packages/ods/vue/tests/_app/src/components/ods-select.vue b/packages/ods/vue/tests/_app/src/components/ods-select.vue new file mode 100644 index 0000000000..7db8db8f2f --- /dev/null +++ b/packages/ods/vue/tests/_app/src/components/ods-select.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/ods/vue/tests/e2e/ods-select.e2e.ts b/packages/ods/vue/tests/e2e/ods-select.e2e.ts new file mode 100644 index 0000000000..6301b4fdf7 --- /dev/null +++ b/packages/ods/vue/tests/e2e/ods-select.e2e.ts @@ -0,0 +1,23 @@ +import type { Page } from 'puppeteer'; +import { goToComponentPage, setupBrowser } from '../setup'; + +describe('ods-select vue', () => { + const setup = setupBrowser(); + let page: Page; + + beforeAll(async () => { + page = setup().page; + }); + + beforeEach(async () => { + await goToComponentPage(page, 'ods-select'); + }); + + it('render the component correctly', async () => { + const elem = await page.$('ods-select'); + const boundingBox = await elem?.boundingBox(); + + expect(boundingBox?.height).toBeGreaterThan(0); + expect(boundingBox?.width).toBeGreaterThan(0); + }); +}); diff --git a/packages/ods/vue/tests/jest.config.js b/packages/ods/vue/tests/jest.config.js index b0df76feba..3f5219a58b 100644 --- a/packages/ods/vue/tests/jest.config.js +++ b/packages/ods/vue/tests/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'jest-puppeteer', testRegex: "./*\\e2e\\.ts$", + testTimeout: 10000, transform: { '\\.(ts|tsx)$': 'ts-jest', }, diff --git a/packages/storybook/stories/components/select/documentation.mdx b/packages/storybook/stories/components/select/documentation.mdx new file mode 100644 index 0000000000..efd02b40a3 --- /dev/null +++ b/packages/storybook/stories/components/select/documentation.mdx @@ -0,0 +1,68 @@ +import { Canvas, Meta, Markdown } from '@storybook/blocks'; +import SpecificationsSelect from '@ovhcloud/ods-components/src/components/select/documentation/spec.md?raw'; +import { Banner } from '../../banner'; +import { DocNavigator } from '../../doc-navigator'; +import * as SelectStories from './select.stories'; +import { LINK_ID } from '../../zeroheight'; + + + + + +# Overview + +Select component is used to select one or more items from a list of values: + + + + + +{ SpecificationsSelect } + +# Style customization + +You can add your own style on the select element by adding class on the webcomponent directly. + +Custom select css: + + + +# Examples + +## Default + + + +## Multiple Selection + + + +## Custom Renderer + +If you want to display something more complex that just a string, you can use your own render functions instead of native elements. + +You can customize either: +- `item`: the currently selected element +- `option`: an option on the select option list + +By default, each render function will receive as argument an object containing the following properties: +- `text`: the label of the option +- `value`: the value of the option + +If you need more data per option, you can attach a `data-attribute` to each option, the value will be available +on the render function as well. + +Here is an example of the customization of each elements using specific data: + + + +## Custom Renderer For Multiple Selection + +If you set the property `allow-multiple`, custom option rendering is quite the same as, except that you'll +have to take into account the selection checkbox that will be injected in the DOM. + +As for now, item template is not customizable and will always render as the count of selected items. + +Here is the same example as [previously](?path=/docs/ods-components-form-select--documentation#custom-renderer) updated for multiple selection: + + diff --git a/packages/storybook/stories/components/select/migration.from.17.x.mdx b/packages/storybook/stories/components/select/migration.from.17.x.mdx new file mode 100644 index 0000000000..f556aa5162 --- /dev/null +++ b/packages/storybook/stories/components/select/migration.from.17.x.mdx @@ -0,0 +1,220 @@ +import { Meta } from '@storybook/blocks'; +import * as SelectStories from './select.stories'; + + + +# Select - migrate from v17 to v18 +---- + +## Usage changes + +Select component has been entirely reworked, there is now only one component `ods-select` +(no more `osds-select-option` and `osds-select-group`). + +This component expects the same native children as a classic `select` element (option, optgroup). + +Here is an example of the previous declaration: + +```html + + Select a country + Europe + France + Italy + Germany + +``` + +The same result would now be achieved using: + +```html + + + + + + + +``` + +## Attributes changes + +`allowMultiple` + +New attribute (optional). + +Set this to true to allow multiple selection in the select. + +`customRenderer` + +New attribute (optional). + +You can use this to customize the rendering of some of the select elements. + +See [this example](?path=/docs/ods-components-form-select--documentation#custom-renderer) for more details. + +`color ` + +Has been removed. + +Form components does not provide color variant anymore. + +`disabled` + +Has been updated. + +You can use the new `isDisabled` attribute to obtain the same behavior. + +`error` + +Has been updated. + +You can use the new `hasError` attribute to obtain the same behavior. + +`inline` + +Has been removed. + +Select now always take the size of its container, this ensure consistency between the select control and the option list. + +If you need to set a specific width, you can do by adding a css class on the webcomponent directly (see [this example](?path=/docs/ods-components-form-select--documentation#style-customization)) + +`multipleSelectionLabel` + +New attribute (optional). + +Define the label displayed before the selection count in the select. +Only relevant if `allowMultiple` is set to true. + +`opened ` + +Has been removed. + +You can use the webcomponent `open` / `close` method if you need to update the select state programmatically. + +`placeholder` + +New attribute (optional). + +Use this if you want to display a placeholder text when no value is yet selected. + +`required` + +Has been updated. + +You can use the new `isRequired` attribute to obtain the same behavior. + +`size` + +Has been removed. + +This attribute was not used, as there was only one single possible value. + +## Migration examples + +Default select: +```html + + France + Italy + Germany + + + + + + + + + +``` + +Disabled select: +```html + + France + Italy + Germany + + + + + + + + + +``` + +Error select: +```html + + France + Italy + Germany + + + + + + + + + +``` + +Placeholder: +```html + + Select a country + France + Italy + Germany + + + + + + + + + +``` + +Optgroup: +```html + + Europe + France + Italy + Germany + + + + + + + + + + + +``` + +Required select: +```html + + France + Italy + Germany + + + + + + + + + +``` + diff --git a/packages/storybook/stories/components/select/select.stories.ts b/packages/storybook/stories/components/select/select.stories.ts new file mode 100644 index 0000000000..d1ddc9e3cc --- /dev/null +++ b/packages/storybook/stories/components/select/select.stories.ts @@ -0,0 +1,288 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { defineCustomElement } from '@ovhcloud/ods-components/dist/components/ods-select'; +import { html } from 'lit-html'; +import { CONTROL_CATEGORY, orderControls } from '../../control'; + +defineCustomElement(); + +const meta: Meta = { + title: 'ODS Components/Form/Select', + component: 'ods-select', + decorators: [(story) => html`
${story()}
`], +}; + +export default meta; + +export const Demo: StoryObj = { + render: (arg) => html` + + + + + + + + + `, + argTypes: orderControls({ + hasError: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + isDisabled: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + isRequired: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + placeholder: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: 'ø' }, + type: { summary: 'string' }, + }, + control: 'text', + }, + }), + args: { + hasError: false, + isDisabled: false, + isRequired: false, + }, +}; + +export const DemoMultiple: StoryObj = { + render: (arg) => html` + + + + + + + + + `, + argTypes: orderControls({ + hasError: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + isDisabled: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + isRequired: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, + multipleSelectionLabel: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: 'Selected item' }, + type: { summary: 'string' }, + }, + control: 'text', + }, + placeholder: { + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: 'ø' }, + type: { summary: 'string' }, + }, + control: 'text', + }, + }), + args: { + hasError: false, + isDisabled: false, + isRequired: false, + multipleSelectionLabel: 'Selected item', + }, +}; + +export const CustomCSS: StoryObj = { + tags: ['isHidden'], + render: () => html` + + + + + + + + + + `, +}; + +export const CustomRenderer: StoryObj = { + tags: ['isHidden'], + render: () => html` + + + + + + + + `, +}; + +export const CustomRendererMultiple: StoryObj = { + tags: ['isHidden'], + render: () => html` + + + + + + + + `, +}; + +export const Default: StoryObj = { + tags: ['isHidden'], + render: () => html` + + + + + + + + + `, +}; + +export const Multiple: StoryObj = { + tags: ['isHidden'], + render: () => html` + + + + + + + + + `, +}; diff --git a/packages/themes/src/default/index.scss b/packages/themes/src/default/index.scss index cbd6c89e54..ace3200b42 100644 --- a/packages/themes/src/default/index.scss +++ b/packages/themes/src/default/index.scss @@ -22,6 +22,7 @@ --ods-color-critical-700: #{var.$ods-critical-700}; --ods-color-critical-800: #{var.$ods-critical-800}; --ods-color-critical-900: #{var.$ods-critical-900}; + --ods-color-heading: #{var.$ods-single-color-heading}; --ods-color-information-000: #{var.$ods-information-000}; --ods-color-information-100: #{var.$ods-information-100}; --ods-color-information-200: #{var.$ods-information-200}; diff --git a/yarn.lock b/yarn.lock index 769325d8db..4ed1701e78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3866,6 +3866,7 @@ __metadata: <<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD +<<<<<<< HEAD "@ovhcloud/ods-cdk-dev@17.2.2, @ovhcloud/ods-cdk-dev@workspace:packages/cdk/dev": version: 0.0.0-use.local resolution: "@ovhcloud/ods-cdk-dev@workspace:packages/cdk/dev" @@ -3899,6 +3900,24 @@ __metadata: ======= ======= ======= +======= +"@orchidjs/sifter@npm:^1.0.3": + version: 1.0.3 + resolution: "@orchidjs/sifter@npm:1.0.3" + dependencies: + "@orchidjs/unicode-variants": ^1.0.4 + checksum: 5925ce6e5babb872beaaeb562634f02befea118bf19676a734070647efee0a7eb591f5f4ae280b6bbb5288c7959eb679e9daf2c42a7d9ba1262e9daff3748958 + languageName: node + linkType: hard + +"@orchidjs/unicode-variants@npm:^1.0.4": + version: 1.0.4 + resolution: "@orchidjs/unicode-variants@npm:1.0.4" + checksum: a6ad1c66f4e978527ee529086b875bcaa93e23d75a3b894343bd226a2492b1fa2f9faa2a72967e4ab101a5f2c4cf4751b5a6a14086f8a864b35036f178d9aaeb + languageName: node + linkType: hard + +>>>>>>> 326e7fe13 (feat(select): implement component) "@ovhcloud/ods-component-badge@workspace:packages/ods/src/components/badge": version: 0.0.0-use.local resolution: "@ovhcloud/ods-component-badge@workspace:packages/ods/src/components/badge" @@ -4066,7 +4085,16 @@ __metadata: languageName: unknown linkType: soft +<<<<<<< HEAD >>>>>>> 6e739da36 (refactor(radio): implementation component) +======= +"@ovhcloud/ods-component-select@workspace:packages/ods/src/components/select": + version: 0.0.0-use.local + resolution: "@ovhcloud/ods-component-select@workspace:packages/ods/src/components/select" + languageName: unknown + linkType: soft + +>>>>>>> 326e7fe13 (feat(select): implement component) "@ovhcloud/ods-component-skeleton@workspace:packages/ods/src/components/skeleton": version: 0.0.0-use.local resolution: "@ovhcloud/ods-component-skeleton@workspace:packages/ods/src/components/skeleton" @@ -4800,6 +4828,7 @@ __metadata: replace-in-file: 7.1.0 sass: 1.71.0 stencil-inline-svg: 1.1.0 + tom-select: 2.3.1 ts-jest: 29.1.2 ts-node: 10.9.2 typedoc: 0.25.11 @@ -21565,6 +21594,16 @@ __metadata: languageName: node linkType: hard +"tom-select@npm:2.3.1": + version: 2.3.1 + resolution: "tom-select@npm:2.3.1" + dependencies: + "@orchidjs/sifter": ^1.0.3 + "@orchidjs/unicode-variants": ^1.0.4 + checksum: a70242080e29776770d2cf5f9a110ee9a261d8af3d5503d4e720ee17b195f820130f7e9df8e02b10cb0fb96258b0496a956868e800da5104120e22206a59839b + languageName: node + linkType: hard + "tough-cookie@npm:^4.1.2": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4"