diff --git a/bun.lock b/bun.lock index bf4e7af..d5fdfcc 100644 --- a/bun.lock +++ b/bun.lock @@ -228,6 +228,23 @@ "vitest": "^4.1.10", }, }, + "packages/record": { + "name": "@workspace/record", + "version": "0.0.0", + "dependencies": { + "es-toolkit": "^1.50.0", + "react": "^19.2.6", + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "jsdom": "^30.0.1", + "react-dom": "^19.2.6", + "typescript": "~6", + "vitest": "^4.1.10", + }, + }, "packages/search": { "name": "@workspace/search", "version": "0.0.0", @@ -1066,6 +1083,8 @@ "@workspace/preferences": ["@workspace/preferences@workspace:packages/preferences"], + "@workspace/record": ["@workspace/record@workspace:packages/record"], + "@workspace/search": ["@workspace/search@workspace:packages/search"], "@workspace/ui": ["@workspace/ui@workspace:packages/ui"], diff --git a/packages/record/package.json b/packages/record/package.json new file mode 100644 index 0000000..330925d --- /dev/null +++ b/packages/record/package.json @@ -0,0 +1,26 @@ +{ + "name": "@workspace/record", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "es-toolkit": "^1.50.0", + "react": "^19.2.6" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "jsdom": "^30.0.1", + "react-dom": "^19.2.6", + "typescript": "~6", + "vitest": "^4.1.10" + }, + "exports": { + ".": "./src/index.ts" + } +} \ No newline at end of file diff --git a/packages/record/src/index.ts b/packages/record/src/index.ts new file mode 100644 index 0000000..bd0b424 --- /dev/null +++ b/packages/record/src/index.ts @@ -0,0 +1,7 @@ +export * from "./optional-record-provider" +export * from "./record-context" +export * from "./record-provider" +export * from "./records-iterator" +export * from "./use-field-value" +export * from "./use-record" +export * from "./types" diff --git a/packages/record/src/optional-record-provider.tsx b/packages/record/src/optional-record-provider.tsx new file mode 100644 index 0000000..55558e2 --- /dev/null +++ b/packages/record/src/optional-record-provider.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react" + +import { RecordProvider } from "./record-provider" +import type { RecordValue } from "./types" + +export type OptionalRecordProviderProps< + TRecord extends RecordValue = RecordValue, +> = { + children: ReactNode + value?: TRecord | null +} + +/** Adds a nested record context only when a record is available. */ +export function OptionalRecordProvider< + TRecord extends RecordValue = RecordValue, +>({ value, children }: OptionalRecordProviderProps) { + return value === undefined || value === null ? ( + children + ) : ( + {children} + ) +} diff --git a/packages/record/src/record-context.ts b/packages/record/src/record-context.ts new file mode 100644 index 0000000..0e4007b --- /dev/null +++ b/packages/record/src/record-context.ts @@ -0,0 +1,7 @@ +import { createContext } from "react" + +import type { RecordValue } from "./types" + +export const RecordContext = createContext(undefined) + +RecordContext.displayName = "RecordContext" diff --git a/packages/record/src/record-provider.tsx b/packages/record/src/record-provider.tsx new file mode 100644 index 0000000..e7fff20 --- /dev/null +++ b/packages/record/src/record-provider.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react" + +import { RecordContext } from "./record-context" +import type { RecordValue } from "./types" + +export type RecordProviderProps = { + children: ReactNode + value: TRecord +} + +/** Makes one record available to all descendant record-aware components. */ +export function RecordProvider({ + children, + value, +}: RecordProviderProps) { + return ( + {children} + ) +} diff --git a/packages/record/src/records-iterator.tsx b/packages/record/src/records-iterator.tsx new file mode 100644 index 0000000..d8bee05 --- /dev/null +++ b/packages/record/src/records-iterator.tsx @@ -0,0 +1,76 @@ +import { Fragment, type Key, type ReactNode } from "react" + +import { RecordProvider } from "./record-provider" +import type { RecordValue } from "./types" + +export type RecordKey = + | ((record: TRecord, index: number) => Key) + | (TRecord extends object ? keyof TRecord & string : never) + +export type RecordsIteratorProps = { + children?: ReactNode + data?: readonly TRecord[] + iterator?: (record: TRecord, index: number) => ReactNode + recordKey?: RecordKey + separator?: (index: number) => ReactNode +} + +/** Renders children once per record with an isolated record context. */ +export function RecordsIterator({ + children, + data, + iterator, + recordKey, + separator, +}: RecordsIteratorProps) { + if (!data?.length || !(iterator || children)) { + return null + } + + return ( + <> + {data.map((record, index) => ( + + {index > 0 && separator ? separator(index) : null} + + {iterator ? iterator(record, index) : children} + + + ))} + + ) +} + +function resolveRecordKey( + record: TRecord, + index: number, + recordKey?: RecordKey +): Key { + if (typeof recordKey === "function") return recordKey(record, index) + + if (recordKey !== undefined) { + if (!isIndexable(record)) return index + const value = Reflect.get(record, recordKey) + return isKey(value) ? value : index + } + + if (isKey(record)) return record + if (!isIndexable(record)) return index + + const id = Reflect.get(record, "id") + return isKey(id) ? id : index +} + +function isKey(value: unknown): value is Key { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" + ) +} + +function isIndexable(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || typeof value === "function" + ) +} diff --git a/packages/record/src/types.ts b/packages/record/src/types.ts new file mode 100644 index 0000000..6130148 --- /dev/null +++ b/packages/record/src/types.ts @@ -0,0 +1,84 @@ +type AtomicValue = + | bigint + | boolean + | Date + | ((...args: never[]) => unknown) + | null + | number + | RegExp + | string + | symbol + | undefined + +type IsAny = 0 extends 1 & Value ? true : false + +type PreviousDepth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + +type PathSegment< + Key extends string | number, + Value, + Depth extends number, +> = Value extends AtomicValue + ? `${Key}` + : Value extends readonly (infer Item)[] + ? + | `${Key}` + | `${Key}.${number}` + | (Item extends AtomicValue + ? never + : `${Key}.${number}.${RecordPath}`) + : `${Key}` | `${Key}.${RecordPath}` + +/** Any non-nullish value that can be exposed through the record context. */ +export type RecordValue = NonNullable + +/** A value returned from a record field when its exact type is not known. */ +export type SourceValue = unknown + +/** Dot-separated paths into a record, including numeric array segments. */ +export type RecordPath = + IsAny extends true + ? string + : Depth extends 0 + ? never + : Value extends AtomicValue + ? never + : { + [Key in keyof Value & (string | number)]: PathSegment< + Key, + Value[Key], + Depth + > + }[keyof Value & (string | number)] + +/** Extracts valid record paths and falls back to string for open records. */ +export type ExtractRecordPaths = + IsAny extends true + ? string + : TRecord extends object + ? keyof TRecord extends never + ? string + : string extends keyof TRecord + ? string + : RecordPath + : string + +/** Resolves the value type at a dot-separated record path. */ +export type RecordPathValue< + Value, + Path extends string, +> = Path extends keyof Value + ? Value[Path] + : Path extends `${infer Head}.${infer Tail}` + ? Head extends keyof Value + ? RecordPathValue, Tail> + : Value extends readonly (infer Item)[] + ? Head extends `${number}` + ? RecordPathValue, Tail> + : unknown + : unknown + : Value extends readonly (infer Item)[] + ? Path extends `${number}` + ? Item + : unknown + : unknown diff --git a/packages/record/src/use-field-value.ts b/packages/record/src/use-field-value.ts new file mode 100644 index 0000000..d80cd8e --- /dev/null +++ b/packages/record/src/use-field-value.ts @@ -0,0 +1,30 @@ +import { get } from "es-toolkit/compat" + +import type { ExtractRecordPaths, RecordPathValue, RecordValue } from "./types" +import { useRecord } from "./use-record" + +export type UseFieldValueOptions< + TRecord extends RecordValue = RecordValue, + TSource extends ExtractRecordPaths = ExtractRecordPaths, +> = { + defaultValue?: RecordPathValue + record?: TRecord | null + source: TSource +} + +/** Reads a direct or nested field from a record override or context. */ +export function useFieldValue< + TRecord extends RecordValue = RecordValue, + TSource extends ExtractRecordPaths = ExtractRecordPaths, +>({ + defaultValue, + record: fallbackRecord, + source, +}: UseFieldValueOptions): + | RecordPathValue + | undefined { + const record = useRecord({ record: fallbackRecord }) + return get(record, source, defaultValue) as + | RecordPathValue + | undefined +} diff --git a/packages/record/src/use-record.ts b/packages/record/src/use-record.ts new file mode 100644 index 0000000..355a5f4 --- /dev/null +++ b/packages/record/src/use-record.ts @@ -0,0 +1,16 @@ +import { useContext } from "react" + +import { RecordContext } from "./record-context" +import type { RecordValue } from "./types" + +export type UseRecordOptions = { + record?: TRecord | null +} + +/** Reads a record override first, then falls back to the nearest context. */ +export function useRecord( + options?: UseRecordOptions +): TRecord | undefined { + const context = useContext(RecordContext) as TRecord | undefined + return options?.record ?? context +} diff --git a/packages/record/tsconfig.json b/packages/record/tsconfig.json new file mode 100644 index 0000000..d6e79fa --- /dev/null +++ b/packages/record/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "paths": { + "@workspace/record": ["./src/index.ts"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file