Skip to content
Open
100 changes: 66 additions & 34 deletions demo/app-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,43 @@ import { customElement } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';

const storyModules = import.meta.glob(
['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'],
{ eager: true }
[
'../src/elements/**/*-story.ts',
'../src/labs/**/*-story.ts',
'../src/parsers/**/*-story.ts',
],
{ eager: true },
);

const storyEntries = Object.keys(storyModules)
.map(path => {
.map((path) => {
const labs = path.includes('/src/labs/');
const parts = path.split('/');
const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts"
const tag = filename.replace(/-story\.ts$/, '');
return { tag, storyTag: `${tag}-story`, id: `elem-${tag}`, labs };
// Stories under src/elements or src/labs are custom elements; others
// (e.g. parsers) are plain modules and shouldn't be shown as `<tag>`.
const component =
path.includes('/src/elements/') || path.includes('/src/labs/');
return {
tag,
storyTag: `${tag}-story`,
id: `elem-${tag}`,
labs,
component,
};
})
.sort((a, b) => a.tag.localeCompare(b.tag));

const productionEntries = storyEntries.filter(e => !e.labs);
const labsEntries = storyEntries.filter(e => e.labs);
const productionEntries = storyEntries.filter((e) => !e.labs);
const labsEntries = storyEntries.filter((e) => e.labs);
const ALL_ENTRIES = [...productionEntries, ...labsEntries];

@customElement('app-root')
export class AppRoot extends LitElement {
createRenderRoot() { return this; }
createRenderRoot() {
return this;
}

private _observer?: IntersectionObserver;
private _abortController = new AbortController();
Expand All @@ -34,65 +50,81 @@ export class AppRoot extends LitElement {
return html`
<nav id="ia-sidebar">
<h2>Production-Ready</h2>
${productionEntries.map(e => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
${productionEntries.map(
(e) =>
html`<a href="#${e.id}">${e.component ? `<${e.tag}>` : e.tag}</a>`,
)}
<h2>Labs 🧪</h2>
${labsEntries.map(e => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
${labsEntries.map((e) => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
</nav>
<div id="ia-content">
<h1>Internet Archive Elements</h1>
<h2>Production-Ready Elements</h2>
${productionEntries.map(e => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`)}
${productionEntries.map(
(e) => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`,
)}
<h2>Labs Elements</h2>
${labsEntries.map(e => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`)}
${labsEntries.map(
(e) => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`,
)}
</div>
`;
}

firstUpdated() {
const allIds = ALL_ENTRIES.map(e => e.id);
const allIds = ALL_ENTRIES.map((e) => e.id);

const links = Object.fromEntries(
allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)])
allIds.map((id) => [
id,
this.querySelector(`#ia-sidebar a[href="#${id}"]`),
]),
);

const visible = new Set<string>();

// Only anchors in the top 30% of the viewport count as "active".
// The first (topmost) visible anchor wins.
this._observer = new IntersectionObserver(
entries => {
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
const activeId = allIds.find(id => visible.has(id)) ?? allIds[0];
allIds.forEach(id => links[id]?.classList.toggle('active', id === activeId));
const activeId = allIds.find((id) => visible.has(id)) ?? allIds[0];
allIds.forEach((id) =>
links[id]?.classList.toggle('active', id === activeId),
);
},
{ rootMargin: '0px 0px -70% 0px' },
);

allIds.forEach(id => {
allIds.forEach((id) => {
const el = document.getElementById(id);
if (el) this._observer!.observe(el);
});

allIds.forEach(id => {
links[id]?.addEventListener('click', (e: Event) => {
e.preventDefault();
const el = document.getElementById(id);
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' });
}
}, { signal: this._abortController.signal });
allIds.forEach((id) => {
links[id]?.addEventListener(
'click',
(e: Event) => {
e.preventDefault();
const el = document.getElementById(id);
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' });
}
},
{ signal: this._abortController.signal },
);
});
}

Expand Down
13 changes: 12 additions & 1 deletion demo/story-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export class StoryTemplate extends LitElement {

@property({ type: String }) customExampleUsage?: string;

/* Overrides the derived import snippet — for non-component modules
* (parsers, services, models) whose import path isn't `<tag>/<tag>`. */
@property({ type: String }) customImport?: string;

/* Plain-text heading for non-component modules. When set, it's shown as-is
* instead of as an `<element-tag>`. */
@property({ type: String }) heading?: string;

/* Optional stringified properties to always include in the example usage */
@property({ type: String }) defaultUsageProps?: string;

Expand Down Expand Up @@ -62,7 +70,9 @@ export class StoryTemplate extends LitElement {
return html`
<div id="container">
<h2>
<code>&lt;${this.elementTag}&gt;</code>
${this.heading
? this.heading
: html`<code>&lt;${this.elementTag}&gt;</code>`}
${when(
this.labs,
() =>
Expand Down Expand Up @@ -214,6 +224,7 @@ export class StoryTemplate extends LitElement {
}

private get importCode(): string {
if (this.customImport) return this.customImport;
if (this.elementClassName) {
return `import '${this.modulePath}';\nimport { ${this.elementClassName} } from '${this.modulePath}';`;
} else {
Expand Down
10 changes: 10 additions & 0 deletions src/parsers/field-parser-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type FieldParserRawValue = string | number | boolean;

export interface FieldParserInterface<T> {
/**
* Parse the raw value and return a value of type T or undefined if unparseable
*
* @param rawValue T | undefined
*/
parseValue(rawValue: FieldParserRawValue): T | undefined;
}
189 changes: 189 additions & 0 deletions src/parsers/field-parsers-story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { css, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';

import '@demo/story-template';

import type { FieldParserInterface } from './field-parser-interface';
import { BooleanParser } from './field-types/boolean';
import { ByteParser } from './field-types/byte';
import { DateParser } from './field-types/date';
import { DurationParser } from './field-types/duration';
import { ListParser } from './field-types/list';
import { MediaTypeParser } from './field-types/mediatype';
import { NumberParser } from './field-types/number';
import { PageProgressionParser } from './field-types/page-progression';
import { StringParser } from './field-types/string';

interface ParserOption {
label: string;
parser: FieldParserInterface<unknown>;
example: string;
}

const PARSERS: ParserOption[] = [
{ label: 'number', parser: NumberParser.shared, example: '1234.5' },
{ label: 'boolean', parser: BooleanParser.shared, example: 'true' },
{ label: 'byte', parser: ByteParser.shared, example: '1572864' },
{ label: 'date', parser: DateParser.shared, example: '2021-11-18' },
{ label: 'duration', parser: DurationParser.shared, example: '1:02:03' },
{
label: 'list (of numbers)',
parser: new ListParser(NumberParser.shared),
example: '1; 2; 3',
},
{ label: 'mediatype', parser: MediaTypeParser.shared, example: 'texts' },
{
label: 'page-progression',
parser: PageProgressionParser.shared,
example: 'rl',
},
{ label: 'string', parser: StringParser.shared, example: 'hello' },
];

const IMPORT_EXAMPLE = `import { NumberParser } from '@internetarchive/elements/parsers/field-types/number';`;

const USAGE_EXAMPLE = `const result = NumberParser.shared.parseValue('1234.5');
// result === 1234.5`;

/**
* Demo story for the field-type parsers. Renders inside the shared
* <story-template> chrome with an interactive playground in the demo slot:
* pick a parser, type a raw value, and see the parsed output and runtime type.
*/
@customElement('field-parsers-story')
export class FieldParsersStory extends LitElement {
@state() private selectedIndex = 0;

@state() private rawValue = PARSERS[0].example;

private get selected(): ParserOption {
return PARSERS[this.selectedIndex];
}

render() {
const result = this.selected.parser.parseValue(this.rawValue);
return html`
<story-template
heading="Field Parsers"
.customImport=${IMPORT_EXAMPLE}
.customExampleUsage=${USAGE_EXAMPLE}
>
<div slot="demo" class="playground">
<div class="controls">
<label>
Parser
<select @change=${this.onParserChange}>
${PARSERS.map(
(p, i) =>
html`<option
value=${i}
?selected=${i === this.selectedIndex}
>
${p.label}
</option>`,
)}
</select>
</label>
<label>
Input
<input
.value=${this.rawValue}
@input=${this.onInput}
placeholder="raw value"
/>
</label>
<button @click=${this.useExample}>Use example</button>
</div>
<div class="result ${result === undefined ? 'unparseable' : ''}">
<span class="arrow">Result →</span>
<code>${this.format(result)}</code>
<span class="type">${this.typeLabel(result)}</span>
</div>
</div>
</story-template>
`;
}

private onParserChange(e: Event) {
this.selectedIndex = Number((e.target as HTMLSelectElement).value);
this.rawValue = this.selected.example;
}

private onInput(e: Event) {
this.rawValue = (e.target as HTMLInputElement).value;
}

private useExample() {
this.rawValue = this.selected.example;
}

private format(value: unknown): string {
if (value === undefined) return 'undefined (unparseable)';
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return JSON.stringify(value);
return String(value);
}

private typeLabel(value: unknown): string {
if (value === undefined) return 'undefined';
if (value instanceof Date) return 'Date';
if (Array.isArray(value)) return 'array';
return typeof value;
}

static styles = css`
.playground {
font-family: system-ui, sans-serif;
}

.controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-bottom: 1rem;
}

label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
font-weight: 600;
}

select,
input {
padding: 4px 6px;
font-size: 0.9rem;
}

.result {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
}

.result.unparseable code {
color: #a00;
}

.arrow {
color: #666;
}

.result code {
font-size: 1rem;
}

.type {
margin-left: auto;
font-size: 0.75rem;
color: #666;
}
`;
}
Loading
Loading