@uniweb/kit 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/hooks/index.js +6 -0
- package/src/hooks/useCacheEntry.js +53 -0
- package/src/hooks/useCollectionQueryable.js +74 -0
- package/src/hooks/useEntityDetail.js +51 -0
- package/src/hooks/useFetched.js +115 -0
- package/src/hooks/usePageState.js +52 -0
- package/src/hooks/useWebsiteState.js +44 -0
- package/src/index.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"fuse.js": "^7.0.0",
|
|
39
39
|
"shiki": "^3.0.0",
|
|
40
40
|
"tailwind-merge": "^2.6.0",
|
|
41
|
-
"@uniweb/core": "0.
|
|
41
|
+
"@uniweb/core": "0.7.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/hooks/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { useWebsite, default } from './useWebsite.js'
|
|
2
|
+
export { useFetched } from './useFetched.js'
|
|
3
|
+
export { useCacheEntry } from './useCacheEntry.js'
|
|
4
|
+
export { useEntityDetail } from './useEntityDetail.js'
|
|
5
|
+
export { useCollectionQueryable } from './useCollectionQueryable.js'
|
|
2
6
|
export { useRouting } from './useRouting.js'
|
|
3
7
|
export { useActiveRoute } from './useActiveRoute.js'
|
|
4
8
|
export { useVersion } from './useVersion.js'
|
|
@@ -8,6 +12,8 @@ export { useAccordion } from './useAccordion.js'
|
|
|
8
12
|
export { useGridLayout, getGridClasses } from './useGridLayout.js'
|
|
9
13
|
export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
|
|
10
14
|
export { useInView, useIsInView } from './useInView.js'
|
|
15
|
+
export { usePageState } from './usePageState.js'
|
|
16
|
+
export { useWebsiteState } from './useWebsiteState.js'
|
|
11
17
|
|
|
12
18
|
// Theme data hooks (runtime theme access)
|
|
13
19
|
export {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCacheEntry — read-only observation of a DataStore entry.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to a cache key without triggering a fetch. Returns null
|
|
5
|
+
* while the entry is absent; `{ data, meta }` once it's populated.
|
|
6
|
+
* Used for components that want to display data someone else fetched
|
|
7
|
+
* (e.g., an "articles count" shown in a header while the main page
|
|
8
|
+
* fetches the collection).
|
|
9
|
+
*
|
|
10
|
+
* Pairs with useFetched: useFetched triggers the dispatch and fills
|
|
11
|
+
* the cache; useCacheEntry reads it. They share the same keyspace.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* function ArticlesCount() {
|
|
15
|
+
* const entry = useCacheEntry({ path: '/data/articles.json', schema: 'articles' })
|
|
16
|
+
* if (!entry) return null
|
|
17
|
+
* return <span>{entry.data.length} articles</span>
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useState } from 'react'
|
|
22
|
+
import { getUniweb, deriveCacheKey } from '@uniweb/core'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object|null} request - A fetch request spec. Pass null/undefined
|
|
26
|
+
* to skip (returns null without subscribing).
|
|
27
|
+
* @returns {{ data: any, meta?: Object } | null}
|
|
28
|
+
*/
|
|
29
|
+
export function useCacheEntry(request) {
|
|
30
|
+
const website = getUniweb()?.activeWebsite ?? null
|
|
31
|
+
const key = request && website ? deriveCacheKey(request) : null
|
|
32
|
+
|
|
33
|
+
const [entry, setEntry] = useState(() =>
|
|
34
|
+
key ? website.dataStore.get(key) : null,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!key || !website) {
|
|
39
|
+
setEntry(null)
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setEntry(website.dataStore.get(key))
|
|
44
|
+
const unsubscribe = website.dataStore.subscribe(key, () => {
|
|
45
|
+
setEntry(website.dataStore.get(key))
|
|
46
|
+
})
|
|
47
|
+
return unsubscribe
|
|
48
|
+
}, [key, website])
|
|
49
|
+
|
|
50
|
+
return entry
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default useCacheEntry
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCollectionQueryable — read the queryable-surface metadata for a
|
|
3
|
+
* collection.
|
|
4
|
+
*
|
|
5
|
+
* Authors declare the queryable surface of an entity in `site.yml`:
|
|
6
|
+
*
|
|
7
|
+
* collections:
|
|
8
|
+
* members:
|
|
9
|
+
* path: collections/members
|
|
10
|
+
* queryable:
|
|
11
|
+
* department:
|
|
12
|
+
* type: enum
|
|
13
|
+
* label: Department
|
|
14
|
+
* options: [biology, physics, chemistry, geology]
|
|
15
|
+
* tenured:
|
|
16
|
+
* type: boolean
|
|
17
|
+
* label: Tenured
|
|
18
|
+
* start_year:
|
|
19
|
+
* type: range
|
|
20
|
+
* label: Start year
|
|
21
|
+
* min: 1800
|
|
22
|
+
* max: 2025
|
|
23
|
+
*
|
|
24
|
+
* Foundations consume this hook to render filter UIs and compose
|
|
25
|
+
* where-objects from user interactions. The kit doesn't ship UI
|
|
26
|
+
* components for the controls (different foundations have different
|
|
27
|
+
* vocabularies); foundations build their own controls against the
|
|
28
|
+
* metadata returned here.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* function MemberFilters({ onChange }) {
|
|
32
|
+
* const queryable = useCollectionQueryable('members')
|
|
33
|
+
* const [values, setValues] = useState({})
|
|
34
|
+
* if (!queryable) return null
|
|
35
|
+
* return (
|
|
36
|
+
* <div>
|
|
37
|
+
* {Object.entries(queryable).map(([field, def]) => (
|
|
38
|
+
* <FilterControl
|
|
39
|
+
* key={field}
|
|
40
|
+
* field={field}
|
|
41
|
+
* def={def}
|
|
42
|
+
* value={values[field]}
|
|
43
|
+
* onChange={(v) => {
|
|
44
|
+
* const next = { ...values, [field]: v }
|
|
45
|
+
* setValues(next)
|
|
46
|
+
* onChange(composeWhereObject(next))
|
|
47
|
+
* }}
|
|
48
|
+
* />
|
|
49
|
+
* ))}
|
|
50
|
+
* </div>
|
|
51
|
+
* )
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* Returns null when the collection has no `queryable:` declared.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} collectionName - Name of the collection to read queryable
|
|
57
|
+
* metadata for. Pass null/undefined to skip (returns null).
|
|
58
|
+
* @returns {Object|null} The queryable metadata object, or null.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { getUniweb } from '@uniweb/core'
|
|
62
|
+
|
|
63
|
+
export function useCollectionQueryable(collectionName) {
|
|
64
|
+
if (!collectionName) return null
|
|
65
|
+
const website = getUniweb()?.activeWebsite
|
|
66
|
+
if (!website) return null
|
|
67
|
+
const config = website.config?.collections?.[collectionName]
|
|
68
|
+
if (!config || typeof config !== 'object') return null
|
|
69
|
+
const queryable = config.queryable
|
|
70
|
+
if (!queryable || typeof queryable !== 'object') return null
|
|
71
|
+
return queryable
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default useCollectionQueryable
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEntityDetail — fetch the full record for a deferred-field collection.
|
|
3
|
+
*
|
|
4
|
+
* When a collection declares `deferred: [...]` in `site.yml`, the cascade
|
|
5
|
+
* payload omits the deferred fields. The full record (with deferred
|
|
6
|
+
* fields included) lives in a per-record file at
|
|
7
|
+
* `/data/<collection>/<slug>.json`. On dynamic-route pages, the framework
|
|
8
|
+
* routes the singular detail there automatically. Anywhere else (a card
|
|
9
|
+
* grid that wants to show a hover-card preview, a modal that opens an
|
|
10
|
+
* article body) — use this hook.
|
|
11
|
+
*
|
|
12
|
+
* Returns `{ data, error, loading }` like `useFetched`. Shares the same
|
|
13
|
+
* cache. Pass null/undefined to skip without subscribing.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* function ArticleCard({ article }) {
|
|
17
|
+
* const [open, setOpen] = useState(false)
|
|
18
|
+
* const { data: full, loading } = useEntityDetail(open ? article : null, {
|
|
19
|
+
* collection: 'articles',
|
|
20
|
+
* })
|
|
21
|
+
* return (
|
|
22
|
+
* <div>
|
|
23
|
+
* <h3>{article.title}</h3>
|
|
24
|
+
* <p>{article.excerpt}</p>
|
|
25
|
+
* <button onClick={() => setOpen(true)}>Read more</button>
|
|
26
|
+
* {open && (loading ? <Spinner /> : <ArticleBody html={full.body} />)}
|
|
27
|
+
* </div>
|
|
28
|
+
* )
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { useFetched } from './useFetched.js'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Object|null} record - A record from a cascade-delivered collection.
|
|
36
|
+
* Must have a `slug` field. Pass null/undefined to skip the fetch.
|
|
37
|
+
* @param {Object} [options]
|
|
38
|
+
* @param {string} options.collection - The collection name (e.g., 'articles').
|
|
39
|
+
* Required when record is non-null. Used to build the per-record file URL
|
|
40
|
+
* `/data/<collection>/<slug>.json`.
|
|
41
|
+
* @returns {{ data: any, error: string|null, loading: boolean }}
|
|
42
|
+
*/
|
|
43
|
+
export function useEntityDetail(record, options = {}) {
|
|
44
|
+
const collection = options?.collection
|
|
45
|
+
const request = (record && record.slug && collection)
|
|
46
|
+
? { path: `/data/${collection}/${record.slug}.json`, schema: collection }
|
|
47
|
+
: null
|
|
48
|
+
return useFetched(request)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default useEntityDetail
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFetched — imperative Layer-3 fetch hook.
|
|
3
|
+
*
|
|
4
|
+
* Shares the DataStore keyspace with Layer-1 (`content.data.*`) fetches.
|
|
5
|
+
* A declarative fetch for the same request warms the cache; this hook
|
|
6
|
+
* gets a synchronous cache hit on first render.
|
|
7
|
+
*
|
|
8
|
+
* Surface is intentionally narrow — no retries, no refetch intervals,
|
|
9
|
+
* no stale-while-revalidate. If you need those, reach for a
|
|
10
|
+
* purpose-built library and compose it around your own fetch.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* function ProductCard({ params }) {
|
|
14
|
+
* const { data, error, loading } = useFetched({
|
|
15
|
+
* url: params.productUrl,
|
|
16
|
+
* transform: 'data.product',
|
|
17
|
+
* })
|
|
18
|
+
* if (loading) return <Skeleton />
|
|
19
|
+
* if (error) return <Error message={error} />
|
|
20
|
+
* return <Product {...data} />
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useEffect, useState } from 'react'
|
|
25
|
+
import { getUniweb, deriveCacheKey } from '@uniweb/core'
|
|
26
|
+
|
|
27
|
+
function emptyState() {
|
|
28
|
+
return { data: null, error: null, loading: false }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadingState() {
|
|
32
|
+
return { data: null, error: null, loading: true }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readyState(data) {
|
|
36
|
+
return { data, error: null, loading: false }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorState(error, data = null) {
|
|
40
|
+
return { data, error, loading: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object|null} request - A fetch request spec (path / url / schema / ...).
|
|
45
|
+
* Pass null/undefined to skip — the hook returns `{ data: null, error: null,
|
|
46
|
+
* loading: false }` without subscribing or dispatching.
|
|
47
|
+
* @returns {{ data: any, error: string|null, loading: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
export function useFetched(request) {
|
|
50
|
+
const website = getUniweb()?.activeWebsite ?? null
|
|
51
|
+
|
|
52
|
+
// Cache key is also the React dep. Serialized JSON so prop-identity-
|
|
53
|
+
// changes that don't affect the key don't re-run the effect.
|
|
54
|
+
const key = request && website ? deriveCacheKey(request) : null
|
|
55
|
+
|
|
56
|
+
// Synchronous hit on first render.
|
|
57
|
+
const initial = key ? website.dataStore.get(key) : null
|
|
58
|
+
const [state, setState] = useState(() =>
|
|
59
|
+
initial ? readyState(initial.data) : (key ? loadingState() : emptyState()),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!key || !website) {
|
|
64
|
+
setState(emptyState())
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Re-check cache after mount: the key may have changed between
|
|
69
|
+
// render and effect, or a parallel Layer-1 fetch may have landed.
|
|
70
|
+
const cached = website.dataStore.get(key)
|
|
71
|
+
if (cached) {
|
|
72
|
+
setState(readyState(cached.data))
|
|
73
|
+
} else {
|
|
74
|
+
setState(loadingState())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Subscribe first so a concurrent Layer-1 fetch's cache write
|
|
78
|
+
// wakes this hook even if our own dispatch is still in flight.
|
|
79
|
+
const unsubscribe = website.dataStore.subscribe(key, () => {
|
|
80
|
+
const entry = website.dataStore.get(key)
|
|
81
|
+
if (entry) setState(readyState(entry.data))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const controller = new AbortController()
|
|
85
|
+
website.fetcher
|
|
86
|
+
.dispatch(request, { website, signal: controller.signal })
|
|
87
|
+
.then((result) => {
|
|
88
|
+
if (controller.signal.aborted) return
|
|
89
|
+
if (result?.error) {
|
|
90
|
+
setState(errorState(result.error, result.data ?? null))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
// Successful results typically arrive via the subscription (the
|
|
94
|
+
// dispatcher writes to cache and fires listeners). Fall through
|
|
95
|
+
// here to handle cached-hit and null-data paths so a useFetched
|
|
96
|
+
// caller never sits in `loading: true` when dispatch returns.
|
|
97
|
+
if (result && 'data' in result) {
|
|
98
|
+
setState(readyState(result.data))
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
if (controller.signal.aborted) return
|
|
103
|
+
setState(errorState(String(err?.message || err)))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
controller.abort()
|
|
108
|
+
unsubscribe()
|
|
109
|
+
}
|
|
110
|
+
}, [key, website])
|
|
111
|
+
|
|
112
|
+
return state
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default useFetched
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePageState — React bridge into the active page's ObservableState.
|
|
3
|
+
*
|
|
4
|
+
* Reads `page.state.get(key)` on render and subscribes to changes so
|
|
5
|
+
* React re-renders when the value flips. The setter writes back through
|
|
6
|
+
* `page.state.set(key, value)` — one source of truth, readable from
|
|
7
|
+
* outside React (the fetcher, sibling components, persistence helpers).
|
|
8
|
+
*
|
|
9
|
+
* Scope: the *active* page's state. Navigating to a different page
|
|
10
|
+
* swaps the observed scope; the new page's state is read instead.
|
|
11
|
+
* That's usually what foundations want (filters don't carry across
|
|
12
|
+
* pages). For site-wide state use `useWebsiteState`.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} key - The key on page.state.
|
|
15
|
+
* @param {*} [defaultValue] - Returned when the key has never been set.
|
|
16
|
+
* @returns {[any, (value: any) => void]} [value, setValue]
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const [slug, setSlug] = usePageState('selectedQuery', 'all')
|
|
20
|
+
* return <select value={slug} onChange={e => setSlug(e.target.value)}>{...}</select>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useEffect, useReducer } from 'react'
|
|
24
|
+
import { useWebsite } from './useWebsite.js'
|
|
25
|
+
|
|
26
|
+
function forceTick(n) {
|
|
27
|
+
return n + 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function usePageState(key, defaultValue) {
|
|
31
|
+
const { website } = useWebsite()
|
|
32
|
+
const [, tick] = useReducer(forceTick, 0)
|
|
33
|
+
|
|
34
|
+
const page = website.activePage
|
|
35
|
+
const state = page?.state
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!state) return
|
|
39
|
+
return state.subscribe(key, tick)
|
|
40
|
+
}, [state, key])
|
|
41
|
+
|
|
42
|
+
const value = state?.has(key) ? state.get(key) : defaultValue
|
|
43
|
+
|
|
44
|
+
const setValue = (next) => {
|
|
45
|
+
if (!state) return
|
|
46
|
+
state.set(key, typeof next === 'function' ? next(value) : next)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [value, setValue]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default usePageState
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWebsiteState — React bridge into the Website's ObservableState.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `usePageState` but scoped site-wide rather than per-page.
|
|
5
|
+
* Typical uses: active appearance ('light'/'dark'/'system'), authenticated
|
|
6
|
+
* user, cross-page selections (a filter chosen on /search that other
|
|
7
|
+
* pages honor).
|
|
8
|
+
*
|
|
9
|
+
* Same contract: reads on render, subscribes to the keyed slot, re-renders
|
|
10
|
+
* when the value changes, writes via `website.state.set(key, value)`.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} key
|
|
13
|
+
* @param {*} [defaultValue]
|
|
14
|
+
* @returns {[any, (value: any) => void]}
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useReducer } from 'react'
|
|
18
|
+
import { useWebsite } from './useWebsite.js'
|
|
19
|
+
|
|
20
|
+
function forceTick(n) {
|
|
21
|
+
return n + 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useWebsiteState(key, defaultValue) {
|
|
25
|
+
const { website } = useWebsite()
|
|
26
|
+
const [, tick] = useReducer(forceTick, 0)
|
|
27
|
+
const state = website?.state
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!state) return
|
|
31
|
+
return state.subscribe(key, tick)
|
|
32
|
+
}, [state, key])
|
|
33
|
+
|
|
34
|
+
const value = state?.has(key) ? state.get(key) : defaultValue
|
|
35
|
+
|
|
36
|
+
const setValue = (next) => {
|
|
37
|
+
if (!state) return
|
|
38
|
+
state.set(key, typeof next === 'function' ? next(value) : next)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [value, setValue]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default useWebsiteState
|
package/src/index.js
CHANGED
|
@@ -51,6 +51,11 @@ export {
|
|
|
51
51
|
|
|
52
52
|
export {
|
|
53
53
|
useWebsite,
|
|
54
|
+
// Layer-3 data hooks — share the DataStore keyspace with Layer 1.
|
|
55
|
+
useFetched,
|
|
56
|
+
useCacheEntry,
|
|
57
|
+
useEntityDetail,
|
|
58
|
+
useCollectionQueryable,
|
|
54
59
|
useRouting,
|
|
55
60
|
useActiveRoute,
|
|
56
61
|
useVersion,
|
|
@@ -71,7 +76,10 @@ export {
|
|
|
71
76
|
useColorContext,
|
|
72
77
|
useAppearance,
|
|
73
78
|
useThemeColor,
|
|
74
|
-
useThemeColorVar
|
|
79
|
+
useThemeColorVar,
|
|
80
|
+
// Observable state bridges (page.state / website.state)
|
|
81
|
+
usePageState,
|
|
82
|
+
useWebsiteState
|
|
75
83
|
} from './hooks/index.js'
|
|
76
84
|
|
|
77
85
|
// ============================================================================
|