@fraxic/ui 0.3.0 → 0.3.2

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/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # @fraxic/ui
2
+
3
+ Build dashboard pages and handle dashboard interactions from a Fraxic application.
4
+
5
+ Import from `@fraxic/ui` by package name:
6
+
7
+ ```ts
8
+ import {
9
+ Chart,
10
+ ChartDataPoint,
11
+ ChartType,
12
+ ModalBuilder,
13
+ NoticeType,
14
+ SelectOption,
15
+ TextInputBuilder,
16
+ dashboard,
17
+ onButtonInteraction,
18
+ onModalInteraction,
19
+ } from "@fraxic/ui";
20
+ ```
21
+
22
+ ## Dashboard Pages
23
+
24
+ Register one `dashboard` handler. Fraxic calls it whenever the dashboard page renders.
25
+
26
+ ```ts
27
+ dashboard((page) => {
28
+ page.section("Status", (section) => {
29
+ section.notice("Running", NoticeType.SUCCESS);
30
+ section.text("The application is healthy.");
31
+ section.progress("Queue", 3, 10);
32
+ section.button("Configure", "open-settings");
33
+ });
34
+ });
35
+ ```
36
+
37
+ `page.session` is a per-user session store. Use it for temporary UI state such as selected tabs, filters, and draft settings.
38
+
39
+ ```ts
40
+ dashboard((page) => {
41
+ const tab = page.session.get<string>("tab") ?? "overview";
42
+
43
+ page.section("Controls", (section) => {
44
+ section.text(`Current tab: ${tab}`);
45
+ section.button("Overview", "tab:overview");
46
+ section.button("Settings", "tab:settings");
47
+ });
48
+ });
49
+
50
+ onButtonInteraction((interaction) => {
51
+ if (interaction.customId.startsWith("tab:")) {
52
+ interaction.session.set("tab", interaction.customId.slice("tab:".length));
53
+ interaction.reply("Updated.");
54
+ }
55
+ });
56
+ ```
57
+
58
+ ## Layout
59
+
60
+ Every page contains sections. Sections and containers can contain text, notices, progress bars, buttons, charts, tables, separators, and nested containers.
61
+
62
+ ```ts
63
+ dashboard((page) => {
64
+ page.section("Overview", (section) => {
65
+ section.horizontal((row) => {
66
+ row.card((card) => {
67
+ card.text("Processed");
68
+ card.text("128");
69
+ });
70
+
71
+ row.card((card) => {
72
+ card.text("Errors");
73
+ card.notice("0", NoticeType.SUCCESS);
74
+ });
75
+ });
76
+
77
+ section.separator();
78
+
79
+ section.vertical((column) => {
80
+ column.text("Recent activity");
81
+ column.button("Refresh", "refresh");
82
+ });
83
+ });
84
+ });
85
+ ```
86
+
87
+ Container methods:
88
+
89
+ - `horizontal(builder)` creates a horizontal child container.
90
+ - `vertical(builder)` creates a vertical child container.
91
+ - `card(builder)` creates a card child container.
92
+ - `table(builder)` adds a table.
93
+ - `text(content)` adds text.
94
+ - `notice(message, type)` adds an info, warning, error, or success notice.
95
+ - `progress(label, value, max)` adds a progress bar.
96
+ - `separator()` adds a visual separator.
97
+ - `button(label, customId)` adds a button.
98
+ - `chart(chart)` adds a chart.
99
+
100
+ ## Tables
101
+
102
+ Tables have optional header rows and body rows. Each row must have the same number of cells.
103
+
104
+ ```ts
105
+ dashboard((page) => {
106
+ page.section("Jobs", (section) => {
107
+ section.table((table) => {
108
+ table.header((row) => {
109
+ row.cell((cell) => cell.text("Name"));
110
+ row.cell((cell) => cell.text("Status"));
111
+ });
112
+
113
+ table.row((row) => {
114
+ row.cell((cell) => cell.text("Sync"));
115
+ row.cell((cell) => cell.notice("Done", NoticeType.SUCCESS));
116
+ });
117
+ });
118
+ });
119
+ });
120
+ ```
121
+
122
+ ## Charts
123
+
124
+ Use `Chart`, `ChartType`, and `ChartDataPoint`.
125
+
126
+ ```ts
127
+ dashboard((page) => {
128
+ page.section("Usage", (section) => {
129
+ section.chart(
130
+ new Chart(ChartType.LINE)
131
+ .label("Requests")
132
+ .data([
133
+ new ChartDataPoint("09:00", 12),
134
+ new ChartDataPoint("10:00", 19),
135
+ new ChartDataPoint("11:00", 17),
136
+ ])
137
+ );
138
+ });
139
+ });
140
+ ```
141
+
142
+ Chart types:
143
+
144
+ - `ChartType.LINE`
145
+ - `ChartType.BAR`
146
+ - `ChartType.PIE`
147
+ - `ChartType.AREA`
148
+
149
+ ## Buttons
150
+
151
+ Register `onButtonInteraction` handlers for buttons.
152
+
153
+ ```ts
154
+ onButtonInteraction((interaction) => {
155
+ if (interaction.customId === "refresh") {
156
+ interaction.reply("Refreshed.");
157
+ }
158
+ });
159
+ ```
160
+
161
+ Button interaction fields and methods:
162
+
163
+ - `customId` is the button id.
164
+ - `session` is the current user's `Session`.
165
+ - `reply(message)` responds with a message.
166
+ - `showModal(modal)` opens a modal.
167
+
168
+ Each interaction can be replied to once.
169
+
170
+ ## Modals
171
+
172
+ Use `ModalBuilder` to show forms from button interactions.
173
+
174
+ ```ts
175
+ onButtonInteraction((interaction) => {
176
+ if (interaction.customId !== "open-settings") return;
177
+
178
+ interaction.showModal(
179
+ new ModalBuilder("settings", "Settings")
180
+ .addTextInput(
181
+ "Webhook URL",
182
+ new TextInputBuilder("webhookUrl")
183
+ .setPlaceholder("https://example.com/webhook")
184
+ .setRequired(true),
185
+ "Stored by the application."
186
+ )
187
+ .addColorInput("Accent color", new ColorInputBuilder("accent").setDefault("#3366FF"))
188
+ );
189
+ });
190
+
191
+ onModalInteraction((interaction) => {
192
+ if (interaction.customId !== "settings") return;
193
+
194
+ const webhookUrl = interaction.getTextInput("webhookUrl");
195
+ const accent = interaction.getColorInput("accent");
196
+
197
+ if (webhookUrl === null || !webhookUrl.startsWith("https://")) {
198
+ interaction.rejectInputs({ webhookUrl: "Enter an HTTPS URL." });
199
+ return;
200
+ }
201
+
202
+ interaction.session.set("settings", { webhookUrl, accent });
203
+ interaction.reply("Settings saved.");
204
+ });
205
+ ```
206
+
207
+ `ModalBuilder` methods:
208
+
209
+ - `new ModalBuilder(customId, title)` creates a modal.
210
+ - `addTextInput(label, input, description?)`
211
+ - `addSelectInput(label, input, description?)`
212
+ - `addFileInput(label, input, description?)`
213
+ - `addColorInput(label, input, description?)`
214
+ - `addDateInput(label, input, description?)`
215
+ - `addParagraph(content, label?)`
216
+
217
+ Modal interactions:
218
+
219
+ - `getTextInput(customId)` returns `string | null`.
220
+ - `getSelectInput(customId)` returns `string[]`.
221
+ - `getFileInput(customId)` returns `Blob[]`.
222
+ - `getDateInput(customId)` returns `Date | null`.
223
+ - `getColorInput(customId)` returns `string | null`.
224
+ - `reply(message)` closes the interaction with a success message.
225
+ - `reject(message)` rejects the whole modal with a message.
226
+ - `rejectInputs({ [customId]: message })` rejects specific inputs and keeps the modal open.
227
+
228
+ ## Inputs
229
+
230
+ Text input:
231
+
232
+ ```ts
233
+ new TextInputBuilder("name")
234
+ .setPlaceholder("Display name")
235
+ .setDefault("My app")
236
+ .setRequired(true)
237
+ .setMinLength(2)
238
+ .setMaxLength(80);
239
+ ```
240
+
241
+ Use `.multiline()` for a multi-line text input.
242
+
243
+ Select input:
244
+
245
+ ```ts
246
+ new SelectInputBuilder("mode")
247
+ .setPlaceholder("Choose mode")
248
+ .addOption(new SelectOption("Fast", "fast"))
249
+ .addOption(new SelectOption("Careful", "careful").setSelected(true))
250
+ .setRequired(true)
251
+ .setMinValues(1)
252
+ .setMaxValues(1);
253
+ ```
254
+
255
+ Searchable select input:
256
+
257
+ ```ts
258
+ import { SelectInputBuilder, SelectOption, onSelectMenuSearch } from "@fraxic/ui";
259
+
260
+ new SelectInputBuilder("user")
261
+ .setPlaceholder("Search users")
262
+ .setSearchable(true);
263
+
264
+ onSelectMenuSearch((search) => {
265
+ if (search.customId !== "user") return;
266
+
267
+ const options = users
268
+ .filter((user) => user.name.toLowerCase().includes(search.query.toLowerCase()))
269
+ .slice(0, 25)
270
+ .map((user) => new SelectOption(user.name, user.id));
271
+
272
+ search.respond(options);
273
+ });
274
+ ```
275
+
276
+ File input:
277
+
278
+ ```ts
279
+ new FileInputBuilder("attachments")
280
+ .setRequired(false)
281
+ .setMinFiles(0)
282
+ .setMaxFiles(3);
283
+ ```
284
+
285
+ Date input:
286
+
287
+ ```ts
288
+ new DateInputBuilder("runDate")
289
+ .setRequired(true)
290
+ .setDefault("2026-05-26");
291
+
292
+ new DateInputBuilder("runAt")
293
+ .includeTime()
294
+ .setDefault(new Date());
295
+ ```
296
+
297
+ Color input:
298
+
299
+ ```ts
300
+ new ColorInputBuilder("accent")
301
+ .setRequired(false)
302
+ .setDefault("#3366FF");
303
+ ```
304
+
305
+ ## Session
306
+
307
+ `Session` stores temporary per-user dashboard state.
308
+
309
+ ```ts
310
+ const count = interaction.session.get<number>("count") ?? 0;
311
+ interaction.session.set("count", count + 1);
312
+ interaction.session.update<number>("count", (current) => (current ?? 0) + 1);
313
+ interaction.session.delete("count");
314
+ interaction.session.clear();
315
+ ```
316
+
317
+ Session methods:
318
+
319
+ - `get<T>(key)` returns `T | null`.
320
+ - `set<T>(key, value)` stores a value; `null` removes the key.
321
+ - `update<T>(key, updater)` updates a value.
322
+ - `delete(key)` removes a value and returns whether it existed.
323
+ - `list()` returns stored keys.
324
+ - `clear()` removes all session values.
325
+
326
+ Use application files or a database such as SQLite for durable state. Session values are for UI state only.
327
+
328
+ ## Validation And Limits
329
+
330
+ Inputs validate before `onModalInteraction` runs. If validation fails, the modal is shown again with input errors.
331
+
332
+ Important limits:
333
+
334
+ - Text values are limited to 5000 characters.
335
+ - `customId` is limited to 100 characters.
336
+ - A page can contain up to 200 components.
337
+ - UI nesting depth is limited to 8.
338
+ - A table can contain up to 100 rows and 20 cells per row.
339
+ - A chart can contain up to 200 points.
340
+ - A modal can contain up to 10 components.
341
+ - A select input can contain up to 100 options.
342
+ - A file input accepts files up to 5 MiB each.
343
+
344
+ Use stable `customId` values. Prefix ids when there are multiple features, for example `settings:save` or `job:retry:123`.
package/index.d.ts CHANGED
@@ -164,4 +164,3 @@ export declare function dashboard(handler: (page: Page) => void): void;
164
164
  export declare function onButtonInteraction(handler: (interaction: ButtonInteraction) => void): void;
