diff --git a/sandbox/forms/form-demo-with-cc-components.js b/sandbox/forms/form-demo-with-cc-components.js index a0a7d300b..d5e20b41e 100644 --- a/sandbox/forms/form-demo-with-cc-components.js +++ b/sandbox/forms/form-demo-with-cc-components.js @@ -4,6 +4,7 @@ import '../../src/components/cc-input-date/cc-input-date.js'; import '../../src/components/cc-input-number/cc-input-number.js'; import '../../src/components/cc-input-text/cc-input-text.js'; import '../../src/components/cc-picker/cc-picker.js'; +import '../../src/components/cc-range-selector/cc-range-selector.js'; import '../../src/components/cc-select/cc-select.js'; import '../../src/components/cc-toggle/cc-toggle.js'; import { formSubmit } from '../../src/lib/form/form-submit-directive.js'; @@ -30,6 +31,16 @@ const PICKER_OPTIONS = [ { body: 'George Harrison', value: 'HARRISON' }, ]; +const RANGE_SELECTOR_OPTIONS = [ + { body: `L`, value: 'lun' }, + { body: `M`, value: 'mar' }, + { body: `M`, value: 'mer' }, + { body: `J`, value: 'jeu' }, + { body: `V`, value: 'ven' }, + { body: `S`, value: 'sam' }, + { body: `D`, value: 'dim' }, +]; + export class FormDemoWithCcComponents extends LitElement { render() { return html` @@ -43,6 +54,12 @@ export class FormDemoWithCcComponents extends LitElement { + Submit diff --git a/src/components/cc-range-selector-option/cc-range-selector-option.js b/src/components/cc-range-selector-option/cc-range-selector-option.js new file mode 100644 index 000000000..78591db52 --- /dev/null +++ b/src/components/cc-range-selector-option/cc-range-selector-option.js @@ -0,0 +1,193 @@ +import { LitElement, css, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; + +/** + * A tile component that can be used to display a range selection state. + * + * This component is specifically designed for cc-range-selector and is not meant to be used standalone. + * It is a presentational component that displays visual states based on properties set by its parent. + * All interactive behaviors (click handling, keyboard navigation, etc.) are managed by the parent cc-range-selector component. + * + * @cssdisplay inline-flex + * + * @slot body - Content displayed as the main part of the tile. This slot should contain the option's label or visual content and should not be empty. + */ +export class CcRangeSelectorOption extends LitElement { + static get properties() { + return { + disabled: { type: Boolean, reflect: true }, + dragging: { type: Boolean, reflect: true }, + error: { type: Boolean, reflect: true }, + pointer: { type: Boolean, reflect: true }, + readonly: { type: Boolean, reflect: true }, + selected: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + + /** @type {boolean} Whether the component should be disabled (default: 'false') */ + this.disabled = false; + + /** @type {boolean} Whether the option is within a drag selection range (default: 'false') */ + this.dragging = false; + + /** @type {boolean} Whether the component should be in error mode when not disabled nor readonly (default: 'false') */ + this.error = false; + + /** @type {boolean} Whether to show a pointer cursor for interactive states (default: 'false') */ + this.pointer = false; + + /** @type {boolean} Whether the component should be readonly when not disabled (default: 'false') */ + this.readonly = false; + + /** @type {boolean} Whether the option is currently selected when not dragging (default: 'false') */ + this.selected = false; + } + + /** + * Renders the option with appropriate visual states based on its properties. + * Calculates CSS classes for disabled, readonly, error, dragging, selected, and pointer states. + * @return {import('lit').TemplateResult} + */ + render() { + // Calculate CSS classes based on component state + // State priority: disabled > readonly > error > dragging > selected + // Note: dragging state takes visual priority over selected state during drag operations + const classes = { + disabled: this.disabled, + readonly: !this.disabled && this.readonly, + error: !this.disabled && !this.readonly && this.error, + dragging: this.dragging, + selected: !this.dragging && this.selected, // Selected styling is hidden during dragging + }; + + return html` +
+ +
+ `; + } + + static get styles() { + return [ + // language=CSS + css` + /* region global */ + :host { + --cc-icon-size: 1.25em; + + border-radius: var(--cc-border-radius-default, 0.25em); + display: inline-flex; + overflow: hidden; + width: fit-content; + } + + .wrapper { + align-items: stretch; + display: flex; + flex: 1 1 auto; + line-height: 1.5; + } + /* endregion */ + + /* region body section */ + ::slotted([slot='body']) { + background-color: var(--cc-color-bg-neutral, #f5f5f5); + border: 0.125em dotted var(--cc-color-bg-neutral, #f5f5f5); + color: var(--cc-color-text-default, #262626); + display: inline-block; + flex: 1 1 auto; + padding: 0.25em 0.5em; + } + /* endregion */ + + /* region common states */ + .disabled ::slotted([slot='body']) { + background-color: var(--cc-color-bg-default, #fff); + border-color: var(--cc-color-bg-neutral, #f5f5f5); + border-style: solid; + color: var(--cc-color-text-disabled, #595959); + } + + .readonly ::slotted([slot='body']) { + background-color: var(--cc-color-bg-neutral-active, #d9d9d9); + border-color: var(--cc-color-bg-neutral-active, #d9d9d9); + } + + .selected ::slotted([slot='body']) { + background-color: var(--cc-color-bg-primary, #3569aa); + border-color: var(--cc-color-bg-primary, #3569aa); + color: var(--cc-color-text-inverted, #fff); + } + + .dragging ::slotted([slot='body']) { + background-color: var(--cc-color-bg-primary-weaker, #e6eff8); + border-color: var(--cc-color-bg-primary, #3569aa); + color: var(--cc-color-text-primary-strong, #002c9d); + user-select: none; + } + + .error ::slotted([slot='body']) { + background-color: var(--cc-color-bg-danger-weaker, #ffe4e1); + border-color: var(--cc-color-bg-danger-weaker, #ffe4e1); + color: var(--cc-color-text-danger, #be242d); + } + /* endregion */ + + /* region selected & disabled */ + .selected.disabled ::slotted([slot='body']) { + background-color: var(--color-grey-60, #737373); + border-color: var(--color-grey-60, #737373); + color: var(--cc-color-text-inverted, #fff); + } + /* endregion */ + + /* region selected & readonly */ + .selected.readonly ::slotted([slot='body']) { + background-color: var(--cc-color-bg-primary-weak, #cedcff); + border-color: var(--cc-color-bg-primary-weak, #cedcff); + color: var(--cc-color-text-primary-strong, #002c9d); + } + /* endregion */ + + /* region selected & error */ + .selected.error ::slotted([slot='body']) { + background-color: var(--cc-color-bg-danger, #be242d); + border-color: var(--cc-color-bg-danger, #be242d); + color: var(--cc-color-text-inverted, #fff); + } + /* endregion */ + + /* region dragging & error */ + .dragging.error ::slotted([slot='body']) { + border-color: var(--cc-color-bg-danger, #be242d); + } + /* endregion */ + + /* region hover */ + /* Hover state only applies when option is in its default interactive state + (not disabled, readonly, error, selected, or dragging) */ + .wrapper:not(.selected, .dragging, .disabled, .readonly, .error) :hover::slotted([slot='body']) { + background-color: var(--cc-color-bg-neutral-hovered, #e7e7e7); + border-color: var(--cc-color-bg-neutral-hovered, #e7e7e7); + } + + .wrapper.error:not(.selected, .dragging) :hover::slotted([slot='body']) { + background-color: var(--cc-color-bg-danger-weak, #fbc8c2); + border-color: var(--cc-color-bg-danger-weak, #fbc8c2); + } + /* endregion */ + + /* region pointer */ + :host([pointer]) { + cursor: pointer; + } + /* endregion */ + `, + ]; + } +} + +window.customElements.define('cc-range-selector-option', CcRangeSelectorOption); diff --git a/src/components/cc-range-selector-option/cc-range-selector-option.stories.js b/src/components/cc-range-selector-option/cc-range-selector-option.stories.js new file mode 100644 index 000000000..59b0d84fe --- /dev/null +++ b/src/components/cc-range-selector-option/cc-range-selector-option.stories.js @@ -0,0 +1,96 @@ +import { makeStory } from '../../stories/lib/make-story.js'; +import './cc-range-selector-option.js'; + +export default { + tags: ['autodocs'], + title: '🧬 Atoms/', + component: 'cc-range-selector-option', +}; + +const conf = { + component: 'cc-range-selector-option', +}; + +export const defaultStates = makeStory(conf, { + displayMode: 'flex-wrap', + items: [ + { + pointer: true, + innerHTML: ` + Default + `, + }, + { + disabled: true, + innerHTML: ` + Disabled + `, + }, + { + readonly: true, + innerHTML: ` + Readonly + `, + }, + { + error: true, + pointer: true, + innerHTML: ` + Error + `, + }, + ], +}); + +export const selectedStates = makeStory(conf, { + displayMode: 'flex-wrap', + items: [ + { + selected: true, + pointer: true, + innerHTML: ` + Default + `, + }, + { + disabled: true, + selected: true, + innerHTML: ` + Disabled + `, + }, + { + readonly: true, + selected: true, + innerHTML: ` + Readonly + `, + }, + { + error: true, + selected: true, + innerHTML: ` + Error + `, + }, + ], +}); + +export const draggingStates = makeStory(conf, { + displayMode: 'flex-wrap', + items: [ + { + dragging: true, + innerHTML: ` + Default + `, + }, + { + dragging: true, + error: true, + innerHTML: ` + Error + `, + }, + ], +}); diff --git a/src/components/cc-range-selector/cc-range-selector.events.js b/src/components/cc-range-selector/cc-range-selector.events.js new file mode 100644 index 000000000..f272e15b2 --- /dev/null +++ b/src/components/cc-range-selector/cc-range-selector.events.js @@ -0,0 +1,17 @@ +import { CcEvent } from '../../lib/events.js'; + +/** + * Dispatched when the custom option button is clicked. + * The detail contains the current selection value (string for single mode, string array for range mode). + * @extends {CcEvent} + */ +export class CcRangeSelectorSelectCustom extends CcEvent { + static TYPE = 'cc-range-selector-select-custom'; + + /** + * @param {string|string[]} detail + */ + constructor(detail) { + super(CcRangeSelectorSelectCustom.TYPE, detail); + } +} diff --git a/src/components/cc-range-selector/cc-range-selector.js b/src/components/cc-range-selector/cc-range-selector.js new file mode 100644 index 000000000..9b9c8b868 --- /dev/null +++ b/src/components/cc-range-selector/cc-range-selector.js @@ -0,0 +1,998 @@ +import { css, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { iconRemixArrowRightDoubleFill as iconArrow } from '../../assets/cc-remix.icons.js'; +import { EventHandler } from '../../lib/events.js'; +import { CcFormControlElement } from '../../lib/form/cc-form-control-element.abstract.js'; +import { RequiredValidator, Validation, combineValidators, createValidator } from '../../lib/form/validation.js'; +import { trimArray } from '../../lib/utils.js'; +import { accessibilityStyles } from '../../styles/accessibility.js'; +import { i18n } from '../../translations/translation.js'; +import '../cc-icon/cc-icon.js'; +import '../cc-range-selector-option/cc-range-selector-option.js'; +import { CcRangeSelectEvent, CcSelectEvent } from '../common.events.js'; +import { CcRangeSelectorSelectCustom } from './cc-range-selector.events.js'; +import { RangeSelectorDraggingController } from './range-selector-dragging-controller.js'; + +/** + * @typedef {import('./cc-range-selector.types.js').RangeSelectorMode} RangeSelectorMode + * @typedef {import('./cc-range-selector.types.js').RangeSelectorOption} RangeSelectorOption + * @typedef {import('./cc-range-selector.types.js').RangeSelectorSelection} RangeSelectorSelection + * @typedef {import('../../lib/events.types.js').GenericEventWithTarget} HTMLInputElementEvent + * @typedef {import('../../lib/form/validation.types.js').Validator} Validator + * @typedef {import('../../lib/form/validation.types.js').ErrorMessageMap} ErrorMessageMap + * @typedef {import('../../lib/form/form.types.js').FormControlData} FormControlData + * @typedef {import('lit/directives/class-map.js').ClassInfo} ClassInfo + * @typedef {import('lit/directives/ref.js').Ref} HTMLElementRef + * @typedef {import('lit/directives/ref.js').Ref} HTMLFieldSetElementRef + */ + +/** + * @typedef {object} RenderOptionContext + * @property {{ first: number, current: number, last: number }} indexes - Selection index information + * @property {boolean} isModeSingle - Whether the selector is in single selection mode + * @property {boolean} isModeRange - Whether the selector is in range selection mode + * @property {boolean} isLastOption - Whether this is the last option in the list (used to control arrow visibility) + * @property {RangeSelectorOption} nextOption - The next option in the list, used to determine arrow highlighting state + */ + +/** + * A range selector component that allows users to select ranges from a list of options. + * + * It extends `CcFormControlElement`, so it can be used in a form and its value can be submitted. + * + * ## Important Caveats + * + * **Disabled Options in Selections:** + * For visualization purposes, disabled options can appear within a selected range in range mode. + * However, these disabled options are automatically excluded from form submission and the values array. + * Additionally, on component initialization (firstUpdated), any disabled options at the start or end + * of the values array are automatically trimmed to ensure clean selection boundaries. + * + * **Range Mode Interaction:** + * In range mode, the drag interaction uses a clear/preview/apply pattern: + * - On mousedown: Current selection is cleared and stored for potential rollback + * - During drag: "dragging" visual state shows preview, "selected" state is hidden + * - On mouseup: Selection is applied if range is valid (>1 option) + * - On outside click: Previous selection is restored (rollback) + * + * **Single-Click Limitation:** + * In range mode, clicking a single option without dragging will not create a selection. + * This is intentional to distinguish between single clicks and drag operations. + * + * @cssdisplay inline-block + * + * @csspart options - Styles the options container, mainly to modify their layout. + * + * @cssprop {Size} --cc-form-label-gap - The space between the label and the control (defaults: `0.35em`). + * @cssprop {Size} --cc-form-label-gap-inline - The space between the label and the control when layout is inline (defaults: `0.75em`). + * @cssprop {Color} --cc-input-label-color - The color for the input's label (defaults: `inherit`). + * @cssprop {FontSize} --cc-input-label-font-size - The font-size for the input's label (defaults: `inherit`). + * @cssprop {FontWeight} --cc-input-label-font-weight - The font-weight for the input's label (defaults: `normal`). + * @cssprop {Width} --cc-range-selector-options-width - Sets the width of the form control content (defaults: `fit-content`). + * @cssprop {Size} --cc-range-selector-options-indent - horizontal space between the start of the line and the options (defaults: `0.25em`). + * + * @slot help - The help message to be displayed right below the options. Please use a `

` tag. + */ +export class CcRangeSelector extends CcFormControlElement { + static get properties() { + return { + ...super.properties, + disabled: { type: Boolean, reflect: true }, + inline: { type: Boolean, reflect: true }, + label: { type: String }, + mode: { type: String, reflect: true }, + options: { type: Array }, + readonly: { type: Boolean, reflect: true }, + required: { type: Boolean, reflect: true }, + selection: { type: Object }, + showCustom: { type: Boolean, attribute: 'show-custom' }, + value: { type: String }, + _isCustomOptionActive: { type: Boolean, state: true }, + }; + } + + static reactiveValidationProperties = ['required', 'options']; + + constructor() { + super(); + + /** @type {boolean} Whether the component should be disabled (default: 'false') */ + this.disabled = false; + + /** @type {boolean} Sets the `

+
+ + ${this.label} + ${this.required ? html` ${i18n('cc-range-selector.required')} ` : ''} + + +
+ ${this.options.map((option, index) => + this._renderOption(option, hasErrorMessage, { + indexes: { + first, + current: index, + last, + }, + isModeSingle, + isModeRange, + isLastOption: index === this.options.length - 1, + nextOption: this.options.at(index + 1), + }), + )} + ${this.showCustom ? this._renderCustomOption() : ''} +
+ +
+ +
+ + ${hasErrorMessage + ? html`

${this.errorMessage}

` + : ''} +
+
+ `; + } + + /** + * Renders a single option element + * @param {RangeSelectorOption} option + * @param {boolean} isError + * @param {RenderOptionContext} context + * @return {import('lit').TemplateResult} + * @private + */ + _renderOption(option, isError, context) { + const { body, value, disabled } = option; + const { indexes, isModeSingle, isModeRange, isLastOption, nextOption } = context; + + // Calculate basic option states + const id = this.name + '-' + value; + const isDisabled = this.disabled || disabled; + + // Calculate selection state + const isFirst = indexes.current === indexes.first; + const isLast = indexes.current === indexes.last; + const isSelected = this._isOptionSelected(value); + + // Check if option is within a range selection but not at the boundaries (used for border-radius styling) + const withinSelection = indexes.first < indexes.current && indexes.current < indexes.last; + + // Check if the next option exists and is selected (used for arrow highlighting logic) + const nextOptionSelected = + !this.disabled && nextOption != null && !nextOption.disabled && this._isOptionSelected(nextOption.value); + + // Calculate CSS classes using helper + const classes = this._calculateOptionClasses({ + isSelected, + isDisabled, + isError, + isFirst, + isLast, + withinSelection, + nextOptionSelected, + }); + + // Calculate additional render states + const inRangeWhileDragging = isModeRange && this._dragCtrl.isInRange(indexes.current); + const hasPointer = !isDisabled && !this.readonly && (isModeRange || !isSelected); + + return html` +
+ + +
+ +
+
+ `; + } + + /** + * Renders the custom option button at the end of the options list. + * This button allows users to trigger custom selection logic (e.g., opening custom form controls). + * @return {import('lit').TemplateResult} + * @private + */ + _renderCustomOption() { + const isDisabled = false; + const isSelected = this._isCustomOptionActive; + const isError = false; + const inRangeWhileDragging = false; + const hasPointer = !this._isCustomOptionActive; + + return html` +
+ +
+ +
+
+ `; + } + + static get styles() { + return [ + accessibilityStyles, + // language=CSS + css` + /* region global */ + :host { + display: inline-block; + } + /* endregion */ + + /* region fieldset */ + fieldset { + border: none; + display: inline-block; + margin: 0; + padding: 0; + } + + fieldset, + .fieldset-content { + width: var(--cc-range-selector-options-width, fit-content); + } + + fieldset:focus-visible { + border-radius: var(--cc-border-radius-default, 0.25em); + outline: var(--cc-focus-outline); + outline-offset: 0.5em; + } + + fieldset.is-error:focus-visible { + outline: var(--cc-focus-outline-error); + } + + button { + background-color: initial; + border: none; + font-size: 1em; + margin: 0; + padding: 0; + text-align: initial; + } + /* endregion */ + + /* region legend */ + .legend { + align-items: flex-end; + cursor: pointer; + display: flex; + gap: 2em; + justify-content: space-between; + padding-block-end: var(--cc-form-label-gap, 0.35em); + width: 100%; + } + + .legend-text { + color: var(--cc-input-label-color, inherit); + font-size: var(--cc-input-label-font-size, inherit); + font-weight: var(--cc-input-label-font-weight, normal); + } + + .required { + color: var(--cc-color-text-weak, #404040); + font-size: 0.9em; + font-variant: small-caps; + } + /* endregion */ + + /* region options layout */ + .options { + display: flex; + flex-wrap: wrap; + grid-area: input; + padding-inline-start: var(--cc-range-selector-options-indent, 0.25em); + row-gap: 1em; + } + /* endregion */ + + /* region option */ + .option-wrapper { + align-items: stretch; + display: inline-flex; + } + + .option-wrapper > .option-label { + flex: 1 1 auto; + } + + .option-wrapper > .arrow-wrapper { + flex: 0 0 auto; + } + + .option-label { + display: inline-flex; + } + + .option-label cc-range-selector-option { + flex: 1 1 auto; + } + + .arrow-wrapper { + align-items: center; + color: var(--cc-color-text-weak, #404040); + display: inline-flex; + padding-inline: 0.25em; + visibility: hidden; + } + + .arrow-wrapper.arrow-visible { + visibility: visible; + } + + .hidden-input:focus-visible + label cc-range-selector-option { + outline: var(--cc-focus-outline, #3569aa solid 2px); + outline-offset: var(--cc-focus-outline-offset, 2px); + } + + .hidden-input:focus-visible + label cc-range-selector-option[error] { + outline-color: var(--cc-color-border-danger, #be242d); + } + /* endregion */ + + /* region option - arrow highlighted */ + .arrow-highlighted .arrow-wrapper { + background-color: var(--cc-color-bg-primary, #3569aa); + border-color: var(--cc-color-bg-primary, #3569aa); + color: var(--cc-color-text-inverted, #fff); + } + + .arrow-highlighted.is-error .arrow-wrapper { + background-color: var(--cc-color-bg-danger, #be242d); + border-color: var(--cc-color-bg-danger, #be242d); + } + + .arrow-highlighted.arrow-highlighted--disabled .arrow-wrapper { + background-color: var(--color-grey-60, #737373); + border-color: var(--color-grey-60, #737373); + } + + :host([readonly]) .arrow-highlighted .arrow-wrapper { + background-color: var(--cc-color-bg-primary-weak, #cedcff); + border-color: var(--cc-color-bg-primary-weak, #cedcff); + color: var(--cc-color-text-primary-strong, #002c9d); + } + /* endregion */ + + /* region option - custom button */ + .btn-custom { + align-items: stretch; + border-radius: var(--cc-border-radius-default, 0.25em); + display: inline-flex; + flex: 1 1 auto; + outline-offset: var(--cc-focus-outline-offset, 2px); + } + + .btn-custom:focus-within { + outline: var(--cc-focus-outline, #3569aa solid 2px); + } + + .btn-custom cc-range-selector-option { + flex: 1 1 auto; + } + /* endregion */ + + /* region option - border-radius managment */ + .option-wrapper.range-continues-right cc-range-selector-option { + border-end-end-radius: 0; + border-start-end-radius: 0; + } + + .option-wrapper.range-continues-left cc-range-selector-option { + border-end-start-radius: 0; + border-start-start-radius: 0; + } + + .within-selection cc-range-selector-option { + border-radius: 0; + } + /* endregion */ + + /* region inline layout */ + :host([inline]) .fieldset-content { + align-items: baseline; + display: grid; + gap: 0 var(--cc-form-label-gap-inline, 0.75em); + grid-auto-rows: min-content; + grid-template-areas: + 'label input' + 'label help' + 'label error'; + grid-template-columns: auto 1fr; + } + + :host([inline]) .legend { + flex-direction: column; + gap: 0; + grid-area: label; + line-height: normal; + padding: 0; + width: auto; + } + /* endregion */ + + /* region help & error messages */ + slot[name='help']::slotted(*) { + color: var(--cc-color-text-weak, #404040); + font-size: 0.9em; + margin: 0.3em 0 0; + } + + .help-container { + grid-area: help; + } + + .error-container { + color: var(--cc-color-text-danger, #be242d); + grid-area: error; + margin: 0.5em 0 0; + } + /* endregion */ + `, + ]; + } +} + +window.customElements.define('cc-range-selector', CcRangeSelector); diff --git a/src/components/cc-range-selector/cc-range-selector.stories.js b/src/components/cc-range-selector/cc-range-selector.stories.js new file mode 100644 index 000000000..c834cc803 --- /dev/null +++ b/src/components/cc-range-selector/cc-range-selector.stories.js @@ -0,0 +1,377 @@ +import { html, render } from 'lit'; +import { iconRemixCpuLine as iconCpu, iconRemixRamLine as iconRam } from '../../assets/cc-remix.icons.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import '../cc-icon/cc-icon.js'; +import './cc-range-selector.js'; + +/** + * @typedef {import('./cc-range-selector.js').CcRangeSelector} CcRangeSelector + */ + +const OPTIONS_DEFAULT = [ + { body: `L`, value: 'lun' }, + { body: `M`, value: 'mar' }, + { body: `M`, value: 'mer' }, + { body: `J`, value: 'jeu' }, + { body: `V`, value: 'ven' }, + { body: `S`, value: 'sam' }, + { body: `D`, value: 'dim' }, +]; + +const OPTIONS_WITH_DISABLED = [ + { body: `L`, value: 'lun' }, + { body: `M`, value: 'mar' }, + { body: `M`, value: 'mer', disabled: true }, + { body: `J`, value: 'jeu', disabled: true }, + { body: `V`, value: 'ven' }, + { body: `S`, value: 'sam' }, + { body: `D`, value: 'dim' }, +]; + +const DEFAULT_SELECTOR_RANGE = { + label: 'Select a range', + mode: 'range', + name: 'range-selector', + options: OPTIONS_DEFAULT, +}; + +const DEFAULT_SELECTOR_SINGLE = { + label: 'Select an option', + mode: 'single', + name: 'single-selector', + options: OPTIONS_DEFAULT, +}; + +export default { + tags: ['autodocs'], + title: '🧬 Atoms/', + component: 'cc-range-selector', +}; + +const conf = { + component: 'cc-range-selector', +}; + +export const defaultStory = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + innerHTML: ` +

Default

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + disabled: true, + innerHTML: ` +

Disabled

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + selection: { + startValue: 'mar', + endValue: 'jeu', + }, + innerHTML: ` +

With default value

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + disabled: true, + selection: { + startValue: 'mar', + endValue: 'jeu', + }, + innerHTML: ` +

Disabled with default value

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + readonly: true, + innerHTML: ` +

Readonly

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + readonly: true, + selection: { + startValue: 'mar', + endValue: 'jeu', + }, + innerHTML: ` +

Readonly with default value

+ `, + }, + ], +}); + +export const multiline = makeStory(conf, { + css: `cc-range-selector { + font-family: 'SourceCodePro', 'monaco', monospace; + }`, + items: [ + { + label: 'Select a period', + mode: 'range', + name: 'range-selector', + options: Array.from({ length: 27 }, (_, i) => { + const body = (i + 1).toString().padStart(2, '0'); + return { + body: html` ${body} `, + value: (i + 1).toString(), + }; + }), + }, + ], +}); + +export const errorWithRequired = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + required: true, + }, + ], + /** @param {CcRangeSelector} component */ + onUpdateComplete: (component) => { + component.reportInlineValidity(); + component.focus(); + }, +}); + +export const errorWithInvalidSelection = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + selection: {}, + innerHTML: ` +

Empty selection

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + selection: { + startValue: 'jeu', + endValue: 'mar', + }, + innerHTML: ` +

