feat(record): add generic record context APIs
This commit is contained in:
@@ -228,6 +228,23 @@
|
|||||||
"vitest": "^4.1.10",
|
"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": {
|
"packages/search": {
|
||||||
"name": "@workspace/search",
|
"name": "@workspace/search",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -1066,6 +1083,8 @@
|
|||||||
|
|
||||||
"@workspace/preferences": ["@workspace/preferences@workspace:packages/preferences"],
|
"@workspace/preferences": ["@workspace/preferences@workspace:packages/preferences"],
|
||||||
|
|
||||||
|
"@workspace/record": ["@workspace/record@workspace:packages/record"],
|
||||||
|
|
||||||
"@workspace/search": ["@workspace/search@workspace:packages/search"],
|
"@workspace/search": ["@workspace/search@workspace:packages/search"],
|
||||||
|
|
||||||
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
|
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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<TRecord>) {
|
||||||
|
return value === undefined || value === null ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<RecordProvider value={value}>{children}</RecordProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
import type { RecordValue } from "./types"
|
||||||
|
|
||||||
|
export const RecordContext = createContext<RecordValue | undefined>(undefined)
|
||||||
|
|
||||||
|
RecordContext.displayName = "RecordContext"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { RecordContext } from "./record-context"
|
||||||
|
import type { RecordValue } from "./types"
|
||||||
|
|
||||||
|
export type RecordProviderProps<TRecord extends RecordValue = RecordValue> = {
|
||||||
|
children: ReactNode
|
||||||
|
value: TRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes one record available to all descendant record-aware components. */
|
||||||
|
export function RecordProvider<TRecord extends RecordValue = RecordValue>({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: RecordProviderProps<TRecord>) {
|
||||||
|
return (
|
||||||
|
<RecordContext.Provider value={value}>{children}</RecordContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TRecord extends RecordValue> =
|
||||||
|
| ((record: TRecord, index: number) => Key)
|
||||||
|
| (TRecord extends object ? keyof TRecord & string : never)
|
||||||
|
|
||||||
|
export type RecordsIteratorProps<TRecord extends RecordValue = RecordValue> = {
|
||||||
|
children?: ReactNode
|
||||||
|
data?: readonly TRecord[]
|
||||||
|
iterator?: (record: TRecord, index: number) => ReactNode
|
||||||
|
recordKey?: RecordKey<TRecord>
|
||||||
|
separator?: (index: number) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders children once per record with an isolated record context. */
|
||||||
|
export function RecordsIterator<TRecord extends RecordValue = RecordValue>({
|
||||||
|
children,
|
||||||
|
data,
|
||||||
|
iterator,
|
||||||
|
recordKey,
|
||||||
|
separator,
|
||||||
|
}: RecordsIteratorProps<TRecord>) {
|
||||||
|
if (!data?.length || !(iterator || children)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.map((record, index) => (
|
||||||
|
<Fragment key={resolveRecordKey(record, index, recordKey)}>
|
||||||
|
{index > 0 && separator ? separator(index) : null}
|
||||||
|
<RecordProvider value={record}>
|
||||||
|
{iterator ? iterator(record, index) : children}
|
||||||
|
</RecordProvider>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecordKey<TRecord extends RecordValue>(
|
||||||
|
record: TRecord,
|
||||||
|
index: number,
|
||||||
|
recordKey?: RecordKey<TRecord>
|
||||||
|
): 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
type AtomicValue =
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| Date
|
||||||
|
| ((...args: never[]) => unknown)
|
||||||
|
| null
|
||||||
|
| number
|
||||||
|
| RegExp
|
||||||
|
| string
|
||||||
|
| symbol
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
type IsAny<Value> = 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<Item, PreviousDepth[Depth]>}`)
|
||||||
|
: `${Key}` | `${Key}.${RecordPath<Value, PreviousDepth[Depth]>}`
|
||||||
|
|
||||||
|
/** Any non-nullish value that can be exposed through the record context. */
|
||||||
|
export type RecordValue = NonNullable<unknown>
|
||||||
|
|
||||||
|
/** 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<Value, Depth extends number = 6> =
|
||||||
|
IsAny<Value> 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<TRecord extends RecordValue> =
|
||||||
|
IsAny<TRecord> extends true
|
||||||
|
? string
|
||||||
|
: TRecord extends object
|
||||||
|
? keyof TRecord extends never
|
||||||
|
? string
|
||||||
|
: string extends keyof TRecord
|
||||||
|
? string
|
||||||
|
: RecordPath<TRecord>
|
||||||
|
: 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<NonNullable<Value[Head]>, Tail>
|
||||||
|
: Value extends readonly (infer Item)[]
|
||||||
|
? Head extends `${number}`
|
||||||
|
? RecordPathValue<NonNullable<Item>, Tail>
|
||||||
|
: unknown
|
||||||
|
: unknown
|
||||||
|
: Value extends readonly (infer Item)[]
|
||||||
|
? Path extends `${number}`
|
||||||
|
? Item
|
||||||
|
: unknown
|
||||||
|
: unknown
|
||||||
@@ -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<TRecord> = ExtractRecordPaths<TRecord>,
|
||||||
|
> = {
|
||||||
|
defaultValue?: RecordPathValue<TRecord, TSource>
|
||||||
|
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<TRecord> = ExtractRecordPaths<TRecord>,
|
||||||
|
>({
|
||||||
|
defaultValue,
|
||||||
|
record: fallbackRecord,
|
||||||
|
source,
|
||||||
|
}: UseFieldValueOptions<TRecord, TSource>):
|
||||||
|
| RecordPathValue<TRecord, TSource>
|
||||||
|
| undefined {
|
||||||
|
const record = useRecord({ record: fallbackRecord })
|
||||||
|
return get(record, source, defaultValue) as
|
||||||
|
| RecordPathValue<TRecord, TSource>
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useContext } from "react"
|
||||||
|
|
||||||
|
import { RecordContext } from "./record-context"
|
||||||
|
import type { RecordValue } from "./types"
|
||||||
|
|
||||||
|
export type UseRecordOptions<TRecord extends RecordValue = RecordValue> = {
|
||||||
|
record?: TRecord | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a record override first, then falls back to the nearest context. */
|
||||||
|
export function useRecord<TRecord extends RecordValue = RecordValue>(
|
||||||
|
options?: UseRecordOptions<TRecord>
|
||||||
|
): TRecord | undefined {
|
||||||
|
const context = useContext(RecordContext) as TRecord | undefined
|
||||||
|
return options?.record ?? context
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user