165
165
  export declare function onModalInteraction(handler: (interaction: ModalInteraction) => void): void;
166
166
  export declare function onSelectMenuSearch(handler: (search: SelectMenuSearch) => void): void;
167
- export declare function __fraxicHandleUiRequest(payload: unknown): unknown;
package/index.js CHANGED
@@ -18,6 +18,8 @@ const modalHandlers = [];
18
18
  const selectMenuSearchHandlers = [];
19
19
  const sessions = new Map();
20
20
  const modalSchemas = new Map();
21
+ let ipcRegistered = false;
22
+ let ipcRegistrationQueued = false;
21
23
 
22
24
  function requireText(name, value) {
23
25
  if (typeof value !== "string") throw new Error(`${name} must be a string`);
@@ -894,21 +896,25 @@ export class SelectMenuSearch {
894
896
 
895
897
  export function dashboard(handler) {
896
898
  dashboardHandler = handler;
899
+ ensureIpcRegistered();
897
900
  }
898
901
 
899
902
  export function onButtonInteraction(handler) {
900
903
  buttonHandlers.push(handler);
904
+ ensureIpcRegistered();
901
905
  }
902
906
 
903
907
  export function onModalInteraction(handler) {
904
908
  modalHandlers.push(handler);
909
+ ensureIpcRegistered();
905
910
  }
906
911
 
907
912
  export function onSelectMenuSearch(handler) {
908
913
  selectMenuSearchHandlers.push(handler);
914
+ ensureIpcRegistered();
909
915
  }
910
916
 
911
- export function __fraxicHandleUiRequest(payload) {
917
+ function handleUiRequest(payload) {
912
918
  const request = payload;
913
919
  if (request.type === "render") return render(request);
914
920
  if (request.type === "button") return handleButton(request);
@@ -984,8 +990,19 @@ function noResponse() {
984
990
  return { ok: false, reason: "noResponse" };
985
991
  }
986
992
 
993
+ function ensureIpcRegistered() {
994
+ if (ipcRegistered || ipcRegistrationQueued) return;
995
+ ipcRegistrationQueued = true;
996
+ queueMicrotask(() => {
997
+ ipcRegistrationQueued = false;
998
+ if (ipcRegistered) return;
999
+ const listen = globalThis.__fraxic?.ipc?.listen;
1000
+ if (typeof listen !== "function") return;
1001
+ listen("ui", handleUiRequest);
1002
+ ipcRegistered = true;
1003
+ });
1004
+ }
1005
+
987
1006
  function ensureModalHasComponents(modal) {
988
1007
  if (modal.toMap().components.length === 0) throw new Error("modal must have at least 1 component");
989
1008
  }
990
-
991
- globalThis.__fraxic?.ipc?.listen?.("ui", __fraxicHandleUiRequest);
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@fraxic/ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
7
7
  "files": [
8
8
  "index.js",
9
- "index.d.ts"
9
+ "index.d.ts",
10
+ "README.md"
10
11
  ],
11
12
  "exports": {
12
13
  ".": {