Selection end value before start value

+ `, + }, + ], + /** @param {CcRangeSelector} component */ + onUpdateComplete: (component) => { + component.reportInlineValidity(); + }, +}); + +export const withCustomOption = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + showCustom: true, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + showCustom: true, + }, + ], +}); + +export const partiallyDisabled = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + innerHTML: ` +

Without a default value

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + selection: { + startValue: 'mar', + endValue: 'ven', + }, + innerHTML: ` +

Default value including disabled options

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + selection: { + startValue: 'lun', + endValue: 'mar', + }, + innerHTML: ` +

Default value ending before disabled options

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + selection: { + startValue: 'ven', + endValue: 'dim', + }, + innerHTML: ` +

Default value starting after disabled options

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + selection: { + startValue: 'lun', + endValue: 'jeu', + }, + innerHTML: ` +

Default value ending with the disabled options

+ `, + }, + { + ...DEFAULT_SELECTOR_RANGE, + options: OPTIONS_WITH_DISABLED, + selection: { + startValue: 'mer', + endValue: 'dim', + }, + innerHTML: ` +

Default value starting with the disabled options

+ `, + }, + ], +}); + +export const singleMode = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_SINGLE, + innerHTML: ` +

Default

+ `, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + disabled: true, + innerHTML: ` +

