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"