diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index fbf30e7a88a..a9fc28adbca 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,32 +5,32 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -import { AnyBindings } from "./components/common/interface/bindings"; -import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; +import { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; import { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; import { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; +import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; import { RedirectionPayload } from "./components/common/search-box/redirection-payload"; +import { AnyBindings } from "./components/common/interface/bindings"; import { i18n } from "i18next"; import { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; -export { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -export { AnyBindings } from "./components/common/interface/bindings"; -export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; +export { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; export { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; export { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; +export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; export { RedirectionPayload } from "./components/common/search-box/redirection-payload"; +export { AnyBindings } from "./components/common/interface/bindings"; export { i18n } from "i18next"; export { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; export namespace Components { @@ -60,16 +60,6 @@ export namespace Components { */ "sendHoverEndEvent": (citationHoverTimeMs: number) => void; } - /** - * Internal component made to be integrated in a NumericFacet. - */ - interface AtomicFacetNumberInput { - "bindings": AnyBindings; - "filter": NumericFilter; - "filterState": NumericFilterState; - "label": string; - "type": NumberInputType; - } /** * Internal component, only to use through `atomic-generated-answer` or `atomic-insight-generated-answer` */ @@ -1179,10 +1169,6 @@ export namespace Components { "withDatePicker": boolean; } } -export interface AtomicFacetNumberInputCustomEvent extends CustomEvent { - detail: T; - target: HTMLAtomicFacetNumberInputElement; -} export interface AtomicGeneratedAnswerFeedbackModalCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicGeneratedAnswerFeedbackModalElement; @@ -1249,26 +1235,6 @@ declare global { prototype: HTMLAtomicCitationElement; new (): HTMLAtomicCitationElement; }; - interface HTMLAtomicFacetNumberInputElementEventMap { - "atomic/numberInputApply": any; - } - /** - * Internal component made to be integrated in a NumericFacet. - */ - interface HTMLAtomicFacetNumberInputElement extends Components.AtomicFacetNumberInput, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLAtomicFacetNumberInputElement, ev: AtomicFacetNumberInputCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLAtomicFacetNumberInputElement, ev: AtomicFacetNumberInputCustomEvent) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; - } - var HTMLAtomicFacetNumberInputElement: { - prototype: HTMLAtomicFacetNumberInputElement; - new (): HTMLAtomicFacetNumberInputElement; - }; interface HTMLAtomicGeneratedAnswerFeedbackModalElementEventMap { "feedbackSent": any; } @@ -1905,7 +1871,6 @@ declare global { }; interface HTMLElementTagNameMap { "atomic-citation": HTMLAtomicCitationElement; - "atomic-facet-number-input": HTMLAtomicFacetNumberInputElement; "atomic-generated-answer-feedback-modal": HTMLAtomicGeneratedAnswerFeedbackModalElement; "atomic-insight-edit-toggle": HTMLAtomicInsightEditToggleElement; "atomic-insight-facet": HTMLAtomicInsightFacetElement; @@ -1996,17 +1961,6 @@ declare namespace LocalJSX { */ "sendHoverEndEvent": (citationHoverTimeMs: number) => void; } - /** - * Internal component made to be integrated in a NumericFacet. - */ - interface AtomicFacetNumberInput { - "bindings": AnyBindings; - "filter": NumericFilter; - "filterState": NumericFilterState; - "label": string; - "onAtomic/numberInputApply"?: (event: AtomicFacetNumberInputCustomEvent) => void; - "type": NumberInputType; - } /** * Internal component, only to use through `atomic-generated-answer` or `atomic-insight-generated-answer` */ @@ -3093,7 +3047,6 @@ declare namespace LocalJSX { } interface IntrinsicElements { "atomic-citation": AtomicCitation; - "atomic-facet-number-input": AtomicFacetNumberInput; "atomic-generated-answer-feedback-modal": AtomicGeneratedAnswerFeedbackModal; "atomic-insight-edit-toggle": AtomicInsightEditToggle; "atomic-insight-facet": AtomicInsightFacet; @@ -3165,10 +3118,6 @@ declare module "@stencil/core" { * Internal component, only to use through `atomic-generated-answer` or `atomic-insight-generated-answer` */ "atomic-citation": LocalJSX.AtomicCitation & JSXBase.HTMLAttributes; - /** - * Internal component made to be integrated in a NumericFacet. - */ - "atomic-facet-number-input": LocalJSX.AtomicFacetNumberInput & JSXBase.HTMLAttributes; /** * Internal component, only to use through `atomic-generated-answer` or `atomic-insight-generated-answer` */ diff --git a/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.spec.ts b/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.spec.ts new file mode 100644 index 00000000000..6109fe22dd5 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.spec.ts @@ -0,0 +1,305 @@ +import type {NumericFilter, NumericFilterState} from '@coveo/headless'; +import {html} from 'lit'; +import {describe, expect, it, vi} from 'vitest'; +import {page} from 'vitest/browser'; +import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture'; +import {AtomicFacetNumberInput} from './atomic-facet-number-input'; +import './atomic-facet-number-input'; + +describe('atomic-facet-number-input', () => { + let filter: NumericFilter; + + const locators = { + minLabel: page.getByText('Min'), + maxLabel: page.getByText('Max'), + applyButton: page.getByRole('button', {name: /Apply/}), + parts: (element: AtomicFacetNumberInput) => ({ + form: element.querySelector('[part="input-form"]')!, + startInput: element.querySelector( + '[part="input-start"]' + )! as HTMLInputElement, + endInput: element.querySelector( + '[part="input-end"]' + )! as HTMLInputElement, + startLabel: element.querySelector('[part="label-start"]')!, + endLabel: element.querySelector('[part="label-end"]')!, + applyButton: element.querySelector('[part="input-apply-button"]')!, + }), + }; + + const setupElement = async ( + props: Partial<{ + type: 'integer' | 'decimal'; + label: string; + filter: NumericFilter; + filterState: NumericFilterState; + }> = {} + ) => { + const filterState = + props.filterState || + ({facetId: 'test-facet', range: undefined} as NumericFilterState); + + filter = + props.filter || + ({ + state: filterState, + setRange: vi.fn(), + } as unknown as NumericFilter); + + const {element} = + await renderInAtomicSearchInterface({ + template: html``, + selector: 'atomic-facet-number-input', + }); + + return {element}; + }; + + it('should be defined', async () => { + const {element} = await setupElement(); + expect(element).toBeInstanceOf(AtomicFacetNumberInput); + }); + + it('should render the form with correct input and values', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + expect(parts.startInput.value).toBe('10'); + expect(parts.endInput.value).toBe('100'); + }); + + it('should render empty input values when range is not provided', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: undefined, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + expect(parts.startInput.value).toBe(''); + expect(parts.endInput.value).toBe(''); + }); + + it('should render the form with part="input-form"', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + expect(parts.form).toBeInTheDocument(); + }); + + it('should render the label for start input with part="label-start"', async () => { + await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + + await expect.element(locators.minLabel).toBeInTheDocument(); + }); + + it('should render the start input with part="input-start"', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + expect(parts.startInput.getAttribute('part')).toBe('input-start'); + }); + + it('should render the end input with part="input-end"', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + expect(parts.endInput.getAttribute('part')).toBe('input-end'); + }); + + it('should render the label for end input with part="label-end"', async () => { + await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + + await expect.element(locators.maxLabel).toBeInTheDocument(); + }); + + it('should render the apply button with part="input-apply-button"', async () => { + await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + + await expect.element(locators.applyButton).toBeInTheDocument(); + }); + + it('should render step="1" for integer type', async () => { + const {element} = await setupElement({ + type: 'integer', + }); + const parts = locators.parts(element); + + expect(parts.startInput.getAttribute('step')).toBe('1'); + }); + + it('should render step="any" for decimal type', async () => { + const {element} = await setupElement({ + type: 'decimal', + }); + const parts = locators.parts(element); + + expect(parts.startInput.getAttribute('step')).toBe('any'); + }); + + describe('when the apply button is clicked', () => { + it('should emit atomic/numberInputApply event', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const spy = vi.fn(); + element.addEventListener('atomic/numberInputApply', spy); + await locators.applyButton.click(); + expect(spy).toHaveBeenCalled(); + }); + + it('should call filter.setRange with correct values', async () => { + await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + await locators.applyButton.click(); + expect(filter.setRange).toHaveBeenCalledWith({ + start: 10, + end: 100, + }); + }); + + it('should call filter.setRange with updated values when inputs are changed', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + parts.startInput.value = '50'; + parts.endInput.value = '200'; + parts.startInput.dispatchEvent(new Event('input', {bubbles: true})); + parts.endInput.dispatchEvent(new Event('input', {bubbles: true})); + await locators.applyButton.click(); + + expect(filter.setRange).toHaveBeenCalledWith({ + start: 50, + end: 200, + }); + }); + }); + + describe('when invalid input is entered', () => { + it('should not call filter.setRange when start input is empty', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: undefined, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + parts.endInput.value = '100'; + parts.endInput.dispatchEvent(new Event('input', {bubbles: true})); + await locators.applyButton.click(); + + expect(parts.startInput.validity.valid).toBe(false); + expect(parts.startInput.validity.valueMissing).toBe(true); + expect(filter.setRange).not.toHaveBeenCalled(); + }); + + it('should not call filter.setRange when end input is empty', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: undefined, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + parts.startInput.value = '10'; + parts.startInput.dispatchEvent(new Event('input', {bubbles: true})); + await locators.applyButton.click(); + + expect(parts.endInput.validity.valid).toBe(false); + expect(parts.endInput.validity.valueMissing).toBe(true); + expect(filter.setRange).not.toHaveBeenCalled(); + }); + + it('should not call filter.setRange when start value is greater than end value', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: {start: 10, end: 100}, + } as NumericFilterState, + }); + const parts = locators.parts(element); + + parts.startInput.value = '200'; + parts.endInput.value = '100'; + parts.startInput.dispatchEvent(new Event('input', {bubbles: true})); + parts.endInput.dispatchEvent(new Event('input', {bubbles: true})); + await locators.applyButton.click(); + + expect(parts.startInput.validity.valid).toBe(false); + expect(parts.startInput.validity.rangeOverflow).toBe(true); + expect(filter.setRange).not.toHaveBeenCalled(); + }); + + it('should not emit atomic/numberInputApply event when inputs are invalid', async () => { + const {element} = await setupElement({ + filterState: { + facetId: 'test-facet', + range: undefined, + } as NumericFilterState, + }); + const parts = locators.parts(element); + const spy = vi.fn(); + element.addEventListener('atomic/numberInputApply', spy); + await locators.applyButton.click(); + + expect(parts.startInput.validity.valid).toBe(false); + expect(parts.endInput.validity.valid).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.ts b/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.ts new file mode 100644 index 00000000000..a19736df717 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-facet-number-input/atomic-facet-number-input.ts @@ -0,0 +1,207 @@ +import type {NumericFilter, NumericFilterState} from '@coveo/headless'; +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {createRef, type Ref, ref} from 'lit/directives/ref.js'; +import {renderButton} from '@/src/components/common/button'; +import {bindingGuard} from '@/src/decorators/binding-guard'; +import {bindings} from '@/src/decorators/bindings'; +import {errorGuard} from '@/src/decorators/error-guard'; +import type {InitializableComponent} from '@/src/decorators/types'; +import {LightDomMixin} from '@/src/mixins/light-dom'; +import type {NumberInputType} from '../facets/facet-number-input/number-input-type'; +import type {AnyBindings} from '../interface/bindings'; + +/** + * Internal component made to be integrated in a NumericFacet. + * @internal + */ +@customElement('atomic-facet-number-input') +@bindings() +export class AtomicFacetNumberInput + extends LightDomMixin(LitElement) + implements InitializableComponent +{ + /** + * The type of number input (integer or decimal). + */ + @property({type: String}) public type!: NumberInputType; + /** + * The label for the numeric facet input, used for accessibility and display. + */ + @property({type: String}) public label!: string; + /** + * The NumericFilter controller instance from Headless. + */ + @property({type: Object}) public filter!: NumericFilter; + /** + * The NumericFilterState from the filter controller. + */ + @property({type: Object, attribute: 'filter-state'}) + public filterState!: NumericFilterState; + + @state() + bindings!: AnyBindings; + @state() + error!: Error; + @state() private start?: number; + @state() private end?: number; + + private startRef: Ref = createRef(); + private endRef: Ref = createRef(); + + static styles = css` + [part='input-form'] { + display: grid; + grid-template-areas: + 'label-start label-end .' + 'input-start input-end apply-button'; + grid-template-columns: 1fr 1fr auto; + } + + [part='label-start'] { + grid-area: label-start; + } + [part='label-end'] { + grid-area: label-end; + } + [part='input-start'] { + grid-area: input-start; + } + [part='input-end'] { + grid-area: input-end; + } + + [part='input-apply-button'] { + grid-area: apply-button; + } + `; + + public initialize() { + this.start = this.filterState?.range?.start; + this.end = this.filterState?.range?.end; + } + + @bindingGuard() + @errorGuard() + render() { + const label = this.bindings.i18n.t(this.label); + const minText = this.bindings.i18n.t('min'); + const maxText = this.bindings.i18n.t('max'); + const minAria = this.bindings.i18n.t('number-input-minimum', {label}); + const maxAria = this.bindings.i18n.t('number-input-maximum', {label}); + const apply = this.bindings.i18n.t('apply'); + const applyAria = this.bindings.i18n.t('number-input-apply', {label}); + + const inputClasses = + 'p-2.5 input-primary placeholder-neutral-dark min-w-0 mr-1'; + const labelClasses = 'text-neutral-dark text-sm'; + + const step = this.type === 'integer' ? '1' : 'any'; + + return html` +
{ + e.preventDefault(); + this.apply(); + return false; + }} + > + + { + this.start = (e.target as HTMLInputElement).valueAsNumber; + }} + ${ref(this.startRef)} + /> + + { + this.end = (e.target as HTMLInputElement).valueAsNumber; + }} + ${ref(this.endRef)} + /> + ${renderButton({ + props: { + style: 'outline-primary', + type: 'submit', + part: 'input-apply-button', + class: 'flex-none truncate p-2.5', + ariaLabel: applyAria, + text: apply, + }, + })(html``)} +
+ `; + } + + private apply() { + if ( + !this.startRef.value?.validity.valid || + !this.endRef.value?.validity.valid + ) { + return; + } + this.dispatchEvent( + new CustomEvent('atomic/numberInputApply', { + bubbles: true, + composed: true, + }) + ); + this.filter.setRange({ + start: this.start!, + end: this.end!, + }); + } + + private get startValue() { + return this.filterState.range?.start !== undefined + ? String(this.filterState.range.start) + : ''; + } + + private get endValue() { + return this.filterState.range?.end !== undefined + ? String(this.filterState.range.end) + : ''; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-facet-number-input': AtomicFacetNumberInput; + } +} diff --git a/packages/atomic/src/components/common/facets/facet-number-input/atomic-facet-number-input.pcss b/packages/atomic/src/components/common/facets/facet-number-input/atomic-facet-number-input.pcss deleted file mode 100644 index f31f36895e0..00000000000 --- a/packages/atomic/src/components/common/facets/facet-number-input/atomic-facet-number-input.pcss +++ /dev/null @@ -1,24 +0,0 @@ -[part='input-form'] { - display: grid; - grid-template-areas: - 'label-start label-end .' - 'input-start input-end apply-button'; - grid-template-columns: 1fr 1fr auto; -} - -[part='label-start'] { - grid-area: label-start; -} -[part='label-end'] { - grid-area: label-end; -} -[part='input-start'] { - grid-area: input-start; -} -[part='input-end'] { - grid-area: input-end; -} - -[part='input-apply-button'] { - grid-area: apply-button; -} diff --git a/packages/atomic/src/components/common/facets/facet-number-input/facet-number-input.tsx b/packages/atomic/src/components/common/facets/facet-number-input/facet-number-input.tsx deleted file mode 100644 index dd6d466669e..00000000000 --- a/packages/atomic/src/components/common/facets/facet-number-input/facet-number-input.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import {NumericFilter, NumericFilterState} from '@coveo/headless'; -import {Component, h, State, Prop, Event, EventEmitter} from '@stencil/core'; -import {AnyBindings} from '../../interface/bindings'; -import {Button} from '../../stencil-button'; -import {NumberInputType} from './number-input-type'; - -/** - * Internal component made to be integrated in a NumericFacet. - * @internal - */ -@Component({ - tag: 'atomic-facet-number-input', - styleUrl: 'atomic-facet-number-input.pcss', - shadow: false, -}) -export class FacetNumberInput { - @State() private start?: number; - @State() private end?: number; - private startRef!: HTMLInputElement; - private endRef!: HTMLInputElement; - - @Prop() public bindings!: AnyBindings; - @Prop() public type!: NumberInputType; - @Prop() public filter!: NumericFilter; - @Prop() public filterState!: NumericFilterState; - @Prop() public label!: string; - - @Event({ - eventName: 'atomic/numberInputApply', - }) - private applyInput!: EventEmitter; - - public connectedCallback() { - this.start = this.filterState.range?.start; - this.end = this.filterState.range?.end; - } - - private apply() { - if (!this.startRef.validity.valid || !this.endRef.validity.valid) { - return; - } - - this.applyInput.emit(); - this.filter.setRange({ - start: this.start!, - end: this.end!, - }); - } - - render() { - const label = this.bindings.i18n.t(this.label); - const minText = this.bindings.i18n.t('min'); - const maxText = this.bindings.i18n.t('max'); - const minAria = this.bindings.i18n.t('number-input-minimum', {label}); - const maxAria = this.bindings.i18n.t('number-input-maximum', {label}); - const apply = this.bindings.i18n.t('apply'); - const applyAria = this.bindings.i18n.t('number-input-apply', {label}); - - const inputClasses = - 'p-2.5 input-primary placeholder-neutral-dark min-w-0 mr-1'; - const labelClasses = 'text-neutral-dark text-sm'; - - const step = this.type === 'integer' ? '1' : 'any'; - - return ( -
{ - e.preventDefault(); - this.apply(); - return false; - }} - > - - (this.startRef = ref!)} - class={inputClasses} - aria-label={minAria} - required - min={Number.MIN_SAFE_INTEGER} - max={this.end} - value={this.filterState.range?.start} - onInput={(e) => - (this.start = (e.target as HTMLInputElement).valueAsNumber) - } - /> - - (this.endRef = ref!)} - class={inputClasses} - aria-label={maxAria} - required - min={this.start} - max={Number.MAX_SAFE_INTEGER} - value={this.filterState.range?.end} - onInput={(e) => - (this.end = (e.target as HTMLInputElement).valueAsNumber) - } - /> - -
- ); - } -} diff --git a/packages/atomic/src/components/common/index.ts b/packages/atomic/src/components/common/index.ts index b6356318648..8d461688afb 100644 --- a/packages/atomic/src/components/common/index.ts +++ b/packages/atomic/src/components/common/index.ts @@ -2,6 +2,7 @@ export {AtomicAriaLive} from './atomic-aria-live/atomic-aria-live.js'; export {AtomicComponentError} from './atomic-component-error/atomic-component-error.js'; export {AtomicFacetDateInput} from './atomic-facet-date-input/atomic-facet-date-input.js'; +export {AtomicFacetNumberInput} from './atomic-facet-number-input/atomic-facet-number-input.js'; export {AtomicFacetPlaceholder} from './atomic-facet-placeholder/atomic-facet-placeholder.js'; export {AtomicFocusTrap} from './atomic-focus-trap/atomic-focus-trap.js'; export {AtomicIcon} from './atomic-icon/atomic-icon.js'; diff --git a/packages/atomic/src/components/common/lazy-index.ts b/packages/atomic/src/components/common/lazy-index.ts index a2ea123164e..1d9261345f2 100644 --- a/packages/atomic/src/components/common/lazy-index.ts +++ b/packages/atomic/src/components/common/lazy-index.ts @@ -6,6 +6,8 @@ export default { await import('./atomic-component-error/atomic-component-error.js'), 'atomic-facet-date-input': async () => await import('./atomic-facet-date-input/atomic-facet-date-input.js'), + 'atomic-facet-number-input': async () => + await import('./atomic-facet-number-input/atomic-facet-number-input.js'), 'atomic-facet-placeholder': async () => await import('./atomic-facet-placeholder/atomic-facet-placeholder.js'), 'atomic-focus-trap': async () => diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 31447ae997f..105ca7723b5 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -46,6 +46,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-facet', 'atomic-facet-date-input', 'atomic-facet-manager', + 'atomic-facet-number-input', 'atomic-facet-placeholder', 'atomic-field-condition', 'atomic-focus-trap',