Disabled

+ `, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + value: 'mer', + innerHTML: ` +

With default value

+ `, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + disabled: true, + value: 'mer', + innerHTML: ` +

Disabled with default value

+ `, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + readonly: true, + innerHTML: ` +

Readonly

+ `, + }, + { + ...DEFAULT_SELECTOR_SINGLE, + readonly: true, + value: 'mer', + innerHTML: ` +

Readonly with default value

+ `, + }, + ], +}); + +export const inline = makeStory(conf, { + items: [ + { + ...DEFAULT_SELECTOR_RANGE, + inline: true, + }, + ], +}); + +const INSTANCE_SIZES = [ + { value: 'pico', name: 'pico', cpu: 'Shared CPU', ram: '256 MiB RAM' }, + { value: 'nano', name: 'nano', cpu: 'Shared CPU', ram: '512 MiB RAM' }, + { value: 'xs', name: 'extra small', cpu: '1 CPUs', ram: '1 GiB RAM' }, + { value: 'sm', name: 'small', cpu: '2 CPUs', ram: '2 GiB RAM' }, + { value: 'med', name: 'medium', cpu: '4 CPUs', ram: '4 GiB RAM' }, + { value: 'lg', name: 'large', cpu: '6 CPUs', ram: '8 GiB RAM' }, + { value: 'xl', name: 'extra large', cpu: '8 CPUs', ram: '16 GiB RAM' }, + { value: '2xl', name: 'extra large+', cpu: '12 CPUs', ram: '24 GiB RAM' }, + { value: '3xl', name: 'extra large++', cpu: '16 CPUs', ram: '32 GiB RAM' }, +]; + +const INSTANCE_SIZES_OPTIONS = INSTANCE_SIZES.map((option) => { + const { value, name, cpu, ram } = option; + return { + body: html` +
+
${name}
+
+ ${cpu} +
+
+ ${ram} +
+
+ `, + value, + disabled: value === 'pico', + }; +}); + +export const customStyle = makeStory(conf, { + css: ` + cc-range-selector { + --cc-input-label-color: #475569; + --cc-input-label-font-size: 1.2em; + --cc-input-label-font-weight: bold; + --cc-form-label-gap: 0.5em; + --cc-range-selector-options-width: 100%; + display: block; + } + + cc-range-selector::part(options) { + display: grid; + gap: 1em 0; + grid-template-columns: repeat(auto-fit, minmax(10em, 1fr)); + } + + ::part(btn-custom) { + font-weight: 600; + padding: 0.575em 1em; + } + `, + dom: (container) => { + render( + html` + +

Drag and Drop the sizes to choose which one will be activated during a scale-up scenario.

+
+ `, + container, + ); + }, +}); diff --git a/src/components/cc-range-selector/cc-range-selector.types.d.ts b/src/components/cc-range-selector/cc-range-selector.types.d.ts new file mode 100644 index 000000000..6bdac0891 --- /dev/null +++ b/src/components/cc-range-selector/cc-range-selector.types.d.ts @@ -0,0 +1,12 @@ +export type RangeSelectorMode = 'single' | 'range'; + +export interface RangeSelectorOption { + body: string | Node; + disabled?: boolean; + value: string; +} + +export interface RangeSelectorSelection { + startValue: string; + endValue: string; +} diff --git a/src/components/cc-range-selector/range-selector-dragging-controller.js b/src/components/cc-range-selector/range-selector-dragging-controller.js new file mode 100644 index 000000000..d6f661167 --- /dev/null +++ b/src/components/cc-range-selector/range-selector-dragging-controller.js @@ -0,0 +1,153 @@ +/** + * @typedef {import('./cc-range-selector.js').CcRangeSelector} CcRangeSelector + * @typedef {import('./cc-range-selector.types.js').RangeSelectorSelection} RangeSelectorSelection + */ + +/** + * Controller for managing drag selection state in cc-range-selector component. + * + * This controller handles the drag-to-select interaction in range mode, tracking: + * - Start and end indices of the current drag operation + * - Whether a drag is currently in progress + * - Previous selection boundary values for cancellation/rollback + * + * The controller maintains drag state and notifies the host component via requestUpdate() + * when state changes require re-rendering. + */ +export class RangeSelectorDraggingController { + /** + * Creates a new dragging controller instance. + * @param {CcRangeSelector} host - The host cc-range-selector component + */ + constructor(host) { + /** @type {CcRangeSelector} */ + this._host = host; + + /** @type {boolean} */ + this._dragging = false; + + /** @type {number | null} */ + this._startIndex = null; + + /** @type {number | null} */ + this._endIndex = null; + + /** @type {RangeSelectorSelection | null} */ + this._previousSelection = null; + } + + /** + * Starts a new drag selection at the specified index. + * Sets both start and end indices to the same value and marks dragging as active. + * @param {number} value - The index where the drag starts + */ + start(value) { + this._dragging = true; + this._startIndex = this._endIndex = value; + this._host.requestUpdate(); + } + + /** + * Updates the end index of the current drag selection. + * Called as the user drags across options to expand the selection range. + * @param {number} index - The new end index for the drag range + */ + update(index) { + if (index == null) { + return; + } + this._endIndex = index; + this._host.requestUpdate(); + } + + /** + * Stops the current drag operation and resets drag state. + * Clears the start and end indices and marks dragging as inactive. + */ + stop() { + this._dragging = false; + this._startIndex = this._endIndex = null; + this._host.requestUpdate(); + } + + /** + * Checks if a drag operation is currently in progress. + * @returns {boolean} True if dragging, false otherwise + */ + isDragging() { + return this._dragging; + } + + /** + * Checks if a given index falls within the current drag range. + * Handles both forward (left-to-right) and backward (right-to-left) drags. + * @param {number} index - The index to check + * @returns {boolean} True if index is within the drag range, false otherwise + */ + isInRange(index) { + if (this._startIndex == null || this._endIndex == null) { + return false; + } + + // Handle backward drag (right-to-left) + if (this._startIndex > this._endIndex) { + return this._endIndex <= index && index <= this._startIndex; + } + // Handle forward drag (left-to-right) + return this._startIndex <= index && index <= this._endIndex; + } + + /** + * Gets the size of the current drag selection (number of positions between start and end). + * Returns 0 if clicking a single option without dragging, or if no drag is active. + * @returns {number} The absolute distance between start and end indices + */ + getSize() { + if (this._startIndex == null || this._endIndex == null) { + return 0; + } + + return Math.abs(this._endIndex - this._startIndex); + } + + /** + * Gets the normalized drag range with start and end in correct order. + * Always returns start <= end regardless of drag direction. + * @returns {{ start: number, end: number }} The normalized range indices + */ + getRanges() { + // Swap if dragged backward (right-to-left) + if (this._startIndex > this._endIndex) { + return { + start: this._endIndex, + end: this._startIndex, + }; + } + return { + start: this._startIndex, + end: this._endIndex, + }; + } + + /** + * Stores the previous selection boundary values for potential rollback. + * Used when canceling a drag operation (e.g., clicking outside). + * @param {RangeSelectorSelection | null} selection - The boundary values to store + */ + setPreviousSelection(selection) { + if (selection == null) { + this._previousSelection = null; + return; + } + // Create a shallow copy of the boundary object + this._previousSelection = { ...selection }; + } + + /** + * Retrieves the previously stored selection boundary values. + * @returns {RangeSelectorSelection | null} The stored previous boundary values, or null if none stored + */ + getPreviousSelection() { + return this._previousSelection; + } +} diff --git a/src/components/common.events.js b/src/components/common.events.js index ecaaa9cd6..bd9d09467 100644 --- a/src/components/common.events.js +++ b/src/components/common.events.js @@ -31,6 +31,21 @@ export class CcSelectEvent extends CcEvent { } } +/** + * Dispatched when a range selection changes. + * @extends {CcEvent} + */ +export class CcRangeSelectEvent extends CcEvent { + static TYPE = 'cc-range-select'; + + /** + * @param {string[]} detail + */ + constructor(detail) { + super(CcRangeSelectEvent.TYPE, detail); + } +} + /** * Dispatched when a multi selection changes. * @extends {CcEvent>} diff --git a/src/lib/events-map.types.d.ts b/src/lib/events-map.types.d.ts index e442e2e29..1bfce6c52 100644 --- a/src/lib/events-map.types.d.ts +++ b/src/lib/events-map.types.d.ts @@ -124,6 +124,7 @@ import { CcPricingTemporalityChangeEvent, CcPricingZoneChangeEvent, } from '../components/cc-pricing-page/cc-pricing-page.events.js'; +import { CcRangeSelectorSelectCustom } from '../components/cc-range-selector/cc-range-selector.events.js'; import { CcSshKeyCreateEvent, CcSshKeyDeleteEvent, @@ -142,6 +143,7 @@ import { import { CcClickEvent, CcPasswordResetEvent, + CcRangeSelectEvent, CcRequestSubmitEvent, CcToggleEvent, CcTokenRevokeEvent, @@ -262,6 +264,8 @@ declare global { 'cc-pricing-temporality-change': CcPricingTemporalityChangeEvent; 'cc-pricing-zone-change': CcPricingZoneChangeEvent; 'cc-product-create': CcProductCreateEvent; + 'cc-range-select': CcRangeSelectEvent; + 'cc-range-selector-select-custom': CcRangeSelectorSelectCustom; 'cc-request-submit': CcRequestSubmitEvent; 'cc-select': CcSelectEvent; 'cc-ssh-key-create': CcSshKeyCreateEvent; diff --git a/src/lib/utils.js b/src/lib/utils.js index e3b3cd2b4..daade5e9a 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -307,3 +307,32 @@ export function isVisibleInContainer(element, container) { elementRect.right <= containerRect.right ); } + +/** + * Trims elements from both the start and end of an array based on a condition function. + * Removes consecutive elements from the beginning and end that match the condition, + * while preserving all middle elements (including those that match the condition). + * + * @param {Array} array - The array to trim + * @param {Function} condition - Function that returns true for elements to trim + * @returns {Array} A new array with matching elements removed from both ends + */ +export function trimArray(array, condition) { + if (array == null) { + return []; + } + + let start = 0; + let end = array.length - 1; + + while (start <= end && condition(array[start])) { + start++; + } + + // Find first non-matching from end + while (end >= start && condition(array[end])) { + end--; + } + + return array.slice(start, end + 1); +} diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index c85f88235..ed74576c7 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -1557,6 +1557,12 @@ export const translations = { 'cc-product-list.search-empty': `No products matching your search criteria were found.`, 'cc-product-list.search-label': `Search for a product`, //#endregion + //#region cc-range-selector + 'cc-range-selector.custom': `Customize`, + 'cc-range-selector.error.empty': `This field is required.`, + 'cc-range-selector.error.invalid-selection': `Invalid selection.`, + 'cc-range-selector.required': `required`, + //#endregion //#region cc-select 'cc-select.error.empty': `You must select a value`, 'cc-select.required': `required`, diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index 8b4afaa39..72a96225c 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -1581,6 +1581,12 @@ export const translations = { 'cc-product-list.search-empty': `Aucun produit ne correspond à vos critères de recherche.`, 'cc-product-list.search-label': `Chercher un produit`, //#endregion + //#region cc-range-selector + 'cc-range-selector.custom': `Personnaliser`, + 'cc-range-selector.error.empty': `Ce champ est obligatoire.`, + 'cc-range-selector.error.invalid-selection': `Sélection invalide.`, + 'cc-range-selector.required': `obligatoire`, + //#endregion //#region cc-select 'cc-select.error.empty': `Sélectionnez une valeur`, 'cc-select.required': `obligatoire`, diff --git a/test/utils.test.js b/test/utils.test.js index 69143c9a3..91b32d9a8 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -8,6 +8,7 @@ import { isStringEmpty, randomString, range, + trimArray, } from '../src/lib/utils.js'; describe('range function', function () { @@ -163,3 +164,153 @@ describe('generateDevHubHref', () => { expect(generateDevHubHref()).to.equal('https://www.clever.cloud/developers/'); }); }); + +describe('trimArray function', () => { + describe('null and undefined handling', () => { + it('should return empty array when input is null', () => { + expect(trimArray(null, (x) => x == null)).to.eql([]); + }); + + it('should return empty array when input is undefined', () => { + expect(trimArray(undefined, (x) => x == null)).to.eql([]); + }); + }); + + describe('empty array handling', () => { + it('should return empty array when input is empty', () => { + expect(trimArray([], (x) => x === 0)).to.eql([]); + }); + }); + + describe('basic trimming functionality', () => { + it('should trim nullish values from both ends', () => { + expect(trimArray([null, null, 1, 2, 3, null, null], (x) => x == null)).to.eql([1, 2, 3]); + }); + + it('should trim zeros from both ends', () => { + expect(trimArray([0, 0, 1, 2, 3, 0, 0], (x) => x === 0)).to.eql([1, 2, 3]); + }); + + it('should trim empty strings from both ends', () => { + expect(trimArray(['', '', 'a', 'b', 'c', '', ''], (x) => x === '')).to.eql(['a', 'b', 'c']); + }); + + it('should trim falsy values from both ends', () => { + expect(trimArray([false, 0, null, 1, 'text', true, null, 0, false], (x) => !x)).to.eql([1, 'text', true]); + }); + + it('should preserve middle elements that match condition', () => { + expect(trimArray([0, 0, 1, 0, 2, 0, 0], (x) => x === 0)).to.eql([1, 0, 2]); + }); + }); + + describe('edge cases with matching elements', () => { + it('should return empty array when all elements match condition', () => { + expect(trimArray([0, 0, 0, 0], (x) => x === 0)).to.eql([]); + }); + + it('should return original array when no elements match condition', () => { + expect(trimArray([1, 2, 3, 4], (x) => x === 0)).to.eql([1, 2, 3, 4]); + }); + + it('should return empty array for single element that matches', () => { + expect(trimArray([0], (x) => x === 0)).to.eql([]); + }); + + it('should return single element that does not match', () => { + expect(trimArray([1], (x) => x === 0)).to.eql([1]); + }); + }); + + describe('asymmetric trimming', () => { + it('should trim only from start when only start elements match', () => { + expect(trimArray([0, 0, 0, 1, 2, 3], (x) => x === 0)).to.eql([1, 2, 3]); + }); + + it('should trim only from end when only end elements match', () => { + expect(trimArray([1, 2, 3, 0, 0, 0], (x) => x === 0)).to.eql([1, 2, 3]); + }); + + it('should handle start with one matching element', () => { + expect(trimArray([0, 1, 2, 3], (x) => x === 0)).to.eql([1, 2, 3]); + }); + + it('should handle end with one matching element', () => { + expect(trimArray([1, 2, 3, 0], (x) => x === 0)).to.eql([1, 2, 3]); + }); + }); + + describe('complex conditions', () => { + it('should trim based on numeric comparison (negative numbers)', () => { + expect(trimArray([-2, -1, 0, 1, 2, -1, -2], (x) => x < 0)).to.eql([0, 1, 2]); + }); + + it('should trim based on numeric comparison (values below threshold)', () => { + expect(trimArray([1, 2, 5, 10, 15, 3, 1], (x) => x < 5)).to.eql([5, 10, 15]); + }); + + it('should trim objects based on property values', () => { + const input = [{ value: 0 }, { value: 0 }, { value: 1 }, { value: 2 }, { value: 0 }, { value: 0 }]; + expect(trimArray(input, (x) => x.value === 0)).to.eql([{ value: 1 }, { value: 2 }]); + }); + + it('should trim based on type checking', () => { + expect(trimArray(['', '', 1, 2, 'text', '', ''], (x) => typeof x === 'string' && x === '')).to.eql([ + 1, + 2, + 'text', + ]); + }); + + it('should handle array of booleans', () => { + expect(trimArray([false, false, true, false, true, false, false], (x) => x === false)).to.eql([ + true, + false, + true, + ]); + }); + }); + + describe('preserving array structure', () => { + it('should preserve order of remaining elements', () => { + expect(trimArray([0, 5, 4, 3, 2, 1, 0], (x) => x === 0)).to.eql([5, 4, 3, 2, 1]); + }); + + it('should not modify original array', () => { + const original = [0, 1, 2, 0]; + const result = trimArray(original, (x) => x === 0); + expect(original).to.eql([0, 1, 2, 0]); // Original unchanged + expect(result).to.eql([1, 2]); // Result is trimmed + }); + + it('should handle consecutive matching elements correctly', () => { + expect(trimArray([1, 1, 1, 1, 2, 3, 1, 1, 1], (x) => x === 1)).to.eql([2, 3]); + }); + }); + + describe('real-world use cases', () => { + it('should trim whitespace-only strings from chart data', () => { + const data = [' ', ' ', 'Jan', 'Feb', 'Mar', ' ', ' ']; + expect(trimArray(data, (x) => x.trim() === '')).to.eql(['Jan', 'Feb', 'Mar']); + }); + + it('should trim undefined entries from data array', () => { + const data = [undefined, undefined, 'data1', 'data2', undefined, 'data3', undefined, undefined]; + expect(trimArray(data, (x) => x === undefined)).to.eql(['data1', 'data2', undefined, 'data3']); + }); + + it('should trim placeholder objects from list', () => { + const data = [ + { placeholder: true }, + { placeholder: true }, + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { placeholder: true }, + ]; + expect(trimArray(data, (x) => x.placeholder === true)).to.eql([ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]); + }); + }); +});