@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,10 @@
1
+ import { InjectionKey, Ref } from 'vue';
2
+ /**
3
+ * Typed provide/inject keys — the analog of Solid's typed createContext.
4
+ *
5
+ * Using a typed Symbol key instead of a bare string gives inject() the right
6
+ * type automatically and makes the provider/consumer contract greppable and
7
+ * collision-proof.
8
+ */
9
+ /** Whether the app sidebar is collapsed. Provided by AppLayout, read by sidebars. */
10
+ export declare const SidebarCollapsedKey: InjectionKey<Ref<boolean>>;
@@ -0,0 +1,36 @@
1
+ import { App, Component } from 'vue';
2
+ import { MediaEndpoints } from './Components/Media/mediaEndpoints';
3
+ export interface CreatePanelOptions {
4
+ /** Mount path of the panel backend, e.g. '/panel' or '/admin'. */
5
+ baseUrl?: string;
6
+ /** Extra icons for <Icon>, name → component. Override built-ins by name. */
7
+ icons?: Record<string, Component>;
8
+ /** App-specific blueprint field types, type → async component loader. */
9
+ fields?: Record<string, () => Promise<Component | {
10
+ default: Component;
11
+ }>>;
12
+ /**
13
+ * Where overlays (dialogs, drawers, dropdowns) are teleported.
14
+ * Defaults to 'body'; set it when overlays must live inside a themed or
15
+ * transformed subtree instead.
16
+ */
17
+ teleportTarget?: string | HTMLElement;
18
+ /**
19
+ * Backend paths the media picker calls, relative to `baseUrl`. Defaults to
20
+ * `/api/media/picker` and `/api/media/picker/albums`; override them when the
21
+ * app serves media under different routes.
22
+ */
23
+ media?: Partial<MediaEndpoints>;
24
+ }
25
+ /**
26
+ * Vue plugin — the single required setup call for panel consumers:
27
+ *
28
+ * app.use(createPanel({
29
+ * baseUrl: '/panel',
30
+ * icons: { camera: CameraIcon },
31
+ * fields: { 'block-editor': () => import('./Fields/BlockEditorField.vue') },
32
+ * }))
33
+ */
34
+ export declare function createPanel(options?: CreatePanelOptions): {
35
+ install(_app: App): void;
36
+ };
@@ -0,0 +1,11 @@
1
+ /** User-menu item rendered by TopNavigation's dropdown. */
2
+ export interface MenuItem {
3
+ label: string;
4
+ href?: string;
5
+ icon?: string;
6
+ divider?: boolean;
7
+ method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
8
+ action?: () => void;
9
+ badge?: string | number;
10
+ badgeColor?: 'primary' | 'success' | 'danger' | 'warning';
11
+ }
@@ -0,0 +1,159 @@
1
+ # Async writes: ordering, staleness and in-flight state
2
+
3
+ Admin panels do a lot of small writes — a toggle in a row, a debounced text
4
+ field, an optimistic star. Two of them overlapping is normal, and the
5
+ failure modes are quiet: no error, just the wrong value left on screen.
6
+
7
+ These primitives exist for that. All are exported from the package root.
8
+
9
+ ## The rule they share
10
+
11
+ > The order that matters is the order requests were **dispatched** in, not
12
+ > the order responses **settled** in.
13
+
14
+ A slow first request must never overwrite what a faster second one already
15
+ applied. Nothing here retries or queues; they only decide which result is
16
+ allowed to land.
17
+
18
+ ## `writeGate` — deciding which write wins
19
+
20
+ ```ts
21
+ import { writeGate, writeKey, LOCAL_WRITE } from '@modufolio/panel'
22
+
23
+ const key = writeKey('users', user.id, 'is_active') // "users/42/is_active"
24
+
25
+ const stamp = writeGate.next(true) // take a sequence number
26
+ // … dispatch the request …
27
+ if (writeGate.admit(key, stamp)) {
28
+ // still the newest write for this key — safe to apply
29
+ }
30
+ ```
31
+
32
+ | Member | Purpose |
33
+ |---|---|
34
+ | `next(record: boolean)` | Take the next sequence number. Call at **dispatch**, not on settle. |
35
+ | `admit(key, stamp)` | May this write land? Recording stamps also book the key. |
36
+ | `seal(key)` | The record is gone server-side — reject every write still in flight for it. |
37
+ | `clear()` | Drop all bookkeeping. Intended for tests. |
38
+ | `writeKey(resource, id, field?)` | One spelling for keys, so call sites agree on identity. |
39
+ | `LOCAL_WRITE` | A write with no dispatch order — seeding state, a value that never went over the wire. Always admitted, records nothing. |
40
+
41
+ Three details that are load-bearing:
42
+
43
+ - **Only mutating requests should record** (`next(true)`). A read that
44
+ recorded its number would gate out a save dispatched before it. Pass
45
+ `next(false)` for reads, or to *ask* whether your write is still newest
46
+ without moving the gate.
47
+ - **`LOCAL_WRITE` is a value you name, not a parameter you omit.** The
48
+ un-ordered case should be a decision at the call site.
49
+ - **`seal()` takes a fresh number**, not the delete's own. A delete becomes
50
+ true when it *settles*; anything still in flight at that moment is dead
51
+ data and must not re-create the record.
52
+
53
+ The counter is global and monotonic; comparison is per key. A per-key
54
+ counter restarting at zero would let a number minted for one key match a
55
+ later slot's — which is how a deleted record comes back to life.
56
+
57
+ ## `optimistic()` — apply now, roll back if refused
58
+
59
+ ```ts
60
+ import { optimistic, writeKey } from '@modufolio/panel'
61
+
62
+ const ok = await optimistic(
63
+ () => { // apply, returning a rollback
64
+ const previous = item.starred
65
+ item.starred = !previous
66
+ return () => { item.starred = previous }
67
+ },
68
+ () => apiFetch(url, { method: 'PATCH', body: { starred: item.starred } }),
69
+ writeKey('items', item.id, 'starred'), // optional, but see below
70
+ )
71
+ ```
72
+
73
+ Resolves `true` on success, `false` if the request failed (already rolled
74
+ back).
75
+
76
+ **Pass a key whenever the same state can be mutated twice concurrently.**
77
+ Without one, two quick toggles interleave so that the *first* failing rolls
78
+ state back to before either ran — silently erasing the second toggle, whose
79
+ request succeeded. With a key, a failure only rolls back while its own write
80
+ is still the newest.
81
+
82
+ Bulk actions that span many records are the honest exception: one key cannot
83
+ represent them, so leave it off.
84
+
85
+ ## `useFieldSaver()` — debounced saves with status
86
+
87
+ ```ts
88
+ const { saveStatus, save, debouncedSave } = useFieldSaver(
89
+ (value: string) => apiFetch(url, { method: 'PATCH', body: { title: value } }),
90
+ )
91
+ ```
92
+
93
+ `saveStatus` is `null | 'saving' | 'saved' | 'error'`. Ordering is built in:
94
+ a slow save settling after a faster later one cannot report its stale
95
+ outcome in either direction — no stale `error` over a newer success, no
96
+ stale `saved` hiding a newer failure. Timers are cleared on scope dispose.
97
+
98
+ ## `usePendingKeys()` — which row is busy
99
+
100
+ ```ts
101
+ const { isPending, anyPending, run } = usePendingKeys()
102
+
103
+ const toggle = (row) => run(row.id, () => apiFetch(/* … */))
104
+ ```
105
+
106
+ ```vue
107
+ <Spinner v-if="isPending(row.id)" />
108
+ ```
109
+
110
+ One boolean per component answers "is anything saving"; a list needs "is
111
+ *this row* saving", or every row spins together. `run` also guards
112
+ re-entry — a key already in flight refuses a second call and resolves
113
+ `undefined`, which is what a double-click should mean.
114
+
115
+ ## `useUnsavedChangesWarning()` — don't discard edits silently
116
+
117
+ ```ts
118
+ const { allowNextNavigation } = useUnsavedChangesWarning(form)
119
+
120
+ function submit() {
121
+ allowNextNavigation() // this navigation *is* the save
122
+ form.post(url)
123
+ }
124
+ ```
125
+
126
+ Hooks Inertia's `before` event, so it covers every ordinary visit —
127
+ including logout, which is a POST visit like any other — plus `beforeunload`
128
+ for tab closes.
129
+
130
+ Takes Inertia's form object, or **a getter** for anything else that knows
131
+ whether it is dirty:
132
+
133
+ ```ts
134
+ useUnsavedChangesWarning(() => draft.isDirty.value || other.isDirty.value)
135
+ ```
136
+
137
+ The getter is read at event time, never captured, so state that flips after
138
+ setup is still seen. `allowNextNavigation()` skips exactly one prompt and
139
+ re-arms, so a save that leaves the form dirty does not open a hole.
140
+
141
+ ## `moduleSingleton()` — state that survives a duplicated module
142
+
143
+ ```ts
144
+ const state = moduleSingleton('toast-store', () => ({
145
+ items: ref([]),
146
+ nextId: 0,
147
+ }))
148
+ ```
149
+
150
+ A bundler can instantiate the same module twice — Vite's dep pre-bundler,
151
+ for example, inlines a package's TS while leaving its `.vue` SFCs as
152
+ external raw-source imports. A store imported from both sides of that
153
+ boundary splits: one copy is written, the other is rendered, and nothing
154
+ appears. It fails silently, which is what makes it expensive to find.
155
+
156
+ Keying the state off `globalThis` with `Symbol.for` hands every copy the
157
+ same object. Use it **only** for process-wide state. Anything scoped to a
158
+ component or an app instance belongs in provide/inject — a global would leak
159
+ across SSR requests.
@@ -0,0 +1,71 @@
1
+ # Custom field types
2
+
3
+ Blueprint forms resolve field components through a registry. Built-ins cover
4
+ the standard inputs (`text`, `textarea`, `select`, `multiselect`, `toggle`,
5
+ `checkbox`, `date`, `date-range`, `file`, `color`, `belongs-to`, `repeater`,
6
+ `toggle-buttons`). Anything app-specific is registered at boot:
7
+
8
+ ```js
9
+ createPanel({
10
+ fields: {
11
+ 'writer': () => import('@/Fields/WriterField.vue'),
12
+ 'block-editor': () => import('@/Fields/BlockEditorField.vue'),
13
+ },
14
+ })
15
+ ```
16
+
17
+ or imperatively:
18
+
19
+ ```js
20
+ import { registerFieldType } from '@modufolio/panel'
21
+ registerFieldType('signature', () => import('./SignatureField.vue'))
22
+ ```
23
+
24
+ A field component that needs the resolver itself (a container type
25
+ rendering its own sub-fields) should import it from `fieldRegistry`, not
26
+ from `useBlueprint` — the composable imports the registry too, and going
27
+ back the other way re-creates a cycle between a composable and a component.
28
+
29
+ Then use the type in any blueprint:
30
+
31
+ ```js
32
+ const fields = [
33
+ { type: 'signature', key: 'signature', label: 'Sign here' },
34
+ ]
35
+ ```
36
+
37
+ ## Field component contract
38
+
39
+ A field component receives (via `useBlueprint`'s `fieldProps`):
40
+
41
+ | Prop | Type | Notes |
42
+ |---|---|---|
43
+ | `modelValue` | any | v-model value for the field's key |
44
+ | `label` | string | |
45
+ | `width` | string | grid width (`full`, `1/2`, ...) — use `useFieldWidth` |
46
+ | `required` | boolean | |
47
+ | `error` | string | validation message ('' when valid) |
48
+ | `help`, `placeholder` | string? | only when set in the FieldDef |
49
+ | `options` | OptionItem[]? | for choice-style fields |
50
+
51
+ plus anything passed through the FieldDef's `props` object. Emit
52
+ `update:modelValue` with the new value.
53
+
54
+ ### Container fields
55
+
56
+ A field holding a list of rows (a repeater, a matrix) also receives:
57
+
58
+ | Prop | Type | Notes |
59
+ |---|---|---|
60
+ | `fields` | FieldDef[] | the child's own field declarations |
61
+ | `nestedErrors` | Record&lt;string, string&gt; | row-scoped messages keyed `{index}.{subKey}`, the container's own prefix already stripped |
62
+
63
+ Render sub-fields through `resolveFieldComponent` so custom types work
64
+ inside containers too. See [relation-fields.md](relation-fields.md) for the
65
+ server side of that contract.
66
+
67
+ Registering an existing name (e.g. `'file'`) **overrides** the built-in —
68
+ that's the supported way to swap in, say, a resumable-upload field.
69
+
70
+ Unknown types throw at resolve time with the list of known types, so typos
71
+ fail loudly rather than rendering nothing.
@@ -0,0 +1,130 @@
1
+ # Drawer Stack — server/client protocol
2
+
3
+ The Drawer components render a stack of overlay panels driven by ordinary
4
+ Inertia visits ("URL = state"). Any backend can drive them by honoring this
5
+ contract; the reference implementation is appkit-portfolio's
6
+ `src/Inertia/DrawerStack.php`.
7
+
8
+ ## Request side
9
+
10
+ Every visit initiated from inside a drawer (DrawerLink, useDrawerStack
11
+ navigation, Drawer record-pagination) carries the header:
12
+
13
+ ```
14
+ X-Inertia-Drawer-Stack: 1
15
+ ```
16
+
17
+ The server uses its presence to decide to render the *underlying page* with a
18
+ drawer stack on top, instead of a full page swap.
19
+
20
+ ## Response side
21
+
22
+ The page component receives a **`stack`** prop: an ordered array of stack
23
+ items, bottom-most first. Each item:
24
+
25
+ | Field | Type | Meaning |
26
+ |---|---|---|
27
+ | `type` | string | Which drawer content to render — the client maps types to components (consumer-defined) |
28
+ | `data` | object | Payload for that drawer (the record, options, ...) |
29
+ | `title` | string | Header title |
30
+ | `description` | string | Optional header subtitle |
31
+ | `width` | string | `sm` \| `md` \| `lg` \| ... (Drawer width presets) |
32
+ | `href` | string | Canonical URL of this drawer (used when re-opening / deep-linking) |
33
+ | `nextRecordUrl` | string\|null | Optional record-pagination target (renders next/prev arrows) |
34
+ | `previousRecordUrl` | string\|null | Optional record-pagination target |
35
+ | `tabs` | array\|null | Optional sections for the drawer body — key, label, count, and per-type extras (`fields`, `sections`, `primary`, `empty`, …) |
36
+ | `presentation` | `'drawer'` \| `'dialog'` | Which frame renders this item. Absent means `drawer` — see [Two frames](#two-frames) |
37
+
38
+ An empty array (or absent prop) means no drawers are open.
39
+
40
+ ## Two frames
41
+
42
+ A drawer is a side panel you read a record in and can page through. A dialog is
43
+ a centred modal for one decision or one short form. They are the **same stack**:
44
+ same header, same URL-is-state contract, same per-type slots — a frame declares
45
+ which one it wants and nothing else changes.
46
+
47
+ ```php
48
+ DrawerStack::create()->push(
49
+ type: 'publish',
50
+ data: [...],
51
+ title: 'Publish post',
52
+ href: '/panel/posts/{uuid}/publish',
53
+ width: 'sm',
54
+ presentation: DrawerStack::AS_DIALOG,
55
+ );
56
+ ```
57
+
58
+ ```vue
59
+ <DrawerStack :stack="stack" base-url="/panel/posts">
60
+ <template #publish="{ item }">…the dialog body…</template>
61
+ <template #footer-publish="{ item }">…its buttons…</template>
62
+ </DrawerStack>
63
+ ```
64
+
65
+ A second protocol for dialogs would have been a second set of things to get
66
+ subtly wrong, and it would have bought nothing: because a dialog is a stack
67
+ item, it is addressable, deep-linkable and closed by navigating away — which a
68
+ dialog held in page state is not. `Dialog` draws its own overlay, so the
69
+ stack's shared overlay stands down when no item in the stack is a drawer.
70
+
71
+ **Frames are pointers, which is the point.** A row action can carry a dialog
72
+ URL (`RowAction::dialog()`) instead of naming a behaviour the table has to
73
+ implement, so a new interaction is a new route rather than a new case in the
74
+ client. See [table-schema.md](../../../docs/table-schema.md#actions).
75
+
76
+ ## Client components
77
+
78
+ - `<DrawerStack :stack="stack" :base-url="...">` — renders the items;
79
+ `baseUrl` is where closing the whole stack navigates (Inertia visit).
80
+ Provides push/pop/close via `provide/inject` (`useDrawerStackContext`).
81
+ - `<DrawerLink href="...">` — opens a URL as a new drawer on the stack
82
+ (adds the header, keeps the underlying page).
83
+ - `useDrawerPage()` — inside drawer content: access the current item's
84
+ `data` with page-like ergonomics.
85
+ - `useIsDrawer()` — is this component rendering inside a drawer?
86
+ - `useFocusedStackRow(props, type, records)` — index of the listing row the
87
+ top stack item is showing, or -1. `SchemaTable` uses this internally when
88
+ given a `stack` prop, so the highlighted row follows record pagination;
89
+ reach for the composable directly only outside `SchemaTable`.
90
+
91
+ ## Never hand-write the visit
92
+
93
+ The request side is three things at once — the header, `only: ['stack']`, and
94
+ the preserve flags that keep the page underneath mounted. Get one third wrong
95
+ and it still *looks* right: fetching only the stack renders a drawer even when
96
+ the server never took the drawer path. A hand-written `X-Drawer` header did
97
+ exactly that, and survived a manual check.
98
+
99
+ So there is one way to navigate the stack, and every caller uses it:
100
+
101
+ ```ts
102
+ import { visitDrawer } from '@modufolio/panel'
103
+
104
+ visitDrawer('/panel/movies/42', { queryParams: { sort: 'title' } })
105
+ ```
106
+
107
+ `DrawerLink`, `Drawer`'s record pagination, `useDrawerStack` and `SchemaTable`'s
108
+ declared `drawer` action all route through it; `visit-drawer.spec.ts` asserts
109
+ the trio so a fifth caller cannot quietly drop a third of it. Reach for
110
+ `drawerVisitOptions()` only when Inertia needs the options themselves (a form
111
+ submission rather than a navigation).
112
+
113
+ `queryParams` keeps empty strings — the server distinguishes "this filter is
114
+ set to nothing" from "this filter was not sent", and dropping them silently
115
+ reset filters whenever a drawer opened.
116
+
117
+ ## Server responsibilities (checklist for a new backend)
118
+
119
+ 1. Detect the `X-Inertia-Drawer-Stack` header.
120
+ 2. For drawer requests, resolve the *base* page (the URL under the drawer)
121
+ and its props as usual.
122
+ 3. Build the stack array (deep-linkable: a fresh GET with a drawer URL should
123
+ reconstruct the same stack) and pass it as the `stack` prop.
124
+ 4. Set `presentation` on any item that should render as a dialog; omit it for
125
+ drawers.
126
+ 5. Closing is plain navigation — no special endpoint needed.
127
+
128
+ A dialog route is an ordinary route: it renders the *listing* underneath with
129
+ one extra frame on top. `PostController::publishDialog()` is the reference —
130
+ it differs from `show()` by one argument.
@@ -0,0 +1,106 @@
1
+ # Overlay primitives
2
+
3
+ Dialogs, drawers, dropdowns and popovers in this package share a small set of
4
+ primitives in `src/Primitives/` rather than each carrying its own copy of the
5
+ behaviour. They are exported, so an application can build its own overlays on
6
+ the same foundations.
7
+
8
+ ## The layer stack — `useDismissableLayer`
9
+
10
+ Every open overlay registers itself while it is open. Escape and outside
11
+ presses go to the **top of the stack only**.
12
+
13
+ ```ts
14
+ useDismissableLayer(isOpen, {
15
+ // Everything that counts as "inside": a teleported panel and the trigger
16
+ // that opened it live in different parts of the DOM.
17
+ elements: () => [triggerRef.value, panelRef.value],
18
+ onDismiss: (reason) => { isOpen.value = false },
19
+ dismissOnOutsidePointer: true,
20
+ // Supplying this marks the layer modal: the rest of the page is hidden from
21
+ // assistive technology while it is on top.
22
+ modalElement: () => panelRef.value,
23
+ })
24
+ ```
25
+
26
+ The layer decides what to do with a dismissal, so a non-closable dialog can
27
+ ignore Escape without the press falling through to the drawer beneath it.
28
+
29
+ `modalElement` exists because `aria-modal="true"` is not reliably honoured: a
30
+ screen reader can otherwise walk out of an open dialog and read a page the user
31
+ cannot reach. The page behind is marked `inert` rather than `aria-hidden` —
32
+ the browser refuses the latter over the focused element ("Blocked aria-hidden
33
+ on an element because its descendant retained focus"), while `inert` moves
34
+ focus out on its own and takes the page out of the tab order too. Only the
35
+ highest modal layer applies, and the other registered layers are exempt — so a
36
+ dropdown opened inside a dialog is not silenced by the dialog containing it,
37
+ and a toast region keeps announcing.
38
+
39
+ **An overlay the host app renders itself must register too.** A raw
40
+ `<Teleport to="body">` panel is invisible to the stack: Escape closes the
41
+ drawer beneath it instead of the panel, and that drawer's focus trap reaches
42
+ into it — which shows up as a combobox inside the panel closing the instant it
43
+ opens. Registering it is the whole fix:
44
+
45
+ ```ts
46
+ const panelRef = ref<HTMLElement | null>(null)
47
+
48
+ useDismissableLayer(() => isVisible.value, {
49
+ elements: () => [panelRef.value],
50
+ onDismiss: (reason) => { if (reason === 'escape') close() },
51
+ dismissOnOutsidePointer: false, // the panel draws its own scrim
52
+ modalElement: () => panelRef.value,
53
+ })
54
+ ```
55
+
56
+ ## Scroll lock — `useBodyScrollLock`
57
+
58
+ Reference counted across every overlay. The page scrolls again only once the
59
+ last holder releases, so a dialog opened from inside a drawer cannot unlock the
60
+ page while the drawer is still covering it. It also compensates for the
61
+ scrollbar's width, so the page behind does not jump sideways, and exposes that
62
+ width as `--panel-scrollbar-width`.
63
+
64
+ ```ts
65
+ const locked = useBodyScrollLock(props.isOpen)
66
+ watch(() => props.isOpen, (open) => { locked.value = open })
67
+ ```
68
+
69
+ ## Positioning — `useAnchoredPosition`
70
+
71
+ A thin wrapper over `@floating-ui/vue`: flipping, viewport clamping, optional
72
+ width matching and height capping, and — through `autoUpdate` — following the
73
+ anchor through scrolls and resizes without per-component listeners.
74
+
75
+ ```ts
76
+ const { floatingStyles } = useAnchoredPosition(triggerRef, panelRef, isOpen, {
77
+ placement: 'bottom-end',
78
+ matchWidth: true,
79
+ })
80
+ ```
81
+
82
+ The available height is applied as `max-height` and published as
83
+ `--panel-available-height` for panels that scroll an inner region instead.
84
+
85
+ ## Keyboard — `useArrowNavigation`, `useTypeahead`
86
+
87
+ `useArrowNavigation(container)` gives a menu the WAI-ARIA keyboard model:
88
+ arrows move focus between items, Home/End jump to the ends, and typing jumps to
89
+ an item by name. `resolveNavigationIndex()` is the same key model as a pure
90
+ function, for lists driven by a highlighted index — a combobox, where focus
91
+ stays in the text field and `aria-activedescendant` points at the row.
92
+
93
+ ## Ids — `useId`
94
+
95
+ Wraps Vue's own `useId()`, which is stable across a server render and its
96
+ hydration. Ids count per application, so two components in the same app always
97
+ differ; two independently created apps restart the sequence.
98
+
99
+ ## Where overlays render — `teleportTarget`
100
+
101
+ Overlays teleport to `body` by default. An application whose overlays must live
102
+ inside a themed or transformed subtree sets it once:
103
+
104
+ ```ts
105
+ app.use(createPanel({ teleportTarget: '#panel-overlays' }))
106
+ ```
@@ -0,0 +1,112 @@
1
+ # Relation fields: lookups and repeaters
2
+
3
+ Two field types talk to the server while the form is open: `belongs-to`
4
+ (pick one related record) and `repeater` (edit a list of child rows). Both
5
+ work from props the backend sends, so a page declares nothing.
6
+
7
+ ## `belongs-to` — a lookup
8
+
9
+ Small relations ship their whole option list and behave like a select.
10
+ Larger ones ship no options and a `searchUrl` instead:
11
+
12
+ ```js
13
+ {
14
+ type: 'belongs-to',
15
+ key: 'author_id',
16
+ label: 'Author',
17
+ props: {
18
+ searchUrl: '/panel/posts/relations/author_id',
19
+ valueKey: 'value',
20
+ labelKey: 'label',
21
+ clearable: true, // offer a way back to "no selection"
22
+ allowCreate: true, // offer "Create …" for an unmatched name
23
+ },
24
+ }
25
+ ```
26
+
27
+ ### Endpoint contract
28
+
29
+ One path answers three things:
30
+
31
+ | Request | Returns |
32
+ |---|---|
33
+ | `GET ?q=term` | `{ data: [{ value, label }], meta: { total, limit, truncated } }` |
34
+ | `GET ?values=id[,id]` | `{ data: [...] }` — labels for identifiers the field already holds |
35
+ | `POST { label }` | `{ data: { value, label } }` — create from a typed name |
36
+
37
+ `?values=` exists because a searchable field arrives with an identifier but
38
+ no label: the list it came from was never sent, so the control asks what to
39
+ call its current value before rendering.
40
+
41
+ `meta.truncated` is rendered as a visible notice. A silently capped list
42
+ reads as "that is everything", which is how someone concludes a record does
43
+ not exist.
44
+
45
+ ### Behaviour worth knowing
46
+
47
+ **The input always names what is actually held.** Typing filters the list,
48
+ but typed-and-never-chosen text is not a selection: dismissing the dropdown
49
+ (Escape, blur, clicking away) reverts the input to the held value's label.
50
+ Without that, the display and the value disagree and the next submit proves
51
+ it. Emptying the input still means deselect.
52
+
53
+ **`allowCreate` is an offer, not a permission.** The row is hidden when the
54
+ exact name is already listed, and the `POST` must re-check server-side that
55
+ the target really can be created from a label alone — a forged offer buys
56
+ nothing. A refusal is shown verbatim under the field. Without a `searchUrl`,
57
+ the field emits a `create` event instead for the page to handle.
58
+
59
+ ## `repeater` — child rows
60
+
61
+ ```js
62
+ {
63
+ type: 'repeater',
64
+ key: 'lines',
65
+ label: 'Lines',
66
+ fields: [ // the child's own fields
67
+ { key: 'item_id', type: 'belongs-to', label: 'Item', width: '1/2' },
68
+ { key: 'quantity', type: 'text', label: 'Quantity', width: '1/2',
69
+ props: { type: 'number', step: '0.001' } },
70
+ ],
71
+ }
72
+ ```
73
+
74
+ Rows are plain objects keyed by the sub-field keys, plus an `id` for rows
75
+ that already exist. **A row with no `id` is new** — that is how the server
76
+ tells create from update, and how it knows which rows were removed.
77
+
78
+ Sub-fields render through the same registry as top-level fields, so a
79
+ `belongs-to` inside a row behaves exactly like one outside it (including
80
+ its own `searchUrl`, usually addressed with a dotted path such as
81
+ `lines.item_id`).
82
+
83
+ ## Row-addressed errors
84
+
85
+ Server validation for a child row comes back under a dotted key:
86
+
87
+ ```json
88
+ { "errors": { "lines.1.quantity": "Another row already uses this." } }
89
+ ```
90
+
91
+ `BlueprintForm` fans those out to the container field as `nestedErrors`,
92
+ keyed relative to it (`1.quantity`), and the repeater pins each message to
93
+ its row. A container field type that wants this only has to accept a
94
+ `nestedErrors` prop — see [custom-fields.md](custom-fields.md).
95
+
96
+ Two lifetime rules, both deliberate:
97
+
98
+ - **Editing the container clears its row messages.** Any edit may have been
99
+ the fix, and a stale message pinned to a reordered list points at the
100
+ wrong row.
101
+ - **A fresh batch of server errors resets what counts as "already edited".**
102
+ Server errors only show on untouched fields — but every field the user
103
+ filled in before submitting is touched, so without this reset the server's
104
+ answer *about that edit* would be suppressed on arrival. After the message
105
+ appears, editing again hides it: that edit is a response to it.
106
+
107
+ ## Field widths
108
+
109
+ Sub-fields honour the same `width` values as top-level fields (`1/4`,
110
+ `1/3`, `1/2`, `2/3`, `3/4`, `full`) against a twelve-column grid, so a
111
+ two-field row reads as halves and a three-field row as thirds. Past three,
112
+ columns get too narrow for a label and each field takes its own row.
@@ -0,0 +1,38 @@
1
+
2
+ ### Correction: the panel package always had tests (2026-08-25)
3
+
4
+ Several entries above claim the panel package had "zero tests" and count a
5
+ package suite growing from nothing. **That was wrong.** The package has its
6
+ own `tests/` directory and its own `vitest.config.ts`, and has carried 32
7
+ spec files / 479 tests throughout — including `useQuery.spec.ts`,
8
+ `useAsyncData.spec.ts`, `reconcile.spec.ts` and `optimistic.spec.ts`, the
9
+ very modules described here as untested.
10
+
11
+ Two scoped-wrong checks produced the error and agreed with each other: a
12
+ `find src -name "*.test.ts"` that never looked outside `src/`, and a root
13
+ `vitest run` whose `include` only covered `assets/**`. Neither would have
14
+ caught it; running the package's own suite would have.
15
+
16
+ Consequences cleaned up:
17
+
18
+ - The duplicate specs written on that premise (`useQuery`, `useAsyncData`,
19
+ `reconcile`) are deleted — the existing ones cover the same ground, down
20
+ to the invalidation-coalescing case.
21
+ - Genuinely new specs (writeGate, moduleSingleton, usePendingKeys,
22
+ useFieldSaver ordering, optimistic ordering, unsaved-changes, belongs-to
23
+ revert and create, blueprint row errors) moved from `src/` into `tests/`
24
+ where the package's own runner sees them: 42 files, 530 tests.
25
+ - The root `vitest.config.ts` include added for the misplaced files is
26
+ reverted.
27
+
28
+ **And the consolidation found a real bug.** Adding files changed timing
29
+ enough to expose an intermittently failing pre-existing spec: `useQuery`
30
+ skipped a revalidation when a subscriber arrived in the *same millisecond*
31
+ as the previous fetch, because `Date.now() - updatedAt > staleTime` reads as
32
+ "still fresh" at zero elapsed — so the default `staleTime: 0` did not
33
+ reliably mean "always revalidate". Now `>=`. Three consecutive full runs
34
+ green.
35
+
36
+ The claim to keep from those entries is narrower but still true: the package
37
+ had **no component (`.vue`) mounting tests** before this work, and now has
38
+ them for BlueprintForm and BelongsToSelect.