Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ src
```
Export your component in `src/index.ts`

### Naming
Custom elements all share one global registry, and that registry is shared with every other script on the host page. Give each element a name specific enough that nothing else would plausibly want it.

If a component is built from several elements, prefix its parts with the component's own name rather than naming them generically, and keep the file name matching the tag:
```
src
- elements
- ia-foobar
- ia-foobar.ts // ia-foobar
- ia-foobar-list-item.ts // ia-foobar-list-item, not ia-list-item
```
Declare each element in `HTMLElementTagNameMap` so `querySelector` is typed and a mistyped tag in a template is caught at build time:
```ts
declare global {
interface HTMLElementTagNameMap {
'ia-foobar-list-item': IAFoobarListItem;
}
}
```

### Story
To demo your component, we have a component catalog that you can add your demo to. Create a component in your component directory. Name it `COMPONENT-NAME-story.ts`, ie `ia-button-story.ts`.

Expand Down
223 changes: 223 additions & 0 deletions demo/story-components/story-styles-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { html } from 'lit';
import type {
StoryStylesSettings,
StyleInputData,
StylePalette,
} from './story-styles-settings';
import './story-styles-settings';

Expand All @@ -24,6 +25,18 @@ function getInput(el: StoryStylesSettings, id: string): HTMLInputElement {
return input as HTMLInputElement;
}

/** Finds one of the panel's action buttons by its visible label. */
function button(el: StoryStylesSettings, label: string): HTMLButtonElement {
const match = [...(el.shadowRoot?.querySelectorAll('button') ?? [])].find(
(b) => b.textContent?.includes(label),
);
expect(match, `button "${label}" should exist`).to.exist;
return match as HTMLButtonElement;
}

const randomizeButton = (el: StoryStylesSettings) => button(el, 'Randomize');
const revertButton = (el: StoryStylesSettings) => button(el, 'Revert');

describe('StoryStylesSettings', () => {
describe('range inputs', () => {
const rangeData: StyleInputData = {
Expand Down Expand Up @@ -78,6 +91,28 @@ describe('StoryStylesSettings', () => {
expect(readout?.textContent).to.equal('200px');
});

test('the panel still re-renders after the readout has updated', async () => {
const el = await makeSettings(rangeData);
const input = getInput(el, 'foos');

input.value = '200';
input.dispatchEvent(new Event('input'));
await el.updateComplete;

// Writing to the readout imperatively would eject Lit's markers, so
// every later re-render of the panel would throw.
el.requestUpdate();
await el.updateComplete;

expect(el.shadowRoot?.querySelectorAll('.style-input')).to.have.lengthOf(
1,
);
expect(
el.shadowRoot?.querySelector<HTMLOutputElement>('output.style-readout')
?.textContent,
).to.equal('200px');
});

test('readout omits unit when unit is not provided', async () => {
const el = await makeSettings({
settings: [
Expand Down Expand Up @@ -156,6 +191,41 @@ describe('StoryStylesSettings', () => {
expect(input.hasAttribute('step')).to.be.false;
});

test('leaves the panel untouched when a story opts into nothing', async () => {
const el = await makeSettings({
settings: [
{
label: 'Ink',
cssVariable: '--ink',
defaultValue: '#ffffff',
inputType: 'color',
},
],
});

// Stories that ask for no extras must render exactly as before, so
// adding these controls for one component does not alter every demo.
const buttons = [
...(el.shadowRoot?.querySelectorAll('button') ?? []),
].map((b) => b.textContent?.trim());
expect(buttons).to.deep.equal(['Apply']);
expect(el.shadowRoot?.querySelector('.style-var')).to.not.exist;
expect(el.shadowRoot?.querySelector('.applied-palette')).to.not.exist;
});

test('shows the CSS variable only when the story asks for it', async () => {
const withVars = await makeSettings({
showCssVariables: true,
settings: [
{ label: 'Ink', cssVariable: '--ink', defaultValue: '#ffffff' },
],
});

expect(
withVars.shadowRoot?.querySelector('.style-var')?.textContent,
).to.contain('--ink');
});

test('input without inputType defaults to type=text', async () => {
const el = await makeSettings({
settings: [
Expand All @@ -171,4 +241,157 @@ describe('StoryStylesSettings', () => {
expect(input.type).to.equal('text');
});
});

describe('palettes', () => {
const PALETTES: StylePalette[] = [
{
name: 'Midnight',
values: { '--ink': '#e0e6ed', '--paper': '#1b263b' },
},
{
name: 'Forest',
values: { '--ink': '#e8f5e9', '--paper': '#14301a' },
},
];

const paletteData: StyleInputData = {
revertable: true,
settings: [
{
label: 'Ink',
cssVariable: '--ink',
defaultValue: '#ffffff',
inputType: 'color',
},
{
label: 'Paper',
cssVariable: '--paper',
defaultValue: '#000000',
inputType: 'color',
},
{ label: 'Width', cssVariable: '--width', defaultValue: '10px' },
],
palettes: PALETTES,
};

/** The palette whose values currently fill the inputs, if any. */
function activePalette(el: StoryStylesSettings): StylePalette | undefined {
return PALETTES.find(
(p) =>
getInput(el, 'ink').value === p.values['--ink'] &&
getInput(el, 'paper').value === p.values['--paper'],
);
}

test('applies one whole palette rather than unrelated colors', async () => {
const el = await makeSettings(paletteData);
const applied = new Promise<CustomEvent>((resolve) => {
el.addEventListener('stylesApplied', (e) => resolve(e as CustomEvent), {
once: true,
});
});

randomizeButton(el).click();

// Every color came from the same palette — that coordination is what
// keeps the foreground/background pairings legible.
const palette = activePalette(el);
expect(palette, 'inputs should match exactly one palette').to.exist;

// The non-color input is left alone so the layout holds still.
expect(getInput(el, 'width').value).to.equal('10px');

// The palette is applied, not just staged in the inputs.
const styles = (await applied).detail.styles as string;
expect(styles).to.contain(`--ink: ${palette?.values['--ink']}`);
expect(styles).to.contain(`--paper: ${palette?.values['--paper']}`);
});

test('names the theme it applied', async () => {
const el = await makeSettings(paletteData);

randomizeButton(el).click();
await el.updateComplete;

const readout =
el.shadowRoot?.querySelector('.applied-palette')?.textContent;
expect(readout).to.contain(activePalette(el)?.name);
});

test('never applies the same theme twice in a row', async () => {
const el = await makeSettings(paletteData);
let previous: string | undefined;

// Each click must visibly change something, otherwise the control looks
// broken.
for (let i = 0; i < 6; i++) {
randomizeButton(el).click();
await el.updateComplete;
const current = activePalette(el)?.name;
expect(current, `click ${i + 1} should apply a palette`).to.exist;
expect(
current,
`click ${i + 1} repeated the previous theme`,
).to.not.equal(previous);
previous = current;
}
});

test('reverting clears both the colors and the theme name', async () => {
const el = await makeSettings(paletteData);

randomizeButton(el).click();
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.applied-palette')).to.exist;

revertButton(el).click();
await el.updateComplete;

expect(getInput(el, 'ink').value).to.equal('#ffffff');
expect(getInput(el, 'paper').value).to.equal('#000000');
expect(el.shadowRoot?.querySelector('.applied-palette')).to.not.exist;
});

test('survives repeated randomize/revert cycles', async () => {
const el = await makeSettings(paletteData);

// Re-rendering must not disturb the settings rows. Interpolating <tr>
// straight into <table> lets the parser hoist them into an implicit
// tbody, ejecting Lit's markers and throwing on the next update.
for (let i = 0; i < 3; i++) {
randomizeButton(el).click();
await el.updateComplete;
revertButton(el).click();
await el.updateComplete;
}

expect(el.shadowRoot?.querySelectorAll('.style-input')).to.have.lengthOf(
3,
);
expect(el.shadowRoot?.querySelector('.applied-palette')).to.not.exist;
expect(getInput(el, 'ink').value).to.equal('#ffffff');
});

test('offers randomize only alongside palettes, and revert only on request', async () => {
const revertOnly = await makeSettings({
revertable: true,
settings: [
{
label: 'Ink',
cssVariable: '--ink',
defaultValue: '#ffffff',
inputType: 'color',
},
],
});

const buttons = [
...(revertOnly.shadowRoot?.querySelectorAll('button') ?? []),
].map((b) => b.textContent?.trim());
// Color inputs alone are not enough — without themes there is nothing
// coherent to swap in.
expect(buttons.some((b) => b?.includes('Randomize'))).to.be.false;
expect(buttons.some((b) => b?.includes('Revert'))).to.be.true;
});
});
});
Loading