@kematjaya/crud-ui-generator 0.1.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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Entity-agnostic CRUD UI primitives, genericized from boilerplate/frontend's hand-written
3
+ * Notes feature (DeleteConfirmModal, NotesBulkActionsBar, NotesPaginationBar, NotesSearchPanel,
4
+ * ExportAllButton — all under src/components/notes/). Written once per frontend project into
5
+ * src/components/crud/ (skipped on repeat runs / for later entities) so N generated entities
6
+ * don't each carry a byte-identical copy.
7
+ */
8
+ export const deleteConfirmModal = `'use client';
9
+
10
+ import type { ReactNode } from 'react';
11
+ import { Button } from '@kematjaya/bootstrap-ui-kit';
12
+
13
+ type Props = {
14
+ title: string;
15
+ message: ReactNode;
16
+ confirmLabel?: string;
17
+ confirming: boolean;
18
+ onConfirm: () => void;
19
+ onCancel: () => void;
20
+ };
21
+
22
+ export function DeleteConfirmModal({
23
+ title,
24
+ message,
25
+ confirmLabel = 'Delete',
26
+ confirming,
27
+ onConfirm,
28
+ onCancel
29
+ }: Props) {
30
+ return (
31
+ <>
32
+ <div className="modal-backdrop fade show" />
33
+ <div
34
+ className="modal fade show d-block"
35
+ tabIndex={-1}
36
+ role="dialog"
37
+ aria-modal="true"
38
+ aria-labelledby="crud-delete-modal-title"
39
+ >
40
+ <div className="modal-dialog modal-dialog-centered">
41
+ <div className="modal-content">
42
+ <div className="modal-header">
43
+ <h5 className="modal-title" id="crud-delete-modal-title">
44
+ {title}
45
+ </h5>
46
+ <button type="button" className="btn-close" aria-label="Close" onClick={onCancel} />
47
+ </div>
48
+ <div className="modal-body">
49
+ <p className="m-0">{message}</p>
50
+ </div>
51
+ <div className="modal-footer" style={{ flexDirection: 'row-reverse' }}>
52
+ <Button variant="danger" disabled={confirming} onClick={onConfirm}>
53
+ {confirming ? 'Deleting…' : confirmLabel}
54
+ </Button>
55
+ <Button variant="outline" onClick={onCancel}>
56
+ Cancel
57
+ </Button>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </>
63
+ );
64
+ }
65
+ `;
66
+ export const bulkActionsBar = `type Props = {
67
+ selectedCount: number;
68
+ noun: string;
69
+ pluralNoun: string;
70
+ disabled?: boolean;
71
+ onDelete?: () => void;
72
+ onExportSelected?: () => void;
73
+ onClearSelection: () => void;
74
+ };
75
+
76
+ export function BulkActionsBar({
77
+ selectedCount,
78
+ noun,
79
+ pluralNoun,
80
+ disabled = false,
81
+ onDelete,
82
+ onExportSelected,
83
+ onClearSelection
84
+ }: Props) {
85
+ return (
86
+ <div
87
+ className="crud-bulk-bar"
88
+ role="toolbar"
89
+ aria-label="Bulk actions"
90
+ >
91
+ <span className="crud-bulk-count">
92
+ {selectedCount} {selectedCount === 1 ? noun : pluralNoun}{' '}
93
+ selected
94
+ </span>
95
+ <div className="crud-bulk-actions">
96
+ {onDelete && (
97
+ <button
98
+ type="button"
99
+ className="btn btn-outline-danger btn-sm"
100
+ disabled={disabled}
101
+ onClick={onDelete}
102
+ >
103
+ <i className="bi bi-trash" aria-hidden="true" /> Delete
104
+ </button>
105
+ )}
106
+ {onExportSelected && (
107
+ <button
108
+ type="button"
109
+ className="btn btn-outline-primary btn-sm"
110
+ disabled={disabled}
111
+ onClick={onExportSelected}
112
+ >
113
+ <i className="bi bi-download" aria-hidden="true" /> Export
114
+ Selected
115
+ </button>
116
+ )}
117
+ <button
118
+ type="button"
119
+ className="btn btn-sm btn-link p-0"
120
+ onClick={onClearSelection}
121
+ disabled={disabled}
122
+ >
123
+ Clear selection
124
+ </button>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+ `;
130
+ export const exportAllButton = `type Props = {
131
+ totalItems: number;
132
+ filtered: boolean;
133
+ exporting: boolean;
134
+ disabled: boolean;
135
+ onExport: () => void;
136
+ };
137
+
138
+ export function ExportAllButton({
139
+ totalItems,
140
+ filtered,
141
+ exporting,
142
+ disabled,
143
+ onExport
144
+ }: Props) {
145
+ return (
146
+ <div className="crud-export-all">
147
+ <button
148
+ type="button"
149
+ className="btn btn-outline-primary"
150
+ disabled={disabled}
151
+ aria-busy={exporting}
152
+ onClick={onExport}
153
+ >
154
+ <i className="bi bi-download" aria-hidden="true" /> Export All (
155
+ {totalItems})
156
+ </button>
157
+ {filtered && (
158
+ <span className="crud-export-filter" aria-live="polite">
159
+ matching current search
160
+ </span>
161
+ )}
162
+ </div>
163
+ );
164
+ }
165
+ `;
166
+ export const paginationBar = `import { Button, LinkButton, dashButtonClassName } from '@kematjaya/bootstrap-ui-kit';
167
+
168
+ export type PageSizeChange = { page?: number; itemsPerPage?: number };
169
+
170
+ type Props = {
171
+ page: number;
172
+ itemsPerPage: number;
173
+ pageSizes: readonly number[];
174
+ totalItems: number;
175
+ loadedItems: number;
176
+ buildHref: (patch: PageSizeChange) => string;
177
+ onPageSizeChange: (itemsPerPage: number) => void;
178
+ onPageChange: (page: number) => void;
179
+ };
180
+
181
+ function PageControl({
182
+ disabled,
183
+ href,
184
+ label
185
+ }: {
186
+ disabled: boolean;
187
+ href: string;
188
+ label: string;
189
+ }) {
190
+ if (disabled) {
191
+ return (
192
+ <span
193
+ className={\`\${dashButtonClassName('outline')} disabled\`}
194
+ aria-disabled="true"
195
+ >
196
+ {label}
197
+ </span>
198
+ );
199
+ }
200
+
201
+ return (
202
+ <LinkButton variant="outline" href={href}>
203
+ {label}
204
+ </LinkButton>
205
+ );
206
+ }
207
+
208
+ export function PaginationBar({
209
+ page,
210
+ itemsPerPage,
211
+ pageSizes,
212
+ totalItems,
213
+ loadedItems,
214
+ buildHref,
215
+ onPageSizeChange,
216
+ onPageChange
217
+ }: Props) {
218
+ if (totalItems <= itemsPerPage) return null;
219
+
220
+ const pageCount = Math.max(1, Math.ceil(totalItems / itemsPerPage));
221
+ const first = totalItems === 0 ? 0 : (page - 1) * itemsPerPage + 1;
222
+ const last = first === 0 ? 0 : first + loadedItems - 1;
223
+ const summary = \`\${first}–\${last} of \${totalItems}\`;
224
+
225
+ return (
226
+ <nav className="crud-pagination" aria-label="Pagination">
227
+ <p className="dash-card-subtitle" aria-live="polite">
228
+ Showing {summary}
229
+ </p>
230
+ <label className="crud-page-size">
231
+ <span>Rows</span>
232
+ <select
233
+ className="form-select form-select-sm"
234
+ value={itemsPerPage}
235
+ aria-label="Rows per page"
236
+ onChange={(event) =>
237
+ onPageSizeChange(Number(event.target.value))
238
+ }
239
+ >
240
+ {pageSizes.map((size) => (
241
+ <option key={size} value={size}>
242
+ {size}
243
+ </option>
244
+ ))}
245
+ </select>
246
+ </label>
247
+ <div className="crud-page-links">
248
+ <PageControl
249
+ disabled={page === 1}
250
+ href={buildHref({ page: 1 })}
251
+ label="First"
252
+ />
253
+ <PageControl
254
+ disabled={page === 1}
255
+ href={buildHref({ page: Math.max(1, page - 1) })}
256
+ label="Previous"
257
+ />
258
+ <span className="dash-card-subtitle">
259
+ Page {page} of {pageCount}
260
+ </span>
261
+ <form
262
+ className="crud-page-jump"
263
+ onSubmit={(event) => {
264
+ event.preventDefault();
265
+ const value = Number(
266
+ new FormData(event.currentTarget).get('page')
267
+ );
268
+ if (Number.isInteger(value))
269
+ onPageChange(Math.min(pageCount, Math.max(1, value)));
270
+ }}
271
+ >
272
+ <label>
273
+ <span>Go to page</span>
274
+ <input
275
+ key={page}
276
+ className="form-control form-control-sm"
277
+ name="page"
278
+ type="number"
279
+ min={1}
280
+ max={pageCount}
281
+ defaultValue={page}
282
+ aria-label="Go to page"
283
+ />
284
+ </label>
285
+ <Button type="submit" variant="outline">
286
+ Go
287
+ </Button>
288
+ </form>
289
+ <PageControl
290
+ disabled={page >= pageCount}
291
+ href={buildHref({ page: Math.min(pageCount, page + 1) })}
292
+ label="Next"
293
+ />
294
+ <PageControl
295
+ disabled={page >= pageCount}
296
+ href={buildHref({ page: pageCount })}
297
+ label="Last"
298
+ />
299
+ </div>
300
+ </nav>
301
+ );
302
+ }
303
+ `;
304
+ export const searchPanel = `import { useId } from 'react';
305
+ import { Button } from '@kematjaya/bootstrap-ui-kit';
306
+
307
+ type Props = {
308
+ label: string;
309
+ value: string;
310
+ totalItems: number;
311
+ pluralNoun: string;
312
+ pending: boolean;
313
+ disabled: boolean;
314
+ onSearch: (value: string) => void;
315
+ onClear: () => void;
316
+ };
317
+
318
+ function summary(totalItems: number, pluralNoun: string, filtered: boolean) {
319
+ return filtered
320
+ ? \`\${totalItems} \${pluralNoun} match your search\`
321
+ : \`\${totalItems} \${pluralNoun} total\`;
322
+ }
323
+
324
+ export function SearchPanel({
325
+ label,
326
+ value,
327
+ totalItems,
328
+ pluralNoun,
329
+ pending,
330
+ disabled,
331
+ onSearch,
332
+ onClear
333
+ }: Props) {
334
+ const inputId = useId();
335
+ const trimmedValue = value.trim();
336
+ const searchDisabled = pending || disabled;
337
+
338
+ return (
339
+ <div className="crud-search-panel">
340
+ <form
341
+ className="crud-toolbar"
342
+ method="get"
343
+ role="search"
344
+ onSubmit={(event) => {
345
+ event.preventDefault();
346
+ onSearch(
347
+ new FormData(event.currentTarget)
348
+ .get('q')
349
+ ?.toString()
350
+ .trim() ?? ''
351
+ );
352
+ }}
353
+ >
354
+ <div className="crud-search-field">
355
+ <label htmlFor={inputId} className="form-label mb-1">
356
+ {label}
357
+ </label>
358
+ <input
359
+ id={inputId}
360
+ type="search"
361
+ className="form-control"
362
+ name="q"
363
+ defaultValue={value}
364
+ placeholder={label}
365
+ autoComplete="off"
366
+ disabled={searchDisabled}
367
+ />
368
+ </div>
369
+ <div className="crud-toolbar-actions">
370
+ <Button type="submit" disabled={searchDisabled}>
371
+ Search
372
+ </Button>
373
+ <Button
374
+ variant="outline"
375
+ onClick={(event) => {
376
+ event.preventDefault();
377
+ onClear();
378
+ }}
379
+ aria-label="Clear search"
380
+ disabled={!trimmedValue || pending}
381
+ >
382
+ Clear
383
+ </Button>
384
+ </div>
385
+ </form>
386
+ <div className="crud-search-meta">
387
+ <p
388
+ className="dash-card-subtitle m-0"
389
+ role="status"
390
+ aria-live="polite"
391
+ aria-busy={pending}
392
+ >
393
+ {pending ? 'Loading...' : summary(totalItems, pluralNoun, Boolean(trimmedValue))}
394
+ </p>
395
+ {trimmedValue && (
396
+ <div
397
+ className="crud-filter-chip"
398
+ aria-label={\`Active filter: \${trimmedValue}\`}
399
+ >
400
+ <span>Search: {trimmedValue}</span>
401
+ <button
402
+ type="button"
403
+ className="btn btn-sm btn-link p-0"
404
+ onClick={onClear}
405
+ disabled={pending}
406
+ >
407
+ Clear all filters
408
+ </button>
409
+ </div>
410
+ )}
411
+ </div>
412
+ </div>
413
+ );
414
+ }
415
+ `;