feat(record): add generic record context APIs
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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