@modufolio/panel 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.
Files changed (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/dist/Builder/ImageNodeView.d.ts +33 -0
  4. package/dist/Builder/document.d.ts +17 -0
  5. package/dist/Builder/dragHandle.d.ts +13 -0
  6. package/dist/Builder/editing.d.ts +53 -0
  7. package/dist/Builder/plugins.d.ts +27 -0
  8. package/dist/Builder/schema.d.ts +20 -0
  9. package/dist/Components/Actions/Action.vue.d.ts +116 -0
  10. package/dist/Components/Actions/ActionGroup.vue.d.ts +25 -0
  11. package/dist/Components/Actions/ActionGroupItem.vue.d.ts +48 -0
  12. package/dist/Components/Auth/ForgotPassword.vue.d.ts +17 -0
  13. package/dist/Components/Auth/Login.vue.d.ts +33 -0
  14. package/dist/Components/Auth/ResetPassword.vue.d.ts +27 -0
  15. package/dist/Components/Auth/TwoFactor.vue.d.ts +32 -0
  16. package/dist/Components/Auth/TwoFactorVerify.vue.d.ts +31 -0
  17. package/dist/Components/Columns/BadgeColumn.vue.d.ts +34 -0
  18. package/dist/Components/Columns/BooleanColumn.vue.d.ts +78 -0
  19. package/dist/Components/Columns/CellActions.vue.d.ts +34 -0
  20. package/dist/Components/Columns/ColorColumn.vue.d.ts +84 -0
  21. package/dist/Components/Columns/CopyButton.vue.d.ts +15 -0
  22. package/dist/Components/Columns/DateColumn.vue.d.ts +60 -0
  23. package/dist/Components/Columns/IconColumn.vue.d.ts +56 -0
  24. package/dist/Components/Columns/ImageColumn.vue.d.ts +46 -0
  25. package/dist/Components/Columns/SelectColumn.vue.d.ts +71 -0
  26. package/dist/Components/Columns/TextColumn.vue.d.ts +69 -0
  27. package/dist/Components/Columns/TextInputColumn.vue.d.ts +75 -0
  28. package/dist/Components/Columns/ToggleColumn.vue.d.ts +91 -0
  29. package/dist/Components/Columns/TreeColumn.vue.d.ts +60 -0
  30. package/dist/Components/Columns/columnRegistry.d.ts +6 -0
  31. package/dist/Components/Composables/useInlineEdit.d.ts +27 -0
  32. package/dist/Components/Composables/useRelationship.d.ts +38 -0
  33. package/dist/Components/Core/Badge.vue.d.ts +47 -0
  34. package/dist/Components/Core/Button.vue.d.ts +131 -0
  35. package/dist/Components/Core/Dropdown.vue.d.ts +24 -0
  36. package/dist/Components/Core/Empty.vue.d.ts +54 -0
  37. package/dist/Components/Core/ErrorBoundary.vue.d.ts +24 -0
  38. package/dist/Components/Core/FlashMessages.vue.d.ts +7 -0
  39. package/dist/Components/Core/Icon.vue.d.ts +23 -0
  40. package/dist/Components/Core/Icons/DocumentLinesIcon.vue.d.ts +3 -0
  41. package/dist/Components/Core/Icons/SitemapIcon.vue.d.ts +3 -0
  42. package/dist/Components/Core/Label.vue.d.ts +52 -0
  43. package/dist/Components/Core/LoadingButton.vue.d.ts +18 -0
  44. package/dist/Components/Core/Modal.vue.d.ts +26 -0
  45. package/dist/Components/Core/Pagination.vue.d.ts +10 -0
  46. package/dist/Components/Core/SelectInput.vue.d.ts +26 -0
  47. package/dist/Components/Core/Tag.vue.d.ts +65 -0
  48. package/dist/Components/Core/TextInput.vue.d.ts +25 -0
  49. package/dist/Components/Core/iconRegistry.d.ts +3 -0
  50. package/dist/Components/Dialogs/ConfirmDialog.vue.d.ts +16 -0
  51. package/dist/Components/Dialogs/DeleteConfirmDialog.vue.d.ts +30 -0
  52. package/dist/Components/Dialogs/Dialog.vue.d.ts +81 -0
  53. package/dist/Components/Drawer/Drawer.vue.d.ts +130 -0
  54. package/dist/Components/Drawer/DrawerFieldGrid.vue.d.ts +39 -0
  55. package/dist/Components/Drawer/DrawerLink.vue.d.ts +96 -0
  56. package/dist/Components/Drawer/DrawerRelationList.vue.d.ts +81 -0
  57. package/dist/Components/Drawer/DrawerStack.vue.d.ts +93 -0
  58. package/dist/Components/Drawer/DrawerTabs.vue.d.ts +42 -0
  59. package/dist/Components/Drawer/NestedDrawerForm.vue.d.ts +55 -0
  60. package/dist/Components/Drawer/drawerFieldGrid.d.ts +25 -0
  61. package/dist/Components/Drawer/drawerTabs.d.ts +16 -0
  62. package/dist/Components/Drawer/useDrawerPage.d.ts +34 -0
  63. package/dist/Components/Drawer/useDrawerStack.d.ts +93 -0
  64. package/dist/Components/Drawer/useFocusedStackRow.d.ts +22 -0
  65. package/dist/Components/Drawer/useIsDrawer.d.ts +66 -0
  66. package/dist/Components/Drawer/visitDrawer.d.ts +48 -0
  67. package/dist/Components/Fields/BelongsToSelect.vue.d.ts +247 -0
  68. package/dist/Components/Fields/BlueprintForm.vue.d.ts +96 -0
  69. package/dist/Components/Fields/Calendar/CalendarPanel.vue.d.ts +63 -0
  70. package/dist/Components/Fields/Calendar/MonthGrid.vue.d.ts +82 -0
  71. package/dist/Components/Fields/CheckboxField.vue.d.ts +92 -0
  72. package/dist/Components/Fields/ColorPickerField.vue.d.ts +105 -0
  73. package/dist/Components/Fields/DatePickerField.vue.d.ts +102 -0
  74. package/dist/Components/Fields/DateRangePickerField.vue.d.ts +138 -0
  75. package/dist/Components/Fields/DateTimePickerField.vue.d.ts +134 -0
  76. package/dist/Components/Fields/FieldGrid.vue.d.ts +13 -0
  77. package/dist/Components/Fields/FileUploadField.vue.d.ts +117 -0
  78. package/dist/Components/Fields/MultiSelectField.vue.d.ts +129 -0
  79. package/dist/Components/Fields/ProseMirrorBuilderField.vue.d.ts +71 -0
  80. package/dist/Components/Fields/RangeField.vue.d.ts +122 -0
  81. package/dist/Components/Fields/RepeaterField.vue.d.ts +174 -0
  82. package/dist/Components/Fields/RichTextEditorField.vue.d.ts +118 -0
  83. package/dist/Components/Fields/SelectField.vue.d.ts +102 -0
  84. package/dist/Components/Fields/TagsField.vue.d.ts +102 -0
  85. package/dist/Components/Fields/TextField.vue.d.ts +138 -0
  86. package/dist/Components/Fields/TextareaField.vue.d.ts +111 -0
  87. package/dist/Components/Fields/TimePickerField.vue.d.ts +115 -0
  88. package/dist/Components/Fields/ToggleButtonsField.vue.d.ts +93 -0
  89. package/dist/Components/Fields/ToggleField.vue.d.ts +95 -0
  90. package/dist/Components/Fields/fieldRegistry.d.ts +25 -0
  91. package/dist/Components/Fields/fieldsFromSpec.d.ts +10 -0
  92. package/dist/Components/Fields/useBlueprint.d.ts +94 -0
  93. package/dist/Components/Fields/useFieldWidth.d.ts +10 -0
  94. package/dist/Components/Fields/validation.d.ts +43 -0
  95. package/dist/Components/Filters/DateRangeFilter.vue.d.ts +92 -0
  96. package/dist/Components/Filters/FacetedFilter.vue.d.ts +41 -0
  97. package/dist/Components/Filters/FilterIndicators.vue.d.ts +40 -0
  98. package/dist/Components/Filters/FilterPopover.vue.d.ts +38 -0
  99. package/dist/Components/Filters/MultiSelectFilter.vue.d.ts +51 -0
  100. package/dist/Components/Filters/NumberFilter.vue.d.ts +61 -0
  101. package/dist/Components/Filters/QueryBuilder.vue.d.ts +32 -0
  102. package/dist/Components/Filters/SelectFilter.vue.d.ts +61 -0
  103. package/dist/Components/Filters/TernaryFilter.vue.d.ts +73 -0
  104. package/dist/Components/Layout/AppLayout.vue.d.ts +137 -0
  105. package/dist/Components/Layout/Breadcrumbs.vue.d.ts +44 -0
  106. package/dist/Components/Layout/Cluster.vue.d.ts +88 -0
  107. package/dist/Components/Layout/Container.vue.d.ts +69 -0
  108. package/dist/Components/Layout/Grid.vue.d.ts +110 -0
  109. package/dist/Components/Layout/PageHeader.vue.d.ts +62 -0
  110. package/dist/Components/Layout/Sidebar.vue.d.ts +59 -0
  111. package/dist/Components/Layout/Stack.vue.d.ts +69 -0
  112. package/dist/Components/Layout/TopNavigation.vue.d.ts +124 -0
  113. package/dist/Components/Media/MediaPickerDialog.vue.d.ts +20 -0
  114. package/dist/Components/Media/mediaEndpoints.d.ts +18 -0
  115. package/dist/Components/Notifications/Toast.vue.d.ts +17 -0
  116. package/dist/Components/Notifications/useToast.d.ts +43 -0
  117. package/dist/Components/Relations/RelationManager.vue.d.ts +232 -0
  118. package/dist/Components/Sections/FieldsSection.vue.d.ts +56 -0
  119. package/dist/Components/Sections/FilesSection.vue.d.ts +120 -0
  120. package/dist/Components/Sections/FormSection.vue.d.ts +111 -0
  121. package/dist/Components/Sections/InfoSection.vue.d.ts +77 -0
  122. package/dist/Components/Sections/Section.vue.d.ts +67 -0
  123. package/dist/Components/Table/ColumnToggle.vue.d.ts +42 -0
  124. package/dist/Components/Table/ExportButton.vue.d.ts +93 -0
  125. package/dist/Components/Table/SchemaCell.d.ts +50 -0
  126. package/dist/Components/Table/SchemaFilterPanel.vue.d.ts +64 -0
  127. package/dist/Components/Table/SchemaTable.vue.d.ts +255 -0
  128. package/dist/Components/Table/Table.vue.d.ts +232 -0
  129. package/dist/Components/Table/TableEmptyState.vue.d.ts +24 -0
  130. package/dist/Components/Table/TablePagination.vue.d.ts +71 -0
  131. package/dist/Components/Table/TableSkeletonRows.vue.d.ts +52 -0
  132. package/dist/Components/Table/TableSortButton.vue.d.ts +38 -0
  133. package/dist/Components/Table/TableToolbar.vue.d.ts +62 -0
  134. package/dist/Components/Table/schemaLinks.d.ts +16 -0
  135. package/dist/Components/Table/tableSchema.d.ts +243 -0
  136. package/dist/Components/Table/tableTypes.d.ts +13 -0
  137. package/dist/Components/Table/useSchemaActions.d.ts +82 -0
  138. package/dist/Components/Table/useSchemaFilters.d.ts +28 -0
  139. package/dist/Components/Table/useTableExport.d.ts +16 -0
  140. package/dist/Components/Table/useTableGrouping.d.ts +9 -0
  141. package/dist/Components/Table/useTableRowFocus.d.ts +27 -0
  142. package/dist/Components/Table/useTableSelection.d.ts +15 -0
  143. package/dist/Components/Table/useTableTree.d.ts +23 -0
  144. package/dist/Components/Widgets/StatCard.vue.d.ts +81 -0
  145. package/dist/Components/Widgets/StatsWidget.vue.d.ts +27 -0
  146. package/dist/Components/Wizard/Wizard.vue.d.ts +99 -0
  147. package/dist/Composables/useAsyncData.d.ts +48 -0
  148. package/dist/Composables/useDeleteConfirmation.d.ts +86 -0
  149. package/dist/Composables/useDragReorder.d.ts +12 -0
  150. package/dist/Composables/useFieldSaver.d.ts +15 -0
  151. package/dist/Composables/useFocusTrap.d.ts +5 -0
  152. package/dist/Composables/useLocalStoragePersistence.d.ts +18 -0
  153. package/dist/Composables/useNestedDrawerForm.d.ts +58 -0
  154. package/dist/Composables/usePagination.d.ts +23 -0
  155. package/dist/Composables/usePendingKeys.d.ts +30 -0
  156. package/dist/Composables/useQuery.d.ts +61 -0
  157. package/dist/Composables/useReconciled.d.ts +22 -0
  158. package/dist/Composables/useUnsavedChangesWarning.d.ts +16 -0
  159. package/dist/Primitives/hideOthers.d.ts +12 -0
  160. package/dist/Primitives/teleportTarget.d.ts +2 -0
  161. package/dist/Primitives/useAnchoredPosition.d.ts +32 -0
  162. package/dist/Primitives/useArrowNavigation.d.ts +30 -0
  163. package/dist/Primitives/useBodyScrollLock.d.ts +9 -0
  164. package/dist/Primitives/useDismissableLayer.d.ts +46 -0
  165. package/dist/Primitives/useId.d.ts +13 -0
  166. package/dist/Primitives/useTypeahead.d.ts +11 -0
  167. package/dist/Utils/apiFetch.d.ts +28 -0
  168. package/dist/Utils/csrf.d.ts +2 -0
  169. package/dist/Utils/dates.d.ts +50 -0
  170. package/dist/Utils/moduleSingleton.d.ts +21 -0
  171. package/dist/Utils/optimistic.d.ts +33 -0
  172. package/dist/Utils/reconcile.d.ts +17 -0
  173. package/dist/Utils/tagsChanged.d.ts +16 -0
  174. package/dist/Utils/url.d.ts +40 -0
  175. package/dist/Utils/writeGate.d.ts +82 -0
  176. package/dist/index.css +1 -0
  177. package/dist/index.d.ts +157 -0
  178. package/dist/index.js +27193 -0
  179. package/dist/index.js.map +1 -0
  180. package/dist/injectionKeys.d.ts +10 -0
  181. package/dist/plugin.d.ts +36 -0
  182. package/dist/types/menu.d.ts +11 -0
  183. package/docs/async-writes.md +159 -0
  184. package/docs/custom-fields.md +71 -0
  185. package/docs/drawer-protocol.md +130 -0
  186. package/docs/overlays.md +106 -0
  187. package/docs/relation-fields.md +112 -0
  188. package/docs/roadmap.md +38 -0
  189. package/docs/table-schema.md +371 -0
  190. package/package.json +150 -0
  191. package/styles/buttons.css +31 -0
  192. package/styles/form.css +74 -0
  193. package/styles/index.css +15 -0
  194. package/styles/tokens.css +79 -0
@@ -0,0 +1,371 @@
1
+ # Server-driven table schema
2
+
3
+ `SchemaTable` renders a table from a description the **server** sends as an
4
+ Inertia prop, instead of a `columns` array maintained in the page component.
5
+
6
+ It wraps `Table` rather than replacing it — pages using `Table` directly are
7
+ unaffected.
8
+
9
+ ```vue
10
+ <SchemaTable
11
+ :schema="table"
12
+ :records="organizations.data"
13
+ :summaries="organizations.meta?.summaries ?? {}"
14
+ :filter-values="form"
15
+ :query-params="computedParams"
16
+ :sort-column="computedSortColumn"
17
+ :sort-direction="computedSortDirection"
18
+ @update:search="updateSearch"
19
+ @sort="handleSort"
20
+ @update:filter="setFilter"
21
+ />
22
+ ```
23
+
24
+ The reference backend implementation is appkit-portfolio's `App\Table\TableSchema`,
25
+ but the contract below is plain JSON — any backend can produce it.
26
+
27
+ ---
28
+
29
+ ## Props
30
+
31
+ | Prop | Type | Description |
32
+ |---|---|---|
33
+ | `schema` | `TableSchema` | Required. The server-authored description |
34
+ | `records` | `object[]` | Row data |
35
+ | `summaries` | `Record<string, Summary[]>` | Column aggregates, from `meta.summaries` |
36
+ | `filterValues` | `Record<string, unknown>` | Current filter form values |
37
+ | `cellHandlers` | `Record<string, fn>` | Save callbacks for `editable` columns |
38
+ | `queryParams` | `object` | Appended to record links so a drawer keeps list state |
39
+ | `rowActionHandlers` | `Record<string, fn>` | Services `behaviour: 'handler'` row actions, by name |
40
+ | `bulkActionHandlers` | `Record<string, fn>` | The same for bulk actions |
41
+ | `visibleColumns` | `string[] \| null` | Which columns to render. `null` shows them all — `hiddenByDefault` is only honoured when the page seeds this, see [Column visibility](#column-visibility) |
42
+ | `stack` | `StackItem[]` | The page's drawer stack. When given, the highlighted row follows the record the top drawer shows (arrow-key record pagination changes the stack without a click) |
43
+ | `drawerType` | `string` | Restricts stack-driven highlighting to top items of this drawer type. Omitted = match any top item by id (safe with UUIDs) |
44
+ | `search`, `sortColumn`, `sortDirection`, `loading`, `externalFocusedRowIndex` | | Forwarded to `Table`; an explicit `externalFocusedRowIndex ≥ 0` overrides the stack-derived row |
45
+
46
+ Events: `update:search`, `sort`, `rowClick`, `update:filter (key, value)`,
47
+ `resetFilters`.
48
+
49
+ ---
50
+
51
+ ## The schema contract
52
+
53
+ ```ts
54
+ interface TableSchema {
55
+ columns: SchemaColumn[]
56
+ filters?: SchemaFilter[]
57
+ groups?: Array<{ value: string; label: string }>
58
+ constraints?: SchemaConstraint[]
59
+ recordUrl?: string | null // '/panel/organizations/{id}'
60
+ emptyStateTitle?: string | null
61
+ emptyStateDescription?: string | null
62
+ searchable?: boolean
63
+ bulkActions?: boolean
64
+ stickyHeader?: boolean
65
+ }
66
+ ```
67
+
68
+ ### Columns
69
+
70
+ ```ts
71
+ interface SchemaColumn {
72
+ key: string
73
+ name: string
74
+ label: string
75
+ type: 'text' | 'select' | 'money' | 'numeric' | 'badge' | 'date'
76
+ | 'boolean' | 'image' | 'icon' | 'color'
77
+ sortable: boolean // resolved server-side; never hand-declared
78
+ valueKey?: string // dot path, e.g. 'organization.name'
79
+ descriptionKey?: string // second line, read from another field
80
+ linksToRecord?: boolean
81
+ urlTemplate?: string // dot-path placeholders: '{id}', '{organization.id}'
82
+ showArrow?: boolean
83
+ placeholder?: string // default '—'
84
+ toggleable?: boolean
85
+ hiddenByDefault?: boolean
86
+ weight?: 'medium' | 'bold'
87
+ align?: 'left' | 'center' | 'right'
88
+ color?: string
89
+ icon?: string
90
+ limit?: number
91
+ copyable?: boolean
92
+ size?: string // type: 'image': 'sm' | 'md' | 'lg' | 'xl'
93
+ rounded?: string // type: 'image': 'none' | 'sm' | 'md' | 'lg' | 'full'
94
+ currency?: string // type: 'money'
95
+ decimals?: number // type: 'numeric'
96
+ format?: string // type: 'date'
97
+ relative?: boolean
98
+ colors?: Record<string, string>
99
+ options?: Array<{ label: string; value: string; class?: string }>
100
+ editable?: boolean
101
+ disabledWhen?: string
102
+ readOnlyWhen?: string
103
+ summaries?: Array<{ type: string; label: string }>
104
+ }
105
+ ```
106
+
107
+ Helpers exported alongside: `getPath`, `resolveRecordUrl`, `isEmptyValue`,
108
+ `cellClasses`, `truncate`, `formatValue`, `emptyFilterValue`, `filterDefaults`,
109
+ `visibleColumnDefaults`.
110
+
111
+ ### Empty cells never link
112
+
113
+ A cell whose value is empty (`null`, `undefined`, `''`) renders its
114
+ `placeholder` — `'—'` by default — **unlinked**, whatever the column declares.
115
+ `linksToRecord` and `urlTemplate` are both ignored for that row.
116
+
117
+ This is deliberate. A `urlTemplate` such as
118
+ `/panel/contacts/{id}/organization/{organization.id}` has nothing to resolve
119
+ `{organization.id}` to when the contact has no organization, and a link
120
+ labelled `—` that lands somewhere arbitrary is worse than plain text. So a
121
+ contact *with* an organization drills into it and one *without* is inert —
122
+ including for `type: 'image'`, where an absent picture shows the placeholder
123
+ graphic and clicking it does nothing.
124
+
125
+ The consequence to design around: the row itself is not a link, so an empty
126
+ cell is dead space. Put the record's own link on a column that is always
127
+ present (the name or title), which every listing here does.
128
+
129
+ ### Image columns
130
+
131
+ `type: 'image'` reads **one URL** from the row — `size` and `rounded` control
132
+ how it renders, and both belong in the schema rather than in a page override,
133
+ so two listings cannot drift apart:
134
+
135
+ ```php
136
+ Column::make('cover_url')->label('Cover')->type('image')
137
+ ->size('lg')->rounded('none')->linksToRecord()->notSortable(),
138
+ ```
139
+
140
+ `rounded` defaults to `'full'` (an avatar); artwork and covers usually want
141
+ `'none'` or `'md'`. When the row holds a media *object* rather than a URL,
142
+ point `valueKey` at the field: `->value('cover.thumbnail_url')`. An empty value
143
+ renders the placeholder graphic, not a dash.
144
+
145
+ ---
146
+
147
+ ## Actions
148
+
149
+ `schema.actions` and `schema.bulkActionItems` are rendered by `SchemaTable`
150
+ itself — including the confirmation dialogs — when the page passes no
151
+ `#actions` / `#bulkActions` slot. A page slot always wins.
152
+
153
+ ```ts
154
+ interface SchemaRowAction {
155
+ name: string
156
+ behaviour: 'drawer' | 'dialog' | 'visit' | 'delete' | 'handler'
157
+ label: string
158
+ icon?: string; color?: string
159
+ urlTemplate?: string // dot-path placeholders, resolved per row
160
+ previewUrl?: string // 'delete' only: what the deletion would cost
161
+ hiddenWhen?: string; visibleWhen?: string
162
+ confirm?: boolean; confirmMessage?: string
163
+ }
164
+
165
+ interface SchemaBulkAction {
166
+ name: string
167
+ behaviour: 'post' | 'handler'
168
+ label: string
169
+ icon?: string; color?: string; variant?: string
170
+ url?: string // POST { ids: [...] }
171
+ confirm?: boolean
172
+ confirmMessage?: string // '{count}' is replaced with the selection size
173
+ }
174
+ ```
175
+
176
+ | Behaviour | What the table does |
177
+ |---|---|
178
+ | `drawer` | Opens the row's `recordUrl` on the drawer stack (`visitDrawer`) |
179
+ | `dialog` | The same navigation, to the action's own `urlTemplate` — the frame is the server's answer, so the table never learns what the dialog does |
180
+ | `visit` | `router.visit(url)` |
181
+ | `delete` | Confirms — with the server's plan when `previewUrl` is set — then `router.delete(url)` |
182
+ | `post` | `router.post(url, { ids })` |
183
+ | `handler` | Calls `rowActionHandlers[name]` / `bulkActionHandlers[name]`; nothing happens if none is registered, because a schema can outlive the page that serviced it |
184
+
185
+ `drawer` and `dialog` are one code path: both are frames on the same stack, and
186
+ the item the server returns carries `presentation` to say which renders. That
187
+ is what keeps the behaviour list from growing per feature — a new interaction
188
+ is a new route, not a new case here. See the
189
+ [drawer protocol](./drawer-protocol.md#two-frames).
190
+
191
+ Two delete flows exist because `useDeleteConfirmation` decides at construction
192
+ whether it has a preview: an action with `previewUrl` asks the server what the
193
+ deletion would cost, one without degrades to a plain confirmation. Handing the
194
+ preview flow an empty URL would report every record as blocked.
195
+
196
+ ```vue
197
+ <SchemaTable
198
+ :schema="table"
199
+ :row-action-handlers="{ restore: (record) => router.put(`/panel/x/${record.id}/restore`) }"
200
+ />
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Column visibility
206
+
207
+ Columns marked `toggleable` appear in `ColumnToggle`'s dropdown; ones marked
208
+ `hiddenByDefault` start switched off. Neither flag does anything on its own —
209
+ `Table` filters against the list the page hands it, so the page owns the state:
210
+
211
+ ```vue
212
+ <SchemaTable :schema="table" :visible-columns="visibleColumns" …>
213
+ <template #headerActions>
214
+ <ColumnToggle v-model="visibleColumns" :columns="table.columns" />
215
+ </template>
216
+ </SchemaTable>
217
+ ```
218
+
219
+ ```ts
220
+ const visibleColumns = ref<string[]>(visibleColumnDefaults(props.table))
221
+ ```
222
+
223
+ `visibleColumnDefaults` is what honours `hiddenByDefault`; seeding the ref with
224
+ every key instead silently ignores the flag. Hiding a column changes nothing
225
+ about the query — it is a client-side concern, and is not reflected in the URL.
226
+ A column left un-`toggleable` cannot be hidden, which is how the identity
227
+ column (name, title) stays on screen.
228
+
229
+ ---
230
+
231
+ ## Slots
232
+
233
+ Everything you pass is forwarded to `Table`; anything you don't is generated.
234
+
235
+ | Slot | Behaviour |
236
+ |---|---|
237
+ | `cell-{key}` | **Overrides** the generated cell for that column |
238
+ | `filters` | **Replaces** the generated filter popover entirely |
239
+ | `headerActions`, `actions`, `bulkActions`, `pagination`, `header` | Forwarded as-is |
240
+
241
+ Overriding one column doesn't opt you out of the others — the component filters
242
+ its generated columns against `useSlots()`.
243
+
244
+ ---
245
+
246
+ ## Filters
247
+
248
+ When `schema.filters` is present, `SchemaTable` renders a `FilterPopover`
249
+ containing the right control per filter type (`select`, `multiSelect`,
250
+ `ternary`, `trashed`, `dateRange`), plus a group-by select and the query
251
+ builder when the schema declares them.
252
+
253
+ Values are **controlled** — the component never mutates `filterValues`. It
254
+ emits `update:filter (key, value)`; the page writes it into its own form state:
255
+
256
+ ```js
257
+ const { form } = useListFilters('/organizations', props.filters, {
258
+ defaults: filterDefaults(props.table), // keys come from the schema
259
+ })
260
+
261
+ function setFilter(key, value) {
262
+ form[key] = value
263
+ }
264
+ ```
265
+
266
+ `filterDefaults(schema)` returns the key → empty-value map, including `group`
267
+ and `constraints` when those are declared, so the page never restates filter
268
+ keys.
269
+
270
+ ---
271
+
272
+ ## Editable cells
273
+
274
+ The schema declares *that* a column is editable and *what* it offers; a save
275
+ callback can't be JSON, so the page injects it:
276
+
277
+ ```js
278
+ const cellHandlers = {
279
+ role: (record, _column, value) => updateField(record, 'role', value),
280
+ status: (record, _column, value) => updateField(record, 'account_status', value),
281
+ }
282
+ ```
283
+
284
+ Keyed by **column key** — note `status` above persists to `account_status`,
285
+ which the schema's `valueKey` handles for display and the handler for the write.
286
+
287
+ An editable cell is never wrapped in a record link; the control would navigate
288
+ away on the first click.
289
+
290
+ ---
291
+
292
+ ## Summaries
293
+
294
+ Aggregates arrive per column and render in a `<tfoot>` row via `Table`'s
295
+ `summary` slot:
296
+
297
+ ```json
298
+ { "name": [{ "type": "count", "label": "Organizations", "value": 5 }] }
299
+ ```
300
+
301
+ They belong with the data, not the schema — they change with every filter. The
302
+ server should compute them over the **filtered set**, not the current page.
303
+
304
+ ---
305
+
306
+ ## Grouping
307
+
308
+ `schema.groups` offers the user a grouping; the active key rides in
309
+ `filterValues.group`. The **server** orders rows by the group field so they
310
+ cluster, and `Table`'s `groupBy` prop draws a heading whenever the value
311
+ changes. The client does not re-sort.
312
+
313
+ ---
314
+
315
+ ## Query builder
316
+
317
+ `schema.constraints` drives `QueryBuilder.vue`: the user picks a field, an
318
+ operator from that field's type, and value(s). Each operator carries its
319
+ arity (`0`, `1` or `2`), so the UI knows how many inputs to draw. Conditions
320
+ are ANDed and sent as `constraints[]`.
321
+
322
+ The server must treat both the field and the operator as allowlists — the
323
+ client is a convenience, not a guarantee.
324
+
325
+ ---
326
+
327
+ ## Why no closures
328
+
329
+ The schema is JSON, so it cannot carry callbacks. Anything per-row is either
330
+ computed server-side into a field the schema names (`descriptionKey`), or
331
+ handled by a `#cell-{key}` slot override.
332
+
333
+ ## Editable text cells
334
+
335
+ `Column::make('phone')->editable()` on a `text` column renders an inline input:
336
+ Enter or blur saves through the page's handler for that column key, Escape
337
+ reverts, and a rejected save keeps the typed value on screen with the reason
338
+ attached rather than making the user retype it from memory.
339
+
340
+ The page supplies the *how*, as it already does for editable selects and
341
+ toggles — a save closure cannot cross the schema's JSON boundary.
342
+
343
+ ## Custom column types
344
+
345
+ Columns have the same extension seam as blueprint fields:
346
+
347
+ ```ts
348
+ import { registerColumnType } from '@modufolio/panel'
349
+ import SparklineColumn from './Columns/SparklineColumn.vue'
350
+
351
+ registerColumnType('sparkline', SparklineColumn)
352
+ ```
353
+
354
+ A registered component receives `{ value, record, column, label, onUpdate }`
355
+ and owns its cell completely, including how it renders an empty value. Unlike
356
+ the field registry this resolves synchronously — a column renders once per
357
+ visible cell, and an async loader would leave holes in the grid on first paint.
358
+ Pass `defineAsyncComponent` yourself if you want that behaviour.
359
+
360
+ Registering a built-in type's name replaces it everywhere the schema asks for
361
+ it, which is how an application restyles `badge` without forking the table.
362
+
363
+ ## Active filter indicators
364
+
365
+ `SchemaTable` renders a chip per active filter between the toolbar and the
366
+ rows: the filter's label, its value in words (the option's label, not the raw
367
+ query-string value), and an X that clears just that one. Filters live behind a
368
+ popover, so without this the only sign a result set has been narrowed is a
369
+ count badge on a closed button — and "no results" reads as "no such records".
370
+
371
+ Pass a `#filterIndicators` slot to replace the strip entirely.
package/package.json ADDED
@@ -0,0 +1,150 @@
1
+ {
2
+ "name": "@modufolio/panel",
3
+ "version": "0.1.0",
4
+ "description": "Schema-driven admin panel components for Inertia.js + Vue 3 — tables, blueprint forms, filters, actions, drawers, widgets.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "author": "Maarten Thiebou",
8
+ "sideEffects": [
9
+ "*.css"
10
+ ],
11
+ "files": [
12
+ "dist",
13
+ "styles",
14
+ "docs",
15
+ "README.md"
16
+ ],
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./styles": "./styles/index.css",
26
+ "./styles/*": "./styles/*"
27
+ },
28
+ "scripts": {
29
+ "build": "vite build",
30
+ "prepublishOnly": "npm run build",
31
+ "test": "vitest --run --config vitest.config.ts",
32
+ "type-check": "vue-tsc --noEmit -p tsconfig.json",
33
+ "lint": "eslint src"
34
+ },
35
+ "peerDependencies": {
36
+ "@heroicons/vue": "^2.0.0",
37
+ "@inertiajs/vue3": "^3.0.0",
38
+ "@vueuse/core": ">=10",
39
+ "lodash": "^4.17.0",
40
+ "prosemirror-commands": "^1.7.1",
41
+ "prosemirror-dropcursor": "^1.8.3",
42
+ "prosemirror-gapcursor": "^1.4.1",
43
+ "prosemirror-history": "^1.5.0",
44
+ "prosemirror-inputrules": "^1.5.1",
45
+ "prosemirror-keymap": "^1.2.3",
46
+ "prosemirror-model": "^1.25.11",
47
+ "prosemirror-schema-list": "^1.5.1",
48
+ "prosemirror-state": "^1.4.4",
49
+ "prosemirror-view": "^1.42.2",
50
+ "vue": "^3.5.0",
51
+ "vuedraggable": "^4.1.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@vueuse/core": {
55
+ "optional": true
56
+ },
57
+ "lodash": {
58
+ "optional": true
59
+ },
60
+ "prosemirror-commands": {
61
+ "optional": true
62
+ },
63
+ "prosemirror-dropcursor": {
64
+ "optional": true
65
+ },
66
+ "prosemirror-gapcursor": {
67
+ "optional": true
68
+ },
69
+ "prosemirror-history": {
70
+ "optional": true
71
+ },
72
+ "prosemirror-inputrules": {
73
+ "optional": true
74
+ },
75
+ "prosemirror-keymap": {
76
+ "optional": true
77
+ },
78
+ "prosemirror-model": {
79
+ "optional": true
80
+ },
81
+ "prosemirror-schema-list": {
82
+ "optional": true
83
+ },
84
+ "prosemirror-state": {
85
+ "optional": true
86
+ },
87
+ "prosemirror-view": {
88
+ "optional": true
89
+ },
90
+ "vuedraggable": {
91
+ "optional": true
92
+ }
93
+ },
94
+ "devDependencies": {
95
+ "@heroicons/vue": "^2.2.0",
96
+ "@inertiajs/vue3": "3.0.0",
97
+ "@types/lodash": "^4.17.24",
98
+ "@typescript-eslint/eslint-plugin": "^8.68.0",
99
+ "@typescript-eslint/parser": "^8.68.0",
100
+ "@vitejs/plugin-vue": "^6.0.1",
101
+ "@vue/test-utils": "^2.4.6",
102
+ "@vueuse/core": "^13.0.0",
103
+ "eslint": "^9.39.5",
104
+ "eslint-plugin-vue": "^10.10.0",
105
+ "happy-dom": "^20.6.1",
106
+ "lodash": "^4.17.21",
107
+ "prosemirror-commands": "^1.7.1",
108
+ "prosemirror-dropcursor": "^1.8.3",
109
+ "prosemirror-gapcursor": "^1.4.1",
110
+ "prosemirror-history": "^1.5.0",
111
+ "prosemirror-inputrules": "^1.5.1",
112
+ "prosemirror-keymap": "^1.2.3",
113
+ "prosemirror-model": "^1.25.11",
114
+ "prosemirror-schema-list": "^1.5.1",
115
+ "prosemirror-state": "^1.4.4",
116
+ "prosemirror-view": "^1.42.2",
117
+ "typescript": "^5.9.3",
118
+ "vite": "^6.4.1",
119
+ "vite-plugin-dts": "^5.0.3",
120
+ "vitest": "^4.0.18",
121
+ "vue": "^3.5.18",
122
+ "vue-eslint-parser": "^10.4.1",
123
+ "vue-tsc": "^3.2.4",
124
+ "vuedraggable": "^4.1.0"
125
+ },
126
+ "repository": {
127
+ "type": "git",
128
+ "url": "git+https://github.com/modufolio/panel.git",
129
+ "directory": "ui"
130
+ },
131
+ "homepage": "https://github.com/modufolio/panel/tree/main/ui#readme",
132
+ "bugs": "https://github.com/modufolio/panel/issues",
133
+ "keywords": [
134
+ "vue",
135
+ "inertia",
136
+ "admin",
137
+ "panel",
138
+ "crud",
139
+ "table",
140
+ "form",
141
+ "blueprint",
142
+ "filament-alternative"
143
+ ],
144
+ "publishConfig": {
145
+ "access": "public"
146
+ },
147
+ "dependencies": {
148
+ "@floating-ui/vue": "^2.0.1"
149
+ }
150
+ }
@@ -0,0 +1,31 @@
1
+ .btn-indigo {
2
+ @apply px-6 py-3 rounded-sm bg-indigo-600 text-white text-sm leading-4 font-bold whitespace-nowrap hover:bg-orange-400 focus:bg-orange-400;
3
+ }
4
+
5
+ .btn-spinner,
6
+ .btn-spinner:after {
7
+ border-radius: 50%;
8
+ width: 1.5em;
9
+ height: 1.5em;
10
+ }
11
+
12
+ .btn-spinner {
13
+ font-size: 10px;
14
+ position: relative;
15
+ text-indent: -9999em;
16
+ border-top: 0.2em solid white;
17
+ border-right: 0.2em solid white;
18
+ border-bottom: 0.2em solid white;
19
+ border-left: 0.2em solid transparent;
20
+ transform: translateZ(0);
21
+ animation: spinning 1s infinite linear;
22
+ }
23
+
24
+ @keyframes spinning {
25
+ 0% {
26
+ transform: rotate(0deg);
27
+ }
28
+ 100% {
29
+ transform: rotate(360deg);
30
+ }
31
+ }
@@ -0,0 +1,74 @@
1
+ .form-label {
2
+ @apply mb-2 block text-gray-700 select-none;
3
+ }
4
+
5
+ .form-input,
6
+ .form-textarea,
7
+ .form-select {
8
+ @apply p-2 leading-normal block w-full border text-gray-700 bg-white font-sans rounded-sm text-left appearance-none relative focus:border-indigo-400 focus:ring-3;
9
+
10
+ &::placeholder {
11
+ @apply text-gray-500 opacity-100;
12
+ }
13
+ }
14
+
15
+ .form-select {
16
+ @apply pr-6;
17
+
18
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAQCAYAAAAMJL+VAAAABGdBTUEAALGPC/xhBQAAAQtJREFUOBG1lEEOgjAQRalbGj2OG9caOACn4ALGtfEuHACiazceR1PWOH/CNA3aMiTaBDpt/7zPdBKy7M/DCL9pGkvxxVp7KsvyJftL5rZt1865M+Ucq6pyyF3hNcI7Cuu+728QYn/JQA5yKaempxuZmQngOwEaYx55nu+1lQh8GIatMGi+01NwBcEmhxBqK4nAPZJ78K0KKFAJmR3oPp8+Iwgob0Oa6+TLoeCvRx+mTUYf/FVBGTPRwDkfLxnaSrRwcH0FWhNOmrkWYbE2XEicqgSa1J0LQ+aPCuQgZiLnwewbGuz5MGoAhcIkCQcjaTBjMgtXGURMVHC1wcQEy0J+Zlj8bKAnY1/UzDe2dbAVqfXn6wAAAABJRU5ErkJggg==');
19
+ background-size: 0.7rem;
20
+ background-repeat: no-repeat;
21
+ background-position: right 0.7rem center;
22
+
23
+ &::-ms-expand {
24
+ @apply opacity-0;
25
+ }
26
+ }
27
+
28
+ .form-input.error,
29
+ .form-textarea.error,
30
+ .form-select.error {
31
+ @apply border-red-500 focus:ring-3 focus:ring-red-200;
32
+ }
33
+
34
+ .form-error {
35
+ @apply text-red-700 mt-2 text-sm;
36
+ }
37
+
38
+ /**
39
+ * Standard bordered form control: inputs, selects, textareas, and any custom
40
+ * control that should read as a form field (contenteditable editors, tag and
41
+ * multi-select wrappers) — matching by role rather than by tag name, so a
42
+ * div-based editor cannot drift away from a plain <input>.
43
+ *
44
+ * Skin and default padding only. Layout (`w-full`, `flex-1`, explicit widths),
45
+ * padding overrides and behaviour (`resize-y`, `appearance-none`) stay on the
46
+ * element: Tailwind's utilities layer wins over this components layer, so a
47
+ * local utility always overrides the default.
48
+ */
49
+ .ui-input {
50
+ /* focus-within, not focus: an <input> matches its own :focus-within, so the
51
+ same rule covers both plain controls and div-based wrappers. */
52
+ @apply border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition-colors
53
+ focus-within:border-primary-600 focus-within:ring-2 focus-within:ring-primary-600/20
54
+ disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500;
55
+
56
+ border-radius: var(--radius-input);
57
+ min-height: var(--input-height);
58
+ }
59
+
60
+ /* Single-line controls get an exact height so they line up regardless of
61
+ border and line-height; textareas and div-based wrappers only get a floor. */
62
+ input.ui-input,
63
+ select.ui-input {
64
+ height: var(--input-height);
65
+ }
66
+
67
+ /* Opt out for controls that are deliberately compact (filter bars, inline
68
+ editors) and size themselves from their own padding instead. */
69
+ .ui-input-auto,
70
+ input.ui-input-auto,
71
+ select.ui-input-auto {
72
+ height: auto;
73
+ min-height: 0;
74
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @modufolio/panel styles entry.
3
+ *
4
+ * Import from the consumer's Tailwind CSS entry, inside the components layer:
5
+ *
6
+ * @import '@modufolio/panel/styles' layer(components);
7
+ *
8
+ * and add the package to Tailwind's content scanning so component classes
9
+ * survive the build:
10
+ *
11
+ * @source "../node_modules/@modufolio/panel";
12
+ */
13
+ @import './tokens.css';
14
+ @import './buttons.css';
15
+ @import './form.css';