diff --git a/packages/ui/src/core.ts b/packages/ui/src/core.ts index cb0ff5c..821b7f6 100644 --- a/packages/ui/src/core.ts +++ b/packages/ui/src/core.ts @@ -1 +1,2 @@ -export {}; +export * from './core/context.ts'; +export * from './core/node.ts'; diff --git a/packages/ui/src/core/context.test.ts b/packages/ui/src/core/context.test.ts new file mode 100644 index 0000000..97b24de --- /dev/null +++ b/packages/ui/src/core/context.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'vitest'; +import { createContext, MissingContextError } from './context.ts'; +import { Node } from './node.ts'; + +const theme = createContext('theme'); +const count = createContext('count'); +const locale = createContext('locale', 'en'); + +describe('context', () => { + describe('on a single node', () => { + test('get returns undefined for a context that was never set', () => { + expect(theme.get(new Node())).toBeUndefined(); + }); + + test('does not inherit values from Object.prototype', () => { + const constructor = createContext('constructor'); + + expect(constructor.get(new Node())).toBeUndefined(); + }); + + test('get returns the value after it is set', () => { + const n = new Node(); + theme.set(n, 'dark'); + expect(theme.get(n)).toBe('dark'); + }); + + test('the last set wins', () => { + const n = new Node(); + theme.set(n, 'dark'); + theme.set(n, 'light'); + expect(theme.get(n)).toBe('light'); + }); + + test('keeps falsy values — 0 is stored, not treated as unset', () => { + const n = new Node(); + count.set(n, 0); + expect(count.get(n)).toBe(0); + }); + + test('distinct contexts do not collide', () => { + const n = new Node(); + theme.set(n, 'dark'); + expect(count.get(n)).toBeUndefined(); + }); + + test('contexts with the same name share a value', () => { + const first = createContext('shared'); + const second = createContext('shared'); + const n = new Node(); + first.set(n, 'value'); + expect(second.get(n)).toBe('value'); + }); + + test('expect returns the value when the context is set', () => { + const n = new Node(); + theme.set(n, 'dark'); + expect(theme.expect(n)).toBe('dark'); + }); + + test('expect throws MissingContextError when unset with no default', () => { + expect(() => theme.expect(new Node())).toThrow(MissingContextError); + }); + + test('expect falls back to the default when unset', () => { + expect(locale.expect(new Node())).toBe('en'); + }); + + test('a set value takes precedence over the default', () => { + const n = new Node(); + locale.set(n, 'fr'); + expect(locale.expect(n)).toBe('fr'); + }); + + test('get returns the default when the context is unset', () => { + expect(locale.get(new Node())).toBe('en'); + }); + + test('a set value overrides the default for get', () => { + const n = new Node(); + locale.set(n, 'fr'); + expect(locale.get(n)).toBe('fr'); + }); + + test('hasOwn is false before a value is set', () => { + expect(theme.hasOwn(new Node())).toBe(false); + }); + + test('hasOwn is true once a value is set on the node', () => { + const n = new Node(); + theme.set(n, 'dark'); + expect(theme.hasOwn(n)).toBe(true); + }); + }); + + describe('inheritance through the node tree', () => { + test('a child inherits a value set on its parent', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + expect(theme.get(new Node(parent))).toBe('dark'); + }); + + test('a value set on a child shadows the parent for that child only', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + const child = new Node(parent); + theme.set(child, 'light'); + expect(theme.get(child)).toBe('light'); + expect(theme.get(parent)).toBe('dark'); + }); + + test('setting a value on a child does not leak up to the parent', () => { + const parent = new Node(); + const child = new Node(parent); + theme.set(child, 'light'); + expect(theme.get(parent)).toBeUndefined(); + }); + + test('expect inherits an ancestor value', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + expect(theme.expect(new Node(parent))).toBe('dark'); + }); + + test('an inherited value resolves through multiple levels', () => { + const grand = new Node(); + theme.set(grand, 'dark'); + const parent = new Node(grand); + expect(theme.get(new Node(parent))).toBe('dark'); + }); + + test('an explicitly set undefined shadows the ancestor value', () => { + const parent = new Node(); + locale.set(parent, 'fr'); + const child = new Node(parent); + locale.set(child, undefined as unknown as string); + expect(locale.get(child)).toBe('en'); + expect(locale.get(parent)).toBe('fr'); + }); + + test('an explicitly set undefined shadows with no default to fall back to', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + const child = new Node(parent); + theme.set(child, undefined as unknown as string); + expect(theme.get(child)).toBeUndefined(); + expect(() => theme.expect(child)).toThrow(MissingContextError); + }); + + test('hasOwn is true for an explicitly set undefined', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + const child = new Node(parent); + theme.set(child, undefined as unknown as string); + expect(theme.hasOwn(child)).toBe(true); + }); + + test('hasOwn is false for a value that is only inherited', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + expect(theme.hasOwn(new Node(parent))).toBe(false); + }); + + test('hasOwn is true only for the node that owns the value', () => { + const parent = new Node(); + theme.set(parent, 'dark'); + const child = new Node(parent); + theme.set(child, 'light'); + expect(theme.hasOwn(child)).toBe(true); + expect(theme.hasOwn(parent)).toBe(true); + }); + + test('expect falls back to the default when neither node nor ancestors set it', () => { + const parent = new Node(); + expect(locale.expect(new Node(parent))).toBe('en'); + }); + + test('get falls back to the default when neither node nor ancestors set it', () => { + const parent = new Node(); + expect(locale.get(new Node(parent))).toBe('en'); + }); + }); +}); diff --git a/packages/ui/src/core/context.ts b/packages/ui/src/core/context.ts new file mode 100644 index 0000000..536764b --- /dev/null +++ b/packages/ui/src/core/context.ts @@ -0,0 +1,67 @@ +import type { Node } from './node.ts'; + +/** + * A `Context` defines a value which is in effect for a given {@link Node}. + * + * Unless a context value is set for a particular node, it will inherit its + * value from that node's parent. + */ +export class Context { + /** A unique identifier for this context. */ + readonly name: string; + + /** The value returned when this context is not present on a node. */ + readonly defaultValue?: T; + + constructor(name: string, defaultValue?: T) { + this.name = name; + this.defaultValue = defaultValue; + } + + get(node: Node): T | undefined { + for (let n: Node | undefined = node; n; n = n.parent) { + const contexts = n.contexts; + if (contexts?.has(this.name)) { + const value = contexts.get(this.name) as T | undefined; + return value === undefined ? this.defaultValue : value; + } + } + return this.defaultValue; + } + + set(node: Node, value: T): void { + const contexts = (node.contexts ??= new Map()); + contexts.set(this.name, value); + } + + expect(node: Node): T { + const value = this.get(node); + if (value === undefined) { + throw new MissingContextError(this.name); + } else { + return value; + } + } + + hasOwn(node: Node): boolean { + return node.contexts?.has(this.name) ?? false; + } +} + +/** + * Create a new {@link Context}. + * + * @param name - the unique name to give this context. + * @param defaultValue - the value used when the context is not present on a node. + * @returns the new context + */ +export function createContext(name: string, defaultValue?: T): Context { + return new Context(name, defaultValue); +} + +export class MissingContextError extends Error { + constructor(name: string) { + super(`no value set for context "${name}"`); + this.name = 'MissingContextError'; + } +} diff --git a/packages/ui/src/core/node.ts b/packages/ui/src/core/node.ts new file mode 100644 index 0000000..2049b94 --- /dev/null +++ b/packages/ui/src/core/node.ts @@ -0,0 +1,11 @@ +export class Node { + readonly parent?: Node; + readonly children: Set; + + contexts?: Map; + + constructor(parent?: Node) { + this.parent = parent; + this.children = new Set(); + } +}