@fraxic/ui 0.3.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.
Files changed (3) hide show
  1. package/index.d.ts +167 -0
  2. package/index.js +991 -0
  3. package/package.json +17 -0
package/index.d.ts ADDED
@@ -0,0 +1,167 @@
1
+ export declare const ChartType: {
2
+ readonly LINE: "line";
3
+ readonly BAR: "bar";
4
+ readonly PIE: "pie";
5
+ readonly AREA: "area";
6
+ };
7
+
8
+ export declare const NoticeType: {
9
+ readonly INFO: "info";
10
+ readonly WARNING: "warning";
11
+ readonly ERROR: "error";
12
+ readonly SUCCESS: "success";
13
+ };
14
+
15
+ export type ChartTypeValue = typeof ChartType[keyof typeof ChartType];
16
+ export type NoticeTypeValue = typeof NoticeType[keyof typeof NoticeType];
17
+
18
+ export declare class Page {
19
+ readonly session: Session;
20
+ constructor(session: Session);
21
+ section(title: string, builder: (section: Section) => void): void;
22
+ toMap(): { components: Array<Record<string, unknown>> };
23
+ }
24
+
25
+ export declare class Section extends Container {}
26
+ export declare class Horizontal extends Container {}
27
+ export declare class Vertical extends Container {}
28
+ export declare class Card extends Container {}
29
+ export declare class TableCell extends Container {}
30
+
31
+ export declare class Container {
32
+ horizontal(builder: (container: Horizontal) => void): void;
33
+ vertical(builder: (container: Vertical) => void): void;
34
+ card(builder: (container: Card) => void): void;
35
+ table(builder: (table: Table) => void): void;
36
+ text(content: string): void;
37
+ notice(message: string, type?: NoticeTypeValue): void;
38
+ progress(label: string, value: number, max: number): void;
39
+ separator(): void;
40
+ button(label: string, customId: string): void;
41
+ chart(chart: Chart): void;
42
+ }
43
+
44
+ export declare class Table {
45
+ header(builder: (row: TableRow) => void): void;
46
+ row(builder: (row: TableRow) => void): void;
47
+ toMap(): Record<string, unknown>;
48
+ }
49
+
50
+ export declare class TableRow {
51
+ cell(builder: (cell: TableCell) => void): void;
52
+ }
53
+
54
+ export declare class Chart {
55
+ constructor(type: ChartTypeValue);
56
+ label(label: string): this;
57
+ data(points: ChartDataPoint[]): this;
58
+ toMap(): Record<string, unknown>;
59
+ }
60
+
61
+ export declare class ChartDataPoint {
62
+ readonly label: string;
63
+ readonly value: number;
64
+ constructor(label: string, value: number);
65
+ }
66
+
67
+ export declare class ModalBuilder {
68
+ constructor(customId: string, title: string);
69
+ addTextInput(label: string, input: TextInputBuilder, description?: string | null): this;
70
+ addSelectInput(label: string, input: SelectInputBuilder, description?: string | null): this;
71
+ addFileInput(label: string, input: FileInputBuilder, description?: string | null): this;
72
+ addColorInput(label: string, input: ColorInputBuilder, description?: string | null): this;
73
+ addDateInput(label: string, input: DateInputBuilder, description?: string | null): this;
74
+ addParagraph(content: string, label?: string | null): this;
75
+ toMap(): Record<string, unknown>;
76
+ }
77
+
78
+ export declare class TextInputBuilder {
79
+ constructor(customId: string);
80
+ multiline(): this;
81
+ setPlaceholder(value: string | null): this;
82
+ setDefault(value: string | null): this;
83
+ setRequired(value: boolean | null): this;
84
+ setMinLength(value: number): this;
85
+ setMaxLength(value: number): this;
86
+ }
87
+
88
+ export declare class SelectInputBuilder {
89
+ constructor(customId: string);
90
+ setPlaceholder(value: string | null): this;
91
+ addOption(option: SelectOption): this;
92
+ setRequired(value: boolean | null): this;
93
+ setMinValues(value: number): this;
94
+ setMaxValues(value: number): this;
95
+ setSearchable(value: boolean | null): this;
96
+ isSearchable(): boolean;
97
+ optionCount(): number;
98
+ }
99
+
100
+ export declare class SelectOption {
101
+ readonly label: string;
102
+ readonly value: string;
103
+ readonly selected: boolean;
104
+ constructor(label: string, value: string);
105
+ setSelected(value: boolean | null): this;
106
+ }
107
+
108
+ export declare class FileInputBuilder {
109
+ constructor(customId: string);
110
+ setRequired(value: boolean | null): this;
111
+ setMinFiles(value: number): this;
112
+ setMaxFiles(value: number): this;
113
+ }
114
+
115
+ export declare class DateInputBuilder {
116
+ constructor(customId: string);
117
+ includeTime(): this;
118
+ setRequired(value: boolean | null): this;
119
+ setDefault(value: Date | string | null): this;
120
+ }
121
+
122
+ export declare class ColorInputBuilder {
123
+ constructor(customId: string);
124
+ setRequired(value: boolean | null): this;
125
+ setDefault(value: string | null): this;
126
+ }
127
+
128
+ export declare class Session {
129
+ readonly userKey: string;
130
+ constructor(userKey: string);
131
+ get<T = unknown>(key: string): T | null;
132
+ set<T = unknown>(key: string, value: T | null): void;
133
+ update<T = unknown>(key: string, updater: (current: T | null) => T | null): void;
134
+ delete(key: string): boolean;
135
+ list(): string[];
136
+ clear(): void;
137
+ }
138
+
139
+ export declare class ButtonInteraction {
140
+ readonly customId: string;
141
+ readonly session: Session;
142
+ reply(message: string): void;
143
+ showModal(modal: ModalBuilder): void;
144
+ }
145
+
146
+ export declare class ModalInteraction extends ButtonInteraction {
147
+ getTextInput(customId: string): string | null;
148
+ getSelectInput(customId: string): string[];
149
+ getFileInput(customId: string): Blob[];
150
+ getDateInput(customId: string): Date | null;
151
+ getColorInput(customId: string): string | null;
152
+ reject(message: string): void;
153
+ rejectInputs(inputs: Record<string, string>): void;
154
+ }
155
+
156
+ export declare class SelectMenuSearch {
157
+ readonly customId: string;
158
+ readonly query: string;
159
+ readonly session: Session;
160
+ respond(options: SelectOption[]): void;
161
+ }
162
+
163
+ export declare function dashboard(handler: (page: Page) => void): void;
164
+ export declare function onButtonInteraction(handler: (interaction: ButtonInteraction) => void): void;
165
+ export declare function onModalInteraction(handler: (interaction: ModalInteraction) => void): void;
166
+ export declare function onSelectMenuSearch(handler: (search: SelectMenuSearch) => void): void;
167
+ export declare function __fraxicHandleUiRequest(payload: unknown): unknown;
package/index.js ADDED
@@ -0,0 +1,991 @@
1
+ export const ChartType = Object.freeze({
2
+ LINE: "line",
3
+ BAR: "bar",
4
+ PIE: "pie",
5
+ AREA: "area",
6
+ });
7
+
8
+ export const NoticeType = Object.freeze({
9
+ INFO: "info",
10
+ WARNING: "warning",
11
+ ERROR: "error",
12
+ SUCCESS: "success",
13
+ });
14
+
15
+ let dashboardHandler = null;
16
+ const buttonHandlers = [];
17
+ const modalHandlers = [];
18
+ const selectMenuSearchHandlers = [];
19
+ const sessions = new Map();
20
+ const modalSchemas = new Map();
21
+
22
+ function requireText(name, value) {
23
+ if (typeof value !== "string") throw new Error(`${name} must be a string`);
24
+ if (value.length > 5000) throw new Error(`${name} must be at most 5000 characters`);
25
+ }
26
+
27
+ function requireNonBlankText(name, value) {
28
+ requireText(name, value);
29
+ if (value.length === 0) throw new Error(`${name} must not be blank`);
30
+ }
31
+
32
+ function requireCustomId(customId) {
33
+ requireNonBlankText("customId", customId);
34
+ if (customId.length > 100) throw new Error("customId must be at most 100 characters");
35
+ }
36
+
37
+ function requireWhole(name, value) {
38
+ if (!Number.isFinite(value) || Math.floor(value) !== value) throw new Error(`${name} must be an integer`);
39
+ }
40
+
41
+ function requireRange(name, value, min, max) {
42
+ requireWhole(name, value);
43
+ if (value < min || value > max) throw new Error(`${name} must be between ${min} and ${max}`);
44
+ }
45
+
46
+ function required(item) {
47
+ return item.required !== false;
48
+ }
49
+
50
+ function numericOption(item, key, fallback) {
51
+ return Number.isFinite(item[key]) ? item[key] : fallback;
52
+ }
53
+
54
+ function isMidnightUtc(date) {
55
+ return date.getUTCHours() === 0 && date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0 && date.getUTCMilliseconds() === 0;
56
+ }
57
+
58
+ function formatDateValue(includeTime, date) {
59
+ return includeTime ? date.toISOString() : date.toISOString().slice(0, 10);
60
+ }
61
+
62
+ function parseDateValue(value) {
63
+ if (value === null || value === undefined || value === "") return null;
64
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value : null;
65
+ if (typeof value === "number" || typeof value === "string") {
66
+ const date = new Date(value);
67
+ return Number.isFinite(date.getTime()) ? date : null;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function parseColorValue(value) {
73
+ if (value === null || value === undefined || value === "") return null;
74
+ if (typeof value !== "string") return null;
75
+ if (value.length !== 7 && value.length !== 9) return null;
76
+ if (value[0] !== "#") return null;
77
+ for (let index = 1; index < value.length; index += 1) {
78
+ const code = value.charCodeAt(index);
79
+ const digit = code >= 48 && code <= 57;
80
+ const upper = code >= 65 && code <= 70;
81
+ const lower = code >= 97 && code <= 102;
82
+ if (!digit && !upper && !lower) return null;
83
+ }
84
+ return value.toUpperCase();
85
+ }
86
+
87
+ function normalizeInputValue(item, value) {
88
+ if (item.type === 17) return normalizeFiles(value);
89
+ if (item.type === 18) return parseDateValue(value) ?? value;
90
+ if (item.type === 19) return parseColorValue(value) ?? value;
91
+ return value;
92
+ }
93
+
94
+ function normalizeFiles(value) {
95
+ if (!Array.isArray(value)) return value;
96
+ return value.map((file) => filePayloadToBlob(file));
97
+ }
98
+
99
+ function filePayloadToBlob(file) {
100
+ if (file instanceof Blob) return file;
101
+ if (file === null || typeof file !== "object" || typeof file.data !== "string") return file;
102
+ const bytes = decodeBase64(file.data);
103
+ const type = typeof file.type === "string" ? file.type : "";
104
+ const name = typeof file.name === "string" && file.name.length > 0 ? file.name : "upload";
105
+ if (typeof File === "function") return new File([bytes], name, { type });
106
+ const blob = new Blob([bytes], { type });
107
+ try {
108
+ Object.defineProperty(blob, "name", { value: name, enumerable: true });
109
+ } catch {
110
+ // Some runtimes keep Blob objects non-extensible. The bytes still matter.
111
+ }
112
+ return blob;
113
+ }
114
+
115
+ function decodeBase64(value) {
116
+ const text = atob(value);
117
+ const bytes = new Uint8Array(text.length);
118
+ for (let index = 0; index < text.length; index += 1) bytes[index] = text.charCodeAt(index);
119
+ return bytes;
120
+ }
121
+
122
+ function component(type, values = {}) {
123
+ return { type, ...values };
124
+ }
125
+
126
+ function addComponent(page, children, value) {
127
+ page.addComponent();
128
+ children.push(value);
129
+ }
130
+
131
+ function addContainer(page, children, type, ContainerClass, builder) {
132
+ const container = new ContainerClass(page);
133
+ page.enterContainer();
134
+ try {
135
+ builder(container);
136
+ } finally {
137
+ page.leaveContainer();
138
+ }
139
+ addComponent(page, children, component(type, { children: container.toList() }));
140
+ }
141
+
142
+ function addText(page, children, content) {
143
+ requireText("content", content);
144
+ addComponent(page, children, component(5, { content }));
145
+ }
146
+
147
+ function addNotice(page, children, message, noticeType) {
148
+ requireText("message", message);
149
+ addComponent(page, children, component(6, { message, noticeType }));
150
+ }
151
+
152
+ function addProgress(page, children, label, value, max) {
153
+ requireText("label", label);
154
+ if (!Number.isFinite(value) || value < 0) throw new Error("value must be non-negative");
155
+ if (!Number.isFinite(max) || max <= 0) throw new Error("max must be positive");
156
+ addComponent(page, children, component(7, { label, value, max }));
157
+ }
158
+
159
+ function addButton(page, children, label, customId) {
160
+ requireText("label", label);
161
+ requireCustomId(customId);
162
+ addComponent(page, children, component(9, { customId, label }));
163
+ }
164
+
165
+ export class Container {
166
+ constructor(page) {
167
+ this.page = page;
168
+ this.children = [];
169
+ }
170
+
171
+ horizontal(builder) {
172
+ addContainer(this.page, this.children, 2, Horizontal, builder);
173
+ }
174
+
175
+ vertical(builder) {
176
+ addContainer(this.page, this.children, 3, Vertical, builder);
177
+ }
178
+
179
+ card(builder) {
180
+ addContainer(this.page, this.children, 4, Card, builder);
181
+ }
182
+
183
+ table(builder) {
184
+ const table = new Table(this.page);
185
+ this.page.enterContainer();
186
+ try {
187
+ builder(table);
188
+ } finally {
189
+ this.page.leaveContainer();
190
+ }
191
+ addComponent(this.page, this.children, table.toMap());
192
+ }
193
+
194
+ text(content) {
195
+ addText(this.page, this.children, content);
196
+ }
197
+
198
+ notice(message, noticeType = NoticeType.INFO) {
199
+ addNotice(this.page, this.children, message, noticeType);
200
+ }
201
+
202
+ progress(label, value, max) {
203
+ addProgress(this.page, this.children, label, value, max);
204
+ }
205
+
206
+ separator() {
207
+ addComponent(this.page, this.children, component(8));
208
+ }
209
+
210
+ button(label, customId) {
211
+ addButton(this.page, this.children, label, customId);
212
+ }
213
+
214
+ chart(chart) {
215
+ addComponent(this.page, this.children, chart.toMap());
216
+ }
217
+
218
+ toList() {
219
+ return this.children.slice();
220
+ }
221
+ }
222
+
223
+ export class Page {
224
+ constructor(session) {
225
+ this.session = session;
226
+ this.sections = [];
227
+ this.count = 0;
228
+ this.depth = 0;
229
+ }
230
+
231
+ section(title, builder) {
232
+ requireText("title", title);
233
+ const section = new Section(this);
234
+ this.enterContainer();
235
+ try {
236
+ builder(section);
237
+ } finally {
238
+ this.leaveContainer();
239
+ }
240
+ this.addComponent();
241
+ this.sections.push(component(1, { title, children: section.toList() }));
242
+ }
243
+
244
+ toMap() {
245
+ return { components: this.sections.slice() };
246
+ }
247
+
248
+ addComponent() {
249
+ this.count += 1;
250
+ if (this.count > 200) throw new Error("page component limit exceeded");
251
+ }
252
+
253
+ enterContainer() {
254
+ if (this.depth >= 8) throw new Error("maximum UI nesting depth exceeded");
255
+ this.depth += 1;
256
+ }
257
+
258
+ leaveContainer() {
259
+ this.depth -= 1;
260
+ }
261
+ }
262
+
263
+ export class Section extends Container {}
264
+ export class Horizontal extends Container {}
265
+ export class Vertical extends Container {}
266
+ export class Card extends Container {}
267
+ export class TableCell extends Container {}
268
+
269
+ export class Table {
270
+ constructor(page) {
271
+ this.page = page;
272
+ this.rows = [];
273
+ }
274
+
275
+ header(builder) {
276
+ this.addRow(true, builder);
277
+ }
278
+
279
+ row(builder) {
280
+ this.addRow(false, builder);
281
+ }
282
+
283
+ toMap() {
284
+ return component(14, { rows: this.rows.slice() });
285
+ }
286
+
287
+ addRow(header, builder) {
288
+ if (this.rows.length >= 100) throw new Error("table row limit exceeded");
289
+ const row = new TableRow(this.page);
290
+ this.page.enterContainer();
291
+ try {
292
+ builder(row);
293
+ } finally {
294
+ this.page.leaveContainer();
295
+ }
296
+ const cells = row.toList();
297
+ if (this.rows.length > 0 && cells.length !== this.rows[0].cells.length) throw new Error("table rows must have the same number of cells");
298
+ this.page.addComponent();
299
+ this.rows.push({ header, cells });
300
+ }
301
+ }
302
+
303
+ export class TableRow {
304
+ constructor(page) {
305
+ this.page = page;
306
+ this.cells = [];
307
+ }
308
+
309
+ cell(builder) {
310
+ if (this.cells.length >= 20) throw new Error("table cell limit exceeded");
311
+ const cell = new TableCell(this.page);
312
+ this.page.enterContainer();
313
+ try {
314
+ builder(cell);
315
+ } finally {
316
+ this.page.leaveContainer();
317
+ }
318
+ this.page.addComponent();
319
+ this.cells.push(component(15, { children: cell.toList() }));
320
+ }
321
+
322
+ toList() {
323
+ return this.cells.slice();
324
+ }
325
+ }
326
+
327
+ export class Chart {
328
+ constructor(type) {
329
+ this.type = type;
330
+ this.labelValue = null;
331
+ this.points = [];
332
+ }
333
+
334
+ label(label) {
335
+ requireText("label", label);
336
+ this.labelValue = label;
337
+ return this;
338
+ }
339
+
340
+ data(points) {
341
+ if (!Array.isArray(points)) throw new Error("data must be an array");
342
+ if (points.length > 200) throw new Error("chart data limit exceeded");
343
+ this.points = points.slice();
344
+ return this;
345
+ }
346
+
347
+ toMap() {
348
+ return component(13, { chartType: this.type, label: this.labelValue, data: this.points.map((point) => ({ label: point.label, value: point.value })) });
349
+ }
350
+ }
351
+
352
+ export class ChartDataPoint {
353
+ constructor(label, value) {
354
+ requireText("label", label);
355
+ if (!Number.isFinite(value)) throw new Error("value must be a finite number");
356
+ this.label = label;
357
+ this.value = value;
358
+ }
359
+ }
360
+
361
+ export class ModalBuilder {
362
+ constructor(customId, title) {
363
+ requireCustomId(customId);
364
+ requireNonBlankText("title", title);
365
+ this.customId = customId;
366
+ this.title = title;
367
+ this.components = [];
368
+ }
369
+
370
+ addTextInput(label, input, description = null) {
371
+ return this.addInput(label, description, input.toMap(label, description));
372
+ }
373
+
374
+ addSelectInput(label, input, description = null) {
375
+ if (!input.isSearchable() && input.optionCount() === 0) throw new Error("non-searchable select input must have options");
376
+ return this.addInput(label, description, input.toMap(label, description));
377
+ }
378
+
379
+ addFileInput(label, input, description = null) {
380
+ return this.addInput(label, description, input.toMap(label, description));
381
+ }
382
+
383
+ addColorInput(label, input, description = null) {
384
+ return this.addInput(label, description, input.toMap(label, description));
385
+ }
386
+
387
+ addDateInput(label, input, description = null) {
388
+ return this.addInput(label, description, input.toMap(label, description));
389
+ }
390
+
391
+ addParagraph(content, label = null) {
392
+ requireNonBlankText("content", content);
393
+ if (label !== null) requireNonBlankText("label", label);
394
+ return this.add(component(20, { content, ...(label === null ? {} : { label }) }));
395
+ }
396
+
397
+ toMap() {
398
+ return { customId: this.customId, title: this.title, components: this.components.slice() };
399
+ }
400
+
401
+ schemaKey(session, interactionId) {
402
+ return `${session.userKey}:${interactionId}`;
403
+ }
404
+
405
+ parseData(raw) {
406
+ const parsed = {};
407
+ for (const item of this.components) {
408
+ if (item.customId) parsed[item.customId] = normalizeInputValue(item, raw[item.customId] ?? null);
409
+ }
410
+ return parsed;
411
+ }
412
+
413
+ validateData(raw) {
414
+ const errors = {};
415
+ for (const item of this.components) {
416
+ if (!item.customId) continue;
417
+ const error = validateInput(item, normalizeInputValue(item, raw[item.customId]));
418
+ if (error !== null) errors[item.customId] = error;
419
+ }
420
+ return errors;
421
+ }
422
+
423
+ addInput(label, description, item) {
424
+ requireNonBlankText("label", label);
425
+ if (description !== null) requireNonBlankText("description", description);
426
+ return this.add(item);
427
+ }
428
+
429
+ add(item) {
430
+ if (this.components.length >= 10) throw new Error("modal component limit exceeded");
431
+ this.components.push(item);
432
+ return this;
433
+ }
434
+ }
435
+
436
+ function validateInput(item, value) {
437
+ if (item.type === 16) return validateTextInput(item, value);
438
+ if (item.type === 11) return validateSelectInput(item, value);
439
+ if (item.type === 17) return validateFileInput(item, value);
440
+ if (item.type === 18) return validateDateInput(item, value);
441
+ if (item.type === 19) return validateColorInput(item, value);
442
+ return null;
443
+ }
444
+
445
+ function validateTextInput(item, value) {
446
+ if (value === null || value === undefined || value === "") return required(item) ? "Required." : null;
447
+ if (typeof value !== "string") return "Invalid value.";
448
+ if (item.minLength !== undefined && value.length < item.minLength) return `Must be at least ${item.minLength} characters.`;
449
+ if (item.maxLength !== undefined && value.length > item.maxLength) return `Must be at most ${item.maxLength} characters.`;
450
+ return null;
451
+ }
452
+
453
+ function validateSelectInput(item, value) {
454
+ if (!Array.isArray(value) || value.length === 0) return required(item) ? "Required." : null;
455
+ if (value.some((entry) => typeof entry !== "string")) return "Invalid value.";
456
+ const minValues = numericOption(item, "minValues", 0);
457
+ const maxValues = numericOption(item, "maxValues", 100);
458
+ if (value.length < minValues) return `Select at least ${minValues}.`;
459
+ if (value.length > maxValues) return `Select at most ${maxValues}.`;
460
+ return null;
461
+ }
462
+
463
+ function validateFileInput(item, value) {
464
+ if (!Array.isArray(value) || value.length === 0) return required(item) ? "Required." : null;
465
+ const minFiles = numericOption(item, "minFiles", 0);
466
+ const maxFiles = numericOption(item, "maxFiles", 10);
467
+ const maxFileSize = numericOption(item, "maxFileSize", 5242880);
468
+ if (value.length < minFiles) return `Upload at least ${minFiles} files.`;
469
+ if (value.length > maxFiles) return `Upload at most ${maxFiles} files.`;
470
+ for (const file of value) {
471
+ if (!(file instanceof Blob)) return "Invalid file.";
472
+ if (file.size > maxFileSize) return `File must be at most ${maxFileSize} bytes.`;
473
+ }
474
+ return null;
475
+ }
476
+
477
+ function validateDateInput(item, value) {
478
+ if (value === null || value === undefined || value === "") return required(item) ? "Required." : null;
479
+ const date = parseDateValue(value);
480
+ if (date === null) return "Invalid date.";
481
+ if (item.includeTime !== true && !isMidnightUtc(date)) return "Date must not include a time.";
482
+ return null;
483
+ }
484
+
485
+ function validateColorInput(item, value) {
486
+ if (value === null || value === undefined || value === "") return required(item) ? "Required." : null;
487
+ return parseColorValue(value) === null ? "Invalid color." : null;
488
+ }
489
+
490
+ export class TextInputBuilder {
491
+ constructor(customId) {
492
+ requireCustomId(customId);
493
+ this.customId = customId;
494
+ this.multilineValue = false;
495
+ this.placeholderValue = null;
496
+ this.defaultValue = null;
497
+ this.requiredValue = true;
498
+ this.minimumLength = null;
499
+ this.maximumLength = null;
500
+ }
501
+
502
+ multiline() {
503
+ this.multilineValue = true;
504
+ return this;
505
+ }
506
+
507
+ setPlaceholder(value) {
508
+ if (value !== null) requireNonBlankText("placeholder", value);
509
+ this.placeholderValue = value;
510
+ return this;
511
+ }
512
+
513
+ setDefault(value) {
514
+ if (value !== null) {
515
+ requireNonBlankText("value", value);
516
+ if (this.minimumLength !== null && value.length < this.minimumLength) throw new Error("value is shorter than min length");
517
+ if (this.maximumLength !== null && value.length > this.maximumLength) throw new Error("value is longer than max length");
518
+ }
519
+ this.defaultValue = value;
520
+ return this;
521
+ }
522
+
523
+ setRequired(value) {
524
+ this.requiredValue = value !== false;
525
+ return this;
526
+ }
527
+
528
+ setMinLength(value) {
529
+ requireRange("length", value, 0, 5000);
530
+ if (this.maximumLength !== null && value > this.maximumLength) throw new Error("length must not exceed max length");
531
+ if (this.defaultValue !== null && this.defaultValue.length < value) throw new Error("default value is shorter than min length");
532
+ this.minimumLength = value;
533
+ return this;
534
+ }
535
+
536
+ setMaxLength(value) {
537
+ requireRange("length", value, 1, 5000);
538
+ if (this.minimumLength !== null && value < this.minimumLength) throw new Error("length must not be below min length");
539
+ if (this.defaultValue !== null && this.defaultValue.length > value) throw new Error("default value is longer than max length");
540
+ this.maximumLength = value;
541
+ return this;
542
+ }
543
+
544
+ toMap(label, description = null) {
545
+ return component(16, {
546
+ label,
547
+ customId: this.customId,
548
+ multiline: this.multilineValue,
549
+ required: this.requiredValue,
550
+ ...(description === null ? {} : { description }),
551
+ ...(this.placeholderValue === null ? {} : { placeholder: this.placeholderValue }),
552
+ ...(this.defaultValue === null ? {} : { default: this.defaultValue }),
553
+ ...(this.minimumLength === null ? {} : { minLength: this.minimumLength }),
554
+ ...(this.maximumLength === null ? {} : { maxLength: this.maximumLength }),
555
+ });
556
+ }
557
+ }
558
+
559
+ export class SelectInputBuilder {
560
+ constructor(customId) {
561
+ requireCustomId(customId);
562
+ this.customId = customId;
563
+ this.placeholderValue = null;
564
+ this.options = [];
565
+ this.minimumValues = 1;
566
+ this.maximumValues = 1;
567
+ this.requiredValue = true;
568
+ this.searchableValue = false;
569
+ }
570
+
571
+ setPlaceholder(value) {
572
+ if (value !== null) requireNonBlankText("placeholder", value);
573
+ this.placeholderValue = value;
574
+ return this;
575
+ }
576
+
577
+ addOption(option) {
578
+ if (this.options.length >= 100) throw new Error("select option limit exceeded");
579
+ if (this.options.some((existing) => existing.value === option.value)) throw new Error("select option values must be unique");
580
+ this.options.push(option);
581
+ return this;
582
+ }
583
+
584
+ setRequired(value) {
585
+ this.requiredValue = value !== false;
586
+ return this;
587
+ }
588
+
589
+ setMinValues(value) {
590
+ requireRange("min", value, 0, 100);
591
+ this.minimumValues = value;
592
+ if (this.maximumValues < value) this.maximumValues = value;
593
+ return this;
594
+ }
595
+
596
+ setMaxValues(value) {
597
+ requireRange("max", value, 1, 100);
598
+ this.maximumValues = value;
599
+ if (this.minimumValues > value) this.minimumValues = value;
600
+ return this;
601
+ }
602
+
603
+ setSearchable(value) {
604
+ this.searchableValue = value !== false;
605
+ return this;
606
+ }
607
+
608
+ isSearchable() {
609
+ return this.searchableValue;
610
+ }
611
+
612
+ optionCount() {
613
+ return this.options.length;
614
+ }
615
+
616
+ toMap(label, description = null) {
617
+ return component(11, {
618
+ label,
619
+ customId: this.customId,
620
+ required: this.requiredValue,
621
+ minValues: this.minimumValues,
622
+ maxValues: this.maximumValues,
623
+ options: this.options.map((option) => option.toMap(true)),
624
+ ...(description === null ? {} : { description }),
625
+ ...(this.placeholderValue === null ? {} : { placeholder: this.placeholderValue }),
626
+ ...(this.searchableValue ? { searchable: true } : {}),
627
+ });
628
+ }
629
+ }
630
+
631
+ export class SelectOption {
632
+ constructor(label, value) {
633
+ requireNonBlankText("label", label);
634
+ requireNonBlankText("value", value);
635
+ this.label = label;
636
+ this.value = value;
637
+ this.selected = false;
638
+ }
639
+
640
+ setSelected(value) {
641
+ this.selected = value !== false;
642
+ return this;
643
+ }
644
+
645
+ toMap(includeSelected = false) {
646
+ return { label: this.label, value: this.value, ...(includeSelected && this.selected ? { selected: true } : {}) };
647
+ }
648
+ }
649
+
650
+ export class FileInputBuilder {
651
+ constructor(customId) {
652
+ requireCustomId(customId);
653
+ this.customId = customId;
654
+ this.requiredValue = true;
655
+ this.minimumFiles = 1;
656
+ this.maximumFiles = 1;
657
+ }
658
+
659
+ setRequired(value) {
660
+ this.requiredValue = value !== false;
661
+ return this;
662
+ }
663
+
664
+ setMinFiles(value) {
665
+ requireRange("min", value, 0, 10);
666
+ this.minimumFiles = value;
667
+ if (this.maximumFiles < value) this.maximumFiles = value;
668
+ return this;
669
+ }
670
+
671
+ setMaxFiles(value) {
672
+ requireRange("max", value, 1, 10);
673
+ this.maximumFiles = value;
674
+ if (this.minimumFiles > value) this.minimumFiles = value;
675
+ return this;
676
+ }
677
+
678
+ toMap(label, description = null) {
679
+ return component(17, { label, customId: this.customId, required: this.requiredValue, minFiles: this.minimumFiles, maxFiles: this.maximumFiles, maxFileSize: 5242880, ...(description === null ? {} : { description }) });
680
+ }
681
+ }
682
+
683
+ export class DateInputBuilder {
684
+ constructor(customId) {
685
+ requireCustomId(customId);
686
+ this.customId = customId;
687
+ this.includeTimeValue = false;
688
+ this.requiredValue = true;
689
+ this.defaultValue = null;
690
+ }
691
+
692
+ includeTime() {
693
+ this.includeTimeValue = true;
694
+ return this;
695
+ }
696
+
697
+ setRequired(value) {
698
+ this.requiredValue = value !== false;
699
+ return this;
700
+ }
701
+
702
+ setDefault(value) {
703
+ if (value === null) {
704
+ this.defaultValue = null;
705
+ return this;
706
+ }
707
+ const date = parseDateValue(value);
708
+ if (date === null) throw new Error("Invalid date.");
709
+ if (!this.includeTimeValue && !isMidnightUtc(date)) throw new Error("date must be at midnight UTC unless includeTime() is enabled");
710
+ this.defaultValue = date;
711
+ return this;
712
+ }
713
+
714
+ toMap(label, description = null) {
715
+ const formatted = this.defaultValue instanceof Date ? formatDateValue(this.includeTimeValue, this.defaultValue) : this.defaultValue;
716
+ return component(18, { label, customId: this.customId, includeTime: this.includeTimeValue, required: this.requiredValue, ...(description === null ? {} : { description }), ...(formatted === null ? {} : { default: formatted }) });
717
+ }
718
+ }
719
+
720
+ export class ColorInputBuilder {
721
+ constructor(customId) {
722
+ requireCustomId(customId);
723
+ this.customId = customId;
724
+ this.requiredValue = true;
725
+ this.defaultValue = null;
726
+ }
727
+
728
+ setRequired(value) {
729
+ this.requiredValue = value !== false;
730
+ return this;
731
+ }
732
+
733
+ setDefault(value) {
734
+ if (value === null) {
735
+ this.defaultValue = null;
736
+ return this;
737
+ }
738
+ const color = parseColorValue(value);
739
+ if (color === null) throw new Error("Invalid color.");
740
+ this.defaultValue = color;
741
+ return this;
742
+ }
743
+
744
+ toMap(label, description = null) {
745
+ return component(19, { label, customId: this.customId, required: this.requiredValue, ...(description === null ? {} : { description }), ...(this.defaultValue === null ? {} : { default: this.defaultValue }) });
746
+ }
747
+ }
748
+
749
+ export class Session {
750
+ constructor(userKey) {
751
+ requireText("userKey", userKey);
752
+ this.userKey = userKey;
753
+ this.entries = new Map();
754
+ }
755
+
756
+ get(key) {
757
+ requireText("key", key);
758
+ return this.entries.get(key) ?? null;
759
+ }
760
+
761
+ set(key, value) {
762
+ requireText("key", key);
763
+ if (value === null || value === undefined) this.entries.delete(key);
764
+ else this.entries.set(key, value);
765
+ }
766
+
767
+ update(key, updater) {
768
+ this.set(key, updater(this.get(key)));
769
+ }
770
+
771
+ delete(key) {
772
+ requireText("key", key);
773
+ return this.entries.delete(key);
774
+ }
775
+
776
+ list() {
777
+ return Array.from(this.entries.keys());
778
+ }
779
+
780
+ clear() {
781
+ this.entries.clear();
782
+ }
783
+ }
784
+
785
+ class InteractionBase {
786
+ constructor(customId, session, interactionId) {
787
+ requireCustomId(customId);
788
+ requireNonBlankText("interactionId", interactionId);
789
+ this.customId = customId;
790
+ this.session = session;
791
+ this.interactionId = interactionId;
792
+ this.resultValue = null;
793
+ this.replied = false;
794
+ }
795
+
796
+ reply(message) {
797
+ requireText("message", message);
798
+ this.ensureNotReplied();
799
+ this.replied = true;
800
+ this.resultValue = { ok: true, message };
801
+ }
802
+
803
+ showModal(modal) {
804
+ this.ensureNotReplied();
805
+ ensureModalHasComponents(modal);
806
+ this.replied = true;
807
+ modalSchemas.set(modal.schemaKey(this.session, this.interactionId), modal);
808
+ this.resultValue = { ok: true, modal: modal.toMap() };
809
+ }
810
+
811
+ result() {
812
+ return this.resultValue ?? noResponse();
813
+ }
814
+
815
+ ensureNotReplied() {
816
+ if (this.replied) throw new Error("interaction has already been replied to");
817
+ }
818
+ }
819
+
820
+ export class ButtonInteraction extends InteractionBase {}
821
+
822
+ export class ModalInteraction extends InteractionBase {
823
+ constructor(customId, session, data, interactionId) {
824
+ super(customId, session, interactionId);
825
+ this.data = data;
826
+ }
827
+
828
+ getTextInput(customId) {
829
+ return this.data[customId] ?? null;
830
+ }
831
+
832
+ getSelectInput(customId) {
833
+ return this.data[customId] ?? [];
834
+ }
835
+
836
+ getFileInput(customId) {
837
+ return this.data[customId] ?? [];
838
+ }
839
+
840
+ getDateInput(customId) {
841
+ const value = this.data[customId];
842
+ return value == null ? null : new Date(value);
843
+ }
844
+
845
+ getColorInput(customId) {
846
+ return this.data[customId] ?? null;
847
+ }
848
+
849
+ reject(message) {
850
+ requireNonBlankText("message", message);
851
+ this.ensureNotReplied();
852
+ this.replied = true;
853
+ this.resultValue = { ok: true, rejection: { message } };
854
+ }
855
+
856
+ rejectInputs(inputs) {
857
+ this.ensureNotReplied();
858
+ if (inputs === null || typeof inputs !== "object" || Array.isArray(inputs)) throw new Error("inputs must be an object");
859
+ if (Object.keys(inputs).length === 0) throw new Error("inputs must not be empty");
860
+ const checked = {};
861
+ for (const [customId, message] of Object.entries(inputs)) {
862
+ requireCustomId(customId);
863
+ requireNonBlankText("message", message);
864
+ if (!Object.prototype.hasOwnProperty.call(this.data, customId)) throw new Error("modal input not found");
865
+ checked[customId] = message;
866
+ }
867
+ this.replied = true;
868
+ this.resultValue = inputRejection(checked);
869
+ }
870
+ }
871
+
872
+ export class SelectMenuSearch {
873
+ constructor(customId, query, session) {
874
+ requireCustomId(customId);
875
+ requireText("query", query);
876
+ this.customId = customId;
877
+ this.query = query;
878
+ this.session = session;
879
+ this.resultValue = null;
880
+ this.responded = false;
881
+ }
882
+
883
+ respond(options) {
884
+ if (this.responded) throw new Error("search has already been responded to");
885
+ if (options.length > 100) throw new Error("select option limit exceeded");
886
+ this.responded = true;
887
+ this.resultValue = { ok: true, customId: this.customId, options: options.map((option) => option.toMap(false)) };
888
+ }
889
+
890
+ result() {
891
+ return this.resultValue ?? noResponse();
892
+ }
893
+ }
894
+
895
+ export function dashboard(handler) {
896
+ dashboardHandler = handler;
897
+ }
898
+
899
+ export function onButtonInteraction(handler) {
900
+ buttonHandlers.push(handler);
901
+ }
902
+
903
+ export function onModalInteraction(handler) {
904
+ modalHandlers.push(handler);
905
+ }
906
+
907
+ export function onSelectMenuSearch(handler) {
908
+ selectMenuSearchHandlers.push(handler);
909
+ }
910
+
911
+ export function __fraxicHandleUiRequest(payload) {
912
+ const request = payload;
913
+ if (request.type === "render") return render(request);
914
+ if (request.type === "button") return handleButton(request);
915
+ if (request.type === "modal") return handleModal(request);
916
+ if (request.type === "selectMenuSearch") return handleSelectMenuSearch(request);
917
+ return { ok: false, reason: "invalidRequest" };
918
+ }
919
+
920
+ function render(request) {
921
+ if (dashboardHandler === null) return { ok: false, reason: "noHandler", components: [] };
922
+ const page = new Page(sessionFor(request));
923
+ dashboardHandler(page);
924
+ return { ok: true, components: page.toMap().components };
925
+ }
926
+
927
+ function handleButton(request) {
928
+ if (buttonHandlers.length === 0) return noHandler();
929
+ const interaction = new ButtonInteraction(request.customId, sessionFor(request), request.interactionId);
930
+ for (const handler of buttonHandlers) handler(interaction);
931
+ return interaction.result();
932
+ }
933
+
934
+ function handleModal(request) {
935
+ if (modalHandlers.length === 0) return noHandler();
936
+ const session = sessionFor(request);
937
+ const schemaKey = `${session.userKey}:${request.originInteractionId}`;
938
+ const schema = modalSchemas.get(schemaKey);
939
+ if (!schema) return { ok: false, reason: "gone" };
940
+ const errors = schema.validateData(request.data ?? {});
941
+ if (Object.keys(errors).length > 0) {
942
+ modalSchemas.delete(schemaKey);
943
+ modalSchemas.set(schema.schemaKey(session, request.interactionId), schema);
944
+ return inputRejection(errors);
945
+ }
946
+ const interaction = new ModalInteraction(request.customId, session, schema.parseData(request.data ?? {}), request.interactionId);
947
+ let removeSchema = true;
948
+ try {
949
+ for (const handler of modalHandlers) handler(interaction);
950
+ const result = interaction.result();
951
+ if (result.rejection) {
952
+ modalSchemas.delete(schemaKey);
953
+ modalSchemas.set(schema.schemaKey(session, request.interactionId), schema);
954
+ removeSchema = false;
955
+ }
956
+ return result;
957
+ } finally {
958
+ if (removeSchema) modalSchemas.delete(schemaKey);
959
+ }
960
+ }
961
+
962
+ function handleSelectMenuSearch(request) {
963
+ if (selectMenuSearchHandlers.length === 0) return noHandler();
964
+ const search = new SelectMenuSearch(request.customId, request.query, sessionFor(request));
965
+ for (const handler of selectMenuSearchHandlers) handler(search);
966
+ return search.result();
967
+ }
968
+
969
+ function sessionFor(request) {
970
+ const key = request.sessionKey;
971
+ if (!sessions.has(key)) sessions.set(key, new Session(key));
972
+ return sessions.get(key);
973
+ }
974
+
975
+ function inputRejection(inputs) {
976
+ return { ok: true, rejection: { inputs } };
977
+ }
978
+
979
+ function noHandler() {
980
+ return { ok: false, reason: "noHandler" };
981
+ }
982
+
983
+ function noResponse() {
984
+ return { ok: false, reason: "noResponse" };
985
+ }
986
+
987
+ function ensureModalHasComponents(modal) {
988
+ if (modal.toMap().components.length === 0) throw new Error("modal must have at least 1 component");
989
+ }
990
+
991
+ globalThis.__fraxic?.ipc?.listen?.("ui", __fraxicHandleUiRequest);
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@fraxic/ui",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./index.d.ts",
7
+ "files": [
8
+ "index.js",
9
+ "index.d.ts"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "default": "./index.js"
15
+ }
16
+ }
17
+ }