@startsimpli/ui 0.4.110 → 0.4.112
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 +3 -1
- package/src/components/collection/DraftReviewWorkspace.tsx +29 -3
- package/src/components/collection/TopicReviewWorkspace.tsx +28 -2
- package/src/components/collection/__tests__/scoped-client.test.ts +243 -0
- package/src/components/collection/__tests__/scoped-workspace.test.tsx +179 -0
- package/src/components/collection/index.ts +22 -1
- package/src/components/collection/scoped-client.ts +155 -0
- package/src/components/collection/types.ts +52 -1
- package/src/components/facet-tree/FacetTree.tsx +250 -0
- package/src/components/facet-tree/__tests__/facet-tree.test.tsx +228 -0
- package/src/components/facet-tree/index.ts +26 -0
- package/src/components/facet-tree/path.ts +55 -0
- package/src/components/facet-tree/types.ts +89 -0
- package/src/components/facet-tree/useFacetLevels.ts +197 -0
- package/src/components/index.ts +47 -0
- package/src/components/scope/ScopeSwitcher.tsx +548 -0
- package/src/components/scope/__tests__/scope-client.test.ts +131 -0
- package/src/components/scope/__tests__/scope-switcher.test.tsx +338 -0
- package/src/components/scope/__tests__/use-scope-param.test.tsx +265 -0
- package/src/components/scope/__tests__/use-scoped-client.test.tsx +80 -0
- package/src/components/scope/client.ts +145 -0
- package/src/components/scope/index.ts +40 -0
- package/src/components/scope/types.ts +90 -0
- package/src/components/scope/useScopeParam.ts +200 -0
- package/src/components/scope/useScopedClient.ts +44 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ONE SEAM (bd startsim-nq39j, epic startsim-ux39t).
|
|
3
|
+
*
|
|
4
|
+
* `withScope(client, scope)` returns a `CollectionClient` whose LIST reads carry
|
|
5
|
+
* the active scope and whose WRITES cannot cross one. Because every review
|
|
6
|
+
* surface in this package is client-injected — the app wrapper is ~10 LOC and
|
|
7
|
+
* hands in its authed tenant API — wrapping that one object scopes the whole
|
|
8
|
+
* surface at once. Nothing else in `collection/` knows what a scope is, and
|
|
9
|
+
* nothing else should learn: a second place that narrows is a second place that
|
|
10
|
+
* can forget to.
|
|
11
|
+
*
|
|
12
|
+
* THE WIRE SPELLING OF "EVERYTHING" IS SILENCE, and this is the decision the
|
|
13
|
+
* bead asked to be taken deliberately. The BROWSER URL spells it `?scope=all`
|
|
14
|
+
* (`SCOPE_ALL`), because there a missing parameter has to stay distinguishable
|
|
15
|
+
* from an asserted one — see `../scope/useScopeParam`. The WIRE cannot borrow
|
|
16
|
+
* that spelling and must not borrow the empty one either:
|
|
17
|
+
*
|
|
18
|
+
* • `?scope=all` is a 400 — every stored path begins with the separator, so a
|
|
19
|
+
* value that does not is refused as a value that could only match nothing.
|
|
20
|
+
* • `?scope=` (blank) is ALSO a 400, and it is the dangerous one:
|
|
21
|
+
* `normalize_container("")` returns `/`, so the obvious implementation reads
|
|
22
|
+
* an empty parameter as EVERY scoped row (bd startsim-mj609).
|
|
23
|
+
*
|
|
24
|
+
* So "no scope" OMITS THE KEY. That is not merely the safe encoding, it is the
|
|
25
|
+
* only correct one: an omitted parameter returns unscoped rows too, which today
|
|
26
|
+
* is every row, while `?scope=/` would return only rows that carry a path.
|
|
27
|
+
*
|
|
28
|
+
* A PATH IS PASSED THROUGH VERBATIM, INCLUDING A BAD ONE. A value that is not
|
|
29
|
+
* `all` and not blank goes on the wire as the caller wrote it, even without a
|
|
30
|
+
* leading separator, because the server refuses that and a refusal is a better
|
|
31
|
+
* answer than a client that quietly drops a narrowing it did not understand.
|
|
32
|
+
*
|
|
33
|
+
* REFERENTIALLY STABLE OR NOT AT ALL. The returned client is the query key's
|
|
34
|
+
* input and react-query's identity for the injected API; building a new one on
|
|
35
|
+
* every render is the same mistake as building a new `ScopeClient` on every
|
|
36
|
+
* render (see `../scope/types.ts`). Memoize it on the scope, or use
|
|
37
|
+
* `useScopedCollectionClient` from `@startsimpli/ui/scope`, which does.
|
|
38
|
+
*/
|
|
39
|
+
import type { CollectionClient, EntityFilters, ListEntitiesOptions } from './types';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The query parameter the tenant recognises. It is a plain lowercase word, so
|
|
43
|
+
* unlike `attr.x__gte` it survives a camel/snake rewrite unharmed — but it still
|
|
44
|
+
* travels the {@link EntityFilters} path, because one narrowing mechanism is the
|
|
45
|
+
* whole point of `EntityFilters` existing.
|
|
46
|
+
*/
|
|
47
|
+
export const SCOPE_FILTER = 'scope';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The URL's reserved word for "no bound at all". Recognised here as well as in
|
|
51
|
+
* `useScopeParam` because a caller can reach a raw `?scope=` value without going
|
|
52
|
+
* through the hook — a server component reading `searchParams`, a test, an app
|
|
53
|
+
* that keeps the scope somewhere else — and `all` must mean the same thing to
|
|
54
|
+
* every one of them.
|
|
55
|
+
*/
|
|
56
|
+
const SCOPE_ALL_VALUE = 'all';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A `CollectionClient` that knows what it is bounded to.
|
|
60
|
+
*
|
|
61
|
+
* `scope` is exposed for ONE reason and it is not decoration: a cached read is
|
|
62
|
+
* only valid for the scope it was made under, so a consumer's react-query key
|
|
63
|
+
* has to name it. {@link scopeKey} is that read.
|
|
64
|
+
*/
|
|
65
|
+
export interface ScopedCollectionClient extends CollectionClient {
|
|
66
|
+
/** The active path, or `null` for no bound. */
|
|
67
|
+
readonly scope: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve any spelling of a scope to the one the wire uses: a path, or `null`.
|
|
72
|
+
*
|
|
73
|
+
* `null`, `undefined`, empty, whitespace and the reserved `all` all resolve to
|
|
74
|
+
* `null` — "no bound" — and `null` is spelled on the wire by sending nothing.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveScope(scope: string | null | undefined): string | null {
|
|
77
|
+
if (typeof scope !== 'string') return null;
|
|
78
|
+
const trimmed = scope.trim();
|
|
79
|
+
if (!trimmed || trimmed === SCOPE_ALL_VALUE) return null;
|
|
80
|
+
return trimmed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The filters a scope contributes to a list read — `{}` when there is no bound.
|
|
85
|
+
*
|
|
86
|
+
* IT RETURNS AN ABSENT KEY, NOT AN EMPTY ONE. `{ scope: '' }` is a 400 that
|
|
87
|
+
* would be read as "the root" by an unlucky implementation; the only encoding of
|
|
88
|
+
* "everything" this package will ever put on the wire is no key at all.
|
|
89
|
+
*/
|
|
90
|
+
export function scopeFilters(scope: string | null | undefined): EntityFilters {
|
|
91
|
+
const resolved = resolveScope(scope);
|
|
92
|
+
return resolved === null ? {} : { [SCOPE_FILTER]: resolved };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The cache-key fragment for a client's bound. `'all'` for no bound, so an
|
|
97
|
+
* UNWRAPPED client (a plain `CollectionClient`, which has no `scope` at all)
|
|
98
|
+
* keys identically to an explicitly unbounded one and an existing consumer's
|
|
99
|
+
* cache does not split in two for no reason.
|
|
100
|
+
*/
|
|
101
|
+
export function scopeKey(client: Pick<ScopedCollectionClient, 'scope'> | CollectionClient | null | undefined): string {
|
|
102
|
+
const scope = (client as ScopedCollectionClient | null | undefined)?.scope;
|
|
103
|
+
return resolveScope(scope) ?? SCOPE_ALL_VALUE;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bind a `CollectionClient` to a scope.
|
|
108
|
+
*
|
|
109
|
+
* LIST reads gain `?scope=<path>`; WRITES are handed on BYTE-FOR-BYTE. A write
|
|
110
|
+
* that carried the active scope would be able to MOVE a row: patch a row while
|
|
111
|
+
* `/clients/acme` is selected and it would silently leave `/clients/globex`.
|
|
112
|
+
* Changing a row's scope is an explicit act with its own affordance, never a
|
|
113
|
+
* side effect of editing it somewhere. (The bead's phrase "writes stamp it"
|
|
114
|
+
* belongs to CREATE, on the scope-aware upsert path — there is no create on this
|
|
115
|
+
* interface, so there is nothing here to stamp.)
|
|
116
|
+
*/
|
|
117
|
+
export function withScope(
|
|
118
|
+
client: CollectionClient,
|
|
119
|
+
scope: string | null | undefined,
|
|
120
|
+
): ScopedCollectionClient {
|
|
121
|
+
const resolved = resolveScope(scope);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
scope: resolved,
|
|
125
|
+
|
|
126
|
+
// Passed on with NO arguments at all. The schema read carries no `?type=`,
|
|
127
|
+
// and `?scope=` without `?type=` is a 400 (bd startsim-mj609) — so this is
|
|
128
|
+
// not an omission to tidy up later, it is the refusal being respected.
|
|
129
|
+
listTypes: () => client.listTypes(),
|
|
130
|
+
|
|
131
|
+
listAllEntities: (type: string, options?: ListEntitiesOptions) => {
|
|
132
|
+
// A type-less list read cannot carry a scope for the same reason
|
|
133
|
+
// `listTypes` cannot: the parameter resolves through ONE type's
|
|
134
|
+
// declaration. A blank type is already a bad request; adding a scope to it
|
|
135
|
+
// would turn a legible 400 into a confusing one.
|
|
136
|
+
const scopable = typeof type === 'string' && type.trim() !== '';
|
|
137
|
+
const applies = scopable && options?.scoped !== false;
|
|
138
|
+
const extra = applies ? scopeFilters(resolved) : {};
|
|
139
|
+
// Merged UNDER the caller's own filters, not over them: a consumer that
|
|
140
|
+
// deliberately sent its own `scope` (a fixture, a one-off drill-down)
|
|
141
|
+
// means it, and the decorator does not overrule it.
|
|
142
|
+
const filters: EntityFilters = { ...extra, ...(options?.filters ?? {}) };
|
|
143
|
+
const next: ListEntitiesOptions = { ...options, filters };
|
|
144
|
+
// No filters and no options in means no options through — an
|
|
145
|
+
// implementation that only ever took `(type)` sees exactly what it did
|
|
146
|
+
// before, so wrapping an old client is not a behaviour change.
|
|
147
|
+
if (Object.keys(filters).length === 0 && options === undefined) {
|
|
148
|
+
return client.listAllEntities(type);
|
|
149
|
+
}
|
|
150
|
+
return client.listAllEntities(type, next);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
updateEntity: (id, input) => client.updateEntity(id, input),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -42,13 +42,64 @@ export interface Paginated<T> {
|
|
|
42
42
|
results: T[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Server-side narrowing, spelled EXACTLY as the wire spells it — `type`,
|
|
47
|
+
* `scope`, `attr.published_at__gte`, `attr.topic_ref__in`. The value is a string
|
|
48
|
+
* because it is a query-string value and nothing else.
|
|
49
|
+
*
|
|
50
|
+
* THE KEY GOES IN THE URL, NEVER THROUGH A CLIENT'S `params` BAG. The shared
|
|
51
|
+
* `@startsimpli/api` client rewrites request keys between camelCase and
|
|
52
|
+
* snake_case, and `attr.published_at__gte` is neither — it comes out mangled,
|
|
53
|
+
* the server does not recognise it, and (on an image with
|
|
54
|
+
* TENANT_ALLOW_UNKNOWN_FILTERS on) reads as "no filter" rather than as an
|
|
55
|
+
* error. That is a widening, and a widening under an active filter chip is the
|
|
56
|
+
* failure that cost nine production tandem rows on 2026-08-27. The fork's
|
|
57
|
+
* `listEntities` already builds the query string itself for this reason and is
|
|
58
|
+
* guarded by its own `src/lib/__tests__/entity-filters.test.ts`; this type is
|
|
59
|
+
* the same contract, named, so a `CollectionClient` implementation inherits the
|
|
60
|
+
* requirement instead of rediscovering it.
|
|
61
|
+
*/
|
|
62
|
+
export type EntityFilters = Record<string, string>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How a list read is narrowed. Deliberately the SAME SHAPE the fork's
|
|
66
|
+
* `listAllEntities(type, { filters })` already accepts (bd startsim-nq39j), so
|
|
67
|
+
* the existing, tested filter path is extended rather than joined by a second
|
|
68
|
+
* one — an app that already implements it satisfies this interface unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export interface ListEntitiesOptions {
|
|
71
|
+
/** Server-side narrowing — see {@link EntityFilters}. */
|
|
72
|
+
filters?: EntityFilters;
|
|
73
|
+
/**
|
|
74
|
+
* Opt this read OUT of the active scope. Default: scoped.
|
|
75
|
+
*
|
|
76
|
+
* THE SHARED CORPUS IS WHY THIS EXISTS (epic startsim-ux39t, decision 2: "one
|
|
77
|
+
* news_item corpus, one source registry"). A source registry or an article
|
|
78
|
+
* corpus is subscribed to by several scopes rather than ingested per scope, so
|
|
79
|
+
* narrowing a LOOKUP read of one to the active scope answers the wrong
|
|
80
|
+
* question: either the type declares no scope path and the server 400s
|
|
81
|
+
* (`?scope=` on an unscoped type is a refusal, bd startsim-mj609), or it does
|
|
82
|
+
* and the lookup silently loses every row outside the bound. Deliverable reads
|
|
83
|
+
* — the topics and drafts a client is owed — stay scoped.
|
|
84
|
+
*
|
|
85
|
+
* The polarity is deliberate: forgetting this flag NARROWS loudly, it never
|
|
86
|
+
* widens silently. Do not delete it as dead config.
|
|
87
|
+
*/
|
|
88
|
+
scoped?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
45
91
|
/**
|
|
46
92
|
* The tenant-API surface a review workspace needs. The app implements this over
|
|
47
93
|
* its authed same-origin client (keys are camel/snake-normalized by that client;
|
|
48
94
|
* PATCH replaces the whole data blob, so callers send the full merged data).
|
|
95
|
+
*
|
|
96
|
+
* `listTypes` TAKES NO NARROWING AND MUST NOT. It is a schema read with no
|
|
97
|
+
* `?type=`, and `?scope=` REQUIRES `?type=` server-side — a 400, by design,
|
|
98
|
+
* because the parameter resolves through one type's declaration and an
|
|
99
|
+
* unfiltered list spans many (bd startsim-mj609).
|
|
49
100
|
*/
|
|
50
101
|
export interface CollectionClient {
|
|
51
102
|
listTypes(): Promise<Paginated<EntityTypeDef>>;
|
|
52
|
-
listAllEntities(type: string): Promise<EntityRecord[]>;
|
|
103
|
+
listAllEntities(type: string, options?: ListEntitiesOptions): Promise<EntityRecord[]>;
|
|
53
104
|
updateEntity(id: number | string, input: { name?: string; data?: Record<string, unknown> }): Promise<EntityRecord>;
|
|
54
105
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE TREE, drawn from a level cache and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* Purely presentational: every row it renders comes out of `levels[under]` for
|
|
7
|
+
* the container it is currently rendering, so the security property described in
|
|
8
|
+
* `useFacetLevels` survives the render. There is no path arithmetic here at all
|
|
9
|
+
* — `node.path` is what the server said and it is what goes to the next frame.
|
|
10
|
+
*
|
|
11
|
+
* A11Y, AND WHAT IT DELIBERATELY DOES NOT CLAIM. This is a `<ul>` of disclosure
|
|
12
|
+
* buttons, not a `role="tree"`. The full WAI-ARIA tree pattern owes a reader
|
|
13
|
+
* roving-tabindex focus management and arrow-key traversal; claiming the roles
|
|
14
|
+
* without them tells a screen reader to expect navigation that is not there,
|
|
15
|
+
* which is worse than the plain list this actually is. Tab reaches every control
|
|
16
|
+
* natively, `aria-expanded` states the disclosure, `aria-current` states the
|
|
17
|
+
* selection, and each row's name is spelled out rather than left to the DOM.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react'
|
|
21
|
+
import { FACET_ROOT } from './path'
|
|
22
|
+
import { EMPTY_LEVEL } from './types'
|
|
23
|
+
import type { FacetLevelState } from './types'
|
|
24
|
+
|
|
25
|
+
export type FacetTreeSlot = 'root' | 'node' | 'nodeSelected' | 'count' | 'muted' | 'error' | 'more'
|
|
26
|
+
|
|
27
|
+
const DEFAULTS: Record<FacetTreeSlot, string> = {
|
|
28
|
+
root: 'space-y-0.5',
|
|
29
|
+
node: 'flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground',
|
|
30
|
+
nodeSelected: 'bg-accent font-medium text-accent-foreground',
|
|
31
|
+
count: 'ml-auto shrink-0 text-xs tabular-nums opacity-60',
|
|
32
|
+
muted: 'px-2 py-1 text-xs text-muted-foreground',
|
|
33
|
+
error: 'px-2 py-1 text-xs text-destructive',
|
|
34
|
+
more: 'px-2 py-1 text-xs font-medium underline underline-offset-2 opacity-80 hover:opacity-100 disabled:no-underline disabled:opacity-50',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FacetTreeProps {
|
|
38
|
+
levels: Record<string, FacetLevelState>
|
|
39
|
+
expanded: Record<string, boolean>
|
|
40
|
+
/** The full path currently in scope, or `null` for "no bound at all". */
|
|
41
|
+
selected: string | null
|
|
42
|
+
/** Handed a full path the server returned, verbatim. */
|
|
43
|
+
onSelect: (path: string) => void
|
|
44
|
+
onToggle: (path: string) => void
|
|
45
|
+
onMore: (under: string, page: number) => void
|
|
46
|
+
/** The top of the tree. `/` means everything. */
|
|
47
|
+
root?: string
|
|
48
|
+
/**
|
|
49
|
+
* The noun in a row's accessible name — "record", "document", "topic".
|
|
50
|
+
*
|
|
51
|
+
* "INSIDE" is appended rather than "matching", because the number is
|
|
52
|
+
* DESCENDANTS: a reader who has narrowed the list would otherwise hear
|
|
53
|
+
* "Acme, 8 records" beside a list of none. The word has to stay true whatever
|
|
54
|
+
* the list is currently bounded to.
|
|
55
|
+
*/
|
|
56
|
+
countNoun?: string
|
|
57
|
+
/** The noun in "Show N more …" — "folder", "scope", "category". */
|
|
58
|
+
nodeNoun?: string
|
|
59
|
+
/** Pixels of indent per level. */
|
|
60
|
+
indent?: number
|
|
61
|
+
classNames?: Partial<Record<FacetTreeSlot, string>>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function FacetTree({
|
|
65
|
+
levels,
|
|
66
|
+
expanded,
|
|
67
|
+
selected,
|
|
68
|
+
onSelect,
|
|
69
|
+
onToggle,
|
|
70
|
+
onMore,
|
|
71
|
+
root = FACET_ROOT,
|
|
72
|
+
countNoun = 'record',
|
|
73
|
+
nodeNoun = 'item',
|
|
74
|
+
indent = 14,
|
|
75
|
+
classNames = {},
|
|
76
|
+
}: FacetTreeProps) {
|
|
77
|
+
const cn = { ...DEFAULTS, ...classNames }
|
|
78
|
+
const top = levels[root] ?? EMPTY_LEVEL
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={cn.root}>
|
|
82
|
+
{top.loading && top.rows.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
|
|
83
|
+
{top.error ? (
|
|
84
|
+
<p className={cn.error} role="alert">
|
|
85
|
+
{top.error}
|
|
86
|
+
</p>
|
|
87
|
+
) : null}
|
|
88
|
+
<FacetLevel
|
|
89
|
+
under={root}
|
|
90
|
+
depth={0}
|
|
91
|
+
levels={levels}
|
|
92
|
+
expanded={expanded}
|
|
93
|
+
selected={selected}
|
|
94
|
+
onSelect={onSelect}
|
|
95
|
+
onToggle={onToggle}
|
|
96
|
+
onMore={onMore}
|
|
97
|
+
countNoun={countNoun}
|
|
98
|
+
nodeNoun={nodeNoun}
|
|
99
|
+
indent={indent}
|
|
100
|
+
classNames={cn}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The children of ONE container, and recursively the expanded ones among them.
|
|
110
|
+
*
|
|
111
|
+
* It reads `levels[under]` and nothing else — the whole map travels down only so
|
|
112
|
+
* that recursion needs no context, and each frame touches exactly its own key.
|
|
113
|
+
*/
|
|
114
|
+
function FacetLevel({
|
|
115
|
+
under,
|
|
116
|
+
depth,
|
|
117
|
+
levels,
|
|
118
|
+
expanded,
|
|
119
|
+
selected,
|
|
120
|
+
onSelect,
|
|
121
|
+
onToggle,
|
|
122
|
+
onMore,
|
|
123
|
+
countNoun,
|
|
124
|
+
nodeNoun,
|
|
125
|
+
indent,
|
|
126
|
+
classNames: cn,
|
|
127
|
+
}: {
|
|
128
|
+
under: string
|
|
129
|
+
depth: number
|
|
130
|
+
levels: Record<string, FacetLevelState>
|
|
131
|
+
expanded: Record<string, boolean>
|
|
132
|
+
selected: string | null
|
|
133
|
+
onSelect: (path: string) => void
|
|
134
|
+
onToggle: (path: string) => void
|
|
135
|
+
onMore: (under: string, page: number) => void
|
|
136
|
+
countNoun: string
|
|
137
|
+
nodeNoun: string
|
|
138
|
+
indent: number
|
|
139
|
+
classNames: Record<FacetTreeSlot, string>
|
|
140
|
+
}) {
|
|
141
|
+
const level = levels[under]
|
|
142
|
+
if (!level) return null
|
|
143
|
+
const remaining = level.total - level.rows.length
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<ul className="space-y-0.5">
|
|
147
|
+
{level.rows.map((node) => {
|
|
148
|
+
const open = Boolean(expanded[node.path])
|
|
149
|
+
const child = levels[node.path]
|
|
150
|
+
return (
|
|
151
|
+
<li key={node.path}>
|
|
152
|
+
<div className="flex items-center">
|
|
153
|
+
{/* Indent by DEPTH, not by counting separators in the path — a
|
|
154
|
+
stored path with a doubled slash would indent wrongly, and the
|
|
155
|
+
depth is a fact this render already knows. */}
|
|
156
|
+
<span aria-hidden="true" style={{ width: depth * indent }} className="shrink-0" />
|
|
157
|
+
{node.hasChildren ? (
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="w-4 shrink-0 rounded text-xs opacity-50 hover:opacity-100"
|
|
161
|
+
aria-expanded={open}
|
|
162
|
+
aria-label={`${open ? 'Collapse' : 'Expand'} ${node.name}`}
|
|
163
|
+
onClick={() => onToggle(node.path)}
|
|
164
|
+
>
|
|
165
|
+
{open ? '▾' : '▸'}
|
|
166
|
+
</button>
|
|
167
|
+
) : (
|
|
168
|
+
// A leaf keeps the triangle's width so names stay in one column.
|
|
169
|
+
<span aria-hidden="true" className="w-4 shrink-0" />
|
|
170
|
+
)}
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className={`${cn.node} ${selected === node.path ? cn.nodeSelected : ''}`}
|
|
174
|
+
aria-current={selected === node.path ? 'true' : undefined}
|
|
175
|
+
// SPELLED OUT rather than left to the DOM. Two adjacent spans
|
|
176
|
+
// concatenate with no separator, so the computed name of this
|
|
177
|
+
// control would be "Acme1204" — which is what a screen reader
|
|
178
|
+
// would say. The `title` is the FULL path, because the visible
|
|
179
|
+
// label is only the last segment and a truncated one at that.
|
|
180
|
+
aria-label={`${node.name}, ${node.count.toLocaleString()} ${countNoun}${node.count === 1 ? '' : 's'} inside`}
|
|
181
|
+
title={node.path}
|
|
182
|
+
onClick={() => onSelect(node.path)}
|
|
183
|
+
>
|
|
184
|
+
<span className="truncate">{node.name}</span>
|
|
185
|
+
{/* DESCENDANTS, not direct children — which is why a node can say
|
|
186
|
+
1,204 and open onto nothing of its own. */}
|
|
187
|
+
<span className={cn.count} aria-hidden="true">
|
|
188
|
+
{node.count.toLocaleString()}
|
|
189
|
+
</span>
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{open ? (
|
|
194
|
+
<>
|
|
195
|
+
{child?.loading && child.rows.length === 0 ? (
|
|
196
|
+
<p className={cn.muted} style={{ paddingLeft: (depth + 1) * indent + 16 }}>
|
|
197
|
+
Loading…
|
|
198
|
+
</p>
|
|
199
|
+
) : null}
|
|
200
|
+
{child?.error ? (
|
|
201
|
+
<p
|
|
202
|
+
className={cn.error}
|
|
203
|
+
role="alert"
|
|
204
|
+
style={{ paddingLeft: (depth + 1) * indent + 16 }}
|
|
205
|
+
>
|
|
206
|
+
{child.error}
|
|
207
|
+
</p>
|
|
208
|
+
) : null}
|
|
209
|
+
<FacetLevel
|
|
210
|
+
under={node.path}
|
|
211
|
+
depth={depth + 1}
|
|
212
|
+
levels={levels}
|
|
213
|
+
expanded={expanded}
|
|
214
|
+
selected={selected}
|
|
215
|
+
onSelect={onSelect}
|
|
216
|
+
onToggle={onToggle}
|
|
217
|
+
onMore={onMore}
|
|
218
|
+
countNoun={countNoun}
|
|
219
|
+
nodeNoun={nodeNoun}
|
|
220
|
+
indent={indent}
|
|
221
|
+
classNames={cn}
|
|
222
|
+
/>
|
|
223
|
+
</>
|
|
224
|
+
) : null}
|
|
225
|
+
</li>
|
|
226
|
+
)
|
|
227
|
+
})}
|
|
228
|
+
|
|
229
|
+
{/* MORE AT THIS LEVEL. The server's own `count` for this container against
|
|
230
|
+
what has been drawn, so a container with 900 children costs one page
|
|
231
|
+
until somebody asks for the next — rule 8 applies to the tree exactly as
|
|
232
|
+
it applies to rows. */}
|
|
233
|
+
{remaining > 0 ? (
|
|
234
|
+
<li>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className={cn.more}
|
|
238
|
+
disabled={level.loading}
|
|
239
|
+
style={{ marginLeft: depth * indent + 16 }}
|
|
240
|
+
onClick={() => onMore(under, level.page + 1)}
|
|
241
|
+
>
|
|
242
|
+
{level.loading
|
|
243
|
+
? 'Loading…'
|
|
244
|
+
: `Show ${remaining} more ${nodeNoun}${remaining === 1 ? '' : 's'}`}
|
|
245
|
+
</button>
|
|
246
|
+
</li>
|
|
247
|
+
) : null}
|
|
248
|
+
</ul>
|
|
249
|
+
)
|
|
250
|
+
}
|