diff --git a/demo/app-root.ts b/demo/app-root.ts index 89f9ab1..eb66975 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -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" @@ -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(); @@ -34,33 +35,42 @@ export class AppRoot extends LitElement { return html`

Internet Archive Elements

Production-Ready Elements

- ${productionEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${productionEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}

Labs Elements

- ${labsEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${labsEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}
`; } 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(); @@ -68,31 +78,37 @@ export class AppRoot extends LitElement { // 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 }, + ); }); } diff --git a/src/types/result-story.ts b/src/types/result-story.ts new file mode 100644 index 0000000..046f086 --- /dev/null +++ b/src/types/result-story.ts @@ -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` 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 = { success: 42 }; +const ERROR_RESULT: Result = { + 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 { + return this.scenario === 'success' ? SUCCESS_RESULT : ERROR_RESULT; + } + + render() { + const { result } = this; + return html` + +
+

+ A typed container for a response: it carries either a + success value or a typed error, instead of + an untyped Promise rejection. Modeled after + Swift's Result. Toggle a scenario to see the two shapes and how a caller handles + each. +

+ +
+ + +
+ +
+ The Result value + ${this.formatResult(result)} +
+
+ What the caller does + + ${result.error + ? html`โœ— handle error โ†’ ${result.error.message}` + : html`โœ“ use value โ†’ ${result.success}`} + +
+
+
+ `; + } + + private formatResult(result: Result): 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; + } + `; +} diff --git a/src/types/result.test.ts b/src/types/result.test.ts new file mode 100644 index 0000000..60f018c --- /dev/null +++ b/src/types/result.test.ts @@ -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 = { + 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 = { + error: new FooError(FooErrorType.decodingError), + }; + expect(result.success).toBeUndefined(); + expect(result.error?.type).toBe(FooErrorType.decodingError); + }); +}); diff --git a/src/types/result.ts b/src/types/result.ts new file mode 100644 index 0000000..5a71acc --- /dev/null +++ b/src/types/result.ts @@ -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 { + success?: T; + + error?: E; +}