diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6db7b95f2c..b14fa67450 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,6 +22,8 @@ /**/Ts/** @jattasNI /**/fv/** @jattasNI @fredvisser /**/Fv/** @jattasNI @fredvisser +/**/th/** @atmgrifter00 +/**/Th/** @atmgrifter00 /packages/nimble-tokens @rajsite @jattasNI @fredvisser /packages/storybook/.storybook @rajsite @jattasNI @fredvisser diff --git a/change/@ni-ok-components-235da122-2e5b-42cd-a1f9-52751ccbff2b.json b/change/@ni-ok-components-235da122-2e5b-42cd-a1f9-52751ccbff2b.json new file mode 100644 index 0000000000..9ccd08540c --- /dev/null +++ b/change/@ni-ok-components-235da122-2e5b-42cd-a1f9-52751ccbff2b.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add the TestHub slider component", + "packageName": "@ni/ok-components", + "email": "198982749+Copilot@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@ni-ok-react-7f04b43d-439e-4848-b394-511e0909be4d.json b/change/@ni-ok-react-7f04b43d-439e-4848-b394-511e0909be4d.json new file mode 100644 index 0000000000..7f489ae30f --- /dev/null +++ b/change/@ni-ok-react-7f04b43d-439e-4848-b394-511e0909be4d.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add React wrapper for the TestHub slider", + "packageName": "@ni/ok-react", + "email": "198982749+Copilot@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package-lock.json b/package-lock.json index 7ceaa95fa3..4a01a1f802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33559,6 +33559,7 @@ "@ni/fast-foundation": "^10.2.5", "@ni/fast-web-utilities": "^10.0.0", "@ni/nimble-components": "^35.13.0", + "@ni/nimble-tokens": "^8.19.0", "@ni/spright-components": "^6.22.0", "tslib": "^2.2.0" }, diff --git a/packages/ok-components/package.json b/packages/ok-components/package.json index 0f8e1e7b41..5f9bd26f28 100644 --- a/packages/ok-components/package.json +++ b/packages/ok-components/package.json @@ -44,6 +44,7 @@ "@ni/fast-foundation": "^10.2.5", "@ni/fast-web-utilities": "^10.0.0", "@ni/nimble-components": "^35.13.0", + "@ni/nimble-tokens": "^8.19.0", "@ni/spright-components": "^6.22.0", "tslib": "^2.2.0" }, diff --git a/packages/ok-components/src/all-components.ts b/packages/ok-components/src/all-components.ts index b71fb0dfe4..cf4f0c5784 100644 --- a/packages/ok-components/src/all-components.ts +++ b/packages/ok-components/src/all-components.ts @@ -6,6 +6,7 @@ import '@ni/spright-components/dist/esm/all-components'; +import './th/all-th'; import './fv/all-fv'; import './ex/all-ex'; import './ts/all-ts'; diff --git a/packages/ok-components/src/th/all-th.ts b/packages/ok-components/src/th/all-th.ts new file mode 100644 index 0000000000..61d26f820f --- /dev/null +++ b/packages/ok-components/src/th/all-th.ts @@ -0,0 +1 @@ +import './slider'; diff --git a/packages/ok-components/src/th/slider/index.ts b/packages/ok-components/src/th/slider/index.ts new file mode 100644 index 0000000000..4ee03de8b6 --- /dev/null +++ b/packages/ok-components/src/th/slider/index.ts @@ -0,0 +1,111 @@ +import { attr } from '@ni/fast-element'; +import { + DesignSystem, + Slider as FoundationSlider, + type SliderOptions +} from '@ni/fast-foundation'; +import { Orientation } from '@ni/fast-web-utilities'; +import { styles } from './styles'; +import { template } from './template'; +import { ThSliderShowMinMax } from './types'; + +export { ThSliderShowMinMax }; + +interface FoundationSliderInternals { + calculateNewValue: (rawValue: number) => number; + convertToConstrainedValue: (value: number) => number; +} + +declare global { + interface HTMLElementTagNameMap { + 'ok-th-slider': ThSlider; + } +} + +/** + * A Nimble-styled slider control. + */ +export class ThSlider extends FoundationSlider { + /** + * Whether the current value is displayed next to the thumb. + */ + @attr({ attribute: 'value-visible', mode: 'boolean' }) + public valueVisible = false; + + /** + * Controls when the minimum and maximum value labels are displayed. + */ + @attr({ attribute: 'show-min-max' }) + public showMinMax: ThSliderShowMinMax = ThSliderShowMinMax.hover; + + /** @internal */ + public valueLabel?: HTMLSpanElement; + + private verticalMappingInitialized = false; + + /** @internal */ + public override connectedCallback(): void { + super.connectedCallback(); + + if (!this.verticalMappingInitialized) { + const internals = this as unknown as FoundationSliderInternals; + const calculateFoundationValue = internals.calculateNewValue; + internals.calculateNewValue = rawValue => calculateFoundationValue( + this.orientation === Orientation.vertical + ? this.trackMinHeight + this.trackHeight - rawValue + : rawValue + ); + this.verticalMappingInitialized = true; + } + + this.updateValueLabel(); + } + + /** @internal */ + public override valueChanged(previous: string, next: string): void { + super.valueChanged(previous, next); + this.updateValueLabel(); + } + + /** @internal */ + public override increment(): void { + if (this.orientation !== Orientation.vertical) { + super.increment(); + return; + } + + this.setVerticalValue(Number(this.value) + Number(this.step)); + } + + /** @internal */ + public override decrement(): void { + if (this.orientation !== Orientation.vertical) { + super.decrement(); + return; + } + + this.setVerticalValue(Number(this.value) - Number(this.step)); + } + + private setVerticalValue(value: number): void { + const internals = this as unknown as FoundationSliderInternals; + const constrainedValue = internals.convertToConstrainedValue(value); + this.value = `${Math.min(this.max, Math.max(this.min, constrainedValue))}`; + } + + private updateValueLabel(): void { + if (this.valueLabel) { + this.valueLabel.textContent = this.value; + } + } +} + +const okThSlider = ThSlider.compose({ + baseName: 'th-slider', + baseClass: FoundationSlider, + template, + styles +}); + +DesignSystem.getOrCreate().withPrefix('ok').register(okThSlider()); +export const thSliderTag = 'ok-th-slider'; diff --git a/packages/ok-components/src/th/slider/styles.ts b/packages/ok-components/src/th/slider/styles.ts new file mode 100644 index 0000000000..6d36b0291a --- /dev/null +++ b/packages/ok-components/src/th/slider/styles.ts @@ -0,0 +1,356 @@ +import { css } from '@ni/fast-element'; +import { + applicationBackgroundColor, + bodyDisabledFontColor, + bodyFont, + bodyFontColor, + borderHoverColor, + borderWidth, + fillSelectedColor, + smallPadding, + tooltipCaptionFont +} from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; +import { + Black15, + Black91, + DigitalGreenLight, + PowerGreen, + White +} from '@ni/nimble-tokens/dist/styledictionary/js/tokens'; +import { Theme } from '@ni/nimble-components/dist/esm/theme-provider/types'; +import { hexToRgbaCssColor } from '@ni/nimble-components/dist/esm/utilities/style/colors'; +import { focusVisible } from '@ni/nimble-components/dist/esm/utilities/style/focus'; +import { themeBehavior } from '@ni/nimble-components/dist/esm/utilities/style/theme'; +import { display } from '../../utilities/style/display'; + +export const styles = css` + @layer base, hover, focusVisible, active, disabled, top; + + @layer base { + ${display('inline-block')} + + :host { + width: 100%; + /* Three thumb widths allow three non-overlapping slider positions. */ + min-width: 42px; + height: 32px; + outline: none; + color: ${bodyFontColor}; + font: ${bodyFont}; + cursor: pointer; + touch-action: none; + } + + :host([orientation='vertical']) { + width: 32px; + min-width: 32px; + height: 100%; + min-height: 42px; + } + + :host([readonly]) { + cursor: default; + } + + .positioning-region { + position: relative; + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + } + + .track { + position: relative; + width: 100%; + height: 4px; + border-radius: 2px; + background-color: var(--ni-private-slider-track-background-color); + } + + .track-start { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background-color: var( + --ni-private-slider-thumb-border-selected-color + ); + } + + .thumb-container { + position: absolute; + top: 50%; + display: flex; + width: 14px; + height: 14px; + box-sizing: border-box; + align-items: center; + justify-content: center; + border: ${borderWidth} solid + var(--ni-private-slider-thumb-border-selected-color); + border-radius: 50%; + background-color: ${applicationBackgroundColor}; + transform: translate(50%, -50%); + } + + .thumb-container::after { + content: ''; + width: 10px; + height: 10px; + border-radius: 50%; + opacity: 0; + } + + .value-label { + position: absolute; + inset-block-end: calc(50% + 7px + ${smallPadding}); + display: none; + font: ${tooltipCaptionFont}; + line-height: 1; + white-space: nowrap; + pointer-events: none; + transform: translateX(50%); + } + + :host([value-visible]) .value-label { + display: block; + } + + .range-label { + position: absolute; + inset-block-start: calc(50% + 7px + ${smallPadding}); + display: none; + font: ${tooltipCaptionFont}; + line-height: 1; + white-space: nowrap; + pointer-events: none; + } + + .minimum-label { + inset-inline-start: 0; + transform: translateX(-50%); + } + + .maximum-label { + inset-inline-end: 0; + transform: translateX(50%); + } + + :host([show-min-max='always']) .range-label { + display: block; + } + + :host(:not([show-min-max]):hover) .range-label, + :host(:not([show-min-max]):active) .range-label, + :host([show-min-max='hover']:hover) .range-label, + :host([show-min-max='hover']:active) .range-label { + display: block; + } + + :host([orientation='vertical']) .positioning-region { + align-items: initial; + } + + :host([orientation='vertical']) .track { + width: 4px; + height: 100%; + } + + :host([orientation='vertical']) .track-start { + inset-block-start: auto; + inset-block-end: 0; + inset-inline: 0; + } + + :host([orientation='vertical']) .thumb-container { + top: auto; + left: 50%; + transform: translate(-50%, -50%); + } + + :host([orientation='vertical']) .value-label { + inset-block-end: auto; + inset-inline-start: calc(50% + 7px + ${smallPadding}); + transform: translateY(-50%); + } + + :host([orientation='vertical']) .range-label { + inset-inline-start: auto; + inset-inline-end: calc(50% + 7px + ${smallPadding}); + } + + :host([orientation='vertical']) .minimum-label { + inset-block: auto 0; + transform: translateY(50%); + } + + :host([orientation='vertical']) .maximum-label { + inset-block: 0 auto; + transform: translateY(-50%); + } + + ::slotted(*) { + color: ${bodyFontColor}; + font: ${bodyFont}; + } + } + + @layer hover { + :host(:hover) .thumb-container { + border: calc(${borderWidth} * 2) solid ${borderHoverColor}; + } + + :host([readonly]:hover) .thumb-container { + border: ${borderWidth} solid + var(--ni-private-slider-thumb-border-selected-color); + } + + :host([disabled]:hover) .thumb-container { + border: ${borderWidth} solid + var(--ni-private-slider-thumb-border-disabled-color); + } + } + + @layer focusVisible { + :host(${focusVisible}) .thumb-container { + border: calc(${borderWidth} * 2) solid ${borderHoverColor}; + } + + :host(${focusVisible}) .thumb-container::after { + background-color: ${fillSelectedColor}; + opacity: 1; + } + } + + @layer active { + :host(:active) .thumb-container { + border-width: calc(${borderWidth} * 2); + } + + :host(:active) .thumb-container::after { + background-color: var( + --ni-private-slider-thumb-background-active-color + ); + opacity: 1; + } + + :host([readonly]:active) .thumb-container { + border-width: ${borderWidth}; + } + + :host([readonly]:active) .thumb-container::after, + :host([disabled]:active) .thumb-container::after { + opacity: 0; + } + } + + @layer disabled { + :host([disabled]) { + color: ${bodyDisabledFontColor}; + cursor: default; + } + + :host([disabled]) .track { + background-color: var( + --ni-private-slider-track-background-disabled-color + ); + } + + :host([disabled]) .track-start { + background-color: var( + --ni-private-slider-track-background-disabled-color + ); + } + + :host([disabled]) .thumb-container { + border: ${borderWidth} solid + var(--ni-private-slider-thumb-border-disabled-color); + background-color: ${applicationBackgroundColor}; + } + + :host([disabled]) .thumb-container::after { + opacity: 0; + } + + :host([disabled]) ::slotted(*) { + color: ${bodyDisabledFontColor}; + } + } + + @layer top {} +`.withBehaviors( + themeBehavior( + Theme.light, + css` + :host { + --ni-private-slider-track-background-color: ${hexToRgbaCssColor( + Black91, + 0.1 + )}; + --ni-private-slider-track-background-disabled-color: ${hexToRgbaCssColor( + Black91, + 0.07 + )}; + --ni-private-slider-thumb-background-active-color: ${hexToRgbaCssColor( + DigitalGreenLight, + 0.3 + )}; + --ni-private-slider-thumb-border-selected-color: ${DigitalGreenLight}; + --ni-private-slider-thumb-border-disabled-color: ${hexToRgbaCssColor( + Black91, + 0.15 + )}; + } + ` + ), + themeBehavior( + Theme.dark, + css` + :host { + --ni-private-slider-track-background-color: ${hexToRgbaCssColor( + Black15, + 0.1 + )}; + --ni-private-slider-track-background-disabled-color: ${hexToRgbaCssColor( + Black15, + 0.07 + )}; + --ni-private-slider-thumb-background-active-color: ${hexToRgbaCssColor( + PowerGreen, + 0.3 + )}; + --ni-private-slider-thumb-border-selected-color: ${PowerGreen}; + --ni-private-slider-thumb-border-disabled-color: ${hexToRgbaCssColor( + Black15, + 0.15 + )}; + } + ` + ), + themeBehavior( + Theme.color, + css` + :host { + --ni-private-slider-track-background-color: ${hexToRgbaCssColor( + White, + 0.1 + )}; + --ni-private-slider-track-background-disabled-color: ${hexToRgbaCssColor( + White, + 0.07 + )}; + --ni-private-slider-thumb-background-active-color: ${hexToRgbaCssColor( + White, + 0.3 + )}; + --ni-private-slider-thumb-border-selected-color: ${White}; + --ni-private-slider-thumb-border-disabled-color: ${hexToRgbaCssColor( + White, + 0.3 + )}; + } + ` + ) +); diff --git a/packages/ok-components/src/th/slider/template.ts b/packages/ok-components/src/th/slider/template.ts new file mode 100644 index 0000000000..830ae8e392 --- /dev/null +++ b/packages/ok-components/src/th/slider/template.ts @@ -0,0 +1,50 @@ +import { html, ref } from '@ni/fast-element'; +import { Orientation } from '@ni/fast-web-utilities'; +import type { ThSlider } from '.'; + +const getPosition = (slider: ThSlider): string => ( + slider.orientation === Orientation.vertical + ? (slider.position?.replace('bottom:', 'top:') ?? '') + : slider.position +); + +export const template = html` + +`; diff --git a/packages/ok-components/src/th/slider/testing/th-slider.pageobject.ts b/packages/ok-components/src/th/slider/testing/th-slider.pageobject.ts new file mode 100644 index 0000000000..18c425832a --- /dev/null +++ b/packages/ok-components/src/th/slider/testing/th-slider.pageobject.ts @@ -0,0 +1,64 @@ +import { waitForUpdatesAsync } from '@ni/nimble-components/dist/esm/testing/async-helpers'; +import type { ThSlider } from '..'; + +/** + * Page object for ok-th-slider tests. + */ +export class ThSliderPageObject { + public constructor(private readonly sliderElement: ThSlider) {} + + public async pressKey(key: string): Promise { + this.sliderElement.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true }) + ); + await waitForUpdatesAsync(); + } + + public async mouseDown(clientX = 0, clientY = 0): Promise { + this.sliderElement.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + clientX, + clientY + }) + ); + await waitForUpdatesAsync(); + } + + public async mouseMove(clientX = 0, clientY = 0): Promise { + window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY })); + await waitForUpdatesAsync(); + } + + public getTrackStartPosition(): string { + return this.getElement('[part="track-start"]').style.cssText; + } + + public hasPart(part: string): boolean { + return !!this.sliderElement.shadowRoot?.querySelector(`[part="${part}"]`); + } + + public getThumbPosition(): string { + return this.getElement('[part="thumb-container"]').style.cssText; + } + + public getValueLabelText(): string { + return this.getElement('.value-label').textContent?.trim() ?? ''; + } + + public getRangeLabelText(className: 'minimum-label' | 'maximum-label'): string { + return this.getElement(`.${className}`).textContent?.trim() ?? ''; + } + + public getRangeLabelDisplay(className: 'minimum-label' | 'maximum-label'): string { + return getComputedStyle(this.getElement(`.${className}`)).display; + } + + private getElement(selector: string): T { + const element = this.sliderElement.shadowRoot?.querySelector(selector); + if (!element) { + throw new Error(`Expected slider element matching ${selector}`); + } + return element; + } +} diff --git a/packages/ok-components/src/th/slider/tests/th-slider.spec.ts b/packages/ok-components/src/th/slider/tests/th-slider.spec.ts new file mode 100644 index 0000000000..58bdb5b8dd --- /dev/null +++ b/packages/ok-components/src/th/slider/tests/th-slider.spec.ts @@ -0,0 +1,219 @@ +import { html } from '@ni/fast-element'; +import { waitForUpdatesAsync } from '@ni/nimble-components/dist/esm/testing/async-helpers'; +import { ThSlider, thSliderTag } from '..'; +import { ThSliderShowMinMax } from '../types'; +import { ThSliderPageObject } from '../testing/th-slider.pageobject'; +import { fixture, type Fixture } from '../../../utilities/tests/fixture'; + +async function setup( + markup = html`<${thSliderTag}>` +): Promise> { + return await fixture(markup); +} + +describe('ThSlider', () => { + let pageObject: ThSliderPageObject; + let disconnect: (() => Promise) | undefined; + + afterEach(async () => { + await disconnect?.(); + disconnect = undefined; + }); + + it('can construct an element instance', () => { + expect(document.createElement(thSliderTag)).toBeInstanceOf(ThSlider); + }); + + it('provides the FAST Foundation slider template', async () => { + const fixtureResult = await setup(); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + expect(pageObject.hasPart('track-container')).toBeTrue(); + expect(pageObject.hasPart('track-start')).toBeTrue(); + expect(pageObject.hasPart('thumb-container')).toBeTrue(); + expect(pageObject.getRangeLabelText('minimum-label')).toBe('0'); + expect(pageObject.getRangeLabelText('maximum-label')).toBe('10'); + }); + + it('sets slider accessibility attributes', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} + min="2" + max="12" + value="6" + orientation="vertical" + >` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + element.valueTextFormatter = value => `${value} units`; + await connect(); + await waitForUpdatesAsync(); + + expect(element.getAttribute('role')).toBe('slider'); + expect(element.getAttribute('aria-valuemin')).toBe('2'); + expect(element.getAttribute('aria-valuemax')).toBe('12'); + expect(element.getAttribute('aria-valuenow')).toBe('6'); + expect(element.getAttribute('aria-valuetext')).toBe('6 units'); + expect(element.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('positions a vertical slider from minimum at the bottom to maximum at the top', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} + min="2" + max="12" + value="2" + orientation="vertical" + >` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + await waitForUpdatesAsync(); + + pageObject = new ThSliderPageObject(element); + expect(pageObject.getTrackStartPosition()).toContain('top: 100%'); + expect(pageObject.getThumbPosition()).toContain('top: 100%'); + + element.value = '12'; + await waitForUpdatesAsync(); + + expect(pageObject.getTrackStartPosition()).toContain('top: 0%'); + expect(pageObject.getThumbPosition()).toContain('top: 0%'); + }); + + it('increases a vertical slider value when dragged upward', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} orientation="vertical">` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + spyOn(element.track, 'getBoundingClientRect').and.returnValue( + new DOMRect(0, 100, 4, 200) + ); + await pageObject.mouseDown(0, 300); + await pageObject.mouseMove(0, 100); + + expect(element.value).toBe('10'); + }); + + it('uses up and down arrow keys to increase and decrease a vertical slider', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} + value="5" + orientation="vertical" + >` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + await pageObject.pressKey('ArrowUp'); + expect(element.value).toBe('6'); + + await pageObject.pressKey('ArrowDown'); + expect(element.value).toBe('5'); + }); + + it('sets disabled accessibility attributes and removes tabindex', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} disabled>` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + + expect(element.getAttribute('aria-disabled')).toBe('true'); + expect(element.hasAttribute('tabindex')).toBeFalse(); + }); + + it('sets readonly accessibility attributes', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} readonly>` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + + expect(element.getAttribute('aria-readonly')).toBe('true'); + expect(element.getAttribute('tabindex')).toBe('0'); + }); + + it('can display the current value next to the thumb', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} value="4" value-visible>` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + expect(element.valueVisible).toBeTrue(); + expect(pageObject.getValueLabelText()).toBe('4'); + + element.value = '7'; + await waitForUpdatesAsync(); + expect(pageObject.getValueLabelText()).toBe('7'); + }); + + it('updates range labels when min and max change', async () => { + const fixtureResult = await setup(); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + element.min = 3; + element.max = 9; + await waitForUpdatesAsync(); + + expect(pageObject.getRangeLabelText('minimum-label')).toBe('3'); + expect(pageObject.getRangeLabelText('maximum-label')).toBe('9'); + }); + + it('defaults to showing min and max labels on hover', async () => { + const fixtureResult = await setup(); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + expect(element.showMinMax).toBe(ThSliderShowMinMax.hover); + }); + + it('can always show min and max labels', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} show-min-max="always">` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + expect(element.showMinMax).toBe(ThSliderShowMinMax.always); + expect(pageObject.getRangeLabelDisplay('minimum-label')).toBe('block'); + expect(pageObject.getRangeLabelDisplay('maximum-label')).toBe('block'); + }); + + it('can never show min and max labels', async () => { + const fixtureResult = await setup( + html`<${thSliderTag} show-min-max="never">` + ); + const { element, connect } = fixtureResult; + disconnect = fixtureResult.disconnect; + await connect(); + pageObject = new ThSliderPageObject(element); + + expect(element.showMinMax).toBe(ThSliderShowMinMax.never); + expect(pageObject.getRangeLabelDisplay('minimum-label')).toBe('none'); + expect(pageObject.getRangeLabelDisplay('maximum-label')).toBe('none'); + }); +}); diff --git a/packages/ok-components/src/th/slider/types.ts b/packages/ok-components/src/th/slider/types.ts new file mode 100644 index 0000000000..92d6620ffe --- /dev/null +++ b/packages/ok-components/src/th/slider/types.ts @@ -0,0 +1,7 @@ +export const ThSliderShowMinMax = { + always: 'always', + hover: 'hover', + never: 'never' +} as const; + +export type ThSliderShowMinMax = (typeof ThSliderShowMinMax)[keyof typeof ThSliderShowMinMax]; \ No newline at end of file diff --git a/packages/react-workspace/ok-react/src/th/slider/index.ts b/packages/react-workspace/ok-react/src/th/slider/index.ts new file mode 100644 index 0000000000..7b66c493e7 --- /dev/null +++ b/packages/react-workspace/ok-react/src/th/slider/index.ts @@ -0,0 +1,18 @@ +'use client'; + +import { + ThSlider, + thSliderTag +} from '@ni/ok-components/dist/esm/th/slider'; +import { wrap, type EventName } from '../../utilities/react-wrapper'; + +export { thSliderTag }; +export { type ThSlider }; +export const OkThSlider = wrap(ThSlider, { + events: { + onChange: 'change' as EventName + } +}); +export interface ThSliderChangeEvent extends CustomEvent { + target: ThSlider; +} diff --git a/packages/storybook/src/ok/th/slider/th-slider-matrix.stories.ts b/packages/storybook/src/ok/th/slider/th-slider-matrix.stories.ts new file mode 100644 index 0000000000..1721d63324 --- /dev/null +++ b/packages/storybook/src/ok/th/slider/th-slider-matrix.stories.ts @@ -0,0 +1,71 @@ +import type { Meta, StoryFn } from '@storybook/html-vite'; +import { html, type ViewTemplate } from '@ni/fast-element'; +import { thSliderTag } from '@ni/ok-components/dist/esm/th/slider'; +import { + cartesianProduct, + createMatrix, + createMatrixInteractionsFromStates, + createMatrixThemeStory, + sharedMatrixParameters +} from '../../../utilities/matrix'; + +const metadata: Meta = { + title: 'Tests Ok/Th Slider', + parameters: { + ...sharedMatrixParameters() + } +}; + +export default metadata; + +type SliderState = readonly [string, boolean, boolean]; +type OrientationState = readonly [string, 'horizontal' | 'vertical']; + +const states: SliderState[] = [ + ['Enabled', false, false], + ['Readonly', false, true], + ['Disabled', true, false] +]; + +const orientationStates: OrientationState[] = [ + ['Horizontal', 'horizontal'], + ['Vertical', 'vertical'] +]; + +const sliderSize = '200px'; + +const interactionStates = cartesianProduct([orientationStates, states] as const); + +const getSliderStyle = (orientation: OrientationState[1]): string => ( + orientation === 'horizontal' ? `width: ${sliderSize};` : `height: ${sliderSize};` +); + +const component = ( + [orientationName, orientation]: OrientationState, + [stateName, disabled, readOnly]: SliderState +): ViewTemplate => html` +
+ ${() => `${orientationName} ${stateName}`} + <${thSliderTag} + style="${() => getSliderStyle(orientation)}" + value="4" + orientation="${() => orientation}" + ?disabled="${() => disabled}" + ?readonly="${() => readOnly}" + value-visible + > +
+`; + +export const themeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component, [orientationStates, states]) +); + +export const interactionsThemeMatrix: StoryFn = createMatrixThemeStory( + createMatrixInteractionsFromStates(component, { + hover: interactionStates, + hoverActive: interactionStates, + active: interactionStates, + focus: interactionStates + }) +); diff --git a/packages/storybook/src/ok/th/slider/th-slider.mdx b/packages/storybook/src/ok/th/slider/th-slider.mdx new file mode 100644 index 0000000000..2fea884c72 --- /dev/null +++ b/packages/storybook/src/ok/th/slider/th-slider.mdx @@ -0,0 +1,18 @@ +import { Canvas, Controls, Meta, Title } from '@storybook/addon-docs/blocks'; +import ComponentApisLink from '../../../docs/component-apis-link.mdx'; +import * as sliderStories from './th-slider.stories'; + + + + +A Nimble-styled TestHub slider based on the FAST Foundation slider. +Set `value-visible` to display a live value tooltip beside the thumb as it +moves. + +<Canvas of={sliderStories.slider} /> + +## API + +<Controls of={sliderStories.slider} /> + +<ComponentApisLink /> diff --git a/packages/storybook/src/ok/th/slider/th-slider.stories.ts b/packages/storybook/src/ok/th/slider/th-slider.stories.ts new file mode 100644 index 0000000000..3c0a7ea993 --- /dev/null +++ b/packages/storybook/src/ok/th/slider/th-slider.stories.ts @@ -0,0 +1,121 @@ +import type { HtmlRenderer, Meta, StoryObj } from '@storybook/html-vite'; +import { html } from '@ni/fast-element'; +import { + thSliderTag, + ThSliderShowMinMax, + type ThSliderShowMinMax as ThSliderShowMinMaxType +} from '@ni/ok-components/dist/esm/th/slider'; +import { withActions } from 'storybook/actions/decorator'; +import { + apiCategory, + createUserSelectedThemeStory, + disabledDescription, + okWarning +} from '../../../utilities/storybook'; + +interface SliderArgs { + value: number; + min: number; + max: number; + step: number; + orientation: 'horizontal' | 'vertical'; + disabled: boolean; + readOnly: boolean; + valueVisible: boolean; + showMinMax: ThSliderShowMinMaxType; + change?: (event: Event) => void; +} + +const getSliderStyle = (orientation: SliderArgs['orientation']): string => ( + orientation === 'horizontal' ? 'width: 200px;' : 'height: 200px;' +); + +const metadata: Meta<SliderArgs> = { + title: 'Ok/Th Slider', + decorators: [withActions<HtmlRenderer>], + parameters: { + actions: { + handles: ['change'] + } + }, + render: createUserSelectedThemeStory(html<SliderArgs>` + ${okWarning({ + componentName: 'Th Slider', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} + <${thSliderTag} + style="${x => getSliderStyle(x.orientation)}" + value="${x => x.value}" + min="${x => x.min}" + max="${x => x.max}" + step="${x => x.step}" + orientation="${x => x.orientation}" + ?disabled="${x => x.disabled}" + ?readonly="${x => x.readOnly}" + ?value-visible="${x => x.valueVisible}" + show-min-max="${x => x.showMinMax}" + ></${thSliderTag}> + `), + argTypes: { + value: { + control: { type: 'number' }, + table: { category: apiCategory.attributes } + }, + min: { + control: { type: 'number' }, + table: { category: apiCategory.attributes } + }, + max: { + control: { type: 'number' }, + table: { category: apiCategory.attributes } + }, + step: { + control: { type: 'number' }, + table: { category: apiCategory.attributes } + }, + orientation: { + options: ['horizontal', 'vertical'], + control: { type: 'radio' }, + table: { category: apiCategory.attributes } + }, + disabled: { + description: disabledDescription({ componentName: 'slider' }), + table: { category: apiCategory.attributes } + }, + readOnly: { + name: 'readonly', + table: { category: apiCategory.attributes } + }, + valueVisible: { + name: 'value-visible', + description: 'Displays the current value next to the slider thumb.', + table: { category: apiCategory.attributes } + }, + showMinMax: { + name: 'show-min-max', + description: 'Controls when the minimum and maximum value labels are displayed.', + options: Object.values(ThSliderShowMinMax), + control: { type: 'radio' }, + table: { category: apiCategory.attributes } + }, + change: { + table: { category: apiCategory.events }, + control: false + } + }, + args: { + value: 40, + min: 0, + max: 100, + step: 1, + orientation: 'horizontal', + disabled: false, + readOnly: false, + valueVisible: true, + showMinMax: ThSliderShowMinMax.hover + } +}; + +export default metadata; + +export const slider: StoryObj<SliderArgs> = {};