diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 2b3a832dd4..a387a8334e 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -833,11 +833,12 @@ export namespace Components { "helperText": string; "invalid": boolean; "label": string; + "language": Languages; "readonly": boolean; "required": boolean; "step": number; "unit": string; - "value": number; + "value": number | null; "valuemax": number; "valuemin": number; } @@ -3401,12 +3402,13 @@ export namespace JSX { "helperText"?: string; "invalid"?: boolean; "label"?: string; - "onChange"?: (event: LimelSliderCustomEvent) => void; + "language"?: Languages; + "onChange"?: (event: LimelSliderCustomEvent) => void; "readonly"?: boolean; "required"?: boolean; "step"?: number; "unit"?: string; - "value"?: number; + "value"?: number | null; "valuemax"?: number; "valuemin"?: number; } @@ -3426,6 +3428,8 @@ export namespace JSX { // (undocumented) "label": string; // (undocumented) + "language": Languages; + // (undocumented) "readonly": boolean; // (undocumented) "required": boolean; @@ -3434,7 +3438,7 @@ export namespace JSX { // (undocumented) "unit": string; // (undocumented) - "value": number; + "value": number | null; // (undocumented) "valuemax": number; // (undocumented) diff --git a/src/components/chip-set/examples/chip-set-progress.tsx b/src/components/chip-set/examples/chip-set-progress.tsx index 42a3733297..7bef33bbf7 100644 --- a/src/components/chip-set/examples/chip-set-progress.tsx +++ b/src/components/chip-set/examples/chip-set-progress.tsx @@ -49,8 +49,9 @@ export class ChipSetProgressExample { ); } - private setProgress = (event: CustomEvent) => { + private setProgress = (event: CustomEvent) => { event.stopPropagation(); - this.progress = event.detail; + // The slider is clearable, so it emits `null` when unset. + this.progress = event.detail ?? 0; }; } diff --git a/src/components/chip/examples/chip-progress.tsx b/src/components/chip/examples/chip-progress.tsx index 8a9125891f..45866b64b0 100644 --- a/src/components/chip/examples/chip-progress.tsx +++ b/src/components/chip/examples/chip-progress.tsx @@ -60,7 +60,8 @@ export class ChipProgressExample { ]; } - private handleChange = (event: CustomEvent) => { - this.progress = event.detail; + private handleChange = (event: CustomEvent) => { + // The slider is clearable, so it emits `null` when unset. + this.progress = event.detail ?? 0; }; } diff --git a/src/components/file/examples/file-per-file-progress.tsx b/src/components/file/examples/file-per-file-progress.tsx index 379a1ddda3..037bcd5fba 100644 --- a/src/components/file/examples/file-per-file-progress.tsx +++ b/src/components/file/examples/file-per-file-progress.tsx @@ -71,8 +71,9 @@ export class FilePerFileProgressExample { this.file = event.detail; }; - private setProgress = (event: CustomEvent) => { + private setProgress = (event: CustomEvent) => { event.stopPropagation(); - this.progress = event.detail; + // The slider is clearable, so it emits `null` when unset. + this.progress = event.detail ?? 0; }; } diff --git a/src/components/file/examples/file-per-file-status.tsx b/src/components/file/examples/file-per-file-status.tsx index 333bd032ea..d6cfbb3931 100644 --- a/src/components/file/examples/file-per-file-status.tsx +++ b/src/components/file/examples/file-per-file-status.tsx @@ -89,8 +89,9 @@ export class FilePerFileStatusExample { this.statusText = event.detail; }; - private setProgress = (event: CustomEvent) => { + private setProgress = (event: CustomEvent) => { event.stopPropagation(); - this.progress = event.detail; + // The slider is clearable, so it emits `null` when unset. + this.progress = event.detail ?? 0; }; } diff --git a/src/components/file/examples/file-resize-image.tsx b/src/components/file/examples/file-resize-image.tsx index 6c5786cd0e..3d5485c2f8 100644 --- a/src/components/file/examples/file-resize-image.tsx +++ b/src/components/file/examples/file-resize-image.tsx @@ -124,7 +124,11 @@ export class FileResizeImageExample { /> ) => { + private handleQualityChange = (event: CustomEvent) => { event.stopPropagation(); + + // A cleared slider means "not set", like the dimension fields above: + // omit `quality` so the browser's native encoding quality is used + // rather than an imposed one. + if (event.detail === null) { + this.updateOption('quality', undefined); + + return; + } + const quality = Math.max(0, Math.min(1, event.detail / 100)); this.updateOption('quality', quality); }; diff --git a/src/components/form/widgets/slider.ts b/src/components/form/widgets/slider.ts index 119433e04c..f899841646 100644 --- a/src/components/form/widgets/slider.ts +++ b/src/components/form/widgets/slider.ts @@ -39,7 +39,7 @@ export class Slider extends React.Component { }); } - private handleChange(event: CustomEvent) { + private handleChange(event: CustomEvent) { const props = this.props; event.stopPropagation(); diff --git a/src/components/slider/examples/slider-basic.tsx b/src/components/slider/examples/slider-basic.tsx index 2a2d5fa1b5..236b0e192c 100644 --- a/src/components/slider/examples/slider-basic.tsx +++ b/src/components/slider/examples/slider-basic.tsx @@ -15,7 +15,10 @@ export class SliderBasicExample { private readonly = false; @State() - private value = 25; + private required = false; + + @State() + private value: number | null = 25; private minValue = 15; private maxValue = 75; @@ -30,6 +33,7 @@ export class SliderBasicExample { valuemin={this.minValue} disabled={this.disabled} readonly={this.readonly} + required={this.required} onChange={this.handleChange} />, @@ -43,12 +47,17 @@ export class SliderBasicExample { label="Readonly" onChange={this.setReadonly} /> + , , ]; } - private handleChange = (event: CustomEvent) => { + private handleChange = (event: CustomEvent) => { this.value = event.detail; }; @@ -61,4 +70,9 @@ export class SliderBasicExample { event.stopPropagation(); this.readonly = event.detail; }; + + private readonly setRequired = (event: CustomEvent) => { + event.stopPropagation(); + this.required = event.detail; + }; } diff --git a/src/components/slider/examples/slider-unset.tsx b/src/components/slider/examples/slider-unset.tsx new file mode 100644 index 0000000000..f3f4bbb19e --- /dev/null +++ b/src/components/slider/examples/slider-unset.tsx @@ -0,0 +1,50 @@ +import { Component, h, Host, State } from '@stencil/core'; + +/** + * Unsetting the value + * + * This slider is initialized *unset*, which means its `value` is `null`. + * Therefore the thumb rests in the middle, and the value indicator shows a + * left-right arrow (`↔`) instead of a number. + * Assistive technologies announce the value as "Value not set". + * + * As soon as the user drags the thumb, presses anywhere on the track, or + * nudges the thumb with the arrow keys, the slider becomes set and the + * trailing **clear** button becomes active. + * Pressing it unsets the slider again, emitting `null` on the `change` + * event — so a handler must accept `number | null`. + * + * A `required` slider does not offer the clear button — a required value + * cannot be unset — but it can still start unset to prompt a first choice. + * + * To unset the slider programmatically, set its `value` to `null`. Any other + * value that is not a finite number — `undefined`, or `NaN` — works too. + */ +@Component({ + tag: 'limel-example-slider-unset', + shadow: true, +}) +export class SliderUnsetExample { + @State() + private value: number | null = null; + + public render() { + return ( + + + + + ); + } + + private readonly handleChange = (event: CustomEvent) => { + this.value = event.detail; + }; +} diff --git a/src/components/slider/partial-styles/_thumb.scss b/src/components/slider/partial-styles/_thumb.scss index 75fd36f811..4cf7183f0a 100644 --- a/src/components/slider/partial-styles/_thumb.scss +++ b/src/components/slider/partial-styles/_thumb.scss @@ -86,8 +86,10 @@ border-radius: 1.25rem; padding: 0 0.375rem; height: 1.25rem; + min-width: 1.25rem; display: flex; align-items: center; + justify-content: center; white-space: nowrap; color: rgb(var(--color-white)); diff --git a/src/components/slider/slider.scss b/src/components/slider/slider.scss index e23b726c0a..226b7efed6 100644 --- a/src/components/slider/slider.scss +++ b/src/components/slider/slider.scss @@ -4,6 +4,10 @@ @forward './partial-styles/thumb'; @use '../../style/internal/shared_input-select-picker'; +$size-of-clear-value-button: 1rem; +$gap-of-clear-value-button: 0.25rem; +$color-of-track: rgba(var(--contrast-700), 0.6); + *, *:before, *:after { @@ -84,7 +88,7 @@ input[type='range'] { transform: translateY(-50%); height: 0.5rem; border-radius: 1rem; - background-color: rgba(var(--contrast-700), 0.6); + background-color: $color-of-track; &:before, &:after { @@ -99,7 +103,7 @@ input[type='range'] { width: 0.375rem; height: 0.375rem; border-radius: 50%; - background-color: rgba(var(--contrast-700), 0.6); + background-color: $color-of-track; } &:before { @@ -156,4 +160,83 @@ input[type='range'] { } } +:host(.has-clear-button) { + div[slot='content'] { + padding-right: calc( + #{$size-of-clear-value-button} + #{$gap-of-clear-value-button} + ); + } +} + +:host(.is-unset) { + // When the slider is unset, the thumb is centered and the track shows no + // fill, so it doesn't read as a real selection (the left-right arrow + // indicator — see the TSX — replaces the number). The native input's value + // still rests at the step-aligned midpoint (see `getRestingDisplayValue`) + // so both arrow-key directions are live; the thumb is centered here with + // `left: 50%` because a midpoint that isn't a whole step (e.g. a 4-stop + // range) would otherwise sit slightly off-center. + .track .active { + width: 0; + } + + .thumb { + left: 50%; + } +} + +button.clear-button { + @include mixins.reset-button-user-agent-styles; + @include mixins.visualize-keyboard-focus; + + &:not([disabled]) { + @include mixins.is-flat-clickable( + $color: rgb(var(--contrast-1100)), + $color--hovered: rgb(var(--contrast-100)), + $background-color: $color-of-track, + $background-color--hovered: rgb(var(--contrast-1000)) + ); + + &:focus-visible { + background-color: rgb(var(--contrast-1000)); + } + } + + position: absolute; + // Since helper text can change the height of the component + // we need to be specific about `inset` `top` + inset: 0.75rem 0 0 auto; + + display: flex; + align-items: center; + justify-content: center; + + height: $size-of-clear-value-button; + width: $size-of-clear-value-button; + border-radius: 50%; + + svg { + width: calc(#{$size-of-clear-value-button} - 0.25rem); + height: calc(#{$size-of-clear-value-button} - 0.25rem); + fill: currentColor; + } + + &:before { + content: ''; + position: absolute; + inset: 0 auto 0 0; + margin: auto; + height: 0.125rem; + width: $gap-of-clear-value-button; + background-color: $color-of-track; + transform: translate(-100%, 0); + } + + &[disabled] { + color: rgb(var(--contrast-1000)); + background-color: rgb(var(--contrast-600)); + cursor: not-allowed; + } +} + @include mixins.hide-helper-line-when-not-needed(limel-slider); diff --git a/src/components/slider/slider.spec.tsx b/src/components/slider/slider.spec.tsx new file mode 100644 index 0000000000..6d9109aede --- /dev/null +++ b/src/components/slider/slider.spec.tsx @@ -0,0 +1,451 @@ +import { render, h } from '@stencil/vitest'; +import { vi } from 'vitest'; + +describe('limel-slider — unset & clear', () => { + async function setup(props: Record = {}) { + const { root, waitForChanges } = await render( + + ); + await waitForChanges(); + + return { root, waitForChanges }; + } + + const indicator = (root: any): HTMLElement => + root.shadowRoot?.querySelector('.indicator'); + const rangeInput = (root: any): HTMLInputElement => + root.shadowRoot?.querySelector('input[type="range"]'); + const clearButton = (root: any): HTMLButtonElement => + root.shadowRoot?.querySelector('button.clear-button'); + + describe('unset state', () => { + it('treats a null value as unset', async () => { + const { root } = await setup({ + label: 'Priority', + value: null, + valuemin: 1, + valuemax: 5, + }); + + expect(root.classList.contains('is-unset')).toBe(true); + expect(indicator(root).textContent).toBe('\u2194\uFE0E'); + expect(rangeInput(root).getAttribute('aria-valuetext')).toBe( + 'Value not set' + ); + }); + + it.each([ + ['NaN', Number.NaN], + ['undefined', undefined], + ])('also treats %s as unset', async (_name, value) => { + const { root } = await setup({ value, valuemin: 1, valuemax: 5 }); + + expect(root.classList.contains('is-unset')).toBe(true); + }); + + it('rests the thumb at the midpoint while unset, not at the minimum', async () => { + const { root } = await setup({ + value: null, + valuemin: 0, + valuemax: 10, + }); + + // The native input sits at the midpoint (not the minimum), so both + // arrow-key directions are live and there is no jump on first use. + expect(rangeInput(root).value).toBe('5'); + }); + + it('treats a finite value, including 0, as set', async () => { + const { root } = await setup({ + value: 0, + valuemin: -10, + valuemax: 10, + }); + + expect(root.classList.contains('is-unset')).toBe(false); + expect(indicator(root).textContent).toBe('0'); + expect(rangeInput(root).getAttribute('aria-valuetext')).toBeNull(); + }); + + it('becomes unset again when the value is reset to null', async () => { + const { root, waitForChanges } = await setup({ + value: 3, + valuemin: 1, + valuemax: 5, + }); + expect(root.classList.contains('is-unset')).toBe(false); + + (root as any).value = null; + await waitForChanges(); + + expect(root.classList.contains('is-unset')).toBe(true); + expect(indicator(root).textContent).toBe('\u2194\uFE0E'); + }); + + it('leaves the unset state as soon as the user changes the value', async () => { + const { root, waitForChanges } = await setup({ + value: null, + valuemin: 1, + valuemax: 5, + step: 1, + }); + expect(root.classList.contains('is-unset')).toBe(true); + + const input = rangeInput(root); + input.value = '3'; + input.dispatchEvent(new Event('input')); + await waitForChanges(); + + expect(root.classList.contains('is-unset')).toBe(false); + expect(indicator(root).textContent).toBe('3'); + }); + }); + + describe('clear button', () => { + it('labels the button with the slider label, falling back to a generic label', async () => { + const withLabel = await setup({ label: 'Priority', value: 3 }); + expect(clearButton(withLabel.root).getAttribute('aria-label')).toBe( + 'Clear value of Priority' + ); + + const withoutLabel = await setup({ value: 3 }); + expect( + clearButton(withoutLabel.root).getAttribute('aria-label') + ).toBe('Clear value'); + }); + + it('disables the clear button while unset, and enables it once set', async () => { + const unset = await setup({ + label: 'Priority', + value: null, + valuemin: 1, + valuemax: 5, + }); + expect(clearButton(unset.root).hasAttribute('disabled')).toBe(true); + + const set = await setup({ + label: 'Priority', + value: 3, + valuemin: 1, + valuemax: 5, + }); + expect(clearButton(set.root).hasAttribute('disabled')).toBe(false); + }); + + it('offers no clear button for required or readonly sliders', async () => { + const required = await setup({ + label: 'Priority', + required: true, + value: 3, + }); + expect(clearButton(required.root)).toBeNull(); + + const readonly = await setup({ + label: 'Priority', + readonly: true, + value: 3, + }); + expect(clearButton(readonly.root)).toBeNull(); + }); + + it('marks the host whenever the clear button is rendered', async () => { + // The stylesheet reserves room for the button off this class, so + // it has to track the button itself rather than the attributes. + const clearable = await setup({ value: 3 }); + expect(clearButton(clearable.root)).not.toBeNull(); + expect(clearable.root.classList.contains('has-clear-button')).toBe( + true + ); + + for (const props of [{ required: true }, { readonly: true }]) { + const { root } = await setup({ value: 3, ...props }); + expect(clearButton(root)).toBeNull(); + expect(root.classList.contains('has-clear-button')).toBe(false); + } + }); + + it('keeps the button and the reserved room in step for required="false"', async () => { + // Stencil parses the attribute string "false" into `false`, so the + // button is rendered — a stylesheet matching on `[required]` would + // reserve nothing for it. Vue templates emit exactly this. + const { root } = await render( + + ); + + expect(clearButton(root)).not.toBeNull(); + expect(root.classList.contains('has-clear-button')).toBe(true); + }); + }); + + describe('clearing', () => { + it('emits change with null and enters the unset state', async () => { + const { root, waitForChanges } = await setup({ + label: 'Priority', + value: 3, + valuemin: 1, + valuemax: 5, + }); + + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + clearButton(root).click(); + await waitForChanges(); + + expect(details).toHaveLength(1); + expect(details[0]).toBeNull(); + expect(root.classList.contains('is-unset')).toBe(true); + }); + + it('moves focus to the slider input when cleared', async () => { + const { root, waitForChanges } = await setup({ + label: 'Priority', + value: 3, + valuemin: 1, + valuemax: 5, + }); + + const focusSpy = vi.spyOn(rangeInput(root), 'focus'); + + clearButton(root).click(); + await waitForChanges(); + + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe('setting a value by pressing on the track', () => { + // While unset the native input already rests at the midpoint, so a + // press that lands on that same value changes nothing and the browser + // fires neither `input` nor `change` — only `click`. These tests drive + // that sequence, since a synthetic `input` event would hide the bug. + async function setupUnset() { + const context = await setup({ + value: null, + valuemin: 1, + valuemax: 5, + step: 1, + }); + const details: Array = []; + context.root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + // The thumb rests at the step-aligned midpoint of 1–5. + expect(rangeInput(context.root).value).toBe('3'); + expect(context.root.classList.contains('is-unset')).toBe(true); + + return { ...context, details }; + } + + it('sets the value when the press lands on the resting position', async () => { + const { root, waitForChanges, details } = await setupUnset(); + + rangeInput(root).dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([3]); + expect(root.classList.contains('is-unset')).toBe(false); + expect(indicator(root).textContent).toBe('3'); + }); + + it('sets the value when a drag returns to the resting position', async () => { + const { root, waitForChanges, details } = await setupUnset(); + const input = rangeInput(root); + + // Drag away from the midpoint and back before releasing. `input` + // fires along the way, but the released value equals the value the + // drag started at, so the browser fires no `change`. + input.value = '5'; + input.dispatchEvent(new Event('input')); + input.value = '3'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([3]); + expect(root.classList.contains('is-unset')).toBe(false); + }); + + it('emits change only once when the press does move the value', async () => { + const { root, waitForChanges, details } = await setupUnset(); + const input = rangeInput(root); + + input.value = '5'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('change')); + input.dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([5]); + }); + + it('does not emit when a press on an already set slider changes nothing', async () => { + const { root, waitForChanges } = await setup({ + value: 3, + valuemin: 1, + valuemax: 5, + step: 1, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + rangeInput(root).dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([]); + }); + + it('keeps the value on a step when the range does not start on one', async () => { + // A 1–5 range in steps of 2 stops at 1, 3 and 5. Steps count from + // `valuemin`, so none of those is a multiple of the step itself. + const { root, waitForChanges } = await setup({ + value: null, + valuemin: 1, + valuemax: 5, + step: 2, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + const input = rangeInput(root); + expect(input.value).toBe('3'); + + input.dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([3]); + expect(indicator(root).textContent).toBe('3'); + }); + + it('emits the stops of an offset range unchanged', async () => { + const { root, waitForChanges } = await setup({ + value: 1, + valuemin: 1, + valuemax: 5, + step: 2, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + const input = rangeInput(root); + for (const stop of ['1', '3', '5']) { + input.value = stop; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('change')); + } + + await waitForChanges(); + + // Never rounded up past `valuemax`. + expect(details).toEqual([1, 3, 5]); + }); + + it('rests at the factored midpoint and emits the unfactored value', async () => { + // `form/widgets/slider.ts` sets factor 100 for percent schemas, so + // the native input works in whole percent while `change` carries + // the 0-1 fraction back out. + const { root, waitForChanges } = await setup({ + value: null, + valuemin: 0, + valuemax: 1, + step: 0.1, + factor: 100, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + expect(root.classList.contains('is-unset')).toBe(true); + expect(rangeInput(root).value).toBe('50'); + + rangeInput(root).dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toEqual([0.5]); + expect(indicator(root).textContent).toBe('50'); + }); + + it('clears a factored slider back to null', async () => { + const { root, waitForChanges } = await setup({ + label: 'Probability', + value: 0.3, + valuemin: 0, + valuemax: 1, + step: 0.1, + factor: 100, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + expect(indicator(root).textContent).toBe('30'); + + clearButton(root).click(); + await waitForChanges(); + + expect(details).toEqual([null]); + expect(root.classList.contains('is-unset')).toBe(true); + // The step-aligned midpoint of 0-100 in steps of 10. + expect(rangeInput(root).value).toBe('50'); + }); + + it('emits again after the slider is cleared and pressed', async () => { + const { root, waitForChanges } = await setup({ + label: 'Priority', + value: 5, + valuemin: 1, + valuemax: 5, + step: 1, + }); + const details: Array = []; + root.addEventListener( + 'change', + (event: CustomEvent) => { + details.push(event.detail); + } + ); + + clearButton(root).click(); + await waitForChanges(); + rangeInput(root).dispatchEvent(new Event('click')); + await waitForChanges(); + + expect(details).toHaveLength(2); + expect(details[0]).toBeNull(); + expect(details[1]).toBe(3); + expect(root.classList.contains('is-unset')).toBe(false); + }); + }); +}); diff --git a/src/components/slider/slider.tsx b/src/components/slider/slider.tsx index 9f24b222df..6f3aa2b7f6 100644 --- a/src/components/slider/slider.tsx +++ b/src/components/slider/slider.tsx @@ -10,14 +10,29 @@ import { } from '@stencil/core'; import { getPercentageClass } from './get-percentage-class'; import { createRandomString } from '../../util/random-string'; +import translate from '../../global/translations'; +import { Languages } from '../date-picker/date.types'; const DEFAULT_FACTOR = 1; const DEFAULT_MAX_VALUE = 100; const DEFAULT_MIN_VALUE = 0; const MAX_VISIBLE_STEP_DOTS = 20; +/** + * Whether the slider holds a value at all. `Number.isFinite` is typed + * `(number: unknown) => boolean`, so it tests the right thing but narrows + * nothing; this does, which lets the unset check double as proof that what + * remains is a number the arithmetic can use. `null`, `undefined` and `NaN` + * all mean unset. + * @param value - the slider's `value` prop, which may not be a number. + */ +const isSetValue = (value: unknown): value is number => { + return Number.isFinite(value); +}; + /** * @exampleComponent limel-example-slider-basic + * @exampleComponent limel-example-slider-unset * @exampleComponent limel-example-slider-multiplier * @exampleComponent limel-example-slider-multiplier-percentage-colors * @exampleComponent limel-example-slider-unit @@ -69,6 +84,9 @@ export class Slider { /** * Set to `true` to indicate that the slider is required. + * A required slider does not offer the clear button, since a required + * value cannot be unset. It can still be initialized unset (with a + * non-finite `value`) to prompt the user to make a choice. */ @Prop({ reflect: true }) public required = false; @@ -94,10 +112,19 @@ export class Slider { public unit: string = ''; /** - * The value of the input + * Defines the language for translations of the accessible labels. + */ + @Prop({ reflect: true }) + public language: Languages = 'en'; + + /** + * The value of the input. Set it to `null` to leave the slider unset, + * which is also what the `change` event emits once the value is cleared. + * Any other value that is not a finite number — `undefined`, or `NaN` — + * unsets the slider too. */ @Prop({ reflect: true }) - public value: number; + public value: number | null; /** * The maximum value allowed @@ -118,28 +145,55 @@ export class Slider { public step: number; /** - * Emitted when the value has been changed + * Emitted when the value has been changed. + * Emits `null` when the value has been cleared and the slider becomes + * unset, so handlers must account for a value that is not a number. */ @Event() - private change: EventEmitter; + private change: EventEmitter; @State() - private percentageClass: string; + private percentageClass: string | undefined; @State() private displayValue: number; + /** + * `true` while the slider has no value set. Kept separate from the `value` + * prop so that the unset state survives the brief window during dragging + * where the value has not yet been emitted back to the consumer. + */ + @State() + private isUnset: boolean; + private labelId: string; private helperTextId: string; + private readonly clearButtonId: string; + private inputElement?: HTMLInputElement; + + /** + * `true` while the value on display is one the consumer has been told + * about — either it came from the `value` prop, or it has been emitted on + * `change`. It is `false` from the moment the slider becomes unset until a + * value is committed again. + * + * The native input always holds a value, so while unset it rests at the + * midpoint. A press that lands on that midpoint, or a drag that returns to + * it before releasing, leaves the input's value untouched and fires neither + * `input` nor `change`. Without this flag such an interaction is swallowed: + * the slider either stays unset, or shows a number the consumer never + * received. + */ + private valueIsCommitted = false; public constructor() { this.labelId = createRandomString(); this.helperTextId = createRandomString(); + this.clearButtonId = createRandomString(); } public componentWillLoad() { - this.displayValue = this.multiplyByFactor(this.getValue()); - this.setPercentageClass(this.getValue()); + this.syncStateFromValue(); } public render() { @@ -165,7 +219,7 @@ export class Slider { invalid={this.invalid} disabled={this.disabled} readonly={this.readonly} - hasValue={!!this.value} + hasValue={!this.isUnset} hasFloatingLabel={true} >
@@ -184,8 +238,20 @@ export class Slider { ? this.helperTextId : undefined } + aria-valuetext={ + this.isUnset + ? translate.get( + 'value-not-set', + this.language + ) + : undefined + } + ref={(el?: HTMLInputElement) => { + this.inputElement = el; + }} onInput={this.handleInput} onChange={this.handleChange} + onClick={this.handleClick} {...inputProps} />
@@ -195,7 +261,9 @@ export class Slider {
@@ -211,6 +279,7 @@ export class Slider {
+ {this.renderClearButton()} {this.renderHelperLine()} ); @@ -218,10 +287,31 @@ export class Slider { @Watch('value') protected watchValue() { - this.displayValue = this.multiplyByFactor(this.getValue()); - this.setPercentageClass(this.getValue()); + this.syncStateFromValue(); } + private readonly syncStateFromValue = () => { + const value = this.value; + + if (!isSetValue(value)) { + this.enterUnsetState(); + + return; + } + + this.isUnset = false; + this.valueIsCommitted = true; + this.displayValue = this.multiplyByFactor(value); + this.setPercentageClass(value); + }; + + private readonly enterUnsetState = () => { + this.isUnset = true; + this.valueIsCommitted = false; + this.displayValue = this.getRestingDisplayValue(); + this.percentageClass = undefined; + }; + private renderStepDots = (min: number, max: number) => { if (!this.step) { return; @@ -250,10 +340,50 @@ export class Slider { ); }; + /** + * A required slider must hold a value, so it offers no way to clear it, + * and a readonly one cannot be edited at all. The styling needs the same + * answer to reserve room for the button, so both read it from here rather + * than each deriving it — the stylesheet cannot see a parsed prop, only + * the attribute string, and `required="false"` is a present attribute. + */ + private readonly hasClearButton = (): boolean => { + return !this.readonly && !this.required; + }; + + private readonly renderClearButton = () => { + if (!this.hasClearButton()) { + return; + } + + const label = this.label + ? translate.get('clear-value-of', this.language, { + label: this.label, + }) + : translate.get('clear-value', this.language); + + return ( + + ); + }; + private handleInput = (event: Event) => { event.stopPropagation(); const input = event.target as HTMLInputElement; const value = Number(input.value); + this.isUnset = false; this.displayValue = value; this.setPercentageClass(value / this.factor); }; @@ -261,17 +391,67 @@ export class Slider { private handleChange = (event: Event) => { event.stopPropagation(); const input = event.target as HTMLInputElement; - let value = Number(input.value); + this.commitValue(Number(input.value)); + }; + + private readonly handleClick = (event: MouseEvent) => { + if (this.valueIsCommitted) { + return; + } + + // The interaction is over and the value on display still hasn't + // reached the consumer, so the native input never fired a `change`. + // Commit what it holds. The click is left to bubble, so consumers + // listening for clicks still see it. + const input = event.target as HTMLInputElement; + this.commitValue(Number(input.value)); + }; + + /** + * Leaves the unset state and emits the value, keeping the rendered state + * and the emitted value in step so the two cannot diverge. + * @param displayValue - the value held by the native input, already + * multiplied by `factor`. + */ + private readonly commitValue = (displayValue: number) => { const step = this.multiplyByFactor(this.step); + const min = this.multiplyByFactor(this.valuemin); + let value = displayValue; - if (!this.isMultipleOfStep(value, step)) { - value = this.roundToStep(value, step); + // Steps are counted from `valuemin`, not from zero — a range of 1–5 in + // steps of 2 stops at 1, 3 and 5. Rounding against zero would push + // every one of those to the next even number, past `valuemax`. + if (!this.isMultipleOfStep(value - min, step)) { + value = min + this.roundToStep(value - min, step); } + this.valueIsCommitted = true; + this.isUnset = false; + this.displayValue = value; + this.setPercentageClass(value / this.factor); this.change.emit(value / this.factor); }; + private readonly handleClear = (event: MouseEvent) => { + event.stopPropagation(); + this.enterUnsetState(); + this.change.emit(null); + + // Move focus to the slider itself so keyboard users can immediately + // set a new value, and so assistive tech announces the now-unset state + // instead of focus falling to the body when the button self-disables. + this.inputElement?.focus(); + }; + private getContainerClassList = () => { + return { + 'is-unset': this.isUnset, + 'has-clear-button': this.hasClearButton(), + ...this.getPercentageClassList(), + }; + }; + + private readonly getPercentageClassList = () => { if (!this.percentageClass) { return {}; } @@ -285,13 +465,19 @@ export class Slider { return Math.round(value * this.factor); }; - private getValue = () => { - let value = this.value; - if (!Number.isFinite(value)) { - value = this.valuemin; - } + /** + * The display value the thumb rests at while unset: the midpoint of the + * range, so both arrow-key directions are live (unlike anchoring at the + * minimum). Aligned to the step — native range inputs default to a step of + * 1 — so it matches a real input value and the first key press doesn't jump. + */ + private readonly getRestingDisplayValue = (): number => { + const min = this.multiplyByFactor(this.valuemin); + const max = this.multiplyByFactor(this.valuemax); + const midpoint = (min + max) / 2; + const step = this.step ? this.multiplyByFactor(this.step) : 1; - return value; + return min + Math.round((midpoint - min) / step) * step; }; private getFraction = (): number => { diff --git a/src/examples/whats-new/examples/whats-new-example-slider.tsx b/src/examples/whats-new/examples/whats-new-example-slider.tsx index 4c9df6bf0d..5e4a814e1a 100644 --- a/src/examples/whats-new/examples/whats-new-example-slider.tsx +++ b/src/examples/whats-new/examples/whats-new-example-slider.tsx @@ -12,7 +12,7 @@ export class WhatsNewSliderExample { private invalid = false; @State() - private value = 25; + private value: number | null = 25; private minValue = 15; private maxValue = 75; @@ -44,7 +44,7 @@ export class WhatsNewSliderExample { ]; } - private handleChange = (event: CustomEvent) => { + private handleChange = (event: CustomEvent) => { this.value = event.detail; }; diff --git a/src/global/translations.spec.ts b/src/global/translations.spec.ts new file mode 100644 index 0000000000..f9923c96c5 --- /dev/null +++ b/src/global/translations.spec.ts @@ -0,0 +1,80 @@ +import translate from './translations'; +import sv from '../translations/sv'; + +describe('translations', () => { + describe('get', () => { + it('returns the English translation by default', () => { + expect(translate.get('clear-value')).toBe('Clear value'); + }); + + it('returns the translation for the requested language', () => { + expect(translate.get('value-not-set', 'sv')).toBe( + 'Värde inte angivet' + ); + }); + + it('resolves Norwegian Bokmål to the Norwegian translations', () => { + // `nb` is part of the `Languages` type but has no translation file + // of its own, so it is aliased to `no`. Before it was mapped, this + // threw a `TypeError` for every component rendered with it. + expect(translate.get('value-not-set', 'nb')).toBe( + 'Verdi ikke angitt' + ); + expect(translate.get('value-not-set', 'nb')).toBe( + translate.get('value-not-set', 'no') + ); + }); + + it('falls back to English for a language it does not know', () => { + expect(translate.get('clear-value', 'xx')).toBe('Clear value'); + }); + + it('returns the key itself when the key is unknown', () => { + expect(translate.get('no-such-key', 'sv')).toBe('no-such-key'); + }); + + it('falls back to English for a key a known language is missing', () => { + // Guards against a new key landing in `en.ts` but not in every + // other file, which would otherwise render the key as UI text. + // `translations.ts` maps this very object, so removing a key + // here is what a translation file missing one looks like. + const table: Record = sv; + const key = 'clear-value'; + const original = table[key]; + delete table[key]; + + try { + expect(translate.get(key, 'sv')).toBe('Clear value'); + } finally { + table[key] = original; + } + }); + + it('substitutes merge codes with the given params', () => { + expect( + translate.get('clear-value-of', 'en', { label: 'Priority' }) + ).toBe('Clear value of Priority'); + }); + + it('substitutes falsy merge-code values rather than dropping them', () => { + expect( + translate.get('code-diff.hidden-lines', 'en', { count: 0 }) + ).toBe('\u00B7\u00B7\u00B7 0 hidden lines \u00B7\u00B7\u00B7'); + expect( + translate.get('clear-value-of', 'en', { label: false }) + ).toBe('Clear value of false'); + expect(translate.get('clear-value-of', 'en', { label: '' })).toBe( + 'Clear value of ' + ); + }); + + it('leaves a merge code intact when its param is missing', () => { + expect(translate.get('clear-value-of', 'en', {})).toBe( + 'Clear value of { label }' + ); + expect(translate.get('clear-value-of', 'en')).toBe( + 'Clear value of { label }' + ); + }); + }); +}); diff --git a/src/global/translations.ts b/src/global/translations.ts index 06afbdea7c..7226e24311 100644 --- a/src/global/translations.ts +++ b/src/global/translations.ts @@ -14,6 +14,7 @@ const allTranslations = { fi: fi, fr: fr, no: no, + nb: no, // Norwegian Bokmål shares the Norwegian (`no`) translations nl: nl, sv: sv, }; @@ -22,7 +23,17 @@ const REGEX = /\{\s*(\w+)\s*\}/g; export class Translations { public get(key: string, language = 'en', params?: object): string { - const translation: string = allTranslations[language][key]; + // Fall back to English when the requested language has no translations. + // The `language` props are typed, but a custom element takes whatever + // string an attribute carries, so an unknown language must never make a + // component throw. + const languageTranslations = + allTranslations[language] ?? allTranslations.en; + + // Fall back per key as well: a mapped language whose file is missing + // this one key would otherwise render the key itself as UI text. + const translation: string = + languageTranslations[key] ?? allTranslations.en[key]; if (!translation) { return key; } @@ -30,7 +41,10 @@ export class Translations { return translation.replaceAll( REGEX, (match: string, mergeCodeKey: string) => { - return params[mergeCodeKey] || match; + // Nullish, not falsy: `0` and `false` are values a caller can + // legitimately merge in, and `||` would leave the merge code + // itself in the string instead. + return String(params?.[mergeCodeKey] ?? match); } ); } diff --git a/src/translations/da.ts b/src/translations/da.ts index c526cbb650..dbd5429d7f 100644 --- a/src/translations/da.ts +++ b/src/translations/da.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Ryd værdi', + 'clear-value-of': 'Ryd værdien for { label }', + 'value-not-set': 'Værdi ikke angivet', remove: 'Fjern', save: 'Gem', cancel: 'Annullér', diff --git a/src/translations/de.ts b/src/translations/de.ts index 257a05681d..dd50856418 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Wert löschen', + 'clear-value-of': 'Wert von { label } löschen', + 'value-not-set': 'Wert nicht festgelegt', remove: 'Entfernen', save: 'Speichern', cancel: 'Abbrechen', diff --git a/src/translations/en.ts b/src/translations/en.ts index 720caceb41..c5305f4da7 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Clear value', + 'clear-value-of': 'Clear value of { label }', + 'value-not-set': 'Value not set', remove: 'Remove', save: 'Save', cancel: 'Cancel', diff --git a/src/translations/fi.ts b/src/translations/fi.ts index 3420c6c745..8ec83f0714 100644 --- a/src/translations/fi.ts +++ b/src/translations/fi.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Tyhjennä arvo', + 'clear-value-of': 'Tyhjennä kentän { label } arvo', + 'value-not-set': 'Arvoa ei asetettu', remove: 'Poista', save: 'Tallenna', cancel: 'Peruuta', diff --git a/src/translations/fr.ts b/src/translations/fr.ts index e0e8e6a63b..407ba0f11a 100644 --- a/src/translations/fr.ts +++ b/src/translations/fr.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Effacer la valeur', + 'clear-value-of': 'Effacer la valeur de { label }', + 'value-not-set': 'Valeur non définie', remove: 'Supprimer', save: 'Enregistrer', cancel: 'Annuler', diff --git a/src/translations/nl.ts b/src/translations/nl.ts index f1bbfa1a38..cce6adbcea 100644 --- a/src/translations/nl.ts +++ b/src/translations/nl.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Waarde wissen', + 'clear-value-of': 'Waarde van { label } wissen', + 'value-not-set': 'Waarde niet ingesteld', remove: 'Verwijder', save: 'Opslaan', cancel: 'Annuleren', diff --git a/src/translations/no.ts b/src/translations/no.ts index f7e7bd02ca..ec5aab52ea 100644 --- a/src/translations/no.ts +++ b/src/translations/no.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Tøm verdi', + 'clear-value-of': 'Tøm verdien for { label }', + 'value-not-set': 'Verdi ikke angitt', remove: 'Fjerne', save: 'Lagre', cancel: 'Avbryt', diff --git a/src/translations/sv.ts b/src/translations/sv.ts index c84c508b91..0bfbb602b6 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -1,4 +1,7 @@ export default { + 'clear-value': 'Rensa värde', + 'clear-value-of': 'Rensa värdet för { label }', + 'value-not-set': 'Värde inte angivet', remove: 'Ta bort', save: 'Spara', cancel: 'Avbryt',