diff --git a/demo/story-components/story-styles-settings.test.ts b/demo/story-components/story-styles-settings.test.ts new file mode 100644 index 0000000..cb7b943 --- /dev/null +++ b/demo/story-components/story-styles-settings.test.ts @@ -0,0 +1,174 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import type { + StoryStylesSettings, + StyleInputData, +} from './story-styles-settings'; +import './story-styles-settings'; + +async function makeSettings( + data: StyleInputData, +): Promise { + const el = await fixture(html` + + `); + await el.updateComplete; + return el; +} + +function getInput(el: StoryStylesSettings, id: string): HTMLInputElement { + const input = el.shadowRoot?.querySelector(`#${id}`); + expect(input, `input #${id} should exist`).to.exist; + return input as HTMLInputElement; +} + +describe('StoryStylesSettings', () => { + describe('range inputs', () => { + const rangeData: StyleInputData = { + settings: [ + { + label: 'Foos', + cssVariable: '--foo', + defaultValue: 50, + inputType: 'range', + min: 0, + max: 250, + step: 10, + unit: 'px', + }, + ], + }; + + test('renders an input of type range with min/max/step set', async () => { + const el = await makeSettings(rangeData); + const input = getInput(el, 'foos'); + + expect(input.type).to.equal('range'); + expect(input.min).to.equal('0'); + expect(input.max).to.equal('250'); + expect(input.step).to.equal('10'); + expect(input.value).to.equal('50'); + expect(input.dataset.unit).to.equal('px'); + }); + + test('renders a readout linked to the input', async () => { + const el = await makeSettings(rangeData); + + const readout = el.shadowRoot?.querySelector( + 'output.style-readout', + ); + expect(readout).to.exist; + expect(readout?.getAttribute('for')).to.equal('foos'); + expect(readout?.textContent).to.equal('50px'); + }); + + test('readout updates on input event with value + unit', async () => { + const el = await makeSettings(rangeData); + const input = getInput(el, 'foos'); + + input.value = '200'; + input.dispatchEvent(new Event('input')); + await el.updateComplete; + + const readout = el.shadowRoot?.querySelector( + 'output.style-readout', + ); + expect(readout?.textContent).to.equal('200px'); + }); + + test('readout omits unit when unit is not provided', async () => { + const el = await makeSettings({ + settings: [ + { + label: 'Foos', + cssVariable: '--foo', + defaultValue: 0.5, + inputType: 'range', + min: 0, + max: 1, + step: 0.1, + }, + ], + }); + const input = getInput(el, 'foos'); + + const readout = el.shadowRoot?.querySelector( + 'output.style-readout', + ); + expect(readout?.textContent).to.equal('0.5'); + + input.value = '0.8'; + input.dispatchEvent(new Event('input')); + await el.updateComplete; + + expect(readout?.textContent).to.equal('0.8'); // No unit included + }); + }); + + describe('number inputs', () => { + const numberData: StyleInputData = { + settings: [ + { + label: 'Foos', + cssVariable: '--foo', + defaultValue: 1, + inputType: 'number', + min: 0, + step: 1, + }, + ], + }; + + test('renders an input of type number with min/step set', async () => { + const el = await makeSettings(numberData); + const input = getInput(el, 'foos'); + + expect(input.type).to.equal('number'); + expect(input.min).to.equal('0'); + expect(input.step).to.equal('1'); + expect(input.value).to.equal('1'); + }); + }); + + describe('non-numeric inputs', () => { + test('text input ignores min/max/step even when set on the settings object', async () => { + const el = await makeSettings({ + settings: [ + { + label: 'Foos', + cssVariable: '--foos', + defaultValue: '200px', + inputType: 'text', + // These should be ignored for non-numeric input types + min: 0, + max: 100, + step: 1, + }, + ], + }); + const input = getInput(el, 'foos'); + + expect(input.type).to.equal('text'); + expect(input.hasAttribute('min')).to.be.false; + expect(input.hasAttribute('max')).to.be.false; + expect(input.hasAttribute('step')).to.be.false; + }); + + test('input without inputType defaults to type=text', async () => { + const el = await makeSettings({ + settings: [ + { + label: 'Untyped', + cssVariable: '--untyped', + defaultValue: 'foo', + }, + ], + }); + const input = getInput(el, 'untyped'); + + expect(input.type).to.equal('text'); + }); + }); +}); diff --git a/demo/story-components/story-styles-settings.ts b/demo/story-components/story-styles-settings.ts index 253e764..15bd285 100644 --- a/demo/story-components/story-styles-settings.ts +++ b/demo/story-components/story-styles-settings.ts @@ -1,15 +1,29 @@ -import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit'; +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; import { property, queryAll } from 'lit/decorators.js'; import { customElement } from 'lit/decorators/custom-element.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import themeStyles from '@src/themes/theme-styles'; import { labelToId } from '../story-utils'; +export type StyleInputType = 'color' | 'text' | 'number' | 'range'; + export type StyleInputSettings = { label: string; cssVariable: string; - defaultValue?: string; - inputType?: 'color' | 'text'; + defaultValue: string | number; + inputType?: StyleInputType; + min?: number; + max?: number; + step?: number; + unit?: string; }; export type StyleInputData = { @@ -32,23 +46,8 @@ export class StoryStylesSettings extends LitElement { return html`
- ${this.styleInputData.settings.map( - (input) => html` - - - - - `, + ${this.styleInputData.settings.map((input) => + this.renderStyleRow(input), )}
- - - -
@@ -56,13 +55,66 @@ export class StoryStylesSettings extends LitElement { `; } - /* Applies styles to demo component. */ + /** + * Renders one row of the settings table for the given style input. + */ + private renderStyleRow(input: StyleInputSettings): TemplateResult { + const inputId = labelToId(input.label); + const isNumeric = + input.inputType === 'number' || input.inputType === 'range'; + return html` + + + + + + + ${input.inputType === 'range' + ? html`${input.defaultValue}${input.unit ?? ''}` + : nothing} + + + `; + } + + /** + * Updates the live readout next to a range slider as it moves. + */ + private updateRangeReadout(e: Event): void { + const input = e.currentTarget as HTMLInputElement; + const output = this.renderRoot.querySelector( + `output[for="${CSS.escape(input.id)}"]`, + ); + if (!output) return; + const unit = input.dataset.unit ?? ''; + output.textContent = `${input.value}${unit}`; + } + + /** + * Applies styles to demo component. + */ private applyStyles(): void { const appliedStyles: string[] = []; this.styleInputs?.forEach((input) => { if (!input.dataset.variable || !input.value) return; - appliedStyles.push(`${input.dataset.variable}: ${input.value};`); + const unit = input.dataset.unit ?? ''; + appliedStyles.push(`${input.dataset.variable}: ${input.value}${unit};`); }); this.dispatchEvent( @@ -80,6 +132,20 @@ export class StoryStylesSettings extends LitElement { background-color: var(--primary-background-color); padding: 1em; } + + .style-input-cell { + display: flex; + align-items: center; + } + + .style-readout { + min-width: 3.5em; + text-align: right; + } + + input[type='range'] { + margin: 5px; + } `, ]; } diff --git a/src/elements/ia-combo-box/ia-combo-box-story.ts b/src/elements/ia-combo-box/ia-combo-box-story.ts index 5dd283c..5aa8060 100644 --- a/src/elements/ia-combo-box/ia-combo-box-story.ts +++ b/src/elements/ia-combo-box/ia-combo-box-story.ts @@ -40,6 +40,16 @@ const styleInputSettings: StyleInputSettings[] = [ defaultValue: '250px', inputType: 'text', }, + { + label: 'Dropdown fade duration', + cssVariable: '--combo-box-list-fade-duration', + defaultValue: 125, + inputType: 'range', + min: 0, + max: 1000, + step: 25, + unit: 'ms', + }, ]; // Option sets diff --git a/src/elements/ia-combo-box/ia-combo-box.ts b/src/elements/ia-combo-box/ia-combo-box.ts index 6e925c7..44f2187 100644 --- a/src/elements/ia-combo-box/ia-combo-box.ts +++ b/src/elements/ia-combo-box/ia-combo-box.ts @@ -1217,6 +1217,10 @@ export class IAComboBox extends LitElement { --combo-box-padding--: var(--padding-sm); --combo-box-list-width--: var(--combo-box-list-width, unset); --combo-box-list-max-height--: var(--combo-box-list-max-height, 250px); + --combo-box-list-fade-duration--: var( + --combo-box-list-fade-duration, + 125ms + ); } #container { @@ -1317,7 +1321,7 @@ export class IAComboBox extends LitElement { max-height: 400px; box-shadow: 0 0 1px 1px #ddd; opacity: 0; - transition: opacity 0.125s ease; + transition: opacity var(--combo-box-list-fade-duration--) ease; } #options-list.visible { diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar-story.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar-story.ts index db1618c..4748b8b 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar-story.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar-story.ts @@ -28,6 +28,14 @@ const styleInputSettings: StyleInputSettings[] = [ defaultValue: '5px', inputType: 'text', }, + { + label: 'Dropdown z-index', + cssVariable: '--dropdown-z-index', + defaultValue: 2, + inputType: 'number', + min: 0, + step: 1, + }, ]; // Component defaults diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts index 0ee51c3..426d800 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts @@ -75,7 +75,7 @@ export class IADropdownSearchBar extends LitElement {
`; } - + willUpdate(changed: PropertyValues) { // Push new categories down to the inner dropdown immediately, since ia-dropdown // mutates its own selected option on interaction which can cause Lit's @@ -235,6 +235,7 @@ export class IADropdownSearchBar extends LitElement { --search-bar-width--: var(--search-bar-width, 300px); --search-bar-internal-padding--: var(--padding-sm, 5px); --clear-button-offset--: var(--clear-button-offset, 0); + --dropdown-z-index--: var(--dropdown-z-index, initial); } #container { @@ -276,6 +277,7 @@ export class IADropdownSearchBar extends LitElement { --dropdownBorderRadius: 4px; --buttonSlotPaddingRight: 0; --dropdownTextAlign: left; + --dropdownListZIndex: var(--dropdown-z-index--); } #category-dropdown [slot='dropdown-label'] {