rastack 0.0.44 → 0.0.45

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/admin/adapters.ts CHANGED
@@ -28,6 +28,8 @@ export interface AdminField {
28
28
  primaryKey: boolean;
29
29
  maxLength?: number;
30
30
  nullable: boolean;
31
+ /** Unique constraint — such fields are identifiers, not filter facets. */
32
+ unique?: boolean;
31
33
  /** `{ app, model }` for a foreign key (`type === "fk"`). */
32
34
  relation?: { app: string; model: string };
33
35
  }
@@ -53,6 +55,7 @@ interface RawField {
53
55
  primaryKey?: boolean;
54
56
  maxLength?: number;
55
57
  null?: boolean;
58
+ unique?: boolean;
56
59
  relation?: { app: string; model: string };
57
60
  }
58
61
  interface RawResource {
@@ -81,6 +84,7 @@ export function parseManifest(manifest: object | string): AdminResource[] {
81
84
  primaryKey: f.primaryKey === true,
82
85
  maxLength: f.maxLength,
83
86
  nullable: f.null === true,
87
+ unique: f.unique === true,
84
88
  relation: f.relation,
85
89
  })),
86
90
  search: r.search ?? [],
@@ -237,6 +241,101 @@ export function buildWriteBody(
237
241
  return body;
238
242
  }
239
243
 
