Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 51 additions & 35 deletions demo/app-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { customElement } from 'lit/decorators.js';
// Lit's html`` tag cannot render variable tag names directly.
import { unsafeHTML } from 'lit/directives/unsafe-html.js';

const storyModules = import.meta.glob(
['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'],
{ eager: true }
);
const storyModules = import.meta.glob(['../src/**/*-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"
Expand All @@ -19,13 +18,15 @@ const storyEntries = Object.keys(storyModules)
})
.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 +35,80 @@ 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}">&lt;${e.tag}&gt;</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
155 changes: 155 additions & 0 deletions src/types/result-story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { css, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';

import '@demo/story-template';
import type { Result } from './result';

/**
* `Result<T, E>` has no runtime behavior — it's a typed container
* (`{ success?: T; error?: E }`) modeled after Swift's Result. This story is a
* read-only illustration of the two shapes a Result can take and the narrowing
* pattern a caller uses to consume one.
*/

type Scenario = 'success' | 'error';

const SUCCESS_RESULT: Result<number, Error> = { success: 42 };
const ERROR_RESULT: Result<number, Error> = {
error: new Error('Item not found'),
};

const EXAMPLE_USAGE = `// A function returns a typed Result instead of throwing:
const result = await fetchFilesCount(identifier);

if (result.error) {
// \`result.error\` is a typed Error (or subclass) — not \`any\`
console.error(result.error.message);
} else {
// \`result.success\` holds the value on the happy path
console.log(result.success);
}`;

@customElement('result-story')
export class ResultStory extends LitElement {
@state() private scenario: Scenario = 'success';

private get result(): Result<number, Error> {
return this.scenario === 'success' ? SUCCESS_RESULT : ERROR_RESULT;
}

render() {
const { result } = this;
return html`
<story-template
elementTag="result"
elementClassName="Result"
.customExampleUsage=${EXAMPLE_USAGE}
>
<div slot="demo">
<p class="intro">
A typed container for a response: it carries either a
<code>success</code> value or a typed <code>error</code>, instead of
an untyped Promise rejection. Modeled after
<a
href="https://developer.apple.com/documentation/swift/result"
target="_blank"
rel="noopener"
>Swift's Result</a
>. Toggle a scenario to see the two shapes and how a caller handles
each.
</p>

<div class="controls" role="group" aria-label="Scenario">
<button
class=${this.scenario === 'success' ? 'active' : ''}
@click=${() => (this.scenario = 'success')}
>
Success
</button>
<button
class=${this.scenario === 'error' ? 'active' : ''}
@click=${() => (this.scenario = 'error')}
>
Error
</button>
</div>

<div class="field">
<span class="label">The Result value</span>
<code>${this.formatResult(result)}</code>
</div>
<div class="field">
<span class="label">What the caller does</span>
<code class=${result.error ? 'err' : 'ok'}>
${result.error
? html`✗ handle error → ${result.error.message}`
: html`✓ use value → ${result.success}`}
</code>
</div>
</div>
</story-template>
`;
}

private formatResult(result: Result<number, Error>): string {
if (result.error) return `{ error: Error("${result.error.message}") }`;
return `{ success: ${result.success} }`;
}

static styles = css`
.intro {
margin-top: 0;
max-width: 40rem;
}

.controls {
display: flex;
gap: 8px;
margin-bottom: 1rem;
}

button {
padding: 6px 14px;
font-size: 0.9rem;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
}

button.active {
background: #222;
color: #fff;
border-color: #222;
}

.field {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
}

.label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
}

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

code.ok {
color: #0a7d28;
}

code.err {
color: #a00;
}
`;
}
35 changes: 35 additions & 0 deletions src/types/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest';

import type { Result } from './result';

describe('Result', () => {
test('can be initialized with a success value', () => {
const result: Result<string, Error> = {
success: 'foo',
};
expect(result.success).toBe('foo');
expect(result.error).toBeUndefined();
});

test('can be initialized with an error', () => {
const FooErrorType = {
networkError: 0,
decodingError: 1,
} as const;
type FooErrorType = (typeof FooErrorType)[keyof typeof FooErrorType];

class FooError extends Error {
type?: FooErrorType;

constructor(type: FooErrorType) {
super();
this.type = type;
}
}
const result: Result<string, FooError> = {
error: new FooError(FooErrorType.decodingError),
};
expect(result.success).toBeUndefined();
expect(result.error?.type).toBe(FooErrorType.decodingError);
});
});
18 changes: 18 additions & 0 deletions src/types/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* The Result is a container for a response.
*
* It contains an optional success result which is generic
* and can be anything depending on the context,
* or an Error or subclass of an error.
*
* This allows us to return rich, typed errors instead of
* an untyped Promise rejection.
*
* This is modeled after Swift's Result type:
* https://developer.apple.com/documentation/swift/result
*/
export interface Result<T, E extends Error> {
success?: T;

error?: E;
}
Loading