244
+ // -- filters (Linear-style changelist filter bar) ---------------------------
245
+
246
+ /** How a filter's values are sourced/rendered. */
247
+ export type FilterKind = "fk" | "bool" | "choice";
248
+
249
+ /** A single selectable filter value. */
250
+ export interface FilterOption {
251
+ value: string;
252
+ label: string;
253
+ }
254
+
255
+ /** A field the changelist can filter on, and how to build its value list. */
256
+ export interface FilterableField {
257
+ name: string;
258
+ label: string;
259
+ kind: FilterKind;
260
+ /** FK target table (`kind === "fk"`). */
261
+ relation?: { app: string; model: string };
262
+ }
263
+
264
+ /** Distinct-value cap: above this a `string` field is an identifier, not a facet. */
265
+ export const DISTINCT_CAP = 40;
266
+
267
+ /**
268
+ * The fields a changelist offers as filters, mirroring what makes sense as a
269
+ * Django `list_filter`: every foreign key (filter by related row), every boolean
270
+ * (true/false), and every non-unique plain-string field (filter by one of its
271
+ * observed values). Unique strings, numbers, ids and timestamps are excluded —
272
+ * they identify a row rather than group many, so they belong in search, not a
273
+ * facet.
274
+ */
275
+ export function filterableFields(resource: AdminResource): FilterableField[] {
276
+ const out: FilterableField[] = [];
277
+ for (const f of resource.fields) {
278
+ if (f.primaryKey) continue;
279
+ if (f.type === "fk" && f.relation) {
280
+ out.push({ name: f.name, label: humanizeKey(f.name), kind: "fk", relation: f.relation });
281
+ } else if (f.type === "bool") {
282
+ out.push({ name: f.name, label: humanizeKey(f.name), kind: "bool" });
283
+ } else if (f.type === "string" && !f.unique) {
284
+ out.push({ name: f.name, label: humanizeKey(f.name), kind: "choice" });
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** The fixed True/False options a boolean filter offers. */
291
+ export function boolFilterOptions(): FilterOption[] {
292
+ return [
293
+ { value: "true", label: "True" },
294
+ { value: "false", label: "False" },
295
+ ];
296
+ }
297
+
298
+ /**
299
+ * Distinct non-empty values of `field` across `rows`, sorted, as filter options.
300
+ * Returns `[]` when the field has more than `cap` distinct values (too
301
+ * high-cardinality to be a useful facet — it should be searched, not filtered).
302
+ */
303
+ export function distinctValueOptions(
304
+ rows: Record<string, unknown>[],
305
+ field: string,
306
+ cap = DISTINCT_CAP,
307
+ ): FilterOption[] {
308
+ const seen = new Set<string>();
309
+ for (const row of rows) {
310
+ const v = row[field];
311
+ if (v === null || v === undefined || v === "") continue;
312
+ seen.add(String(v));
313
+ if (seen.size > cap) return [];
314
+ }
315
+ return [...seen].sort().map((value) => ({ value, label: value }));
316
+ }
317
+
318
+ /**
319
+ * The label for an FK filter value: reuse the `<select>` option label heuristic
320
+ * (first non-id string field, else `#id`) against the target table's rows.
321
+ */
322
+ export function fkFilterOptions(rows: Record<string, unknown>[]): FilterOption[] {
323
+ return rows.map((row) => ({
324
+ value: String(row.id ?? ""),
325
+ label: optionLabel(row),
326
+ }));
327
+ }
328
+
329
+ /** Merge active filters into an engine list-query object (field → value). */
330
+ export function withFilters(
331
+ query: Record<string, string>,
332
+ filters: Record<string, string>,
333
+ ): Record<string, string> {
334
+ // Filters first, then reserved keys, so a field named `page`/`search` can
335
+ // never shadow pagination — the reserved keys always win.
336
+ return { ...filters, ...query };
337
+ }
338
+
240
339
  /** Extract a `{detail}` error message from a non-2xx `handle` body. */
241
340
  export function errorDetail(body: unknown): string {
242
341
  if (body && typeof body === "object" && "detail" in body) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * An HTTP-backed `RastackEngine` — the same `handle(method, path, query, body)`
3
+ * seam the WASM engine exposes, implemented over `fetch` against a live
4
+ * `rastack-server`. This is what lets the *identical* admin UI run two ways:
5
+ * over WebAssembly in `rastack dev` (`client.engine`), or over the real Rust
6
+ * API + Iceberg store in `rastack admin` (this adapter) — the components,
7
+ * hooks and adapters above it never learn which.
8
+ */
9
+
10
+ import type { RastackEngine } from "../provider/types";
11
+
12
+ /** Build a same-origin (or `baseUrl`-rooted) HTTP engine. */
13
+ export function createHttpEngine(baseUrl = ""): RastackEngine {
14
+ const base = baseUrl.replace(/\/+$/, "");
15
+
16
+ async function handle(
17
+ method: string,
18
+ path: string,
19
+ query: Record<string, string> | undefined,
20
+ body: unknown,
21
+ ): Promise<{ status: number; body: any }> {
22
+ const qs =
23
+ query && Object.keys(query).length
24
+ ? "?" + new URLSearchParams(query).toString()
25
+ : "";
26
+ const hasBody = body !== undefined && body !== null;
27
+ const res = await fetch(base + path + qs, {
28
+ method,
29
+ headers: hasBody ? { "Content-Type": "application/json" } : undefined,
30
+ body: hasBody ? JSON.stringify(body) : undefined,
31
+ credentials: "same-origin",
32
+ });
33
+ // 204 (delete) and errors may carry no body; parse defensively.
34
+ const text = await res.text();
35
+ let parsed: any = null;
36
+ if (text) {
37
+ try {
38
+ parsed = JSON.parse(text);
39
+ } catch {
40
+ parsed = text;
41
+ }
42
+ }
43
+ return { status: res.status, body: parsed };
44
+ }
45
+
46
+ const unavailable = (op: string) => async (): Promise<never> => {
47
+ throw new Error(`${op} is only available over the local WASM engine.`);
48
+ };
49
+
50
+ return {
51
+ handle,
52
+ seedFile: unavailable("seedFile"),
53
+ importWarehouse: unavailable("importWarehouse"),
54
+ exportWarehouse: unavailable("exportWarehouse") as RastackEngine["exportWarehouse"],
55
+ };
56
+ }
package/admin/index.tsx CHANGED
@@ -1,25 +1,50 @@
1
1
  /**
2
- * Entry point for the `rastack dev` in-browser admin, bundled by esbuild into
3
- * `dist/admin.js` and served at `/dev/admin.js`. It fetches the compiled
4
- * schema/openapi, boots the WASM engine via `<RAStackProvider mode="local">`
5
- * (seeding from the served `/data/warehouse/`), themes everything with
6
- * `<ThemeProvider>`, and renders the manifest-driven `<AdminApp>`.
2
+ * Entry point for the admin bundle (`dist/admin.js`). It runs two ways from the
3
+ * *same* build, chosen by a `window.__RASTACK_ADMIN__` config the host page
4
+ * injects (defaulting to local):
5
+ *
6
+ * • `rastack dev` → `mode: "local"` — boots the WASM engine via
7
+ * `<RAStackProvider mode="local">`, seeding from the served `/data/warehouse/`.
8
+ * • `rastack admin` → `mode: "remote"` — talks to the live `rastack-server`
9
+ * over HTTP through an `httpEngine`, against the real Iceberg warehouse.
10
+ *
11
+ * Either way it fetches the compiled schema, themes everything with
12
+ * `<ThemeProvider>` and the design tokens, and renders the manifest-driven
13
+ * `<AdminApp>` — which never learns which engine it got.
7
14
  */
8
15
 
9
- import React, { useEffect, useState } from "react";
16
+ import React, { useEffect, useMemo, useState } from "react";
10
17
  import { createRoot } from "react-dom/client";
11
- import { RAStackProvider } from "../provider";
18
+ import { RAStackProvider, useRastackClient } from "../provider";
12
19
  import type { RastackWasmModule } from "../provider/types";
20
+ import { Spinner } from "../components";
13
21
  import { ThemeProvider } from "../theme";
14
22
  import { defineTheme } from "../tokens";
15
23
  import { AdminApp } from "./AdminApp";
24
+ import { parseManifest, type AdminResource } from "./adapters";
25
+ import { createHttpEngine } from "./httpEngine";
16
26
 
17
- /**
18
- * Load the wasm engine from the URL `rastack dev` serves it at (`/wasm/…`).
19
- * The dynamic `import()` is hidden behind `new Function` so esbuild leaves it
20
- * for the browser to resolve at runtime (the same trick `defaultEngineLoader`
21
- * uses) rather than trying to bundle the `.wasm` at build time.
22
- */
27
+ interface AdminConfig {
28
+ mode: "local" | "remote";
29
+ /** Remote: base URL the `/api/...` paths are joined onto (default same origin). */
30
+ apiBase?: string;
31
+ /** Where to fetch the compiled `schema.rastack.json`. */
32
+ schemaUrl?: string;
33
+ /** Local: base URL of the committed Iceberg warehouse to seed from. */
34
+ warehouseBaseUrl?: string;
35
+ }
36
+
37
+ const cfg: AdminConfig =
38
+ (typeof window !== "undefined" && (window as any).__RASTACK_ADMIN__) || {
39
+ mode: "local",
40
+ schemaUrl: "/schema.rastack.json",
41
+ warehouseBaseUrl: "/data/warehouse/",
42
+ };
43
+
44
+ // The framework's own design system paints the whole console.
45
+ const theme = defineTheme({ brand: "#4F6BFF", radius: "rounded" });
46
+
47
+ /** Load the WASM engine from the URL `rastack dev` serves it at (`/wasm/…`). */
23
48
  const loadEngine = async (): Promise<RastackWasmModule> => {
24
49
  const runtimeImport = new Function("s", "return import(s)") as (
25
50
  s: string,
@@ -30,55 +55,82 @@ const loadEngine = async (): Promise<RastackWasmModule> => {
30
55
  return mod;
31
56
  };
32
57
 
33
- const theme = defineTheme({ brand: "#2f6feb", radius: "rounded" });
58
+ function Loading({ label }: { label: string }): React.JSX.Element {
59
+ return (
60
+ <div className="ras-boot">
61
+ <Spinner /> {label}
62
+ </div>
63
+ );
64
+ }
34
65
 
35
- interface Compiled {
36
- manifest: object;
37
- openapi?: object;
66
+ /** Local (WASM) admin: wait for the provider's engine, then render. */
67
+ function LocalAdmin({ resources }: { resources: AdminResource[] }): React.JSX.Element {
68
+ const client = useRastackClient();
69
+ if (client.status === "error")
70
+ return <Loading label="The WASM engine failed to start — check the console." />;
71
+ if (!client.engine || client.status !== "ready")
72
+ return (
73
+ <Loading
74
+ label={
75
+ client.status === "seeding"
76
+ ? "Seeding the local warehouse…"
77
+ : "Starting the WASM engine…"
78
+ }
79
+ />
80
+ );
81
+ return <AdminApp engine={client.engine} resources={resources} />;
38
82
  }
39
83
 
40
84
  function Boot(): React.JSX.Element {
41
- const [compiled, setCompiled] = useState<Compiled | null>(null);
42
- const [error, setError] = useState<string | undefined>();
85
+ const [resources, setResources] = useState<AdminResource[] | null>(null);
86
+ const [manifest, setManifest] = useState<object | null>(null);
87
+ const [openapi, setOpenapi] = useState<object | undefined>();
88
+ const [error, setError] = useState<string>();
89
+ const httpEngine = useMemo(() => createHttpEngine(cfg.apiBase ?? ""), []);
43
90
 
44
91
  useEffect(() => {
45
92
  (async () => {
46
- const manifestRes = await fetch("/schema.rastack.json");
47
- if (!manifestRes.ok)
48
- throw new Error(`schema.rastack.json (${manifestRes.status})`);
49
- const manifest = await manifestRes.json();
50
- let openapi: object | undefined;
51
- try {
52
- const openapiRes = await fetch("/openapi.json");
53
- if (openapiRes.ok) openapi = await openapiRes.json();
54
- } catch {
55
- /* openapi is optional */
93
+ const schemaUrl = cfg.schemaUrl || "/schema.rastack.json";
94
+ const res = await fetch(schemaUrl);
95
+ if (!res.ok) throw new Error(`${schemaUrl} (${res.status})`);
96
+ const doc = await res.json();
97
+ setManifest(doc);
98
+ setResources(parseManifest(doc));
99
+ if (cfg.mode === "local") {
100
+ try {
101
+ const o = await fetch("/openapi.json");
102
+ if (o.ok) setOpenapi(await o.json());
103
+ } catch {
104
+ /* openapi is optional */
105
+ }
56
106
  }
57
- setCompiled({ manifest, openapi });
58
107
  })().catch((e) => setError(String(e)));
59
108
  }, []);
60
109
 
61
110
  if (error)
111
+ return <div className="ras-boot">Failed to load the schema: {error}</div>;
112
+ if (!resources || !manifest) return <Loading label="Loading the schema…" />;
113
+
114
+ if (cfg.mode === "remote") {
62
115
  return (
63
- <div className="rastack-boot">
64
- Failed to load the compiled schema: {error}
65
- </div>
116
+ <ThemeProvider tokens={theme}>
117
+ <AdminApp engine={httpEngine} resources={resources} />
118
+ </ThemeProvider>
66
119
  );
67
- if (!compiled)
68
- return <div className="rastack-boot">Compiling the schema…</div>;
120
+ }
69
121
 
70
122
  return (
71
123
  <RAStackProvider
72
124
  mode="local"
73
- manifest={compiled.manifest}
74
- openapi={compiled.openapi}
125
+ manifest={manifest}
126
+ openapi={openapi}
75
127
  loadEngine={loadEngine}
76
- warehouseBaseUrl="/data/warehouse/"
128
+ warehouseBaseUrl={cfg.warehouseBaseUrl ?? "/data/warehouse/"}
77
129
  persist={false}
78
130
  cache={false}
79
131
  >
80
132
  <ThemeProvider tokens={theme}>
81
- <AdminApp manifest={compiled.manifest} />
133
+ <LocalAdmin resources={resources} />
82
134
  </ThemeProvider>
83
135
  </RAStackProvider>
84
136
  );
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The Linear-style changelist filter bar: active filters render as pill "chips"
3
+ * (`Field is Value ×`) whose value opens a switcher popover and whose × drops
4
+ * the filter; a dashed "+ Filter" button opens a two-level menu (pick a field →
5
+ * pick a value). Search sits at the right. Every value is served from the
6
+ * `FilterFieldModel`s discovered by `useFilterModel` (FK target rows, distinct
7
+ * strings, booleans), so the bar reflects the data, and each pick drives an
8
+ * equality filter through the engine.
9
+ */
10
+ import React, { useState } from "react";
11
+ import type { FilterOption } from "../adapters";
12
+ import type { FilterFieldModel } from "../useAdmin";
13
+ import { Popover } from "./Popover";
14
+ import {
15
+ IconChevronRight,
16
+ IconClose,
17
+ IconFilter,
18
+ IconLink,
19
+ IconPlus,
20
+ IconSearch,
21
+ } from "./icons";
22
+
23
+ function facetIcon(kind: FilterFieldModel["kind"]): React.JSX.Element {
24
+ return kind === "fk" ? (
25
+ <IconLink size={15} className="ras-ico" />
26
+ ) : (
27
+ <IconFilter size={15} className="ras-ico" />
28
+ );
29
+ }
30
+
31
+ function labelFor(facet: FilterFieldModel | undefined, value: string): string {
32
+ return facet?.options.find((o) => o.value === value)?.label ?? value;
33
+ }
34
+
35
+ /** A searchable list of value options (used by the add-menu and chip switcher). */
36
+ function OptionList(props: {
37
+ options: FilterOption[];
38
+ selected?: string;
39
+ onPick: (value: string) => void;
40
+ }): React.JSX.Element {
41
+ const [q, setQ] = useState("");
42
+ const shown = q
43
+ ? props.options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()))
44
+ : props.options;
45
+ return (
46
+ <>
47
+ {props.options.length > 8 ? (
48
+ <input
49
+ className="ras-pop-search"
50
+ placeholder="Search…"
51
+ value={q}
52
+ onChange={(e) => setQ(e.target.value)}
53
+ autoFocus
54
+ />
55
+ ) : null}
56
+ <div className="ras-pop-list">
57
+ {shown.length === 0 ? (
58
+ <div className="ras-pop-empty">No matches</div>
59
+ ) : (
60
+ shown.map((o) => (
61
+ <button
62
+ key={o.value}
63
+ type="button"
64
+ className={"ras-pop-item" + (o.value === props.selected ? " is-selected" : "")}
65
+ onClick={() => props.onPick(o.value)}
66
+ >
67
+ {o.label}
68
+ </button>
69
+ ))
70
+ )}
71
+ </div>
72
+ </>
73
+ );
74
+ }
75
+
76
+ /** The two-level "add filter" menu: fields → values. */
77
+ function AddFilterMenu(props: {
78
+ facets: FilterFieldModel[];
79
+ activeFields: string[];
80
+ onPick: (field: string, value: string) => void;
81
+ close: () => void;
82
+ }): React.JSX.Element {
83
+ const [view, setView] = useState<string | null>(null);
84
+ const available = props.facets.filter((f) => !props.activeFields.includes(f.name));
85
+
86
+ if (view) {
87
+ const facet = props.facets.find((f) => f.name === view);
88
+ if (!facet) return <div className="ras-pop-empty">—</div>;
89
+ return (
90
+ <>
91
+ <div className="ras-pop-head" onClick={() => setView(null)}>
92
+ <IconChevronRight size={14} style={{ transform: "rotate(180deg)" }} />
93
+ {facet.label}
94
+ </div>
95
+ <OptionList
96
+ options={facet.options}
97
+ onPick={(v) => {
98
+ props.onPick(facet.name, v);
99
+ props.close();
100
+ }}
101
+ />
102
+ </>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className="ras-pop-list">
108
+ {available.length === 0 ? (
109
+ <div className="ras-pop-empty">No more filters</div>
110
+ ) : (
111
+ available.map((f) => (
112
+ <button
113
+ key={f.name}
114
+ type="button"
115
+ className="ras-pop-item"
116
+ onClick={() => setView(f.name)}
117
+ >
118
+ {facetIcon(f.kind)}
119
+ {f.label}
120
+ <IconChevronRight size={14} className="ras-pop-item-chev" />
121
+ </button>
122
+ ))
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function FilterBar(props: {
129
+ facets: FilterFieldModel[];
130
+ filters: Record<string, string>;
131
+ setFilter: (field: string, value: string) => void;
132
+ removeFilter: (field: string) => void;
133
+ clearFilters: () => void;
134
+ search: string | undefined;
135
+ setSearch: (value: string | undefined) => void;
136
+ searchable: boolean;
137
+ }): React.JSX.Element {
138
+ const active = Object.keys(props.filters);
139
+ const facetOf = (name: string) => props.facets.find((f) => f.name === name);
140
+
141
+ return (
142
+ <div className="ras-filterbar">
143
+ {active.map((field) => {
144
+ const facet = facetOf(field);
145
+ const value = props.filters[field];
146
+ return (
147
+ <span className="ras-chip" key={field}>
148
+ <span className="ras-chip-key">
149
+ {facet ? facetIcon(facet.kind) : null}
150
+ {facet?.label ?? field}
151
+ </span>
152
+ <span className="ras-chip-op">is</span>
153
+ {facet && facet.options.length ? (
154
+ <Popover
155
+ trigger={({ toggle }) => (
156
+ <span className="ras-chip-val" onClick={toggle} role="button" tabIndex={0}>
157
+ {labelFor(facet, value)}
158
+ </span>
159
+ )}
160
+ >
161
+ {({ close }) => (
162
+ <OptionList
163
+ options={facet.options}
164
+ selected={value}
165
+ onPick={(v) => {
166
+ props.setFilter(field, v);
167
+ close();
168
+ }}
169
+ />
170
+ )}
171
+ </Popover>
172
+ ) : (
173
+ <span className="ras-chip-val" style={{ cursor: "default" }}>
174
+ {labelFor(facet, value)}
175
+ </span>
176
+ )}
177
+ <span
178
+ className="ras-chip-x"
179
+ role="button"
180
+ tabIndex={0}
181
+ aria-label={`Remove ${facet?.label ?? field} filter`}
182
+ onClick={() => props.removeFilter(field)}
183
+ >
184
+ <IconClose size={13} />
185
+ </span>
186
+ </span>
187
+ );
188
+ })}
189
+
190
+ {props.facets.length ? (
191
+ <Popover
192
+ trigger={({ toggle }) => (
193
+ <button type="button" className="ras-addfilter" onClick={toggle}>
194
+ <IconPlus size={14} />
195
+ Filter
196
+ </button>
197
+ )}
198
+ >
199
+ {({ close }) => (
200
+ <AddFilterMenu
201
+ facets={props.facets}
202
+ activeFields={active}
203
+ onPick={props.setFilter}
204
+ close={close}
205
+ />
206
+ )}
207
+ </Popover>
208
+ ) : null}
209
+
210
+ {active.length > 1 ? (
211
+ <button type="button" className="ras-clearfilter" onClick={props.clearFilters}>
212
+ Clear all
213
+ </button>
214
+ ) : null}
215
+
216
+ <span className="ras-grow" />
217
+
218
+ {props.searchable ? (
219
+ <span className="ras-search">
220
+ <IconSearch size={15} className="ras-search-ico" />
221
+ <input
222
+ type="search"
223
+ placeholder="Search…"
224
+ value={props.search ?? ""}
225
+ onChange={(e) => props.setSearch(e.target.value || undefined)}
226
+ />
227
+ </span>
228
+ ) : null}
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A tiny controlled popover: a trigger + a floating panel that closes on
3
+ * outside-click or Escape. Used by the Linear-style filter bar (the "add
4
+ * filter" menu and each chip's value switcher). Deliberately dependency-free —
5
+ * no portal, no positioning lib — the panel is absolutely positioned under its
6
+ * trigger and styled by `ui/styles.ts`.
7
+ */
8
+ import React, { useEffect, useRef, useState } from "react";
9
+
10
+ export interface PopoverRenderProps {
11
+ open: boolean;
12
+ toggle: () => void;
13
+ close: () => void;
14
+ }
15
+
16
+ export function Popover(props: {
17
+ align?: "left" | "right";
18
+ trigger: (p: PopoverRenderProps) => React.ReactNode;
19
+ children: (p: { close: () => void }) => React.ReactNode;
20
+ }): React.JSX.Element {
21
+ const [open, setOpen] = useState(false);
22
+ const ref = useRef<HTMLDivElement>(null);
23
+ const close = () => setOpen(false);
24
+
25
+ useEffect(() => {
26
+ if (!open) return;
27
+ const onDown = (e: MouseEvent) => {
28
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
29
+ };
30
+ const onKey = (e: KeyboardEvent) => {
31
+ if (e.key === "Escape") setOpen(false);
32
+ };
33
+ document.addEventListener("mousedown", onDown);
34
+ document.addEventListener("keydown", onKey);
35
+ return () => {
36
+ document.removeEventListener("mousedown", onDown);
37
+ document.removeEventListener("keydown", onKey);
38
+ };
39
+ }, [open]);
40
+
41
+ return (
42
+ <div className="ras-pop-wrap" ref={ref}>
43
+ {props.trigger({ open, toggle: () => setOpen((o) => !o), close })}
44
+ {open ? (
45
+ <div
46
+ className={"ras-pop" + (props.align === "right" ? " align-right" : "")}
47
+ role="menu"
48
+ >
49
+ {props.children({ close })}
50
+ </div>
51
+ ) : null}
52
+ </div>
53
+ );
54
+ }